有時會碰到外部 Dll 或是其他專案成員寫的框架,想在不破壞原始碼為前提更改邏輯,Code Injection
就能派上用場。
問題點 當然,前言只是以前困擾我的問題(以前實作的時候一直報錯),最近是因為我的練槍軟體專案 ALM 的回放機能一直難產,因為使用 PuerTs 給予熱更,本意是讓任務撰寫的自由度變高,但缺點就是沒辦法更好的捕捉程式碼步驟。
考慮過使用 Command
跟 Event Bus
的模式,多個中介層來記錄 Message
,雖然所有動作都可控,而且隨時要遷移到其他引擎都沒問題啦,但這樣不就等於重寫了一套 API 嗎?有點違背這個專案的初衷。。。
先假設我有個 RecordService
提供記錄回放的邏輯,它有個方法是 RecordMethodCall
紀錄方法的呼叫:
RecordService.cs 1 2 3 4 5 6 7 8 public class RecordService { public void RecordMethodCall ( System.Type serviceType, string methodName, params string [] parameters ) { }}
可以在需要紀錄呼叫的方法內部增加 RecordMethodCall()
呼叫,例如我要記錄外部呼叫 BallPoolService.Ball()
這個方法(生成球)的時機點,就會像下面這樣:
BallPoolService.cs 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 readonly RecordService _recordService;public Ball Ball (int typeIndex = 0 ){ _recordService.RecordMethodCall( typeof (BallPoolService), nameof (Ball), typeIndex.ToString()); var ball = Pool.Get(); ball.Color = _objectSetting.GetBallColor(typeIndex); ball.TypeIndex = typeIndex; return ball; }
看起來有點愚蠢,一方面是我很懶惰,另一方面是假設哪天 RecordMethodCall()
改傳入參數或是擴充了其他多載,我不就需要全部引用的地方重寫?想了想何不用 AOP 的思維用 Attribute
的方式來標示哪些方法需要紀錄?
Harmony C#
的反射有 Emit
命名空間,提供動態生成 IL Code
的功能,是非常酷的,但手刻 IL Code
等於是自己把痛苦面具給戴上了,為了減輕一袋米首先想到的是 Harmony
,也就是 Modding 的老朋友,可以在執行階段進行非破壞性注入,同時沒那麼多硬核的 IL Coding
美孜孜噠,馬上用 NuGet
給安排上,後來發現 NuGet
版不適配 Unity,只好載 Dll
自己引用。
題外話,不知道為啥匯入 0Harmony.dll
後 vscode 端 OmniSharp
跑得巨慢。。。
首先遇到的是 API Level
過低的問題,如果是用 .NET Standard 2.1
就不能使用 Reflection.Emit
命名空間,換到 .NET Framework
能解決:
接著開始寫 Attribute
:
RecordMethodCallAttribute.cs 1 2 3 4 5 6 7 8 using System;[AttributeUsage(AttributeTargets.Method) ] public class RecordMethodCallAttribute : Attribute { public RecordMethodCallAttribute () { } }
接著實作 Patch 的邏輯
RecordService.cs 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 static bool _hasPatched; public RecordService (){ if (!_hasPatched) Patch(); } void Patch (){ var harmony = new Harmony("identifier" ); var targets = AppDomain.CurrentDomain.GetAssemblies() .SelectMany(x => x.GetTypes()) .SelectMany(x => x.GetMethods((BindingFlags)int .MaxValue)) .Where(x => x.GetCustomAttributes(typeof (RecordMethodCallAttribute), false ).Length > 0 ); var prefix = typeof (RecordService).GetMethod(nameof (RecordMethodCall)); foreach (var target in targets) harmony.Patch(target, new HarmonyMethod(prefix)); _hasPatched = true ; } public static void RecordMethodCall (object __instance, MethodBase __originalMethod, object [] __args ){ var type = __instance.GetType(); var method = __originalMethod.Name; var args = __args; UnityEngine.Debug.LogWarning($"{type} : {method} : {args } " ); }
之後就可以把需要紀錄的方法都給加上 Attribute
,例如剛才的 BallService.Ball()
:
BallPoolService.cs 1 2 3 4 5 6 7 8 [RecordMethodCall ] public Ball Ball (int typeIndex = 0 ){ var ball = Pool.Get(); ball.Color = _objectSetting.GetBallColor(typeIndex); ball.TypeIndex = typeIndex; return ball; }
更好的事情是,連私有的方法也可以加上,例如我有個做射線檢測的 RaycasterService
:
使用反射的時候,覺得指定 BindingFlags
很麻煩的時候我都是寫 (BindingFlags)int.MaxValue
,這個小技巧我稱之為全反射。
RaycasterService.cs 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 [RecordMethodCall ] void _Cast(in Vector3 origin, in Vector3 direction, out IRaycastTarget target){ target = default ; if (Physics.Raycast( origin, direction, out var hit)) { if (hit.transform.TryGetComponent<IRaycastTarget>(out target)) { target.HitBy(0 ); } } }
看起來很完美,唯一個問題是,目前專案是 Mono Backend
,哪天改成 il2cpp
很難保證 Harmony
正常運行。
簡短說明一下:
Mono Backend
使用 JIT
,執行時透過 Mono
虛擬機將 IL Code
轉換成機器碼。
il2cpp
使用 AOT
,在編譯時將原本虛擬機要使用的 IL Code
轉換成 C++
程式碼,之後用目標平台的 C++
編譯器把轉換後的 C++
程式碼與 libil2cpp
(執行階段庫)一併輸出。
Mono.Cecil Mono.Cecil
提供了竄改 Dll
的功能,同時也封裝了不少好用的方法降低 IL Coding
的難度。
透過把 IL Code
注入編譯後的 Dll
這個方法來實現屬於靜態程式碼,但我們的目的是不動到原始碼,同時目標方法更改的時候也只需要更改注入的邏輯就行了。
首先引入 Mono.Cecil
庫,看要用 NuGet
或是 UPM
,甚至自己找 Dll
丟進 Assets/Plugins/
都可以。
我的主邏輯是寫在 ALM
的程序集內,這裡會將注入的邏輯放在另一個程序集內,由於注入只在打包階段完成,所以可以設定成 Editor
平台限定。
Mono.Cecil
被廣泛運用在很多地方,有時會有版本衝突的問題(以作者的情況,Mono.Cecil
與 Realm
的庫衝突了),可以參考 Unity Dll 版本衝突 ,所以獨立在一個程序集相對好管理引用。
以下的程式碼會使用到 UnityEditor
命名空間,記得加上 #if UNITY_EDITOR ... #endif
的條件式編譯避免輸出時報錯。
先寫讀寫程序集的方法,讀:
RecordCodeInjector.cs 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 static AssemblyDefinition _ReadAssembly(string assemblyPath){ DefaultAssemblyResolver resolver = new (); AppDomain.CurrentDomain.GetAssemblies() .Where(a => !a.IsDynamic) .Select(a => Path.GetDirectoryName(a.Location)) .Distinct() .ToList() .ForEach(path => resolver.AddSearchDirectory(path)); resolver.AddSearchDirectory( Path.GetDirectoryName(EditorApplication.applicationPath) + "/Data/Managed" ); var readerParameters = new ReaderParameters { ReadWrite = true , AssemblyResolver = resolver }; if (File.Exists(assemblyPath + ".pdb" )) readerParameters.ReadSymbols = true ; return AssemblyDefinition.ReadAssembly( assemblyPath, readerParameters); }
resolver.AddSearchDirectory()
的部分是在處理跨程序集的操作。
寫:
RecordCodeInjector.cs 1 2 3 4 5 6 7 8 9 10 static void _WriteAssembly(AssemblyDefinition assembly, string location){ WriterParameters writerParameters = new (); if (File.Exists(location + ".pdb" )) writerParameters.WriteSymbols = true ; assembly.Write(writerParameters); }
AssemblyDefinition.Write()
原本需要提供路徑,但這裡是要寫入原本的 Dll
,如果再給相同路徑會產生 IOException: Sharing violation on path
報錯(等於重複開了這個 Dll
),直接呼叫就會覆蓋了。
實作注入流程的方法:
RecordCodeInjector.cs 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 [MenuItem("ALM/Inject" ) ] public static void Inject (string location = null ){ if (EditorApplication.isCompiling || Application.isPlaying) return ; EditorApplication.LockReloadAssemblies(); try { var recordAttributeType = typeof (RecordMethodCallAttribute); location ??= recordAttributeType.Assembly.Location; var assembly = _ReadAssembly(location); var module = assembly.MainModule; if (module.Types.Any(t => t.Name == "__INJECT_FLAG" )) { assembly.MainModule?.Dispose(); throw new (location + " already injected!" ); } module.Types.Add(new ( "__INJECT_CODE_GEN" , "__INJECT_FLAG" , Mono.Cecil.TypeAttributes.Class, module.TypeSystem.Object)); var targetMethods = module.Types .SelectMany(type => type.Methods) .Where(method => method.CustomAttributes .Any(attr => attr.AttributeType.FullName == recordAttributeType.FullName)); foreach (var method in targetMethods) _InjectMethodBody(method); _WriteAssembly(assembly, location); assembly.MainModule?.Dispose(); Debug.Log("[Injector] " + location + " has been injected!" ); } catch (Exception e) { Debug.LogError(e); } EditorApplication.UnlockReloadAssemblies(); AssetDatabase.Refresh(); }
實際注入的 IL Code
定義在 _InjectMethodBody()
供日後修改:
RecordCodeInjector.cs 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 static void _InjectMethodBody(MethodDefinition method){ var recordMethod = typeof (RecordService) .GetMethod( nameof (RecordService.RecordMethodCall), (BindingFlags)int .MaxValue); var il = method.Body?.GetILProcessor(); if (il is null ) return ; var first = il.Body.Instructions[0 ]; Queue<Instruction> insQueue = new (); insQueue.Enqueue(Instruction.Create( OpCodes.Ldstr, method.FullName)); insQueue.Enqueue(Instruction.Create( OpCodes.Call, method.Module.ImportReference(recordMethod))); while (insQueue.TryDequeue(out var ins)) il.InsertBefore(first, ins); }
做了甚麼
這裡的範例是將呼叫靜態方法 RecordService.RecordMethodCall()
,並將 method.FullName
當作參數傳入。
RecordService.cs 1 2 3 4 5 public static void RecordMethodCall (string info ){ UnityEngine.Debug.Log(info); }
IL
是基於棧操作的,寫起來會有點類似組語:
ldstr
將方法完整名稱壓棧call
呼叫方法實際情況需要看方法定義的參數,如果不是靜態方法的場合還需要注入 field
,呼叫前也需要透過 ldflda
或 ldfld
將實體載入。
最後要實作 IPostBuildPlayerScriptDLLs
介面,打包的時候自動注入:
PostBuildProcessor.cs 1 2 3 4 5 6 7 8 9 10 public class PostBuildProcessor : IPostBuildPlayerScriptDLLs { public int callbackOrder => 0 ; public void OnPostBuildPlayerScriptDLLs (BuildReport report ) { RecordCodeInjector.Inject( report.GetFiles().Single(x => x.path.EndsWith("ALM.dll" )).path); } }
打包出來,用 dnSpy 之類的逆向工程工具看看有沒有注入成功:
後話 大概就是這樣,由於還是逆向工程的菜雞,很多知識點沒辦法敘述得很清楚,特別是 IL
指令根本就不太熟,哪天學成歸來再寫一篇分享。
非常推薦 xLua 的 Hotfix
部分,這篇有不少地方參考它排雷以及實作注入邏輯。