使用ILRuntime遇到的一些问题

使用ILRuntime遇到的一些问题

团队成员对于Lua真的是深恶痛绝,所以立项的时候就决定了使用ILRuntime进行热更。从立项到现在,使用中遇到很多问题,这里写一下。

了解一下jit:

一个程序在它运行的时候 创建 并且 运行 全新的代码 ,而并非那些最初作为这个程序的一部分保存在硬盘上的固有的代码, 就叫Jit。

再了解一下 IOS为什么不能热更 ,因为IOS不支持动态生成的代码具有执行权限。而通常jit就是运行过程中动态编译代码为机器码并缓存/执行,所以也说IOS不支持jit( ios支持反射 ,只是不支持具有jit的反射,比如emit)。

再来了解一下ILRuntime的 工作原理 是什么。说的不一定对,都是个人自己的理解,这里 抛砖引玉 一下。

ILRuntime其实就是一个工作在框架层的解释器,解释外部可以热更的dll中的il指令,逐条翻译并执行。看下图的il指令序列,很像汇编。汇编的执行,其实就是给CPU设置cs:ip为需要执行的汇编的入口即可,然后CPU就逐条开始执行。这里il指令也差不多,只不过ILRuntime充当了CPU的角色,但不是真实的CPU处理。

il指令序列

具体是如何逐条解析的呢?从源码分析,以加法作为例子,其实就是if当前指令目的是做加法,那么则直接两个数据相加。其他指令亦然,只是难易的区别。当然整个过程中必然少不了栈帧的模拟,毕竟肯定有函数调用。这里可以知道热更代码触发的gc和框架代码触发的gc是同一个gc,不像lua一样。而且因为解析的是il指令,而不是上层的C#源码,所以和C#的语法糖关系不大,因为本质上C#的语法糖在编译的时候都会被编译为底层的IL。


讲完原理,自然就知道ILRuntime其实就是使用框架层的现有代码去辅助执行热更层的代码,解释过程中一点儿都没有用到jit,自然就可以热更了。

ilruntime解释热更dll,完全可以简单理解为:ilruntime在解释一个txt文本文件。

下面说一说遇到的问题

  1. 为什么需要clr绑定?

两个作用:防止热更层用到的框架层代码被裁减, 以及 加速热更代码的执行。为什么会被裁减呢?因为Unity打包的时候真的不把这个热更dll看做dll,因为这个热更dll是脱离unity框架层的。自然在unity打包的时候,为了包体大小会把认为没有使用的代码全部过滤掉。这种情况下ILRuntime解释执行的时候,去反射调用框架层代码就会被视为错误,因为框架层不存在这些被调用的代码。 加速热更代码执行其实是ILRuntime解释每条il指令的时候,都会去现有缓存中查找当前指令是否为重定向函数,如果为重定向函数,则直接调用,如果不是重定向函数,则会反射调用,反射这就是效率的隐患(IOS下也可以反射,只是反射中类似于Emit之类的会受限)。重定向函数有自己的函数签名格式,类似lua的LuaCsFunction。

2. appdomain中已经提前注册了Delegate,Activitor等一些的重定向,为什么自动生成绑定文件的时候还会生成相关代码?

自动生成的绑定代码,作者没有过滤一些提前注册过的,这无关紧要。因为重定向是用一个dict存储的,而且做了覆盖防护,所以提前注册的肯定不会被后面注册的重定向覆盖掉。

3. 开发阶段如何快速切换热更执行模式 和 原生执行模式?

上层肯定会有宏来区别执行模式。

另外配合着:

原生模式的时候,将热更dll拷贝到assets下面即可。

热更模式的时候,将assets下面的热更dll删除即可。

非ILR以及非反射模式下,看起来好像有循环引用的问题,因为看上面代码,是非热更层调用了热更层的入口, 而热更层肯定会在逻辑上调用非热更层代码的,这感觉就是一个引用圈。 但是其实,非热更部分分为两部分,一部分是framework/lib层,另一部分是runtime的游戏驱动层,然后热更部分就是hotfix。

非热更模式下的程序集引用关系

在非热更模式下,Runtime会引用Hotfix。

