XLua热更方案深度解析(一):万字详解,带你精通热更
前言
代码热更新则是一种在不需要重新编译打包游戏的情况下,在线更新游戏非核心代码的方式,比如游戏中的活动运营、补丁修复和添加小功能等。Lua热更新是代码热更新的一种常见方式,Lua是一门小巧的脚本语言,它运行在虚拟机上,几乎可以在任何操作系统和平台上运行。Lua虚拟机是一个解释器,负责加载和执行Lua脚本。XLua在程序启动时创建Lua虚拟机实例,并将其与C#环境进行桥接。这样,Lua脚本就可以在Unity环境中运行,并与C#代码进行交互。
接下来,本篇将详细解析XLua热更方案,包括XLua标签、语法、如何为需要热更新的C#函数生成匹配函数、如何注入代码以及如何使用Lua编写补丁等关键步骤。
注意:本文为个人囤货笔记,写于2022年10月,尽管时至今日,XLua的核心原理和机制并未发生显著变化,因此本文的内容仍然具有较高的参考价值。但未来XLua及其热更方案可能会有所更新和发展,请注意时效性。
Lua语言
Lua 频道 | 温文的小屋,系统介绍了Lua的基本语法、核心知识。
加载Lua代码
加载字符串形式的脚本代码
在Xlua中加载Lua代码,可以使用DoString。它允许你直接将一段Lua代码作为字符串传递给LuaEnv执行。
以下是一个简单的示例,展示了如何在XLua中使用DoString方法加载并执行Lua代码:
luaEnv.DoString(@"CS.UnityEngine.Debug.Log('这是热更后的一段文本')")
这种方式,虽然很方便。但是使用DoString方法直接加载一段Lua代码作为字符串,这会使代码的管理变得更加困难。特别是当代码量较大或逻辑复杂时,维护和调试可能会变得更加繁琐。
在实际应用中,我们应该按模块或功能,或一定的规则对脚本进行文件划分。Lua也支持引入、加载一个Lua文件。
引入加载
在lua中引入脚本文件,可以通过dofile或require。再xlua中一般使用require,因为xlua对require做了一定的重写。
当lua代码里头调用require时,xlua会将require参数透传给注册的loader回调,由loader来决定具体导入的内容。如果loader返回为空表示该loader找不到,则还是交由require引入lua文件的内容。给定require一个局部路径,xlua中的require默认会从resource文件夹下读取。如果我们想要统一路径,那么我们可以使用loader。
示例-引入lua文件
luaEnv.DoString("require 'luaFile'")
loader可以自定义做一些处理。
下面展示一个XLua配合AB包加载的流程
示例:
public void InitializeLua()
{
//思路:
//1.先加载lua资源
//2.再去配置lua
Debug.Log("初始化Lua环境");
luaEnv = new LuaEnv();
LoadLuaFromAB(); //加载Lua所在AB,载入Lua内容到内存
luaEnv.AddLoader(CustomLoader);
}
byte[] CustomLoader(ref string key)
{
key = key + ".lua";
if (!luaScriptDic.ContainsKey(key))
{
Debug.LogWarning($"key为{key}的Lua脚本不存在于构建的AB包中,正尝试默认的传统加载");
return null;
}
return luaScriptDic[key].bytes;
}
XLua配置
xLua/Assets/XLua/Doc/configure.md at master · Tencent/xLua · GitHub
Tips:标明这些配置之后最好点击生成代码。
XLua.LuaCallCSharp
对类型有效
一个C#类加了这个配置,xLua会生成这个类型的适配代码(包括构造该类型实例,访问其成员属性、方法,静态属性、方法),否则将会尝试用性能较低的反射方式来访问。
一个类型的扩展方法(Extension Methods)加了这配置,也会生成适配代码并追加到被扩展类型的成员方法上。
xLua只会生成加了该配置的类型,不会自动生成其父类的适配代码,当访问子类对象的父类方法,如果该父类加了LuaCallCSharp配置,则执行父类的适配代码,否则会尝试用反射来访问。
反射访问除了性能不佳之外,在il2cpp下引擎API、系统API可能被代码剪裁调(C#无引用的地方都会被剪裁)而导致无法访问,如果觉得可能会新增C#代码之外的API调用,这些API所在的类型要么加LuaCallCSharp,要么加ReflectionUse;下面介绍的ReflectionUse标签来避免。
那么像Monobehavior 这种挂在物体上的脚本,如果其他脚本没有引用它,Unity编译时候会不会认为它是无用的。
我想unity至少有对物体上脚本去检查是否引用了,不过确实有些特殊情况,比如一个预制件在AB包里面,
XLua.CSharpCallLua
对类型有效
官方文档中说当希望把lua的函数绑定到c#的委托上。或者把luaTable里面的对象适配到C#Interface时,可以用此配置标明目标接口或委托的类型。标明这一配置之后,luaTable对象映射到Interface之间存在引用关系,即修改了C#对象的同时会影响luaTable里的内存。
XLua.ReflectionUse
反射访问除了性能不佳之外,在il2cpp下还有可能因为代码剪裁而导致无法访问,后者可以通过ReflectionUse标签来避免。
一个C#类型类型加了这个配置,xLua会生成link.xml阻止il2cpp的代码剪裁。
对于扩展方法,必须加上LuaCallCSharp或者ReflectionUse才可以被访问到。
建议所有要在Lua访问的类型,要么加LuaCallCSharp,要么加上ReflectionUse,这才能够保证在各平台都能正常运行。
XLua.DoNotGen
XLua.BlackList
如果你不要生成一个类型的一些成员的适配代码,你可以通过这个配置来实现。
标签方式比较简单,对应的成员上加就可以了。
XLua.private_accessible(class)
让一个类的私有字段,属性,方法等在xlua中可用
在xlua新版本中(版本号大于2.1.11)不需要调用xlua.private_accessible就能访问类下的私有字段、一个事件的私有委托,但方法仍需要。
XLua.GCOptimize
一个C#纯值类型加上了这个配置,xLua会为该类型生成gc优化代码,效果是该值类型在lua和c#间传递不产生(C#)gc alloc,该类型的数组访问也不产生gc。
下面是生成期配置,必须放到Editor目录下
CSObjectWrapEditor.GenPath
CSObjectWrapEditor.GenCodeMenu
批量打标签
静态列表
有时我们无法直接给一个类型打标签,比如系统api,没源码的库,或者实例化的泛化类型,这时你可以在一个静态类里声明一个静态字段,该字段的类型除BlackList和AdditionalProperties之外只要实现了IEnumerable和Type就可以了(这两个例外后面具体会说),然后为这字段加上标签:
[LuaCallCSharp]
public static List<Type> mymodule_lua_call_cs_list = new List<Type>()
{
typeof(GameObject),
typeof(Dictionary<string, int>),
};
这个字段需要放到一个 静态类 里头,建议放到 Editor目录 。
动态列表
声明一个静态属性,打上相应的标签即可。
[Hotfix]
public static List<Type> by_property
{
get
{
return (from type in Assembly.Load("Assembly-CSharp").GetTypes()
where type.Namespace == "XXXX"
select type).ToList();
}
}
Getter是代码,你可以实现很多效果,比如按名字空间配置,按程序集配置等等。
这个属性需要放到一个 静态类 里头,建议放到 Editor目录 。
C#CallLua(C#访问Lua内容)
操作一个指定类型的数据
值拷贝映射方式
映射到class和struct
这种方式下xLua会帮你new一个实例,并把对应的字段值拷贝赋值过去。
public class ClassB
{
public int f1;
public string s1;
public override string ToString()
{
return string.Format($"f1:{f1},s1:{s1}");
}
}
void Start()
{
string luaScript = @"
a=1
b={f1=1.1,s1='abc'}";
LuaEnv env = new LuaEnv();
env.DoString(luaScript);
int a = env.Global.Get<int>("a");
ClassB b = env.Global.Get<ClassB>("b");
Debug.Log("a: "+a.ToString());
Debug.Log("a: "+b.ToString());
env.Dispose();
}
映射到Dictionary<>,List<>
不想定义class或者interface的话,可以考虑用这个,前提table下key和value的类型都是一致的。
引用映射方式
映射到Interface
xlua中规定使用interface接受映射可以完成lua到c#引用方式的映射。但Interface本身是无法实例化的,故要想映射到interface,必然需要一个具体的类实现Interface。因我们只需要通过引用映射到interface,那么编写一个具体类的操作来实现接口这个操作实际上是可以自动化生成代码的,在xlua中提供CSharpCallLua标签,此标签声明在interface上xlua会帮我们自动生成一个Wrapper类来实现接口下的属性。
get流程:
-
L指针应该存储着lua虚拟机内存的起始地址
-
字段名压入栈
-
拿到栈顶字段名并查找返回。
这样就实现了c#和lua直接的引用了。
[CSharpCallLua]
public interface IM
{
int c1 { get; set; }
int c2 { get; set; }
}
void Start()
{
string luaScript = @"
a=1
b={f1=1.1,s1='abc'}
c={c1=1,c2=2}
";
LuaEnv env = new LuaEnv();
env.DoString(luaScript);
//Interface
IM m = env.Global.Get<IM>("c");
Debug.Log($"rewrite m before: a-{m.c1},b-{m.c2}");
m.c1 = 11;
m.c2 = 12;
m = env.Global.Get<IM>("c");
Debug.Log($"rewrite m after: a-{m.c1},b-{m.c2}");
//
env.Dispose();
}
映射到LuaTable类
这种方式好处是不需要生成代码,但有一些问题,比如慢,比interface要慢一个数量级,比如没有类型检查。还需要查找字段存在装箱和拆箱。因为lua热更通常是要把将 C# 对象包装成 object 类型,然后再传递给 Lua 解释器。
访问一个全局的function
i. 映射到delegate
建议用这种方式,性能好很多,而且类型安全。缺点是要生成代码(如果没生成代码会抛InvalidCastException异常)。
delegate要怎样声明呢? 对于function的每个参数就声明一个输入类型的参数。 多返回值要怎么处理?
答:从左往右映射到c#的输出参数,输出参数包括返回值,out参数,ref参数。
参数、返回值类型支持哪些呢?
答:都支持,各种复杂类型,out,ref修饰的,甚至可以返回另外一个delegate。
delegate的使用就更简单了,直接像个函数那样用就可以了。
ii. 映射到LuaFunction
这种方式的优缺点刚好和第一种相反。 使用也简单,LuaFunction上有个变参的Call函数,可以传任意类型,任意个数的参数,返回值是object的数组,对应于lua的多返回值。
LuaCallCSharp
XLUA把所有C#相关的都放到CS下,包括构造函数,静态成员属性、方法;
C#这样new一个对象:
var newGameObj = new UnityEngine.GameObject();
对应到Lua是这样:
local newGameObj = CS.UnityEngine.GameObject()
local newGameObj2 = CS.UnityEngine.GameObject('helloworld')
LuaCall:
abc = CS.UnityEngine.GameObject("abc")
function SetABCDec()
abc.transform.name="cba"
end
C#:
public void Test2()
{
string luaScript = @"dofile('K:\\WEB\\untitled\\src\\testLua.lua')
";
LuaEnv env = new LuaEnv();
env.DoString(luaScript);
GameObject abc = env.Global.Get<GameObject>("abc");
Debug.Log(abc.name);
Action action = env.Global.Get<Action>("SetABCDec");
action();
Debug.Log(abc.name);
}
可变参数方法
对于C#的如下方法:
void VariableParamsFunc(int a, params string[] strs)
可以在lua里头这样调用:
testobj:VariableParamsFunc(5, 'hello', 'john')
扩展方法
扩展方法,必须加上LuaCallCSharp或者ReflectionUse才可以被访问到。
在C#里定义了,lua里就能直接使用。
泛化(模版)方法
不直接支持,可以通过Extension methods功能进行封装后调用。
枚举
跟C#类似;枚举类支持__CastFrom方法,可以实现从一个整数或者字符串到枚举值的转换
delegate使用(调用,+,-)
C#的delegate调用:和调用普通lua函数一样
+操作符:对应C#的+操作符,把两个调用串成一个调用链,右操作数可以是同类型的C# delegate或者是lua函数。
-操作符:和+相反,把一个delegate从调用链中移除。
事件
比如testobj里头有个事件定义是这样:public event Action TestEvent;
那么在Lua中则是这样监听事件的:
增加事件回调
testobj:TestEvent('+', lua_event_callback)
移除事件回调
testobj:TestEvent('-', lua_event_callback)
获取类型(相当于C#的typeof)
比如要获取UnityEngine.ParticleSystem类的Type信息,可以这样
typeof(CS.UnityEngine.ParticleSystem)
“强”转
lua没类型,所以不会有强类型语言的“强转”,但有个有点像的东西:告诉xlua要用指定的生成代码去调用一个对象,这在什么情况下能用到呢?有的时候第三方库对外暴露的是一个interface或者抽象类,实现类是隐藏的,这样我们无法对实现类进行代码生成。该实现类将会被xlua识别为未生成代码而用反射来访问,如果这个调用是很频繁的话还是很影响性能的,这时我们就可以把这个interface或者抽象类加到生成代码,然后指定用该生成代码来访问:cast(calc, typeof(CS.Tutorial.Calc))
上面就是指定用CS.Tutorial.Calc的生成代码来访问calc对象。
代码热更(补丁)
上面介绍了lua、charp之间的访问或转换操作,那么如何做热更呢。
xlua提供了一个hotfix特性,用于标明需要热更的的类。
和其他配置一样,有两种方式
方法1:直接在类头打hotfix标签,但打配置的方式在il2cpp下会增加不少的代码量,而且在高版本unity中不支持了。
方法2:在static类里面定义一个静态列表成员(字段和属性都可以)并配置Hotfix,属性因为有get set容器所以可以做更复杂的配置。
public static class HotfixCfg
{
[Hotfix]
public static List<Type> by_field = new List<Type>()
{
typeof(HotFixSubClass),
typeof(GenericClass<>),
};
[Hotfix]
public static List<Type> by_property
{
get
{
return (from type in Assembly.Load("Assembly-CSharp").GetTypes()
where type.Namespace == "XXXX"
select type).ToList();
}
}
}
以方法1做个示例,先看一下大概的热更操作。
1. 标记热更的类型
2. xlua.hotfix 注入lua补丁
- class:热更发生的类,提供CS.NameSpace.TypeName或者直接字符串”NameSpace.TypeName“,如果内嵌类型是非public,只能用字符串方式表示"Namespace.TypeName+NestedTypeName"
- method_name:需要更新的函数,提供函数名
- fix:热更补丁 如果传了method_name,fix将会是一个function,否则通过table提供一组函数。table的组织按key是method_name,value是function的方式。
直接传入table,热更多个方法:
xlua.hotfix(CS.XLuaTest.HotfixCalc, {
Test1 = function(self)
print('Test1', self)
return 1
end;
Test2 = function(self, a, b)
print('Test1', self, a, b)
return a + 10, 1024, b
end;
Test3 = function(a)
print(a)
return 10
end;
Test4 = function(a)
print(a)
end;
Test5 = function(self, a, ...)
print('Test4', self, a, ...)
end
})
3.如果热更的补丁中调用了CS类下的代码,最好在所在类加上luacallcsharp标签,详细看luacallcsharp配置介绍。
xlua还提供一个类似重写前先调用base方法的函数
用util.hotfix_ex,可以调用原先的C#逻辑然后再执行补丁
local util = require 'xlua.util'
util.hotfix_ex(CS.HotfixTest, 'Add', function(self, a, b)
local org_sum = self:Add(a, b)
print('org_sum', org_sum)
return a + b
end)
补丁热更流程
提前预测可能存在改动的类型打上hotfix标识之后再打包版本。
后期需要脚本热更的时候,去更新lua文件。需要更新资源文件时更新打包并上传AB包,用户端根据服务器下传下来的最新的AB包文件载入ROM来进行热更,lua会在执行时进行由lua虚拟机进行解释。
在下一个大版本整体更新前C#的代码要正确,保证之前用热更修复的部分,C#代码也要同步,大版本发布后清除用户端之前的用于修复的热更补丁。
注意函数的划分,不能过泛也不能过细。一个函数内容过泛,不易于热更。因为一个地方需要热更是需要重写整个函数的。
为了防止出现“未标明hotfix的组件出现了问题但又一定要热更的这种情况”,我们可以提前写一个mono模板类,然后在xlua里重写所需的方法,最后物体上挂载这个脚本,并同lua一同热更。
报错
InvalidOperationException: try to dispose a LuaEnv with C# callback!
原因:尝试释放luaEnv环境时,c#还有指向lua空间上的函数。
官方git-faq给出了解决方法:“如果有把lua函数注册到一些C#事件,那就反注册这些回调来释放委托。”
简单来说,就是移除监听。更直接点,就置空。
LuaFunction lf = luaEnv.Global.Get<LuaFunction>("fishDispose");
Action fishDispose = null;
fishDispose += lf.Cast<Action>();
fishDispose();
fishDispose = null;
lf.Dispose();
在XLua里使用反射
需求:
解决方案:
local methodInfo = typeof(CS.InstanceManager):GetMethod("GetInstance"):MakeGenericMethod(typeof(CS.System.String))
methodInfo=xlua.tofunction(methodInfo)
CS.UnityEngine.Debug.Log(methodInfo())
来源:麦瑞克博客
链接:https://www.playcreator.cn/archives/unity/4354/
本博客所有文章除特别声明外,均采用CC BY-NC-SA 4.0许可协议,转载请注明!