现在市面上的游戏热更新方案主要有两种,一个是L#,一个是ILRuntime,前者较为成熟,后者随着更新,也可在多数游戏上有着不错的性价比。
官网地址
1. 环境
- System:MacOS Monterey 12.5,M1 Pro
- Unity Editor: 2022.2.7f1
- JetBrains Rider: 2023.1
我的环境如上,以下的介绍,均是基于此环境。在不同环境下,一些操作上的细节可能会有所差别。
2. 导入包
可以尝试在Package Manager中直接搜索,能不能搜索到就要看Unity Editor的版本,如果是国内特供版是可以搜到的,如果是国际版则搜不到。
我就没有搜到,按照文档,在项目的Packages/manifest.json中,添加ILRuntime的源信息,在这个文件的dependencies节点前增加以下代码,然后就可以搜到了,目前最新的版本是2.1.0
:
1 2 3 4 5 6 7 8 9
| "scopedRegistries": [ { "name": "ILRuntime", "url": "https://registry.npmjs.org", "scopes": [ "com.ourpalm" ] } ],
|
在Package Manager中选中ILRuntime, 右边详细页面中有Samples,点击右方的Import to project
可以将ILRuntime的示例Demo直接导入当前工程。如果报错,内容关于unsafe code
,那么在PlayerSettings里的OtherSettings里找到Allow unsafe code,勾选上即可。
3. 委托适配器和委托转换器
委托,即delegate,如果只在ILRuntime,也就是热更DLL内部使用,则与通常无异。但如果传递到外部,也就是DLL里调用Unity主工程的方法时,传递了委托类型的参数,则需要给委托注册一个适配器。
一般来说,需要委托的地方,直接使用系统的Action和Func就可以满足绝大部分需求,不需要自己去定义新的delegate,这样的话,注册适配器就很简单。
这是注册不带返回值的Action类型:
1 2 3
| Action<int, float> act;
appDomain.DelegateManager.RegisterMethodDelegate<int, float>();
|
这是注册带返回值的Func类型:
1 2 3
| Func<int, float, bool> act;
appDomain.DelegateManager.RegisterFunctionDelegate<int, float, bool>();
|
但是,当你没有使用Action或者Func这个自带的委托,而是自己定义了一个委托,那么就还需要再注册一个转换器:
1 2 3 4 5 6
| delegate void CustomAction(int a, float b);
app.DelegateManager.RegisterDelegateConvertor<CustomAction>((action) => { return new CustomAction((a, b) => { return ((Action<int, float>)action)(a, b); }); });
|
这个转换器,自定义的delegate和系统自带的Action或Func之间的强制转换。换言之,即ILRuntime是认识Action和Func,如果你不用这两种,那么就强制转换一下,因为不管自定义了什么delegate,最终肯定能转换成Action或Func。所以作者他也建议,非必要的话,使用Action和Func即可。
这些个适配器和转换器,要提前注册到代码里,如果遗漏了也不要紧,因为在用到的时候代码会报错,报错信息里就会告诉你需要注册的类型,可以直接复制粘贴。
4. CLR重定向和CLR绑定
对于无法直接调用的方法,如在Unity主工程中,通过new T()
创建热更DLL中的类型的实例,ILRuntime给出的方案就是CLR重定向,通俗来讲,就是给目标方法创建一个影子方法,当IL解译器发现需要调用目标方法时,会将调用重定向到创建的影子方法。
例如,Activator.CreateInstance,它里面就是调用了刚刚说到的new T()
,它的影子方法,也就是重定向方法如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| public static StackObject* CreateInstance(ILIntepreter intp, StackObject* esp, List<object> mStack, CLRMethod method, bool isNewObj) { //获取泛型参数<T>的实际类型 IType[] genericArguments = method.GenericArguments; if (genericArguments != null && genericArguments.Length == 1) { var t = genericArguments[0]; if (t is ILType)//如果T是热更DLL里的类型 { //通过ILRuntime的接口来创建实例 return ILIntepreter.PushObject(esp, mStack, ((ILType)t).Instantiate()); } else return ILIntepreter.PushObject(esp, mStack, Activator.CreateInstance(t.TypeForCLR));//通过系统反射接口创建实例 } else throw new EntryPointNotFoundException(); }
|
注册重定向:
1 2 3 4 5 6 7 8
| foreach (var i in typeof(System.Activator).GetMethods()) { //找到名字为CreateInstance,并且是泛型方法的方法定义 if (i.Name == "CreateInstance" && i.IsGenericMethodDefinition) { appdomain.RegisterCLRMethodRedirection(i, CreateInstance); } }
|
上面只是重定向一个方法的例子。通常情况下,如果要从热更DLL中调用Unity主工程或者Unity的接口,都需要给目标方法做重定向处理,不单单是处理一个方法,而是处理一个类。对于目标类,生成一个影子类,在影子类里,按需对其方法分别生成重定向方法,最后再将这些方法注册到ILRuntime中,这就是CLR绑定。
但是,如果想写出上面这样的代码,需要对ILRuntime底层实现机制有着清晰度的了解,对此,作者提供了一个代码生成工具来自动生成这些重定向的代码,
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
| static void GenerateCLRBindingByAnalysis() { //用新的分析热更dll调用引用来生成绑定代码 var domain = new ILRuntime.Runtime.Enviorment.AppDomain(); using (var fs = new System.IO.FileStream("Assets/StreamingAssets/HotFix_Project.dll", System.IO.FileMode.Open, System.IO.FileAccess.Read)) { domain.LoadAssembly(fs); //Crossbind Adapter is needed to generate the correct binding code InitILRuntime(domain); ILRuntime.Runtime.CLRBinding.BindingCodeGenerator.GenerateBindingCode(domain, "Assets/ILRuntime/Generated"); } AssetDatabase.Refresh(); }
static void InitILRuntime(ILRuntime.Runtime.Enviorment.AppDomain domain) { //这里需要注册所有热更DLL中用到的跨域继承Adapter,否则无法正确抓取引用 domain.RegisterCrossBindingAdaptor(new MonoBehaviourAdapter()); domain.RegisterCrossBindingAdaptor(new CoroutineAdapter()); domain.RegisterCrossBindingAdaptor(new TestClassBaseAdapter()); domain.RegisterValueTypeBinder(typeof(Vector3), new Vector3Binder()); domain.RegisterValueTypeBinder(typeof(Vector2), new Vector2Binder()); domain.RegisterValueTypeBinder(typeof(Quaternion), new QuaternionBinder()); }
|
这个代码很直观:先注册跨域继承适配器(这个一会就说),再提供了一个DLL文件的路径,然后调用GenerateBindingCode方法,随后就会有Binding代码文件输出到目标路径下。生成的代码文件的命名格式一般如下:
1
| namespace_className_Binding.cs
|
5. 跨域继承
这里的继承方向指的是热更DLL中的类,继承Unity主工程中的类,或者实现Unity主工程的接口,这里需要在Unity主工程中实现一个继承适配器。简单来说,就是DLL中的类,不能和主工程中的类或接口直接接触,中间需要一个适配器来调和。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87
| public class TestClass2Adapter : CrossBindingAdaptor { //定义访问方法的方法信息 static CrossBindingMethodInfo mVMethod1_0 = new CrossBindingMethodInfo("VMethod1"); static CrossBindingFunctionInfo<System.Boolean> mVMethod2_1 = new CrossBindingFunctionInfo<System.Boolean>("VMethod2"); static CrossBindingMethodInfo mAbMethod1_3 = new CrossBindingMethodInfo("AbMethod1"); static CrossBindingFunctionInfo<System.Int32, System.Single> mAbMethod2_4 = new CrossBindingFunctionInfo<System.Int32, System.Single>("AbMethod2"); public override Type BaseCLRType { get { return typeof(ILRuntimeTest.TestFramework.TestClass2);//这里是你想继承的类型 } } public override Type AdaptorType { get { return typeof(Adapter); } } public override object CreateCLRInstance(ILRuntime.Runtime.Enviorment.AppDomain appdomain, ILTypeInstance instance) { return new Adapter(appdomain, instance); } public class Adapter : ILRuntimeTest.TestFramework.TestClass2, CrossBindingAdaptorType { ILTypeInstance instance; ILRuntime.Runtime.Enviorment.AppDomain appdomain; //必须要提供一个无参数的构造函数 public Adapter() { } public Adapter(ILRuntime.Runtime.Enviorment.AppDomain appdomain, ILTypeInstance instance) { this.appdomain = appdomain; this.instance = instance; } public ILTypeInstance ILInstance { get { return instance; } } //下面将所有虚函数都重载一遍,并中转到热更内 public override void VMethod1() { if (mVMethod1_0.CheckShouldInvokeBase(this.instance)) base.VMethod1(); else mVMethod1_0.Invoke(this.instance); } public override System.Boolean VMethod2() { if (mVMethod2_1.CheckShouldInvokeBase(this.instance)) return base.VMethod2(); else return mVMethod2_1.Invoke(this.instance); } protected override void AbMethod1() { mAbMethod1_3.Invoke(this.instance); } public override System.Single AbMethod2(System.Int32 arg1) { return mAbMethod2_4.Invoke(this.instance, arg1); } public override string ToString() { IMethod m = appdomain.ObjectType.GetMethod("ToString", 0); m = instance.Type.GetVirtualMethod(m); if (m == null || m is ILMethod) { return instance.ToString(); } else return instance.Type.FullName; } } }
|
6. 值类型绑定
Vector3等Unity常用值类型如果不做任何处理,在ILRuntime中使用会产生较多额外的CPU开销和GC Alloc。我们通过值类型绑定可以解决这个问题,只有Unity主工程的值类型才需要此处理,热更DLL内定义的值类型不需要任何处理。
ILRuntime的示例代码里写了三个值类型的绑定:Vector2、Vector3和Quaternion。常用的也就这几个,直接拿过来用即可。
7. 总结
总的来看,ILRuntime的接入相对简单,主要两个问题:
- DLL调用Unity主工程的方法:需要生成重定向的方法,ILRuntime提供了代码生成工具;如果参数有delegate类型,则需要注册委托适配器;如果delegate的类型不是Action和Func,则还需要注册一个委托转换器。
- DLL继承或实现Unity主工程的方法或接口:需要创建一个跨域继承的适配器,因为过于特殊,所以ILRuntime没有提供代码生成的工具。
8. 创建Hotfix工程
热更工程本质上就是一个独立的工程。
.Net的项目里,有两个概念,一个是Solution,一个是Project。Solution是一个独立的工程,里面可以包含一个或者多个Project,我们编写代码是在Project里,但是Project不能独立存在,需要依附在一个Solution里。它们之间的关系就像Android工程和里面的Module。
所以,需要先创建一个Solution,然后在Solution里添加一个Project。Project的类型有十几种,这里需要的是Framework下的Class Library,中文名叫做类库。
一般放在Assets文件夹的同级目录即可,这样既不会在Unity Editor里显示,同时也会受到同一个git仓库管理。
9. 给Hotfix工程添加依赖
在主工程中,Unity相关的依赖是自动添加的,比如GameObject、UnityWebRequest等。但是在Hotfix工程中,这些依赖则需要我们手动添加(依赖即为DLL文件)。这里的依赖按照位置可以分为两种,一种是Unity自带的,一种是我们生成或添加的。前者在Editor的安装路径下,后在在主工程路径下:
1 2 3
| {Editor安装路径}/Managed/UnityEngine/{依赖名字.dll}
{Unity主工程}/Library/ScriptAssemblies/{依赖名字.dll}
|
需要什么依赖库文件,就去这两个目录下找,然后添加到Hotfix的Dependencies里即可。一般而言,有三个是一定会需要的:
1 2 3 4
| {Editor安装路径}/Managed/UnityEngine/UnityEngine.dll {Editor安装路径}/Managed/UnityEngine/UnityEngine.CoreModule.dll
{Unity主工程}/Library/ScriptAssemblies/Assembly-CSharp.dll
|
前两个是常用到的基本库文件,第三个则是主工程的代码库,我们在主工程中写的代码最终会被编译到Assembly-CSharp.dll。
10. 编译Hotfix工程,生成DLL
这一步很简单,点击工具栏Build里的Build Solution即可,完成后将会在Project下的bin目录生成dll文件,这个目录下会生成一堆dll文件和pdb文件,而我们只需要其中的两个文件:
1 2
| Hotfix/Hotfix/bin/Release/Hotfix.dll Hotfix/Hotfix/bin/Release/Hotfix.pdb
|
11. 在Unity主工程拉起Hotfix DLL
主要分为这么几个步骤:
- 初始化AppDomain
- 加载DLL/PDB文件
- 按需注册跨域适配器/值类型适配器/方法重定向/委托适配器/binding
- 调用DLL的入口方法
这些在上面分别说过了,这里拿过来直接用即可
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107
| namespace Gaia { public class ILRuntimeManager { private static class Holder { public static readonly ILRuntimeManager Instance = new(); }
private const string DllPath = "Assets/AddressableAssets/Hotfix/DLL.bytes"; private const string PdbPath = "Assets/AddressableAssets/Hotfix/PDB.bytes";
public static ILRuntimeManager Get() { return Holder.Instance; }
public IEnumerator Load() { var appdomain = new AppDomain();
#if DEBUG && (UNITY_EDITOR || UNITY_ANDROID || UNITY_IPHONE) //由于Unity的Profiler接口只允许在主线程使用,为了避免出异常,需要告诉ILRuntime主线程的线程ID才能正确将函数运行耗时报告给Profiler appdomain.UnityMainThreadID = System.Threading.Thread.CurrentThread.ManagedThreadId; #endif
yield return LoadAssembly(appdomain);
InitializeILRuntime(appdomain);
RunILRuntime(appdomain); }
#region private
private IEnumerator LoadAssembly(AppDomain appdomain) { Debug.Log("start load dll bytes, path:" + DllPath); var dllHandle = Addressables.LoadAssetAsync<TextAsset>(DllPath); while (!dllHandle.IsDone) { // Debug.Log("load dll bytes progress:" + dllHandle.PercentComplete); yield return null; }
if (dllHandle.Status != AsyncOperationStatus.Succeeded) { Debug.LogError("load dll bytes fail, " + dllHandle.OperationException); yield break; }
var dll = new MemoryStream(dllHandle.Result.bytes);
Debug.Log("load dll file finish, progress:" + dllHandle.PercentComplete + ",size:" + dll.Length);
Addressables.Release(dllHandle); #if DEBUG Debug.Log("start load pdb bytes, path:" + PdbPath); var pdbHandle = Addressables.LoadAssetAsync<TextAsset>(PdbPath); while (!pdbHandle.IsDone) { // Debug.Log("load pdb bytes progress:" + pdbHandle.PercentComplete); yield return null; }
if (pdbHandle.Status != AsyncOperationStatus.Succeeded) { Debug.LogError("load pdb bytes fail, " + pdbHandle.OperationException); yield break; }
var pdb = new MemoryStream(pdbHandle.Result.bytes);
Debug.Log("load pdb file finish, progress:" + pdbHandle.PercentComplete + ",size:" + pdb.Length);
Addressables.Release(pdbHandle);
appdomain.LoadAssembly(dll, pdb, new PdbReaderProvider()); #else appdomain.LoadAssembly(dll, null, null);
#endif }
private void InitializeILRuntime(AppDomain appdomain) { // - 注册跨域继承/实现适配器 CLRAdapter.Register(appdomain); // - 注册值类型绑定 CLRValueBinder.Register(appdomain); // - 注册委托适配器/转换器 CLRDelegate.Register(appdomain); // - 注册重定向 CLRRedirection.Register(appdomain); // - 注册CLR绑定,初始化CLR绑定请放在初始化的最后一步!!(请在生成了绑定代码后解除下面这行的注释) ILRuntime.Runtime.Generated.CLRBindings.Initialize(appdomain); }
private void RunILRuntime(AppDomain appdomain) { Debug.Log("invoke L method"); appdomain.Invoke("Hotfix.Class1", "L", null, null); }
#endregion } }
|
附录
- 问题1: Failed to resolve assembly: ILRuntime.Mono.Cecil
使用工具自动分析DLL文件并生成binding文件时,报了一个这样的错误,以前从来没有遇到过,我试过将Hotfix工程里所有的依赖都删掉,但错误依然在。之后将Burst升级到最新版本1.8.7后,问题又消失了。莫名其妙的来,莫名其妙的走。