CLR重定向

啥叫CLR重定向捏,首先,我们热更DLL中的方法,默认情况下!默认情况下是被IL解释器执行的,执行过程我们管不着,交给IL处理就好,但是呢,如果,某些时候,我们希望对这个方法进行一些额外的操作,那么就需要CLR重定向了,类似于重写LR解释器的方法吧,道理是这么个道理,我还没遇到过这样的需求,具体原理我也不懂,做个记录吧。

首先我们打开CLRRedirectionDemo,注释掉一些重定向

unsafe void InitializeILRuntime()
{
#if DEBUG && (UNITY_EDITOR || UNITY_ANDROID || UNITY_IPHONE)
	//由于Unity的Profiler接口只允许在主线程使用,为了避免出异常,需要告诉ILRuntime主线程的线程ID才能正确将函数运行耗时报告给Profiler
	appdomain.UnityMainThreadID = System.Threading.Thread.CurrentThread.ManagedThreadId;
#endif
    // 把下面这两行注释掉
	// 这里做一些ILRuntime的注册
	// var mi = typeof(Debug).GetMethod("Log", new System.Type[] { typeof(object) });
	// appdomain.RegisterCLRMethodRedirection(mi, Log_11);
}

运行如下

image-20220115131008803

这其实就是调用了一个Debug.Log方法而已,但是你会发现这个堆栈信息非常难顶,因为这行代码的调用来自于IL解释器。。

此时,我们可以对CLR进行重定向

image-20220115131111734

static StackObject* Log_0(ILIntepreter __intp, StackObject* __esp, IList<object> __mStack, CLRMethod __method, bool isNewObj)
{
    ILRuntime.Runtime.Enviorment.AppDomain __domain = __intp.AppDomain;
    StackObject* ptr_of_this_method;
    StackObject* __ret = ILIntepreter.Minus(__esp, 1);

    ptr_of_this_method = ILIntepreter.Minus(__esp, 1);
    System.Object @message = (System.Object)typeof(System.Object).CheckCLRTypes(StackObject.ToObject(ptr_of_this_method, __domain, __mStack), (CLR.Utils.Extensions.TypeFlags)0);
    __intp.Free(ptr_of_this_method);


    UnityEngine.Debug.Log(@message);

    return __ret;
}

其实最终IL就是运行的这一段代码,我们可以重写这一段代码,带到重定向的目的,当然不止直接重写在这里,因为这个类是IL自动生成的,我们需要自己注册一个方法

unsafe void InitializeILRuntime()
{
#if DEBUG && (UNITY_EDITOR || UNITY_ANDROID || UNITY_IPHONE)
    //由于Unity的Profiler接口只允许在主线程使用,为了避免出异常,需要告诉ILRuntime主线程的线程ID才能正确将函数运行耗时报告给Profiler
    appdomain.UnityMainThreadID = System.Threading.Thread.CurrentThread.ManagedThreadId;
#endif
    //这里做一些ILRuntime的注册
    var mi = typeof(Debug).GetMethod("Log", new System.Type[] { typeof(object) });
    appdomain.RegisterCLRMethodRedirection(mi, Log_11);
}
    
unsafe static StackObject* Log_11(ILIntepreter __intp, StackObject* __esp, IList<object> __mStack, CLRMethod __method, bool isNewObj)
{
    //ILRuntime的调用约定为被调用者清理堆栈,因此执行这个函数后需要将参数从堆栈清理干净,并把返回值放在栈顶,具体请看ILRuntime实现原理文档
    ILRuntime.Runtime.Enviorment.AppDomain __domain = __intp.AppDomain;
    StackObject* ptr_of_this_method;
    //这个是最后方法返回后esp栈指针的值,应该返回清理完参数并指向返回值,这里是只需要返回清理完参数的值即可
    StackObject* __ret = ILIntepreter.Minus(__esp, 1);
    //取Log方法的参数,如果有两个参数的话,第一个参数是esp - 2,第二个参数是esp -1, 因为Mono的bug,直接-2值会错误,所以要调用ILIntepreter.Minus
    ptr_of_this_method = ILIntepreter.Minus(__esp, 1);

    //这里是将栈指针上的值转换成object,如果是基础类型可直接通过ptr->Value和ptr->ValueLow访问到值,具体请看ILRuntime实现原理文档
    object message = typeof(object).CheckCLRTypes(StackObject.ToObject(ptr_of_this_method, __domain, __mStack));
    //所有非基础类型都得调用Free来释放托管堆栈
    __intp.Free(ptr_of_this_method);

    //在真实调用Debug.Log前,我们先获取DLL内的堆栈
    var stacktrace = __domain.DebugService.GetStackTrace(__intp);

    //我们在输出信息后面加上DLL堆栈
    UnityEngine.Debug.Log(message + "\n" + stacktrace);

    return __ret;
}

