InjectFix——C#热修复方案分析 & 使用流程

InjectFix——C#热修复方案分析 & 使用流程

一、简介

InjectFix是一个基于Unity的C#代码热修复的实现方案(支持IL2CPP)。和XLua热更方案是同一位作者。

与各类lua解决方案和ILRutime等传统主流实现方式相比,最大区别在于InjectFix在正常情况下是执行原生代码,打补丁后需要修复的部分才局部重定向到IL虚拟机解释执行。(ILRutime是全程解释执行C#编译后的dll,Lua本身就是解释型语言)具有以下优缺点:

  • 优点
    • 运行效率高,由于大部分时候都是执行原生C#(编译型语言)代码,效率相较解释型方案更加优秀
    • 接入简单,侵入性小,后期项目也可以使用。ILRuntime需要分离逻辑代码,Lua型方案可能要全项目重做了
    • 直接在原代码上即可完成修复,不像XLua的hotfix功能,要用Lua重新写一遍实现
  • 缺点
    • 会导致代码段增大,增大的量正比于注入的类的数量
    • 虽然后期也开放了新增功能,但使用起来有诸多限制,性能也可能出现问题(下文详述)且不符合苹果审核规则,并不适合做新增功能。
二、接入流程
  • 接入前准备
    • 建议下载最新版本的源码
      GitHub地址 https://github.com/Tencent/InjectFix
      作者更新很活跃,常有修复bug的更新
    • 阅读源码中ReadMe及Doc下文档(本人文档只是针对某些点补充说明,原文档还是更有阅读价值)
  • 接入流程
    • 打开源码包的Source\VSProj\build_for_unity.bat脚本,将UNITY_HOME变量的值修改为本机unity的安装目录(到Editor上一级目录即可),修改完成后执行该bat脚本
      InjectFix——C#热修复方案分析 & 使用流程_第1张图片
    • 上述bat脚本运行的结果会生产到./Source/UnityProj/目录下(对应的是一个Unity工程目录)
      • IFixToolKit拷贝到自己Unity项目的Assets同级目录
      • Assets/IFix,Assets/Plugins拷贝到自己Unity项目的Assets下
  • 接入结果验证
    • Assets同级目录IFixToolKit下五个文件
      InjectFix——C#热修复方案分析 & 使用流程_第2张图片
    • Assets目录中
      InjectFix——C#热修复方案分析 & 使用流程_第3张图片
    • Unity菜单栏上InjectFix正常生成,没有报错
  • 源码中建议修改的地方
    • IFixEditor.cs脚本中AutoInjectAssemblys加了[PostProcessScene]特性
      会在BuildSettings界面Build打包的时候自动调用注入方法。我们的项目一般都是脚本自动化打包的,可以注释掉[PostProcessScene]标签或者修改AutoInject字段禁用自动打包,手动调用InjectAllAssemblys方法进行inject
三、使用说明
1.功能简介

InjectFix的功能特性如下(摘抄自源码文档)

标签 使用阶段 用途 用法
[IFix.Patch] 补丁 修复函数 只能放在函数上
[IFix.Interpret] 补丁 新增属性,函数,类型 放在属性,函数,类型上
[IFix.CustomBridge] 注入 interface和delegate桥接 只能放在单独写一个静态类上,存储虚拟机的类适配到原生interface或者虚拟机的函数适配到原生delegate,该类不能放Editor目录
[Configure] 注入 配置类 只能放在单独写一个存放在Editor目录下的类上
[IFix] 注入 可能需要修复函数的类的集合 只能放在[Configure]类的一个静态属性上
[Filter] 注入 不想发生注入的函数 只能放在[Configure]类的一个静态函数上
2.使用流程简介
  • 初次使用前一次性配置
    • 在Editor下新建一个配置类打上[configure]特性
      • 使用[IFix]特性标记可能有热修复需求的类
      • 使用[Filter]特性可以过滤掉标记[IFix]的类中部分方法,按需使用,非必须步骤
      • 使用[CustomBridge]特性标记可能需要修复的Interface和delegate,按需使用,非必须步骤
      • 实现加载补丁流程,补丁其实就是个.byte的资源,以TextAsset格式加载
  • 后续使用循环以下步骤
    • 打母包前Inject(MenuItem中选择或自动化脚本中调用InjectAllAssemblys方法)
    • 线上出现bug后,修改代码,给修复后的方法打上[patch]标签
    • Fix得到补丁文件,进行热更 (这个步骤可重复多次)
    • 下次打母包前删除所有[patch]标签,重新Inject

tips:该流程存在的一个问题是,InjectFix的补丁是差异累计型的,即每个补丁包含所有打母包之后修改的内容,官方没有提供补丁版本管理的解决方案,如果有外网要存在多个不同版本的母包的需求,需要考虑如何管理[patch]标签。

3.注意事项 & 代码参考
  • 加载补丁
    • 注意事项
      • 补丁默认路径生成到项目Assets同级文件夹下,文件名为Assembly-CSharp.patch.byte
      • 加载为TextAsset格式(如果有补丁的话),使用InjectFix中的PatchManager.Load()方法加载即可生效
    • 代码参考
//这里使用Resources.Load简单演示
var patch = Resources.Load<TextAsset>("Assembly-CSharp.patch");
if (patch != null)
{
	//如果加载到补丁 使用PatchManager.Load加载即可生效
	PatchManager.Load(new MemoryStream(patch.bytes));
}
  • [Configure] & [IFix] & [Filter]
    由于是Editor下的脚本,可以灵活使用LINQ和反射等十分方便的C#语言特性,详见下方代码
    • 注意事项
      • [Configure] 特性只能用在类上
      • [IFix]特性只能用在属性上,作为[Configure]类中的一个静态属性,通过get返回可能需要修复的类的集合
      • [Filter]标签只能用在方法上,作为[Configure]类中的一个静态方法,返回的bool值代表传入的函数是否需要被过滤。
      • 打上[Configure]标签的类,必须放在Editor目录下。 [IFix],[Filter]这些标签必须放在打上[Configure]标签的类里。(这里的格式基本是定死的,直接复制修改即可)
    • 代码参考
[Configure]
public class TestCfg
{
    [IFix]
    static IEnumerable<Type> hotfix
    {
        get
        {
        	//单个类逐个配置的写法
            //return new List()
            //{
              	//typeof(Test)
            //};

			//LinQ + 反射批量反射某个命名空间下的多个类的写法
            return (from type in Assembly.Load("Assembly-CSharp").GetTypes()
                    where type.Namespace == "XXXX"
                    select type).ToList()
                    );
            };
    }
    [Filter]
    static bool Filter(System.Reflection.MethodInfo methodInfo)
    {
    	//过滤掉Test类中的Div和Mult方法
        return methodInfo.DeclaringType.FullName == "Test" 
            && (methodInfo.Name == "Div" || methodInfo.Name == "Mult");
    }
}
  • [IFix.CustomBridge]
    • 注意事项

      • 该标签只能用在类上,该配置类不能放到Editor目录,且不能内嵌在另一个类里面
      • 以下情况需要使用[CustomBridge]
        • Patch
          • 修复代码赋值一个闭包到一个delegate变量(该delegate变量需要加入[CustomBridge] )
          • 修复代码的Unity协程用了yield return(IEnumerator需要加入[CustomBridge] )
        • Interpret
          • 新增一个函数,赋值到一个delegate变量(该delegate变量需要加入[CustomBridge] )
          • 新增一个类,实现原有的Interface(该Interface需要加入[CustomBridge] )
          • 新增协程,用了yield return(IEnumerator需要加入[CustomBridge] )
    • 代码参考

