UnityGC优化 - 参数与泛型

3 | 参数与泛型

3.1 参数传递


对象和结构体另一个区别体现在参数传递上面。
正常来说,C#中所有参数传递都是值传递,方法传递的参数都是值的副本。对象和结构体的区别就是,因为栈中存放的只能是对象的引用,所以传参时,复制的是引用的值,大小是4字节(32位)或8字节(64位),也就是IntPtr.Size。如果参数是结构体,则会把整个结构体都复制一次,大小就是这个结构体的大小,如果传递的结构体很大,也会造成一定的开销。



3.2 ref,in和out


这三个关键字,可以使参数按引用传递,ref表示传进的参数可读写,in表示参数是只读的,out表示参数是只写的,类似返回值。按引用传参,特别是传递较大的结构体参数,可以减少复制带来的开销。在MSDN的优化建议中也提到,推荐所有大于IntPtr.Size的结构体,传参时都按引用传递。

此外,在 .NET 4.x 以后,ref关键词也可以修饰返回值了。如下面的代码:

    public class Test
    {
        public UnityEngine.Ray ray { get; private set; }
        ...
        void Calc()
        {
            UnityEngine.Ray ray1 = ray;
            ...
            Calc1(ray1);
        }
        void Calc1(UnityEngine.Ray ray2)
        {
            ...
        }
    }

  


Test类缓存了一个Ray类型的结构体(24个字节)给其它地方使用,而在Calc中通过赋值把ray复制了一份出来进行计算,然后又通过传参又复制了一份给Calc1进行计算。一共进行了两次复制,下面将其改成:

    public class Test
    {
        private UnityEngine.Ray _ray;
        public ref UnityEngine.Ray ray => ref _ray;
        ...
        void Calc()
        {
            ref UnityEngine.Ray ray1 = ref ray;
            ...
            Calc1(ref ray1);
        }
        void Calc1(ref UnityEngine.Ray ray2)
        {
            ...
        }
    }

  


同样缓存了一个Ray的结构体,但是公开的变量只是对于这个结构体的引用,Calc和Calc1只是复制了其引用,并没有复制该结构体。这样做有一个缺点,就是公开暴露了ray的引用,可以让其它地方随意修改缓存的_ray变量。下面改成这样:

    public class Test
    {
        private Ray _ray;
        public ref readonly Ray ray => ref _ray;
    
        void Calc()
        {
            ref readonly Ray ray1 = ref ray;
            ...
            Calc1(in ray1);
        }
        void Calc1(in Ray ray2)
        {
            ...
        }
    }

  


通过readonly标记其为只读,其它地方也要做相应的更改。

注意:ref return一定不要返回方法内局部变量的引用。

其它可以参考MSDN上的链接:编写安全有效的 C# 代码



3.3 使用泛型优化装箱


有时方法为了保证通用性,会使用object作为参数,如:

    void Func(object o)
    {
        ...
    }

  


这样如果参数传入值类型,就会产生装箱,这时为了避免装箱会使用泛型来优化,如:

    void Func<T>(T o)
    {
        ...
    }

  


但是这样的方法在IL2CPP中会有一个问题,因为IL2CPP是AOT机制的,所有泛型调用最后会为每一种类型单独生成代码,增加代码体积,同时也会增加堆内存,如:

    struct S
    {
        byte b1;
        byte b2;
        int i1;
    }
    ...
    Func(new S());

  


这样的调用,会生成这样的函数(参数是S_t082EE9B9D612912310173429A71A74D0E6044782):

    extern "C" IL2CPP_METHOD_ATTR void Test_Func_TisS_t082EE9B9D612912310173429A71A74D0E6044782_m2DFFB1A6C1B583FBB182364222DA7BCF8BAC96D2_gshared (Test_tD59136436184CD9997A7B05E8FCAF0CB36B7193E * __this, S_t082EE9B9D612912310173429A71A74D0E6044782  ___t0, const RuntimeMethod* method)

但是如果是:

    Func(new object());
    Func("123");

  


这两种调用只会生成这样一个函数(参数是RuntimeObject):

    extern "C" IL2CPP_METHOD_ATTR void Test_Func_TisRuntimeObject_m8B09041F4A36F01BAEE4FF189468F3AEC4392341_gshared (Test_tD59136436184CD9997A7B05E8FCAF0CB36B7193E * __this, RuntimeObject * ___t0, const RuntimeMethod* method)

 


这是因为IL2CPP中有一个泛型类型共享的机制,如所有引用类型,都会只生成参数是RuntimeObject的函数,还有整数和枚举,也会只生成一个参数是int32_t的函数,但是,对于其它值类型,是没有办法共享的,所以只能针对每一个类型单独生成一个函数。

所以,要权衡其中的利害,谨慎使用泛型参数。



3.4 可变参数


使用params的可变参数是一种语法糖,它和传入一个数组是等价的。
比如:

    void Func(params int[] n);

  

这个方法,如果调用

Func(1,2,3);

  

它其实等价于

Func(new int[]{1,2,3});

  

会临时产生一个数组,但是

Func();

  

这个调用等价于

Func(Array.Empty<int>);

  

这个是C#缓存的一个空数组,并不会临时产生新对象。

所以,只要可变参数不为空,就一定会产生临时的数组,大量调用会产生很多的GC Alloc。

优化方法是用一系列若干数量的参数的重载方法代替,如C#的string.Format是这样写的(来自https://github.com/Unity-Technologies/mono/blob/unity-master/mcs/class/referencesource/mscorlib/system/string.cs ):

    public static string Format (string format, object arg0);
    public static string Format (string format, object arg0, object arg1);
    public static string Format (string format, object arg0, object arg1, object arg2);
    public static string Format (string format, params object[] args);

  


把常用的1个、2个、3个参数的方法单独提出来,剩下的再用可变参数来做。



3.5 Conditional特性


在开发过程当中,一般为了调试方便,会在代码中加入很多控制台输出,比如:

    Debug.Log("123");

然后在真机会调用unityLogger.logEnabled或unityLogger.filterLogType对其进行开关,但是这种并不能阻止传入参数本身造成的GC Alloc,如

    Debug.Log(123);
    ...
    Debug.Log(string.Format("12{0}", 3));

类似这样的装箱和字符串操作都会产生GC Alloc,这里可以使用如下方法:

    #if UNITY_EDITOR
        Debug.Log(123);
    #endif
    ...
    #if UNITY_EDITOR
        Debug.Log(string.Format("12{0}", 3));
    #endif

该方法可以避免在真机上生成代码,可是需要用#if和#endif扩住的地方太多了,代码可读性会降低,这时可以用一个[Conditional]来完成这样的操作,如下面方法:

    [Conditional("UNITY_EDITOR")]
    public static void Print(object message)
    {
        Debug.Log(message);
    }

这样代码中调用:

    Print(123);
    ...
    Print(string.Format("12{0}", 3));

在真机上不会生成任何代码,也就不会发生GC Alloc了,同时也维护了代码的可读性。

 

 

posted on 2020-03-18 11:35  深秋大街道  阅读(707)  评论(0)    收藏  举报

导航