《介绍一种基于Mono的Unity热更新方案》
热更新是Unity3D开发总也绕不过去的话题,甚至影响到了开发语言,程序架构、人员配置,不可谓不重要。文章开头先从一些大家都熟知的东西带入。热更新目前有很多成熟的方案,笔者很早前因为工作需要了解了一些信息,大体分几个流派
- Lua流派/CSharp转Lua流派
- CSharp流派
- JS/TS流派
各个流派均有成熟的框架,优劣势在此不再展开,选择时往往是结合自己团队的情况来取舍。从方向上看,笔者更看好Lua流派,Lua天生就作为脚本语言设计,集成到游戏引擎中作为逻辑脚本似乎是一件很合理的事情。笔者对Lua不是很熟悉,也曾因此在工作面试中被鄙视,从个人喜好上,还是喜欢CSharp这门语言多一点,当然这个喜好也是建立在特定环境下的,语言层面的优劣在此也不再展开。在聊新的方案前,先从头聊一些热更新方面的知识做引子。
热更新的重灾区是在iOS系统,因为一些众所周知的原因,Unity最初的Mono运行时在iOS平台下只能以full AOT模式运行,这样就无法实现热更新了。这里也引出了运行模式的概念,大家熟知的有:
-
JIT运行
Mono,V8等引擎默认运行模式,这种模式是可以动态Load代码的,也就是可以更新代码逻辑。但是在iOS系统上是被禁止的。
-
AOT运行
提前编译成本机代码,运行效率可以比肩原生代码,Unity的Mono引擎在iOS系统上即以此模式运行,但是不能更新代码。
-
Interpreter运行
即解释器执行,顾名思义,脚本语言以此方式运行,并没有生成本地机器码,目前各个热更流派均是以此方式实现热更新,代价是效率低一些。
Lua天生是以解释器运行的,具有体积小,集成灵活等特性作为大家的首选脚本,也发展出了jit模式来解决其他平台上的性能问题,有xLua,toLua等成熟框架。
CSharp也发展出了ILRuntime框架来支持解释模式,从而实现了CSharp热更新。
那么JS/TS呢,笔者以前以为V8引擎一直有解释器的,不然iOS上的Chrome是怎么运行的呢?带着这个问题查了下才发现V8确实加了解释器,并不是很久以前。所以现在JS/TS流派也发展出了成熟的框架比如Puerts。
那么Mono呢?再继续查了下,也有。Mono的解释器命运就比较曲折了,从Mono第一个版本便有,再到后来光荣退场,然后重新出山,当然是肩负了使命的。既然再加回来当然还要再进一步,AOT和Interpreter都可以在iOS上运行,如果可以让热点代码跑在AOT,容易变更的代码跑在Interpreter,两部分代码不需要关心自己的运行时不是更好吗?再继续查了下,也有,Mono内部已经实现了两套运行模式的交互部分,在aot编译时提前生成了交互代码,运行时的代码可以无感知的相互调用,并且完善度已经相当高。
-
Mixed-Mode Execution
Mono支持的一种运行模式,混合了AOT和Interpreter,在执行没有AOT的程序集时,自动将程序集切换到Interpreter内执行,所以支持动态Load代码。
事情在朝着好的方向发展,似乎一切都比较合理。从mono的提交日志看,2018年开始充斥着大量的[interp]模块提交,几乎占到了总提交量的1/3,mono的这个模块发展很快。反观Unity官方mono,恰好停留在[interp]模块加入前,便不再合并mono主干。具体原因我们不再此猜测,只是这样就需要我们自己动手了。
既然运行时已经支持,剩下的工作就是集成到UnityEngine内与Il2cpp亲密无间。在此之前我们先以Unity的默认执行框架做引子,以下为笔者个人理解,不正确的地方请以官方为准。
1号通道最初是通过Mono的Internal call来实现的,Il2cpp同样使用此方式来实现(C)到(A)的请求。
2号通道是UnityEngine通过Il2cpp 然后invoke上层接口来实现回调。
0号通道我们先称之为magic,实现一些定制特性,我们先忽略。
如果要在Unity项目中实现Mono 的Mixed-Mode Execution,我们需要在此系统内再加入一个Mono runtime,同时绑定上述三条通道,这里先说下我们的第一种绑定方案(icall绑定):
- 针对1号通道,Mono原生即支持Internal call(我们简称icall),那么在Mono中直接执行unity assembly,然后将icall调用直接指向(A)即是最直接的方式。
- 针对2号通道需要在(B)层做些手脚,通过查代码我们发现Il2cpp的Method实际是一个函数指针,那么查找到需要回调的函数,并使指针指向我们的实现,然后再Invoke Mono内的相同函数即实现了hook功能,即实现了UnityEngine的回调。
通过以上两种方式我们在自己的Mono runtime内绑定了大部分的Unity功能。为什么是大部分呢,这里可以实现icall绑定的前提是所有icall绑定传递的对象只有一份内存,并且是在(A)内,UnityEngine.Object即是此目的。Unity当然不会就此收手,magic就无用武之地了,除此还有其他一些特性,最麻烦的是0号magic通道,比如MonoBehaviour、Coroutine、传递给(A)一个.Net 的Stream等等。这里因为Unity做了一些特殊处理,具体实现我们不得而知,即使勉强实现了也无法保证以后兼容性,所以我们使用了Wrapper的方式。这里有两种实现方式,后面再详细介绍。
我们再来看看加上Mono runtime后的结构:
如上面所说,我们需要绑定(H)内的icall指向(A)即新增了通道5-3,同时需要hook(B)内的函数指针实现回调,即新增了通道4-5。
同时为了处理magic情况,我们提供两种方案,一种是手动在(F)内实现绑定接口(Unity的icall绑定大部分也是这种无规则的手动实现,所以给我们的自动绑定带来了很多麻烦)这种方案上层用户(I/K)完全无感知,只是因为这部分是由c/cpp实现,对部分团队并不友好。所以我们新增了通道6,也就是我们的第二种绑定方案(Adapter绑定):
Adapter是指在(D)中指定一些需要在(I/K)内使用的程序集,在构建时为这些程序生成两个Adapter程序集,分别位于(E)和(J),这样当用户(I/K)调用(D)内的程序接口时会自动通过通道6调用,调用方无感知,同时通道6是双向的,即同时支持调用与回调。
另外指出的是(H)是直接使用的UnityEngine.*.dll,只需重新绑定icall即可,(F)/(E)/(J)内的绑定代码均由代码生成器生成,即除非需要手动实现icall绑定,通道3/4/5/6均自动生成。
两种方案是否给框架增加了复杂性呢,其实在开发过程中,为了保持简洁笔者在这两种方案中反复切换了多次,每个单独方案都能实现绝大部分的功能,但是总会让一小部分特定的问题复杂化。比如我们全部使用Adapter绑定可以完成需求吗?其实是可以的,但是碰到Unity使用runtime来支持的特性,单纯的从CSharp层来实现复杂度会大大增加,或者需要用户修改程序,而且后续功能的兼容和扩展性会低很多。两种方式一起用,虽然给绑定生成器带来了复杂性,用户使用反而简单一些,所以保留了两种绑定方案。
至于用户的程序集是在(K)内执行还是在(I)内执行,用户可以自己根据实际需求来配置,绑定生成器会在构建时自动触发,根据配置生成不同的工程,然后将此工程以pod库的形式提供给主项目集成。主项目需要在podfile中引用后执行
pod install
即可链接成最终执行项目,以上即是笔者本次介绍的方案,详细使用细节请移步这里。此方案支持iOS平台下Assembly.Load接口。Android平台建议直接使用Unity的Mono运行时,同样支持Assembly.Load接口,这样在架构上不需改动。
此方案其实构思已久,期间做了不少可行性测试,一直因抽不出时间拖着未实现,最终也因2020年这个年终闲的时间长了些才得空实现了出来,其间缝合多个程序边界并实现自动化的复杂度还是超出了预期,总算最终走通了,因为感觉到自己可以调配的精力非常有限,也深知独立开发很难使这个框架完善,所以决定开源出来,也顺便取了个名字:PureScript,起码保持从用户角度看来是一个简洁、单纯的脚本框架。
如果大家有兴趣,后面再补充详细实现细节,目前项目已经开源。对此方案有兴趣的同学欢迎提交PR或者Star。
添加一个录屏