InjectFix实现原理

Tags: C#, Unity, 热更新

简介

InjectFix是腾讯开源的Unity C#热更新解决方案。本文主要介绍InjectFix的相关内容,从手把手的一个例子来介绍如何使用InjectFix,一直到阅读源码来分析它的内部实现原理。

项目主页:

https://github.com/Tencent/InjectFix

原理介绍(原作者):

https://www.oschina.net/news/109803/injectfix-opensource

如何使用InjectFix

这里我们会从一个空项目开始,介绍如何使用InjectFix。并根据这个例子做引子来进行它的原理分析。

本文的例子的源码都在:https://github.com/sandin/InjectFixSample

这里InjectFix的使用说明主要是参考Github上面的官方帮忙文档:

https://github.com/Tencent/InjectFix/blob/master/Doc/quick_start.md

本例中使用的开发环境如下:

  • macOS Big Sur 11.1
  • Unity 2019.4.17f1c1 (安装目录:/Applications/Unity/Hub/Editor/2019.4.17f1c1/Unity.app)

接入InjectFix

第一步就是讲InjectFix的源码clone到本地:

$ git clone git@github.com:Tencent/InjectFix.git

然后准备开始编译源码,windows环境的编译脚本为 build_for_unity.bat ,Mac环境为 build_for_unity.sh ,需要先修改该编译脚本的UNITY_HOME值,将其修改为本机Unity编辑器的安装目录。

例如本例中我们修改 build_for_unity.sh 中的
UNITY_HOME="/Applications/Unity/Hub/Editor/2019.4.17f1c1/Unity.app"

然后执行编译脚本即可开始编译。

$ cd InjectFix/VsProj
$ ./build_for_unity.sh

这个编译脚本会使用Unity自带的Mono编译器,将源码中的一些CS脚本进行编译,并生成一些CS脚本,最后编译出IFix的核心库 IFix.Core.dll ,这个库就是唯一需要接入到项目中去的热更新库。

编译成功后会生成如下几个文件:

  • Source/UnityProj/Assets/Plugins/IFix.Core.dll
  • Source/UnityProj/IFixToolkit/IFix.exe
  • Source/UnityProj/IFixToolkit/IFix.exe.mdb
  • Source/VSProj/Instruction.cs
  • Source/VSProj/ShuffleInstruction.exe

接下来我们创建一个新的项目,并将InjectFix的如下文件夹拷贝到我们的项目根目录。

  • 项目根目录
    • IFixToolKit ← InjectFix/Source/UnityProj/IFixToolKit
    • Assets
      • IFix ← InjectFix/Source/UnityProj/Assets/IFix
      • Plugins ← InjectFix/Source/UnityProj/Assets/Plugins

拷贝后则会发现Unity编辑器的菜单栏增加了 【InjectFix】菜单。

然后我们新建一个C#脚本文件,作为热更新的实验,代码如下:

using System.Collections;
using System.Collections.Generic;
using System.IO;
using IFix;
using IFix.Core;
using UnityEngine;

public class NewBehaviourScript : MonoBehaviour
{
    void Start()
    {
        string text = Path.Combine(Application.streamingAssetsPath, "Assembly-CSharp.patch.bytes");
        bool flag = File.Exists(text);
        if (flag)
        {
            Debug.Log("Load HotFix, patchPath=" + text);
            PatchManager.Load(new FileStream(text, FileMode.Open), true);
        }
    }

    void Update()
    {
    }

    void OnGUI()
    {
        if (GUI.Button(new Rect((Screen.width - 200) / 2, 20, 200, 100), "Call  FuncA"))
        {
            Debug.Log("Button, Call FuncA, result=" + FuncA());
        }
    }

    public string FuncA()
    {
        return "Old";
    }
}

然后通过提供Config文件,告诉IFix我们可能需要热更新的类有哪些(必须放到Editor目录下)。

using System;
using System.Collections.Generic;
using IFix;