在热更模式下,会将Runtime和Hotfix的引用 关系打断 (其实也没有打断,只是不能直接调用了而已),但是不影响。

4. 对于成员函数的调用规则类似c++的thiscall,也就是this指针首先入栈帧。对于静态函数,则类似stdcall。尤其在处理struct中嵌套struct,并且给外层struct写binder文件的时候,经常会漏掉内层struct其实还有一个this成员。( 当然应该也可以利用内置struct自己的binder的函数来实现这个嵌套,懒得尝试了~ )

嵌套struct的binder示范


5. 泛型类,一般需要在热更层/框架层各放置一份。如果只在框架层存在一份,那么在热更中使用这个泛型类,并且传递热更中自定义的类结构的时候,就会出问题,本质上,框架层不识别热更层的自定义类型。Activitor为什么可以识别呢?因为在Appdomain中做了重定向的特殊处理。


6. 热更层中使用到框架层的一些event描述的delegate,那么因为当前自动绑定生成工具不支持生成add_ xxx, remove_xxx的绑定,所以需要 手动修改工具

7. 框架层和热更层是两个独立的vs工程,这就意味着一般情况下开启两个vs才能工作。但是其实可以通过热更层vs的.sln引用框架层.csproject的方式,就可以一个vs处理两个工程。为什么不是框架层sln引用热更层.csproject呢?因为框架层sln会在Unity编译之后被修改,之前手动引用的其他csproject会被清理掉(除非你自定义这里 自定义 VSTU 创建的项目文件 | Microsoft Docs )。所以我们反其道而行之。

2022.3.3 追加:

通过参考上述做法,以及查看了.sln文件的格式, 实现了功能,也就是 在Unity生成sln文件的时候自动添加热更工程的csproj

8. 为什么需要注册委托?

当将热更层的method传给给框架层(一般是 1: 框架的.btn.addListener(热更的.OnBtnClicked), 或者 2: 框架的.action/func.+=/-=(热更的.OnBeginDrag), 或者 直接 3: 框架的.方法(热更的.回调方法)) 的时候,需要得到热更方法的在框架层的具体类型, 比如:

热更层:void OnCenterOn() 对应的是

框架层:Action

得到具体类型之后,ilruntime解释帮你实现类似clr的处理过程,即action = new Action(OnCenterOn), 注册委托就是为了帮你实现这个 method->type->delegate的过程的

还有也会避免被裁剪。


9. 为什么需要委托适配器?

因为ilruntime把热更内部的delegate都看作是action/func的形式,但是框架层可能是自定义的delegate形式,这就需要一层转换。


10. 为什么需要值类型绑定代码?

为了调用值类型的时候可以节省 一些装拆箱的消耗。 同时需要注意在 自动绑定文件代码执行之前,必须先执行值类型代码的绑定。否则值类型绑定依然无效。本质上其实是在绑定文件生成的时候,视值类型绑定文件是否注册,然后生成最终的不同的绑定文件。


11. 如果需要用到GetComponent/s,AddComponent/s系列簇函数处理热更的mono,那么一定要实现相关的重定向。自动生成的GameObject/Transform的绑定文件对于GetComponent/s, AddComponent/s系列簇函数的重定向实现存在问题,都是当作框架层的mono去处理。所以需要参考作者的GetComponent, AddComponent去实现对于热更mono的特殊处理。

但是其实,这里 重定向实现很复杂 ,而一般情况下,我们很少使用热更的mono(有的项目组直接禁止使用热更mono),这种情况下,我们可以使用自动生成的getcomponent的重定向(在UnityEngine_ Gameobject _Binding.cs中)去处理框架层的mono,只在查找/添加热更mono的时候才去调用这个手写的重定向。最好是给处理热更mono的这个接口改个名字,比如GetComponentCustom


12.

项目中如果切换到ILRuntime热更模式,这里经常会因为没有生成绑定文件而出现编译错误,有种很简单的解决方式,可以一劳永逸的解决。就是写下面一段代码,相比较作者给的函数,多了一个缺省的bool参数。整体调用流程就是:如果在ILRuntime热更模式下,没有生成绑定文件,则会执行我们自己写的Initalize(appdomain, defaultFlag), 如果生成了绑定文件,则会执行Initalize(appdomain),此时忽略掉了我们自己手写的Initalize(appdomain, defaultFlag)。