//新增一个类,实现原有的Interface
public interface ISubSystem
{
	bool running { get; }
    void Print();
}

[IFix.Interpret]
public class SubSystem : ISubSystem
{
    public bool running { get { return true; } }
    public void Print()
    {
        UnityEngine.Debug.Log("SubSystem1.Print");
    }
}

//​新增函数(或者修复代码[IFix.Patch]的Unity协程),用到了 yield return 
[IFix.Interpret]
public IEnumerator TestInterface()
{
    yield return new WaitForSeconds(1);
    UnityEngine.Debug.Log("wait one second");
}

//​新增函数(或者修复代码[IFix.Patch]),赋值到一个delegate变量
public class Test 
{
    public delegate int MyDelegate(int a, int b);//这个delegate是原有的
    
    [IFix.Interpret]
    public MyDelegate TestDelegate()
    {
    	//该方法使用lambda表达式(闭包的一种形式)给MyDelegate变量赋值
        return (a,b) => a + b;
    }
}

//如果需要进行类似上述的改动 必须在inject前在[CustomBridge]的类中进行配置
[IFix.CustomBridge]
public static class AdditionalBridge
{
    static List<Type> bridge = new List<Type>()
    {
        typeof(ISubSystem),
        typeof(IEnumerator),
        typeof(Test.MyDelegate)
    };
}
  • [IFix.Patch]
    • 注意事项
      • 该特性只能用在方法上
      • 需要修复的方法的类必须在[Configure]中添加过配置
      • 在[IFix.Patch]时,不支持修复泛型函数,不支持修复构造函数
    • 代码参考
//外网发现了C#侧的bug
public int Add(int a,int b)
{
	//错误的逻辑
    return a*b;
}

//在原方法上修改正确 添加[IFix.Patch]标签
[IFix.Patch]
public int Add(int a,int b)
{
	//正确的逻辑
    return a+b;
}
  • [IFix.Interpret]
    • 注意事项
      • 该标签可以用在属性,方法,类型上,直接在要新增的代码上面标注一下这个标签即可。
      • 在[IFix.Interpret]时,不支持新增类继承原生类,不支持新增类是泛型类。
      • 仅支持新增属性(Property),不支持新增字段(Field),即使在新增类中也不行。另外需要注意的是C#中的Property是个语法糖,新增Property时,如果不存在对应Field,是会隐式生成Field的,所以本质上还是会生产Field导致报错
    • 代码参考