[Configure]
public class InterpertConfig
{
    [IFix]
    static IEnumerable<Type> ToProcess
    {
        get
        {
            return new List<Type>() {
                typeof(NewBehaviourScript),
            };
        }
    }
}

正常运行程序,点击按钮,会看到控制台输出 FuncA 的返回值为字符串 Old .

Unity会将我们的C#代码编译成DLL文件,路径为:<ProjectRoot>\Library\ScriptAssemblies\Assembly-CSharp.dll。此时这个DLL文件是还未进行任何插桩修改的,也就是暂时还没有热更新能力的。

在正式打包之前需要运行编辑器菜单 【InjectFix】-【Inject】来对我们的DLL进行自动插桩。(注意编辑器需要处在非运行状态才可进行注入)。

运行这个菜单工具后,这时IFix会根据我们提供的Config文件去给这些注册的类里面的每个方法插桩,它会直接修改 <ProjectRoot>\Library\ScriptAssemblies\Assembly-CSharp.dll 这个文件,正常注入后即可得到一个拥有热更新能力的DLL文件。

生成补丁

在打包完成后,例如需要对某个函数进行热修复,那么我们需要来制作补丁。

例如我们如下函数进行修复,将FuncA的返回值从 "Old" 修改为 ”New“,那么需要将需要打补丁的函数打上 [Patch] 的注解来告诉IFix我们希望给该函数打补丁。

public class NewBehaviourScript : MonoBehaviour
{
        [Patch]
    public string FuncA()
    {
        return "New";
    }
}

然后运行编辑器菜单 【InjectFix】-【Fix】来对生成补丁,生成的补丁会保存在项目根目录的,文件名为: Assembly-CSharp.patch.bytes, 这是一个二进制的il字节码。

将补丁文件移动到我们想要放置补丁的目录下,使用如下代码即可自动加载和应用这些补丁:

string text = Path.Combine(Application.streamingAssetsPath, "Assembly-CSharp.patch.bytes");
bool flag = File.Exists(text);
if (flag)
{
    Debug.Log("Load HotFix, patchPath=" + text);
    PatchManager.Load(new FileStream(text, FileMode.Open), true);
}

为了在编辑器里面实验,这里我们需要把代码回滚一下,回复到补丁之前的版本来验证热更新是否有效,如下:

public class NewBehaviourScript : MonoBehaviour
{
    public string FuncA()
    {
        return "Old";
    }
}

这时在编辑器里运行,我们会发现控制台输出 FuncA 函数的输出值为 Old

然后我们再次点击菜单 【InjectFix】- 【Inject】 来进行插桩,再次运行则会发现控制台的输出会变成 New

Load HotFix, patchPath=/Users/liudingsan/project/unity/IFixTest/IFixTest/Assets/StreamingAssets/Assembly-CSharp.patch.bytes
Button, Call FuncA, result=New

这里我们就成功的使用InjectFix进行了C#代码的热更新。接下来我们会深入源码中来了解InjectFix的具体实现原理。

原理分析

IFix的原理主要包括两个部分:

  1. 自动插桩,首先在代码里面插桩,进入这些的函数的时候判断是否需要热更新,如果需要则直接跳转去执行热更新补丁中的IL指令。
  2. 生成补丁,将需要热更新的代码生成为IL指令。

技术难点在于去实现一个IL运行时的虚拟机,支持所有的IL指令。

自动插桩

插桩的入口在菜单 【InjectFix】-【Inject】,源码在:Source/UnityProj/Assets/IFix/Editor/ILFixEditor.cs

                [MenuItem("InjectFix/Inject", false, 1)]
        public static void InjectAssemblys()
        {
            if (EditorApplication.isCompiling || Application.isPlaying)
            {
                UnityEngine.Debug.LogError("compiling or playing");
                return;
            }
            EditorUtility.DisplayProgressBar("Inject", "injecting...", 0);
            try
            {
                InjectAllAssemblys();
            }
            catch(Exception e)
            {
                UnityEngine.Debug.LogError(e);
            }
            EditorUtility.ClearProgressBar();
                }