2022.3.1 追加: ilruntime新版本已经处理了这个问题,可以不用上述解决方式了


13. 主工程.net版本需要和热更工程.net版本兼容,直接选择相同版本更加省事。

主工程一般是Unity自己创建出来的,右键打不开csproject的属性页,那就只能去打开Assembly-CSharp.csproj查看了。


然后设置热更工程也为同样的版本号

查看最终的热更工程的csproject设置,会发现这里已经被修改了版本号


14. 为什么需要适配器?

因为热更层与框架层脱离了关系,至少在Unity看来脱离了关系,那么此时Unity就会开始自己的strip优化,框架层中一些仅仅被热更层继承使用的接口,类等就可能被优化掉。所以第一个原因就是:防裁剪。

因为脱离了关系,那么如何在框架层中驱动的时候,可以同步驱动到热更层,这就成了一个问题。这就需要框架层引用热更层的相关instance去驱动 ,那么如何引用?这就是适配器的作用。适配器工作在框架层,其显式强调了需要引用驱动的类型实例,然后重写相关函数体内容,去实质调用 热更类型实例 的方法。具体参考MonoBehaviourAdapter即可理解。

15. 目前ILRuntime在处理逻辑数学计算的时候,效率低于Lua, 本质上是因为一个是stack虚拟机,一个是reigster虚拟机。所以尽量把数学计算多的部分转移到框架层,然后热更层直接调用这个框架层的密集计算接口即可。ilruntime下,热更层调用框架层的接口几乎没开销,差不多和框架层调用框架层代码一个概念,因为有代码绑定的关系,在解释过程中发现是框架层接口的时候,直接就调用绑定的接口了。而lua下,lua调用框架层接口,则是通过table: luanet_ xxx之类的metatable的 __ index先查找一下,如果metatable的cache字段中存在则使用,否则反射调用,这个cache是运行时动态填充的,涉及到就填充一下,而不是像ilruntime一下全部注册的。比如gameobject.transform就是先找gameobject这个userdata的metatable的 _ index元方法,所以涉及到gameobject.transform.positon这种连续 . 操作就会连续查找 __index,从热更发起, 然后框架层把结果组装成userdata, push到luastack上,热更中取出使用。


16. 热更工程与UNITY_EDITOR等框架层宏的关系:如果热更工程是独立的.net工程,那么此时热更工程与这些框架工程中的宏其实就没有任何关系,这些框架宏在热更工程就不会正常起作用。【 宏只会影响编译器的编译,编译成il之后,宏就已经不存在了 】形如下述代码在热更工程中就不会正常工作。如果想正常工作,那么热更工程中按需设置下面的宏,让这些宏在合适的时候使能。或者将这个逻辑抽象一个框架出来,转移到框架层,然后热更调用框架层接口,这样子热更就无需关心宏的情况。


17. 热更工程继承框架层的类的时候,需要适配器。在适配器中写类虚函数的适配的时候,需要格外注意,否则会出现函数的递归调用导致爆栈。之前官方例子没怎么看就开始实操,在这里摔过跤。

18. ios上跑ilruntime包,有时候会遇到莫名其妙的奔溃【安卓上没遇到过】,从xcode堆栈信息上压根看不出来东西,只是说指针乱了。这时候就需要想想是不是爆栈了。因为ios的函数调用栈比较小,需要xcode编译的时候scheme不是debug模式,或者从xcode上调整stack大小


19. 热更层中monobehaviour中的一些类成员的数值往往不能像原生C#那样,直接使用初始值。本质原因是:适配器adaptor只是一个桥接,用于转接mono的start, awake等事件函数给热更层的mono的instance。ilruntime没有调用热更mono的成员初始化,所以需要下面这种特殊的方式构建才能给类成员使用默认值。

来自qq群截图

20. 热更中目前不支持Nullable,所以出现下面这种报错,就去热更层中找找?或者nullable<T>吧

21. 有时候会在框架层遗漏一些delegate的注册,然后上线之后又不能轻易更包,这种情况下,可以提前在框架层写几个万能的delegate解决,防御一下。