额,说实话比较难,需要对IL了解的非常透彻,暂时就不多写了

CLR绑定

第一,目前,越来越多的平台要求打包时开启IL2CPP,64位系统。这会导致一个问题,因为很多方法或者类,虽然我们定义在了主工程中,但可能只有在热更工程中有所使用,在主工程内,没有任何地方使用他们,那么IL2CPP就会认为,这些方法或者类,没有地方使用过,可以删掉(优化),那就会导致最终打包的结果,在热更中出错。

第二,ILRT本质仍然逃不掉反射,而反射会导致很多的拆箱装箱,CLR绑定的目的,也是为了避免,频繁拆装箱导致的性能问题。

我们可以测试一下,运行ILRT自带的示例场景6

image-20211218171128712

不难发现,这个demo,就是在热更里调用了一个测试方法

...
var type = appdomain.LoadedTypes["HotFix_Project.TestCLRBinding"];
var m = type.GetMethod("RunTest", 0);
appdomain.Invoke(m, null, null);
...

热更中的具体方法如下:

namespace HotFix_Project
{
    public class TestCLRBinding
    {
        public static void RunTest()
        {
            for (int i = 0; i < 100000; i++)
            {
                CLRBindingTestClass.DoSomeTest(i, i);
            }
        }
    }
}

主工程中对应的方法如下:

public class CLRBindingTestClass
{
    public static float DoSomeTest(int a, float b)
    {
        return a + b;
    }
}

很简单的一个加法而已,一共调用了10w次,此时我们没有开启CLR绑定,也就是说在没有有优化的情况下,大约需要1200ms

然后我们开启CLR绑定

image-20211218171459838

会发现生成了很多类

image-20211218171648981

只要在热更中调用到的方法,都会被绑定,比如我们上面调用的DoSomeTest

static StackObject* DoSomeTest_0(ILIntepreter __intp, StackObject* __esp, IList<object> __mStack, CLRMethod __method, bool isNewObj)
        {
            ILRuntime.Runtime.Enviorment.AppDomain __domain = __intp.AppDomain;
            StackObject* ptr_of_this_method;
            StackObject* __ret = ILIntepreter.Minus(__esp, 2);

            ptr_of_this_method = ILIntepreter.Minus(__esp, 1);
            System.Single @b = *(float*)&ptr_of_this_method->Value;

            ptr_of_this_method = ILIntepreter.Minus(__esp, 2);
            System.Int32 @a = ptr_of_this_method->Value;


            var result_of_this_method = global::CLRBindingTestClass.DoSomeTest(@a, @b);

            __ret->ObjectType = ObjectTypes.Float;
            *(float*)&__ret->Value = result_of_this_method;
            return __ret + 1;
        }

然后在ILRT初始化时,开启绑定注册,注意事项已经写在了注释里

void InitializeILRuntime()
    {
#if DEBUG && (UNITY_EDITOR || UNITY_ANDROID || UNITY_IPHONE)
        //由于Unity的Profiler接口只允许在主线程使用,为了避免出异常,需要告诉ILRuntime主线程的线程ID才能正确将函数运行耗时报告给Profiler
        appdomain.UnityMainThreadID = System.Threading.Thread.CurrentThread.ManagedThreadId;
#endif
        //这里做一些ILRuntime的注册,如委托适配器,值类型绑定等等


        //初始化CLR绑定请放在初始化的最后一步!!
        //初始化CLR绑定请放在初始化的最后一步!!
        //初始化CLR绑定请放在初始化的最后一步!!

        //请在生成了绑定代码后解除下面这行的注释
        //请在生成了绑定代码后解除下面这行的注释
        //请在生成了绑定代码后解除下面这行的注释
        ILRuntime.Runtime.Generated.CLRBindings.Initialize(appdomain);
    }

在此运行

image-20211218171834463

可以发现,快了几乎10倍

值类型绑定

在热更中使用unity特殊的值类型时,会产生大量的GC,需要手写适配器,暂时还不会写,知道有这事情就成

appdomain.RegisterValueTypeBinder(typeof(Vector3), new Vector3Binder());
appdomain.RegisterValueTypeBinder(typeof(Quaternion), new QuaternionBinder());
appdomain.RegisterValueTypeBinder(typeof(Vector2), new Vector2Binder());