InjectAllAssemblys./Library/ScriptAssemblies 目录下的两个dll文件进行注入:

  • Assembly-CSharp.dll
  • Assembly-CSharp-firstpass.dll
                /// <summary>
        /// 对指定的程序集注入
        /// </summary>
        /// <param name="assembly">程序集路径</param>
        public static void InjectAssembly(string assembly)
        {
                }

反编译它可以看到它给原代码进行了插桩,修改如下:

public class NewBehaviourScript2 : MonoBehaviour
{
    private void Start()
    {
        if (WrappersManagerImpl.IsPatched(16))
        {
            WrappersManagerImpl.GetPatch(16).__Gen_Wrap_0(this);
            return;
        }
        string text = Path.Combine(Application.streamingAssetsPath, "Assembly-CSharp.patch.bytes");
        bool flag = File.Exists(text);
        if (flag)
        {
            Debug.Log("Load HotFix, patchPath=" + text);
            PatchManager.Load(new FileStream(text, FileMode.Open), true);
        }
    }

    private void Update()
    {
        if (WrappersManagerImpl.IsPatched(17))
        {
            WrappersManagerImpl.GetPatch(17).__Gen_Wrap_0(this);
            return;
        }
    }

    private void OnGUI()
    {
        if (WrappersManagerImpl.IsPatched(18))
        {
            WrappersManagerImpl.GetPatch(18).__Gen_Wrap_0(this);
            return;
        }
        bool flag = GUI.Button(new Rect((float)((Screen.width - 200) / 2), 20f, 200f, 100f), "Call FuncA");
        if (flag)
        {
            Debug.Log("Button, Call FuncA, result=" + this.FuncA());
        }
    }

    public string FuncA()
    {
        if (WrappersManagerImpl.IsPatched(19))
        {
            return WrappersManagerImpl.GetPatch(19).__Gen_Wrap_5(this);
        }
        return "Old";
    }
}

可以看到每个函数都增加一个if判断的插桩,用来判断这个方法是否需要热更新的版本,如果有则直接跳转去执行热更新的代码,否则正常执行该方法的原代码。

if (WrappersManagerImpl.IsPatched(19))
{
    return WrappersManagerImpl.GetPatch(19).__Gen_Wrap_5(this);
}

