现在市面上的游戏热更新方案主要有两种,一个是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:
| 12
 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类型:
| 12
 3
 
 | Action<int, float> act;
 appDomain.DelegateManager.RegisterMethodDelegate<int, float>();
 
 | 
这是注册带返回值的Func类型:
| 12
 3
 
 | Func<int, float, bool> act;
 appDomain.DelegateManager.RegisterFunctionDelegate<int, float, bool>();
 
 | 
但是,当你没有使用Action或者Func这个自带的委托,而是自己定义了一个委托,那么就还需要再注册一个转换器:
| 12
 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(),它的影子方法,也就是重定向方法如下:
| 12
 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();
 }
 
 | 
注册重定向:
| 12
 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底层实现机制有着清晰度的了解,对此,作者提供了一个代码生成工具来自动生成这些重定向的代码,
| 12
 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中的类,不能和主工程中的类或接口直接接触,中间需要一个适配器来调和。
| 12
 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的安装路径下,后在在主工程路径下:
| 12
 3
 
 | {Editor安装路径}/Managed/UnityEngine/{依赖名字.dll}
 {Unity主工程}/Library/ScriptAssemblies/{依赖名字.dll}
 
 | 
需要什么依赖库文件,就去这两个目录下找,然后添加到Hotfix的Dependencies里即可。一般而言,有三个是一定会需要的:
| 12
 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文件,而我们只需要其中的两个文件:
| 12
 
 | Hotfix/Hotfix/bin/Release/Hotfix.dllHotfix/Hotfix/bin/Release/Hotfix.pdb
 
 | 
11. 在Unity主工程拉起Hotfix DLL
主要分为这么几个步骤:
- 初始化AppDomain
- 加载DLL/PDB文件
- 按需注册跨域适配器/值类型适配器/方法重定向/委托适配器/binding
- 调用DLL的入口方法
这些在上面分别说过了,这里拿过来直接用即可
| 12
 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后,问题又消失了。莫名其妙的来,莫名其妙的走。