//新增一个属性
private string name;//这个name字段是在inject时就存在的

//[IFix.Interpret] 要打在下面的set & get方法上 打在这是无效的
public string Name
{
    [IFix.Interpret]
    set
    {
    	name = value;    
    }
    [IFix.Interpret]
    get
    {
        return name;
    }
}

//​新增一个函数
[IFix.Interpret]
public int Sub(int a,int b)
{
    return a-b;
}//新增一个类

[IFix.Interpret]
public class NewClass
{
	//private string name; //新增类中新增字段也是无效的
    ...
}
四、性能评估

下面对InjectFix可能会造成的性能方面的影响进行测试

  • 注入方法造成的代码段增大情况
    • 测试过程:对开源前端项目下所有的C#类进行注入,反编译看到的总计注入并动态Wrapper了方法440个
    • 测试结果
Assembly-CSharp.dll 大小
Inject前 712KB
Inject后 997KB

可见,注入代码还是会明显造成代码块的增大,增加的大小和注入方法的数量基本成正比,建议挑选可能会出现bug的代码选择性注入,不要给全部C#代码注入

  • 函数注入代码前后性能对比
    • 测试过程:选择了性能比较敏感的Vector3类型,执行同样的加法计算,执行一百万次,比较以下三种状态下的耗时
    • 测试结果(测试数据来自小米Note3低端测试机,同时也在IPhone X测试了,具体数据不同,但结论相同)
函数 耗时(执行一百万次) 结果分析
原生函数,使用[Filter]过滤,不进行代码注入 51ms C#原生代码,作为对照组
注入代码之后 58ms 相较原生代码,多了一步数组中能否取到的判断,对性能影响不大(一百万次执行才看出差别)
Patch热修复之后 36015ms 由于InjectFix设计理念是有问题才修复,没有预先wrapper任何代码,依赖反射执行开销较大

反编译dll看到注入前后代码的变化如下:

//原生不注入的方法
public Vector3 NoInjectFunc(Vector3 a, Vector3 b)
{
	return a + b;
}

//注入后反编译dll得到的方法 在原方法的开头注入了IsPatched的判断
public int InjectFunc(int a, int b)
{
	//如果补丁中有该函数的实现,就到虚拟机解释执行
	if (WrappersManagerImpl.IsPatched(3))
	{
		return WrappersManagerImpl.GetPatch(3).__Gen_Wrap_1(this, a, b);
	}
	return a + b;
}

真机Profiler中的数据如下

  • 加载修复补丁前
    InjectFix——C#热修复方案分析 & 使用流程_第4张图片
    图中可以看到加载补丁前,相较没有代码注入的原生方法,只多了一个是否有补丁判断的开销
  • 加载修复补丁后
    InjectFix——C#热修复方案分析 & 使用流程_第5张图片
    可以看到加载补丁解释执行后,方法的耗时和gc都有大幅度的增长。

综上,单纯的Inject代码造成的运行效率开销基本可以忽略,但在Patch代码时要特别注意,避开频繁调用的函数(比如如果Patch了Update函数,Update下的所有方法都会到虚拟机解释执行),修复敏感函数后,最好到真机环境下检查运行效率是否能接受。

五、Demo工程简介

Demo工程简单模拟了正式环境热更的流程(省略了版本验证和资源MD5校验的过程)

  • 打母包的时候Inject为需要热更的C#类注入代码
  • 修复/新增代码后Fix得到.byte补丁文件
  • 将.byte补丁文件打入AB包,上传资源服务器,推到CDN服务器
  • 通过Http请求从CDN服务器下载AB包,解包加载补丁文件
    得到热更后的效果

Demo工程截屏:
InjectFix——C#热修复方案分析 & 使用流程_第6张图片
tips:
一旦修改了代码(触发了重新编译),就会生成新的未注入的 dll覆盖掉之前已经注入的dll,故如果想在Editor下测试加载补丁后的效果,应该先Fix后Inject,流程应当如下:

  • 修复代码,打上[Patch]标签,Fix生成补丁
  • 把代码还原到修复前的状态(去除[Patch]标签),模拟错误的版本
  • 执行Inject
  • 加载上述步骤中生成的补丁
六、总结

作者介绍的他们腾讯内部团队的使用情况是XLua + InjectFix,injectFix代替XLua的HotFix进行原生C#代码的热修复(需关闭XLua的Hotfix功能,注入方法的实现相同会冲突)

InjectFix方案主要应用还是用于目前主流C# + Lua的项目中原生C#代码的热修复,减少上线后由于C#侧的bug,被迫换包的情况。靠InjectFix实现纯C#开发热更还是不实际的

你可能感兴趣的:(开发笔记,unity3d,游戏,游戏开发,游戏引擎)