其中判断是否有patch以及获取patch都是由IFix生成的代码来实现的,如下:(生成这段代码的源码在:https://github.com/Tencent/InjectFix/blob/master/Source/VSProj/Src/Tools/CodeTranslator.cs

namespace IFix 
{
    public class WrappersManagerImpl : WrappersManager
    {
        public static bool IsPatched(int id)
        {
            return id < ILFixDynamicMethodWrapper.wrapperArray.Length && ILFixDynamicMethodWrapper.wrapperArray[id] != null;
        }

        public static ILFixDynamicMethodWrapper GetPatch(int id)
        {
            return ILFixDynamicMethodWrapper.wrapperArray[id];
        }
    }
}

调用patch的代码,实现如下:

namespace IFix
{
    public class ILFixDynamicMethodWrapper
    {
        public string __Gen_Wrap_5(object P0)
        {
            Call call = Call.Begin();
            if (this.anonObj != null)
            {
                call.PushObject(this.anonObj);
            }
            call.PushObject(P0);
            this.virtualMachine.Execute(this.methodId, ref call, (this.anonObj != null) ? 2 : 1, 0);
            return call.GetAsType<string>(0);
        }

        private VirtualMachine virtualMachine;
    }
}

这里我们看到热更新的逻辑就是将参数入栈,然后调用IFix实现的il虚拟机( VirtualMachine ) 来执行这个函数。

这里的VirtualMachine是由接入项目中的 Assets\Plugins\IFix.Core.dll 提供的,源码在:https://github.com/Tencent/InjectFix/blob/master/Source/VSProj/Src/Core/VirtualMachine.cs

这个VirtualMachine虚拟机是由加载补丁的时候 PatchManager.Load函数创建的:

public static class PatchManager
{
    unsafe static public VirtualMachine Load(Stream stream, bool checkNew = true)
    {
        
        // ...
        // stream是二进制的补丁,里面放着热更新代码的IL指令,该二进制文件格式参考后面章节
        BinaryReader reader = new BinaryReader(stream);
        // 这里会将二进制的补丁文件的所有热更新的方法定义及IL指令都读出来
        // 并把所有指令都保存到unmanagedCodes变量中,传给 VirtualMachine 构造函数。
        unmanagedCodes = (Instruction**)nativePointer.ToPointer(); 
        
        var virtualMachine = new VirtualMachine(unmanagedCodes, () =>
                {
                    for (int i = 0; i < nativePointers.Count; i++)
                    {
                        System.Runtime.InteropServices.Marshal.FreeHGlobal(nativePointers[i]);
                    }
                })
                {
                    ExternTypes = externTypes,
                    ExternMethods = externMethods,
                    ExceptionHandlers = exceptionHandlers.ToArray(),
                    InternStrings = internStrings,
                    FieldInfos = fieldInfos,
                    AnonymousStoreyInfos = anonymousStoreyInfos,
                    StaticFieldTypes = staticFieldTypes,
                    Cctors = cctors
                };
        // ...
    }
}

创建虚拟机方法如下:

internal VirtualMachine(Instruction** unmanaged_codes, Action on_dispose);
  • 参数1: 热修复的所有函数及其IL指令。
  • 参数2:当虚拟机被消耗时,用于释放相关内存的析构函数。

执行热更新的代码,主要通过调用 VirtualMachineExecute 函数来实现的,这个方法会直接去执行热更新补丁中这个函数的IL指令:

public void Execute(int methodIndex, ref Call call, int argsCount, int refCount = 0)
{
    Execute(unmanagedCodes[methodIndex], call.argumentBase + refCount, call.managedStack,
                call.evaluationStackBase, argsCount, methodIndex, refCount, call.topWriteBack);
}

public Value* Execute(Instruction* pc, Value* argumentBase, object[] managedStack,
            Value* evaluationStackBase, int argsCount, int methodIndex,
            int refCount = 0, Value** topWriteBack = null)
{
    // ...
}

这里传参的pc就直接是热更新代码的IL指令,关于IL的说明可查看wiki:https://en.wikipedia.org/wiki/Common_Intermediate_Language

补丁格式

参考源码:Source\VSProj\Src\Builder\FileVirtualMachineBuilder.cs

补丁二进制文件格式

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 162,408评论 4 371
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 68,690评论 2 307
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 112,036评论 0 255
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,726评论 0 221
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 53,123评论 3 296
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 41,037评论 1 225
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 32,178评论 2 318
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,964评论 0 213
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,703评论 1 250
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,863评论 2 254
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,333评论 1 265
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,658评论 3 263
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,374评论 3 244
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,195评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,988评论 0 201
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 36,167评论 2 285
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,970评论 2 279

推荐阅读更多精彩内容

  • xLua地址:传送门[https://github.com/Tencent/xLua] Xlua是啥?2016年 ...
    APP4x阅读 10,469评论 0 4
  • 本分享的想法源于看了这篇分享由于在对Unity项目后期进行lua热更新方案实施, 我也不想造成源代码的修改, 故在...
    苏三疯阅读 9,731评论 7 34
  • 说明 此项目是awesome-dotnet-core[https://github.com/thangchung/...
    NicoSaron阅读 4,117评论 0 9
  • 如果你看完书中的所有例子,你很可能已经做完你的实验和在已经越狱的iPhone上的研究。因为和许多人一样,几乎所有的...
    fishmai0阅读 15,334评论 2 42
  • 久违的晴天,家长会。 家长大会开好到教室时,离放学已经没多少时间了。班主任说已经安排了三个家长分享经验。 放学铃声...
    飘雪儿5阅读 7,402评论 16 21