乐趣区

Unity下Bug修复神器InjectFix正式对外开源

InjectFix 是腾讯最新对外开源的 Unity 代码逻辑热修复方案,可实现在 Unity 线上客户端内,不用迭代新版本,就能快速修复游戏的线上 bug。

先说几个亮点:
1、直接在 Unity 工程上修改 C# 即可更新;老项目无需修改原有代码即可使用;
2、更符合苹果热更新条款;
3、每个游戏一份私有补丁格式,安全更有保障。

InjectFix 经腾讯内部多个项目应用反馈十分良好,不仅能解决线上 bug,还可以有效的提高日常开发效率,下面我们聊下这项目的前世今生。

热更方案大乱斗
所有支持 ios 的热更方案都有个共同点:更新后代码都是解析执行。如果按其更新前是否解析执行,可以分为两大类:
一类是某些模块甚至整个游戏,都一直解析执行。这是最传统的方式,目前市面上所有主流方案(xLua,slua,tolua,ILRuntime,jsb 等等)都支持这种方式。这种方式的特点:
1、或多或少都会有些侵入性:ILRuntime 解析执行 C#编译后的程序集,在这些方案里头侵入性可能最小,但也需要对代码重构,把要更新的逻辑拆到单独程序集。各种非 C#的脚本侵入性最大,一个已经完成的纯 C# 项目要用意味着重写。
ps:也有一种思路是通过一个 C#转 XX 脚本工具来实现 C# 编码,解析执行,但如果你是一个已有项目想这么转一下,大概率是失败的,除非你一开始就在用这方式在开发,碰到坑就避开,因为这类方案往往不是完整支持全部语法,支持的语法也不一定能完全一致。
2、基于性能,实现便利性等的考虑,一般游戏有些地方要以原生的方式跑,这些原生跑的代码出了 bug 这种方式是无能为力的。
3、如果使用的脚本是动态类型语言,还会带来代码维护困难的问题。
4、优点是可以新增功能,有的游戏甚至可以做到一次下载,后续不用整包更新。但苹果条款分析的章节可以看到,这也不一定是好事。
另外一类是以原生方式跑,如果有 bug,把逻辑重定向到新的,解析执行的逻辑。这种方式的特点:
1、侵入性低,后期项目也可以使用。
2、正常逻辑是原生方式运行,有问题只是局部切换到解析执行,所以性能比较好。
3、会导致代码段增大,增大正比于注入的类的数量。
4、这种方式往往难以新增功能。
第二种方式是接下来讨论的重点,方便起见,我们称之为“热修复”,热修复最早的成熟方案是 xLua 提供,经过两年来的使用已经逐渐被接受,tolua# 后来也加入了这功能,也有一些网友基于 ILRuntime 做了热修复功能。

InjectFix 是什么?
InjectFix 就是一个热修复的实现。那它和其它热修复方案又有什么不同呢?
设想这么个场景,我们有一个一千行代码的函数,其中有一行有问题,我们需要修复它。
如果用 xLua,需要用 lua 去重新实现一遍这个函数,工作量大。而基于 ILRuntime 的热修复,由于其补丁是另一个程序集,它无法直接访问原类的私有成员,所以那 999 行正常代码一般也不能直接使用,需要做较多修改。
而 InjectFix 不需要用 lua,也不需要像 ILRuntime 热修复那样另外建一个工程把那一千行逻辑重实现。只需要在 Unity 原工程直接改掉这行代码,然后标注这函数要更新即可。
不仅如此,InjectFix 还有其它优势:
运行时非常小巧,仅 100K 左右,比各 lua 方案,ILRuntime 都要小很多,而且不依赖第三方库,纯 C# 实现。
支持每个游戏生成一份自己私有的补丁格式,私有的指令定义。这样相比通用的 lua 原代码,lua 字节码,clr 程序集都更安全些。
支持 Assembly-CSharp.dll 之外的 dll 的修复。
免代码生成,更干净。
它也有缺点,不支持新增类,也不支持在已有类新增字段,修 bug 还是够用的,但难以通过热更为游戏增加新功能。InjectFix 就一个纯粹的修 bug 工具而已。

