InjectFix實現(xiàn)原理

Tags: C#, Unity, 熱更新

簡介

InjectFix是騰訊開源的Unity C#熱更新解決方案。本文主要介紹InjectFix的相關(guān)內(nèi)容,從手把手的一個例子來介紹如何使用InjectFix,一直到閱讀源碼來分析它的內(nèi)部實現(xiàn)原理。

項目主頁:

https://github.com/Tencent/InjectFix

原理介紹(原作者):

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

如何使用InjectFix

這里我們會從一個空項目開始,介紹如何使用InjectFix。并根據(jù)這個例子做引子來進行它的原理分析。

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

這里InjectFix的使用說明主要是參考Github上面的官方幫忙文檔:

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

本例中使用的開發(fā)環(huán)境如下:

  • 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環(huán)境的編譯腳本為 build_for_unity.bat ,Mac環(huán)境為 build_for_unity.sh ,需要先修改該編譯腳本的UNITY_HOME值,將其修改為本機Unity編輯器的安裝目錄。

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

然后執(zhí)行編譯腳本即可開始編譯。

$ 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

接下來我們創(chuàng)建一個新的項目,并將InjectFix的如下文件夾拷貝到我們的項目根目錄。

  • 項目根目錄
    • IFixToolKit ← InjectFix/Source/UnityProj/IFixToolKit
    • Assets
      • IFix ← InjectFix/Source/UnityProj/Assets/IFix
      • Plugins ← InjectFix/Source/UnityProj/Assets/Plugins

拷貝后則會發(fā)現(xiàn)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進行自動插樁。(注意編輯器需要處在非運行狀態(tài)才可進行注入)。

運行這個菜單工具后,這時IFix會根據(jù)我們提供的Config文件去給這些注冊的類里面的每個方法插樁,它會直接修改 <ProjectRoot>\Library\ScriptAssemblies\Assembly-CSharp.dll 這個文件,正常注入后即可得到一個擁有熱更新能力的DLL文件。

生成補丁

在打包完成后,例如需要對某個函數(shù)進行熱修復(fù),那么我們需要來制作補丁。

例如我們?nèi)缦潞瘮?shù)進行修復(fù),將FuncA的返回值從 "Old" 修改為 ”New“,那么需要將需要打補丁的函數(shù)打上 [Patch] 的注解來告訴IFix我們希望給該函數(shù)打補丁。

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

然后運行編輯器菜單 【InjectFix】-【Fix】來對生成補丁,生成的補丁會保存在項目根目錄的,文件名為: Assembly-CSharp.patch.bytes, 這是一個二進制的il字節(jié)碼。

將補丁文件移動到我們想要放置補丁的目錄下,使用如下代碼即可自動加載和應(yīng)用這些補?。?/p>

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);
}

為了在編輯器里面實驗,這里我們需要把代碼回滾一下,回復(fù)到補丁之前的版本來驗證熱更新是否有效,如下:

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

這時在編輯器里運行,我們會發(fā)現(xiàn)控制臺輸出 FuncA 函數(shù)的輸出值為 Old 。

然后我們再次點擊菜單 【InjectFix】- 【Inject】 來進行插樁,再次運行則會發(fā)現(xiàn)控制臺的輸出會變成 New

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

這里我們就成功的使用InjectFix進行了C#代碼的熱更新。接下來我們會深入源碼中來了解InjectFix的具體實現(xiàn)原理。

原理分析

IFix的原理主要包括兩個部分:

  1. 自動插樁,首先在代碼里面插樁,進入這些的函數(shù)的時候判斷是否需要熱更新,如果需要則直接跳轉(zhuǎn)去執(zhí)行熱更新補丁中的IL指令。
  2. 生成補丁,將需要熱更新的代碼生成為IL指令。

技術(shù)難點在于去實現(xiàn)一個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";
    }
}

可以看到每個函數(shù)都增加一個if判斷的插樁,用來判斷這個方法是否需要熱更新的版本,如果有則直接跳轉(zhuǎn)去執(zhí)行熱更新的代碼,否則正常執(zhí)行該方法的原代碼。

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

其中判斷是否有patch以及獲取patch都是由IFix生成的代碼來實現(xiàn)的,如下:(生成這段代碼的源碼在: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];
        }
    }
}

調(diào)用patch的代碼,實現(xiàn)如下:

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;
    }
}

這里我們看到熱更新的邏輯就是將參數(shù)入棧,然后調(diào)用IFix實現(xiàn)的il虛擬機( VirtualMachine ) 來執(zhí)行這個函數(shù)。

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

這個VirtualMachine虛擬機是由加載補丁的時候 PatchManager.Load函數(shù)創(chuàng)建的:

public static class PatchManager
{
    unsafe static public VirtualMachine Load(Stream stream, bool checkNew = true)
    {
        
        // ...
        // stream是二進制的補丁,里面放著熱更新代碼的IL指令,該二進制文件格式參考后面章節(jié)
        BinaryReader reader = new BinaryReader(stream);
        // 這里會將二進制的補丁文件的所有熱更新的方法定義及IL指令都讀出來
        // 并把所有指令都保存到unmanagedCodes變量中,傳給 VirtualMachine 構(gòu)造函數(shù)。
        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
                };
        // ...
    }
}

創(chuàng)建虛擬機方法如下:

internal VirtualMachine(Instruction** unmanaged_codes, Action on_dispose);
  • 參數(shù)1: 熱修復(fù)的所有函數(shù)及其IL指令。
  • 參數(shù)2:當虛擬機被消耗時,用于釋放相關(guān)內(nèi)存的析構(gòu)函數(shù)。

執(zhí)行熱更新的代碼,主要通過調(diào)用 VirtualMachineExecute 函數(shù)來實現(xiàn)的,這個方法會直接去執(zhí)行熱更新補丁中這個函數(shù)的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指令,關(guān)于IL的說明可查看wiki:https://en.wikipedia.org/wiki/Common_Intermediate_Language

補丁格式

參考源碼:Source\VSProj\Src\Builder\FileVirtualMachineBuilder.cs

補丁二進制文件格式

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

  • xLua地址:傳送門[https://github.com/Tencent/xLua] Xlua是啥?2016年 ...
    APP4x閱讀 10,986評論 0 4
  • 本分享的想法源于看了這篇分享由于在對Unity項目后期進行l(wèi)ua熱更新方案實施, 我也不想造成源代碼的修改, 故在...
    蘇三瘋閱讀 10,814評論 7 34
  • 如果你看完書中的所有例子,你很可能已經(jīng)做完你的實驗和在已經(jīng)越獄的iPhone上的研究。因為和許多人一樣,幾乎所有的...
    fishmai0閱讀 17,441評論 2 42
  • 久違的晴天,家長會。 家長大會開好到教室時,離放學(xué)已經(jīng)沒多少時間了。班主任說已經(jīng)安排了三個家長分享經(jīng)驗。 放學(xué)鈴聲...
    飄雪兒5閱讀 7,868評論 16 22
  • 今天感恩節(jié)哎,感謝一直在我身邊的親朋好友。感恩相遇!感恩不離不棄。 中午開了第一次的黨會,身份的轉(zhuǎn)變要...
    余生動聽閱讀 10,916評論 0 11

友情鏈接更多精彩內(nèi)容