有时候也需要预留一下monobehaviour, StateMachineBehaviour等

22. 项目使用的是ILRuntime,所以ui的prefab上不会挂载ui脚本,这就带来一个很麻烦的问题,比如美术想快速进游戏查看一个uiprefab的动画是否k的比较合适,那么就需要:点击主界面某个图标->进入目标UI, 这还是比较浅的UI。为了让美术能快速的查看效果, 可以给uiprefab上静态挂载一个mono脚本, 该脚本只在Editor下可以工作,而且打包的时候不会进入包体( 可以通过设置hideflags控制 )。该脚本关联uiprefab以及控制该prefab的ui逻辑。 最终的效果就是:将prefab拖拽进入游戏中,就能正确表现,就像是正常流程打开的一样。其实该mono脚本只是调用了UIMgr的OpenUI, CloseUI的逻辑。很类似lua工作模式下的LuaBehaviour.cs

23. 热更内部为什么不能同时继承热更类,又继承框架接口, 也就是多继承来自两个域(框架层和热更层)的类/接口?

比如该热更类:SysTask : HotfixSingleton<SysTask>, IComparer<int> { }

一般会出现报错:InvalidCastException: Specified cast is not valid.

即使我们写了正确的适配器,还是会存在上述问题。 因为ilruntime不能识别这个SysTask到底是iltypeinstance还是一个IComparer的适配器adaptor . 如下代码,在CheckCLRType的时候,获取到的其实是Adaptor(为什么获取的是Adaptor,目前看代码是在初始化SysTask为IlTypeInstance的时候内部设置出现了问题,具体还不得而知),但是在外层进行了一个IlTypeInstance的强转,自然会导致失败。


24. 偶尔可能会遇到一个简单的赋值操作出现nullreference的异常, 比如

其实很好理解,在c++的概念中,null.unVirtualFunc()中,如果遇到整个函数体中完全没有对于成员变量的操作,那么这个null.unVirtualFunc()表达式是可以走下去的。比如上面这个报错,非虚函数,函数体中对于成员rootComponent有赋值操作,必然会异常。 本质原因是因为函数体和对象其实没有必然的挂钩联系,函数体只是一段执行逻辑,这段逻辑中有可能需要用到this.对象,也有可能完全用不到。

25. 热更内部,自定义对象实例其实是class iltypeinstance, 常规值类型比如bool, short等其实是struct stackobject类型。 stackobject其实自身就是12byte . 也就是说,一个bool在ilruntime内部就是12byte。之前项目组在优化配置表的数据定义结构的时候,将一些int类型修改为bool类型,其实在ilruntime不会有优化效果。当然,可以多个bool组装成一个long或者int来表达,然后获取具体某个bool的时候,使用get property的方式获取可以优化一部分内存浪费。所以说,ilruntime比较耗内存其实主要是iltypeinstance和stackobject。

这就需要注意热更配置表不要一次性导入内存解析,最好使用sql按需管理。

这个,作者在 教程视频 也有讲到。

26. 学习教程的过程中,了解到热更类中如果有值类型的字段,比如

则对于这个值类型字段的赋值操作,即使有绑定器,也会有装箱消耗。 看了代码,在UnityEngine_Vector3_Bindings.cs中的构造函数中,一步步追踪分析,发现其实是在Iltypeinstance的this[index]的set方法中设置字段的时候,会将vector3类型的value装箱为object

后来有个想法,通过out参数在框架层而非热更层赋值,比如

但是这种也绕不开最终的set赋值操作(通过查阅最终生成的重定向代码可知)。

做上述测试的时候,有个想法, 就是在运行过程中,能否将遇到的装箱操作全部输出出来,便于优化 ,目前没发现相关插件,有一个办法估计可行,就是修改mono源码,在jit装箱操作的地方hook住,然后编译这个mono给Unity使用。

27. Unity2019.4.25, il2cpp, ilruntime2.0(未使用jit模式), 在window平台打包的exe, 会在点击退出按钮退出的时候,"未响应"。

使用ProcessExplorer查看进程的各个线程状态,对比未响应前和后的线程状态,发现主线程被挂起了

未响应 前
未响应