黑科技
由于 InjectFix 支持重复加载补丁,新加载补丁会自动覆盖上一个,这特性可以用来实现真机代码逻辑实时修改。
(嵌入视频,视频地址:https://v.qq.com/x/page/v0924…)

苹果政策合规性
各热更方案群的问的频率最高的问题之一:这方案会不会导致我游戏苹果审核不通过。
让我们看看苹果的热更新条款:

可以看到最新条款允许下载代码解析执行,但前提是不能通过新增特性和功能来把程序改得(和审核时相比)面目全非。再看看通常被拒时的理由中的 Guideline 2.5.2 里的一句:Your app, extension, or linked framework appears to contain code designed explicitly with the capability to change your app’s behavior or functionality after App Review approval。
有“新增特性和功能”能力的热更新方案的尴尬之处在于有“改得面目全非”的能力。而 InjectFix 从它提供的能力(只能修改已有函数)来看,并不具备“新增特性和功能”的能力,这本来是弱点,放在这里却成为合规性的保证了。

基本原理
InjectFix 项目的研发挺曲折的。InjectFix 和 xLua 是同一个作者,也是本文笔者,当时 xLua 开源后,不断有人提希望提供个 C# 转 lua 的工具,而深入研究觉得实现个 il 虚拟机工作量还更小,这样还能避免 lua 的一些 gc 问题。
决定要做 il 虚拟机后,也曾想过直接使用 ILRuntime,评估后觉得不太符合我们的使用场景:ILRuntime 并不能实现和原生代码的函数级别配合,这是我们能实现原工程直接改 Bug 的关键;ILRuntime 运行时部分依赖 cecil,除了资源占用大之外,还容易和 unity 自带或者某些插件的 cecil 冲突;加载的是标准的程序集在安全性方面也比较堪忧。虽说这些都可以改,但修改的工作量也挺大的,还不如自己写一个。
InjectFix 实现 bug 修复主要靠这两部分:虚拟机负责新逻辑的解析执行;注入代码负责把调用重定向到虚拟机;下面我们结合最简单的例子介绍下这两部分。

虚拟机
关键部分用几行伪码就可以描述清楚:

导读
1、pc 指向的是函数的第一条指令;
2、argumentBase 指向的是第一个参数;
3、while+switch 一条条指令往下执行,具体指令的操作在 case 那;

argumentBase 指向的是求值栈该函数的栈帧,栈帧是这么安排的:

先放参数(如果有的话),再放本地变量(如果有的话),接着是临时区域,当函数返回时弹掉所有东西,如果有返回值就放到栈顶(函数执行前参数 0 的位置)。

用如下一个静态方法来演示下虚拟机怎么运行:
public static float Add(float a, float b)
{

return a - b;

}
这函数编译后是这四条指令

Add 函数的执行过程
1、指令 1 把参数 0 Push 到栈顶;
2、指令 2 把参数 1 Push 到栈顶;
3、指令 3 把两个栈顶元素弹出(Pop)并相加,结果 Push 到栈顶;
4、指令 4 把栈顶拷贝到参数 0 的位置,清理栈,退出循环,Execute 函数执行结束。

代码注入
上面的 Add 函数注入后是这样的
public static float Add(float a, float b)
{

if (WrappersManagerImpl.IsPatched(92))
{return WrappersManagerImpl.GetPatch(92).__Gen_Wrap_25(a, b);
}
return a - b;

}
比较简单,发现这函数有 patch 的话,就重定向到虚拟机。
而__Gen_Wrap_25 是个适配器函数,赋值把参数压栈,调用虚拟机的 Execute 函数,并把结果返回。__Gen_Wrap_25 的实现如下:
public float __Gen_Wrap_25(float P0, float P1)
{

Call call = Call.Begin();
call.PushSingle(P0);
call.PushSingle(P1);
this.virtualMachine.Execute(this.methodId, ref call, 2, 0);
return call.GetSingle(0);

}

PS:我们的例子仅有三种指令,和这几条指令无关的代码全部简化了,真正复杂得多,有兴趣可以看源码了解。

关于开源
闭门造车很难做出好项目。需要用心聆听,根据反馈不断的改进自己。而开源,能够听到更多的声音,也能更好的改进这个项目。

总结下
InjectFix 使用简单,小巧,合规且安全。即使你不打算用它来更新线上版本,只要你程序有原生部分,接入也能一定程度上提高开发效率,没什么拒绝它的理由,是吧?

退出移动版