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的原理主要包括兩個部分:
- 自動插樁,首先在代碼里面插樁,進入這些的函數(shù)的時候判斷是否需要熱更新,如果需要則直接跳轉(zhuǎn)去執(zhí)行熱更新補丁中的IL指令。
- 生成補丁,將需要熱更新的代碼生成為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)用 VirtualMachine 的 Execute 函數(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