然后打包出来一个vs工程,编译,接着使用windbg调试,发现 可能 是ilruntime的AsyncJITCompilerWorker的一个线程导致的(因为windbg ~* kb 显示出来的所有线程堆栈,只有3个会显示和游戏具体逻辑有关系的内容, 一个是gc, 一个是主线程,一个是ilruntime起来的jitworker线程), 然后看代码结合断点发现,unity关闭的时候,会等待所有foreground线程先全部死亡,才会让主线程死亡,也就是说,主线程会一直Join等待其他foreground线程的死亡。发现剩下一个foreground线程刚好就是ilruntime的那个jitworker线程。

而解决方法就是:需要在exe退出的时候,手动调用一下appdomain的dispose,让这个foreground线程唤醒,然后死亡。 之前就是没有手动调用appdomain的dispose导致的上述问题。 也可以设置ilruntime的那个线程为background线程,但是需要修改ilruntime源码。

28. 如果热更层dll是用unity的AssemblyDefination实现的,而不是一个独立的vs的工程dll ,那么在打包的时候就需要注意:千万不要将这个热更的dll打包进包体,会浪费无畏包体空间。也就是说,需要在打包的时候,将这个热更dll过滤出去。实现也比较简单,就是实现Unity的IFilterBuildAssemblies,然后OnFilterAssemblies函数中remove即可。

29. 字典的foreach遍历在热更层因为有值类型的迭代器,会装箱,所以导致有gc, 我想到的 骚操作 就是把foreach转移到框架层去做 (暂时记录在这儿,后面做测试是否效率有优化):

热更层的method赋值给框架层的func的时候,也有gc,因为生成了一个Func的代理对象(代理本质上也是一个类)。

参考git提交:

使用上有点绕,还是一把梭利落... 或者配合dict在热更那边再维护一个list,然后遍历的时候,用list去代替dict进行遍历

30. 2022.3.3 热更的继承类调用框架层基类的protected成员 这种情况,比如子类override中调用 base.xxx ,比如子类ctor调用基类ctor 目前,ilruntime的自动生成绑定代码分析不出来(分析不出来也合理,因为基本上重定向都是定向public成员/函数,而不是protected的), 所以我们需要手动将这些被调用的基类成员/函数 进行重定向。

注意,反射的时候需要包含BindingFlags.NonPublic,然后在绑定代码中 反射 调用基类成员/函数。

31. 目前ILR的开发效率挺高的,原生c#,有调试...... 但是如果有类似Lua的package.loaded的机制,可以更加提高UI逻辑的开发效率, 你想, 我们经常修改的代码应该就是热更层的UI逻辑代码,如果可以在不开关游戏的情况下,UI逻辑代码可以做到类似txt文本一样的热重载(只需要关闭再开启一下UI界面),岂不是开发效率升上天!

后来在assetstore上找到了一款插件感觉刚好可以解决这个需求,Roslyn C - Runtime Compiler,可以便于开发提速!

近来用Roslyn C - Runtime Compiler尝试了一下,发现动态编译实在慢,索性改变想法,直接将热更工程中的需要重新关开UI就能生效的脚本组织成一个新的独立的HotLoad工程,然后在热更工程中用反射调用即可。UI关开的时候,重新反射调用一下即可。

2022.3.6 追加: 了解到ET可以进行热重载,所以去了解了一下。原来ET把Hotfix层分为了4个子层

热重载只针对Hotfix和HotfixView层生效,看起来就是指针对UI/Controller生效(Hotfix/HotfixView被归结为Data.dll, Hotfix/HotfixView/Model/ModelView这四个被归结为Code.dll, 也就是Code.dll是Data.dll + Logic_*)。做法就是重新加载对应的UI/Controler的程序集Logic_*.dll,然后全局的EventSystem派发事件,让每个Entity都重新reload一下(ET的世界里多数都继承自Entity,Entity会Register到EventSystem里面)。


2023/2/10 追加 qq群友推荐了些开发阶段可以热重载的方案:

github.com/Misaka-Mikot

最近作者开了视屏课程讲解ILRuntime的使用和原理:

编辑于 2023-02-10 13:40 ・IP 属地上海

文章被以下专栏收录