oynix

于无声处听惊雷,于无色处见繁花

Unity接入ILRuntime和使用

现在市面上的游戏热更新方案主要有两种,一个是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后,问题又消失了。莫名其妙的来,莫名其妙的走。
------------- (完) -------------
  • 本文作者: oynix
  • 本文链接: https://oynix.com/2023/06/da23d74be8e6/
  • 版权声明: 本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!

欢迎关注我的其它发布渠道