[Unity]基于IL代码注入的Lua补丁方案

作者: 苏三疯 | 来源:发表于2016-10-12 15:56 被阅读4097次

    本分享的想法源于看了这篇分享
    由于在对Unity项目后期进行lua热更新方案实施, 我也不想造成源代码的修改, 故在此对上文提及到的后续方案进行补充

    本文转载请注明出处: http://www.jianshu.com/p/4bef7f66aefd

    1.我为何有IL[1]代码注入的想法

    • Unity项目如果初期没有很好的规划代码热更, 基本都会选择C#作为开发语言, 那么项目后期引入lua机制, 把旧模块用lua重写并非很好的方案, 此时更希望是给旧代码留一个lua热更入口.
    • 为了减少重复代码, 借鉴J2EE领域中AOP[2]实现思路, 应用到此次需求上.

    2.lua补丁代码雏形

    public class FooBar
    {
        public void Foo(string params1, int params2, Action params3)
        {
            if(LuaPatch.HasPatch("path/to/lua/file", "luaFuncName"))
            {
                LuaPatch.CallPatch("path/to/lua/file", "luaFuncName", params1, params2, params3);
                return;
            }
            // the old code here
            Debug.Log("这里是原来的逻辑代码, 无返回值");
        }
        public Vector2 Bar(string params1, int params2, Action params3)
        {
            if (LuaPatch.HasPatch("path/to/lua/file", "luaFuncName"))
            {
                return (Vector2)LuaPatch.CallPatch("path/to/lua/file", "luaFuncName", params1, params2, params3);
            }
            // the old code here
            Debug.Log("这里是原来的逻辑代码, 有返回值");
            return Vector2.one;
        }
    }
    

    至于是使用sLua或者toLua方案, 大家各自根据项目需要自由选择.

    https://github.com/pangweiwei/slua
    https://github.com/topameng/tolua
    如果没有使用lua做大量向量,三角函数运算, 两个方案没有太大差异

    3.初识IL

    IL语法参考文章:http://www.cnblogs.com/Jax/archive/2009/05/29/1491523.html

    上面LuaPatch判断那一段先使用IL语法重新书写
    由于大家时间都很宝贵, 为了节省时间这里不精通IL语法也行, 这里有一个取巧的方法

    • 请自行下载利器: .NET Reflector
    • 我们使用Reflector打开Unity工程下\Library\ScriptAssemblies\Assembly-CSharp.dll
      找到你事先写好的希望注入到代码模板, 这里我以上面Foobar.cs为例
    .NET Reflector
    • 篇幅限制, 我把核心的IL代码贴出并加上注释, 大家根据具体情况自行使用Reflector获取
    # 代码后附带MSDN文档链接
    L_0000: ldstr "path/to/lua/file"    -- 压入string参数
    L_0005: ldstr "luaFuncName"
    L_000a: call bool LuaPatch::HasPatch (string, string) -- 调用方法, 并指定参数形式
    L_000f: brfalse L_0040              -- 相当于 if(上述返回值为false) jump L_0040行
    L_0014: ldstr "path/to/lua/file"    -- 同样压入参数
    L_0019: ldstr "luaFuncName"
    L_001e: ldc.i4.3                    -- 对应params不定参数, 需要根据具体不定参个数声明对应数组, 这里newarr object, 长度为3
    L_001f: newarr object
    L_0024: dup                         -- 复制栈顶(数组)的引用并压入计算堆栈中
    L_0025: ldc.i4.0                    -- 0下标存放本函数传入第一个参数的引用
    L_0026: ldarg.1                     -- #这里要注意static方法ldarg.0是第一个参数, 非static的ldarg.0存放的是"this"
    L_0027: stelem.ref                  -- 声明上述传入数组的参数为其对象的引用
    L_0028: dup                         -- 作用同上一个dup
    L_0029: ldc.i4.1                    
    L_002a: ldarg.2
    L_002b: box int32
    L_0030: stelem.ref
    L_0031: dup
    L_0032: ldc.i4.2
    L_0033: ldarg.3
    L_0034: stelem.ref
    L_0035: call object LuaPatch ::CallPatch (string, string, object[])
    L_003a: unbox.any [UnityEngine]UnityEngine.Vector2
    L_003f: ret
    

    对IL语法有个大致理解, 有助于稍后用C#进行代码注入, 对于指令可以参考msdn的OpCodes文档.

    4.Mono.Ceil库

    1. 能够标记需要注入的类或者方法
      利用C#的 特性(Attribute)
      1)声明特性如下:
    using System;
    [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
    public class LuaInjectorAttribute : Attribute
    {
    }
    [AttributeUsage(AttributeTargets.Method)]
    public class LuaInjectorIgnoreAttribute : Attribute
    {
    }
    

    2)使用特性进行标记

    [LuaInjector]
    public class CatDog
    {
        public void Cat()
        {
            // 这个类所有函数都会被注入
        }
        [LuaInjectorIgnore]
        public static void Dog()
        {
            // 只有LuaInjectorIgnore标记的会被忽略
        }
    }
    

    上述作为实现参考, 当然你也可以对Namespace, cs代码目录进行遍历, 或者通过代码主动Add(Type targetType)等方式来进行注入标记.
    3)遍历dll中所有的类型

    var assembly = AssemblyDefinition.ReadAssembly("path/to/Library/ScriptAssemblies/Assembly-CSharp.dll");
    foreach (var type in assembly.MainModule.Types)
    {
      // 判断Attribute是否LuaInjector等等
    }
    
    1. C#进行IL代码注入的核心代码
        // 代码片段
        private static bool DoInjector(AssemblyDefinition assembly)
        {
            var modified = false;
            foreach (var type in assembly.MainModule.Types)
            {
                if (type.HasCustomAttribute<LuaInjectorAttribute>())
                {
                    foreach (var method in type.Methods)
                    {
                        if (method.HasCustomAttribute<LuaInjectorIgnoreAttribute>()) continue;
    
                        DoInjectMethod(assembly, method, type);
                        modified = true;
                    }
                }
                else
                {
                    foreach (var method in type.Methods)
                    {
                        if (!method.HasCustomAttribute<LuaInjectorAttribute>()) continue;
    
                        DoInjectMethod(assembly, method, type);
                        modified = true;
                    }
                }
            }
            return modified;
        }
    
        private static void DoInjectMethod(AssemblyDefinition assembly, MethodDefinition method, TypeDefinition type)
        {
            if (method.Name.Equals(".ctor") || !method.HasBody) return;
    
            var firstIns = method.Body.Instructions.First();
            var worker = method.Body.GetILProcessor();
    
            // bool result = LuaPatch.HasPatch(type.Name)
            var hasPatchRef = assembly.MainModule.Import(typeof(LuaPatch).GetMethod("HasPatch"));
            var current = InsertBefore(worker, firstIns, worker.Create(OpCodes.Ldstr, type.Name));
            current = InsertAfter(worker, current, worker.Create(OpCodes.Ldstr, method.Name));
            current = InsertAfter(worker, current, worker.Create(OpCodes.Call, hasPatchRef));
    
            // if(result == false) jump to the under code
            current = InsertAfter(worker, current, worker.Create(OpCodes.Brfalse, firstIns));
    
            // else LuaPatch.CallPatch(type.Name, method.Name, args)
            var callPatchMethod = typeof(LuaPatch).GetMethod("CallPatch");
            var callPatchRef = assembly.MainModule.Import(callPatchMethod);
            current = InsertAfter(worker, current, worker.Create(OpCodes.Ldstr, type.Name));
            current = InsertAfter(worker, current, worker.Create(OpCodes.Ldstr, method.Name));
            var paramsCount = method.Parameters.Count;
            // 创建 args参数 object[] 集合
            current = InsertAfter(worker, current, worker.Create(OpCodes.Ldc_I4, paramsCount));
            current = InsertAfter(worker, current, worker.Create(OpCodes.Newarr, assembly.MainModule.Import(typeof(object))));
            for (int index = 0; index < paramsCount; index++)
            {
                var argIndex = method.IsStatic ? index : index + 1;
                // 压入参数
                current = InsertAfter(worker, current, worker.Create(OpCodes.Dup));
                current = InsertAfter(worker, current, worker.Create(OpCodes.Ldc_I4, index));
                var paramType = method.Parameters[index].ParameterType;
                // 获取参数类型定义, 用来区分是否枚举类 [若你所使用的类型不在本assembly, 则此处需要遍历其他assembly以取得TypeDefinition]
                var paramTypeDef = assembly.MainModule.GetType(paramType.FullName);
                // 这里很重要, 需要判断出 值类型数据(不包括枚举) 是不需要拆箱的
                if (paramType.IsValueType && (paramTypeDef == null || !paramTypeDef.IsEnum))
                {
                    current = InsertAfter(worker, current, worker.Create(OpCodes.Ldarg, argIndex));
                }
                else
                {
                    current = InsertAfter(worker, current, worker.Create(OpCodes.Ldarg, argIndex));
                    current = InsertAfter(worker, current, worker.Create(OpCodes.Box, paramType));
                }
                current = InsertAfter(worker, current, worker.Create(OpCodes.Stelem_Ref));
            }
            current = InsertAfter(worker, current, worker.Create(OpCodes.Call, callPatchRef));
            var methodReturnVoid = method.ReturnType.FullName.Equals("System.Void");
            var patchCallReturnVoid = callPatchMethod.ReturnType.FullName.Equals("System.Void");
            // LuaPatch.CallPatch()有返回值时
            if (!patchCallReturnVoid)
            {
                // 方法无返回值, 则需先Pop出栈区中CallPatch()返回的结果
                if (methodReturnVoid) current = InsertAfter(worker, current, worker.Create(OpCodes.Pop));
                // 方法有返回值时, 返回值进行拆箱
                else current = InsertAfter(worker, current, worker.Create(OpCodes.Unbox_Any, method.ReturnType));
            }
            // return
            InsertAfter(worker, current, worker.Create(OpCodes.Ret));
    
            // 重新计算语句位置偏移值
            ComputeOffsets(method.Body);
        }
        /// <summary>
        /// 语句前插入Instruction, 并返回当前语句
        /// </summary>
        private static Instruction InsertBefore(ILProcessor worker, Instruction target, Instruction instruction)
        {
            worker.InsertBefore(target, instruction);
            return instruction;
        }
    
        /// <summary>
        /// 语句后插入Instruction, 并返回当前语句
        /// </summary>
        private static Instruction InsertAfter(ILProcessor worker, Instruction target, Instruction instruction)
        {
            worker.InsertAfter(target, instruction);
            return instruction;
        }
    
        private static void ComputeOffsets(MethodBody body)
        {
            var offset = 0;
            foreach (var instruction in body.Instructions)
            {
                instruction.Offset = offset;
                offset += instruction.GetSize();
            }
        }
    
    1. 能够在Unity打包时自动执行IL注入
      使用特性PostProcessScene进行标记, 不过注意如果你的项目中有多个Scene需要打包, 这里避免重复调用, 需要添加一个_hasMidCodeInjectored用来标记, 达到只在一个场景时机执行注入处理.
        // 代码片段
        [PostProcessScene]
        private static void MidCodeInjectoring()
        {
            if (_hasMidCodeInjectored) return;
            D.Log("PostProcessBuild::OnPostProcessScene");
    
            // Don't CodeInjector when in Editor and pressing Play
            if (Application.isPlaying || EditorApplication.isPlaying) return;
            //if (!EditorApplication.isCompiling) return;
    
            BuildTarget buildTarget = EditorUserBuildSettings.activeBuildTarget;
    
            if (buildTarget == BuildTarget.Android)
            {
                if (DoCodeInjectorBuild("Android"))
                {
                    _hasMidCodeInjectored = true;
                }
                else
                {
                    D.LogWarning("CodeInjector: Failed to inject Android build!");
                }
            }
            else if (buildTarget == BuildTarget.iPhone)
            {
                if (DoCodeInjectorBuild("iOS"))
                {
                    _hasMidCodeInjectored = true;
                }
                else
                {
                    D.LogWarning("CodeInjector: Failed to inject iOS build!");
                }
            }
        }
    

    4.完整源码
    https://github.com/rayosu/UnityDllInjector


    1. Unity中不管使用C#还是其他语言, 都会编译为IL代码存放为dll形式, iOS打包会进行IL2Cpp转换为C++代码, 所以此处对IL这一中间代码(dll文件)的修改, 可以达成注入的目的.

    2. IL代码注入只是AOP的一种实现方案, AOP(面向切面编程)的思想源自GOF设计模式, 你可以理解为: 用横向的思考角度, 来统一切入一类相同逻辑的某个"切面"(Aspect), 让使用者(逻辑程序员)无需重复关注这个"横向面"需要做的工作.这里的切面就是"判断是否有对应Lua补丁"

    相关文章

      网友评论

      • gonglei007:你好,你提供的这个解决方案非常好,我们所使用的是ToLua,也想采用你做的这个UnityDllInjector来实现注入。注入本身是可以正常工作的,不过遇到了其它的问题——当为某个C#方法标识注入之后,这些方法在Lua那边就不能被C#传过来的对象访问了(做注入前是可以访问的),运行时会提示找不到这个方法(Lua function Class.method not exists)。具体原因还没搞清楚,有没有可能是那些Wrap类中调用的方法的地址有偏移了?这个问题想跟你请教一下。
        苏三疯:我们在QQ沟通过,看了你发过来的工程代码,原因是LuaPatch.HasPatch函数你没有正确判断出这个lua文件以及函数存在(在luaFunc不存在时你却返回了true),导致了尝试去调用。
        这个热更新方案原理上并不依赖于是否toLua,甚至不依赖于lua。
      • typedef708:你的这部分处理内容如果是在il2cpp模式下,也支持的吗?
        不知道unity中 PostProcessScene 该标记是在IL生成完之后执行,还是在生成cpp之后执行的。
        苏三疯:@typedef708 IL后,转Cpp前
      • 陈凯利: :sunglasses: 侵犯了我的专利权
        苏三疯:@公的Kelly 😂 就当是给你分享的补充
      • 巴图鲁:不错

      本文标题:[Unity]基于IL代码注入的Lua补丁方案

      本文链接:https://www.haomeiwen.com/subject/ssdbyttx.html