【系统】unity3D中的assetbundle资源管理器(一)

导言

assetbundle 的解释

说起 U3D 的 assetbundle 大家应该都不会很陌生吧,我的理解是 —— assetbundle 属于一种 unity 自己可认的压缩文件格式,把你想放在一个包里的资源给打包到一起,这样就形成了一个 assetbundle。

但是使用这个压缩格式还有一个注意点,就是可能你要打包的资源,它引用到了另外一处资源,即它对一个外部资源有依赖。所以你在打 assetbundle 的时候,unity 会把这个依赖关系记载在 manifest 的 Dependecies 上,虽然是对 asset 的依赖,但是 unity 帮你管理的是大范围的依赖关系,所以记录下来的是 assetbundle 对 assetbundle 的依赖。

为什么要做 assetbundle 的资源管理器

当我们在使用 assetbundle 做资源热更新的时候,我们需要了解,在使用asset资源的时候,assetbundle是怎么被使用的。比如我们需要加载一个prefab的时候,我们需要先解包这个prefab所在的assetbundle,然后我们还需要解包这个prefab所依赖的asset所在的assetbundle。这个asset到asset的依赖是unity帮我们管理的,会记录在asset中,我们在用文本打开资源的文件的时候,是可以看到在unity中这个asset的所有信息,包括它依赖的asset,(这部分就不详细展开了)。但我们把assetbundle在内存中解压之后,内存就会存放这个assetbundle的镜像,内存就会变大,如果我们只解压不去卸载这部分内存的话,那游戏内存就会持续升高,超出系统所给的内存大小,系统就会把引用杀死,造成闪退。所以我们需要在加载的时候解压assetbundle,在不用的时候把这个assetbundle给回收了。

实现方案

准备阶段

我们将在这个阶段先解压assetbundle的整个manifest,然后给所有的assetbundle给记一个int的唯一标识,这步是为了在后面使用assetbundle的时候可以使用int来做标识,减少string的使用,节省gc。再把所有assetbundle所包含的asset的资源和这个assetbundle做好一个依赖关系。

使用阶段

当我们要加载某一个asset的时候,我们先判断这个asset有被加载,加载过的,我们就asset做一个引用计数+1的操作,如果没有的话,我们就去加载这个asset所在的assetbundle,然后我们在加载assetbundle的时候,看这个assetbundle还使用了哪些assetbundle,我们都做一次加载,做一次引用计数加1的操作,然后我们在实例化出来这个asset。

使用完成阶段

但我们卸载asset的时候,会对这个asset来做一个引用计数-1的操作,如果引用计数小于等于0了,我们就可以卸载这个asset,进而对这个assetbundle来做一次-1的操作,但assetbundle的计数也小于等于0的时候,我们就可以卸载这个assetbundle了,对它所依赖的assetbundle的引用计数-1。这一整个assetbundle的使用周期就结束了

注意点

在做assetbundle的引用计数的时候,我遇到了一个问题,我是使用GetDirectDependencies来获取这个assetbundle所依赖的单一次的assetbundle,所以如果所依赖的assetbundle再依赖我这个assetbundle的时候,我这时候就会出现循环依赖(a->b->a这种情况),然后卸载就会出现问题,因为我在引用计数的时候对a其实做了两次引用计数了,卸载我其实就引用计数-1,然后因为还有一次引用计数所以b不会被卸载,所以对a的引用也不会减少,这样实际上我们其实不需要引用了,但是事实上循环引用导致我们卸载不掉。
在这里我提供两个解决方案:

  1. 我们做一次调用栈的计数,如果这次调用循环中,如果这次调用的资源是记录在栈里的,那我们就不做引用计数+1的操作了。
  2. 我们换一个接口,GetAllDependencies 这个接口返回的就是这个assetbundle所依赖的所有资源,包括它依赖的依赖。这样我们就可以一次性对所有的资源做引用计数加1了。

优化点

  1. 上面所说的assetbundle的循环依赖,其实我们可以做关于asset的依赖管理,asset的依赖是不太会出现asset之间的循环依赖。这种优化还能解决我们关于assetbundle的提早释放。比如assetbundle(a)中两个资源(a1,a2)对两个assetbundle(b,c)里的资源(b1,c1)做依赖。(a1->b1,a2->c1)这种情况的依赖在我现在的方案中,因为是记录的assetbundle的依赖关系,所以就算a1被卸载了,不使用了,但是a还有一个引用,所以a不会被卸载,b和c也就不会被卸载。其实b不再被使用了,但是还是没有办法被卸载了。如果我们做的是asset之间的依赖引用,那么我们a1被卸载了,b就不再被引用了,就可以被卸载出来

  2. 因为我们使用的assetbundle做的资源依赖管理,所以我们调用的卸载接口是直接assetbundle.Unload(true)。这个接口的问题就是,如果我们的资源还在使用,却调用到了这个接口,那么资源就会丢失,就会出现紫色的。所以最后还是要回到使用asset的依赖管理上。

demo

第一版本的demo

【unity】优化-cpu

CPU耗时

类中存在空的updata,lateupdate和FixedUpdate这样的方法

出现问题的主要原因

  1. 这些方法是native层对托管层的调用,c++与c#之间的通信本身就存在一定的开销
  2. 当这些方法的调用,Unity会进行一系列的安全监测(比如保证GameObject没有被销毁)导致cpu时间的消耗

这个类中的方法存在Camera.main的调用

这个方法获取属性的方法实际上是一个Get的方法,每次调用的时候都会去寻找场景中第一个tag为“MainCamera”的相机,并且返回。这个寻找是遍历所有的tag,并且不会被缓存的。

该类的方法中存在ComputeBuffer.GetData的调用

这个是从GPU的buffer中读取对应的计算结果并输入到相应的数据中,由于整个过程是一个同步的操作,所以调用的时候会堵塞调用线程,直到GPU返回数据为止。

该类中存在对纹理SetPixels的调用

SetPixels用于对纹理特定的mipmap层的像素进行修改,它会将一组数据的像素值赋值到贴图的指定mipmap层,调用Apply()后将像素传到显卡。

该类的方法中存在GameObject.SendMessage调用

这个方法会遍历Gameobject上所有的组件,以及组件中的所有函数,会导致很高的cpu消耗。

该类的方法中存在GetComponentsInChindren调用/GetCompoentsInParent调用

这两个的使用都会涉及到较大范围内的搜索遍历。Unity在GameObject的GetComponentsInChildren的方法是支持传入一个List的,这个是可以减少堆内存的分配。

调用了FindObjectsOfType调用

这个会对于场景中的GameObject和Component进行遍历,并将与目标type类型相同的组件以数组的方式返回。这个方法还会产生对堆内存的分配。

存在Reflection相关函数的调用

在调用反射相关的方法时,需要获取类型与函数等信息,并且进行参数校验,错误处理,安全性检查等。还会造成堆内存的分配。

存在对Renderer进行Material/Materials的获取

如果对renderer类型调用.material和.materials,那么unity就会生成新的材质球实例。所以我们可以使用MaterialPropertyBlock来替代Material属性的操作。使用相同的Shader但是Material实例不同的GameObject是无法进行合批操作,所以dc增加了,也就增加了cpu的消耗。
每次调用.material都会生成一个新的Material实例,并且GameObject销毁后,Material的实例是没有办法进行自动销毁的。所以需要手动调用“UnloadUnusedAssets"来进行卸载,就会添加性能开销;如果管理不好的话,内存也会很高。

Ugui中UpdateBatches占用耗时较高

在ugui的UI元素刷新是基于canvas下的,所以当我们ui上的数据做SetActive的时候是会把Canvas下的所有元素都触发了,可以看到Rendering.UpdateBatches的调用会比较多

Ugui中的Rebatch优化

Rebatch发生在c++层面,指的是Canvas分析UI节点生成最优批次的过程,节点数过多会导致算法(贪心)的耗时比较长。对应SetVerticesDirty,当一个canvas中包含的mesh发生改变就会触发(setactivity,transform,颜色,文本内容等),canvas独立处理,互相不影响的,消耗在meshes按照深度和重叠情况排序,共享材质的检测。
优化点:

  1. Canvas动静分离,合理规划,不能太多(会提高DC的消耗)
  2. 减少节点层次和数量,合批计算量小,速度快。
  3. 使用相同材质贴图的ui尽量保持深度相同。

Ugui中的Rebuild

rebuild发生在c#层面,是ugui库中的layout组件调整RectTransform尺寸,Graphic组件更新material,以及mask执行Cull的过程,耗时和发生变化的节点数量基本呈现线性相关。

只有LayoutGroup的直接子节点,并且是 Graphic类型的(比如 Image 和 Text)会触发SetLayoutDirty。

Graphic改变的原因包括,基本的大小、旋转以及文字的变化、图片的修改等等,对应SetMaterialDirty。
优化点:

  1. 少用layout

堆内存优化

  • 无法及时的释放内存。
  • 过多的分配次数会到时内存中的碎片过多,从而无法开辟所需要的连续内存。
  • 内存的GC会照成卡顿

存在.tag的调用

获取tag的时候实际上是调用了get_tag()函数,从native层返回一个字符串,字符串的返回会造成堆内存的分配,unity也没有通过缓存的方法来做优化。

该类中存在对纹理的GetPixels()/GetPixels32调用

一般是为了获取指定mipmap层的全部像素信息,而图片上的像素往往是很庞大的。
会在堆内存上分配内存,用来存储纹理数据的像素信息,而且引擎不会对其进行缓存。

使用了Linq相关的函数调用

linq在执行过程中会产生一些临时变量,而且会用到委托。如果使用委托作为条件的判断方式,时间开销就会很高,并且会造成一定的堆内存分配。

存在对Renderer进行sharedMaterials的获取

对.sharedMaterials的调用,依旧会分配堆内存,每次调用都会分配一个用来存放Material的索引的数组。

存在Input.touches调用

.touches的实现是每次都会new一个数组touches,从而造成一定的堆内存分配。

存在对TextAsset调用

在获取bytes属性的时候,Unity会从Native层获取字节数组(byte[]),从而分配一定的堆内存

C#和lua的穿插引用

c#层会维护一个cache来引用那些被lua访问过的C#层对象,这是为了防止当lua中再次访问该c#对象时,这个对象已经被c#的gc给回收掉了。但是如果lua始终保持着对某个c#层对象的引用,那讲会导致无法被释放。造成堆内存泄漏。

[算法]归并排序

概念

使用的思想是分治思想。把需要排序的数组中重建分成两部分,然后前后两部分分别排序,需要一直分解下去,直到能两个值相互比较。然后再将排序好的两个部分合并在一起。

合并

我们需要申请一个临时数组,这个数组的大小和我们排序的两个组的大小之和一样。然后我们用两个下标i,j,来标识我们当前读到的是两个组的第几个位置,然后这两个下标的值来做比较,但一个值满足要求的时候,我们就把这个值放在临时数组里,然后这个下标后移一位,继续比较。当任意一个下标到达那个组的最后一位的时候,我们就不需要做比较了,直接把另外一组剩下的放在临时数组的最后。

void merge_sort_c(int [] a,int p,int r)
            {
                if (p >= r)
                    return;
                int q = (p + r) / 2;
                merge_sort_c(a,p, q);
                merge_sort_c(a, q + 1, r);
                merage(a, p, q, r);
            }
            void merage(int[]a,int p,int q,int r)
            {
                int i = p;
                int j = q+1;
                int k = 0;
                int[] temp = new int[r - p+1];
                while (i <= q && j <= r)
                {
                    if (a[i] <= a[j])
                    {
                        temp[k++] = a[i++];
                    }
                    else
                    {
                        temp[k++] = a[j++];
                    }
                }
                int start = i;
                int end = q;
                if (j <= r)
                {
                    start = j;
                    end = r;
                }
                while (start <= end)
                {
                    temp[k++] = a[start++];
                }
                for(int ii = 0; ii < r - p+1; ii++)
                {
                    a[p + ii] = temp[ii];
                }
            }
  1. 当我们把第二组的多出来的数据整体放在第一组后面,那归并排序是稳定排序

  2. 归并排序的时间复杂度o(nlogn)

  3. 归并排序的空间复杂度不是原地排序,因为在合并的时候需要临时空间。o(n)

[算法]选择排序

概念

也分为未排序区和排序区。但是选择排序每次都从未排序中找到我们要的元素放在排序区的最后。

int[] t = new int[10] { 43, 23, 432, 5, 2, 6, 43, 2, 67, 4 };
                for(int i = 0; i < t.Length; i++)
                {
                    int v = t[i];
                    int index = i;
                    for (int j = t.Length-1; j > i; --j)
                    {
                        if (v > t[j])
                        {
                            index = j;
                            v = t[j];
                        }
                    }
                    t[index] = t[i];
                    t[i] = v;
                }
                for(int i = 0; i < t.Length; i++)
                {
                    Console.WriteLine("{0}={1}", i, t[i]);
                }
  1. 因为只会交换一次我们找到满足条件的数据放在已排序的最后,所以这个排序的空间复杂度为o(1),是原地排序算法

  2. 这个排序不是稳定排序算法,因为每次都会去找剩下满足的元素和前面的元素进行交换

  3. 最坏,最好,和平均时间复杂度都是o(n*n).

[算法]插入排序

概念

插入排序就是把数组分为两个区间,一个是有序区间,一个是未排序区间。初始的时候有序区间就只有一个元素,那就是数组的第一个元素,然后取未排序区间中的元素往有序区间中插进去,一直等到未排序区间中的所有元素为空。

int[] t = new int[10] { 54, 23, 43, 2, 3, 5, 1, 2, 6, 7 };
                for (int i = 0; i < t.Length; i++)
                {
                    int v = t[i];
                    int j = i - 1;
                    for (; j > 0; --j)
                    {
                        if (v > t[j])
                        {
                            t[j + 1] = t[j];
                        }
                        else
                        {
                            break;
                        }
                    }
                }
                for(int i = 0; i < t.Length; i++)
                {
                    Console.WriteLine("{0}={1}", i, t[i]);
                }
  1. 插入排序算法不需要额外的空间,所以它的空间复杂度为o(1),是原地排序算法。

  2. 插入的时候只是插到它所需要的位置,不会修改它后面位置的顺序,所以是稳定排序算法。

  3. 最好情况下,我们要排序的数据是有序的,所以我们只需要做o(n)的时间复杂度。最坏的情况下,我们要排序的数据是逆序的,所以我们要做o(nn)的时间复杂度。在数组中插入一个数据的时间平均时间复杂度是o(n),我们需要执行n次操作,所以平均时间复杂度是o(nn)。

[算法]冒泡排序

概念

每次冒泡就是相邻的两个数进行比较,如果两个数据的关系不满足我们所要求的的,那我们就进行交换。一次冒泡操作至少会让一个元素移动到它所应该在的位置。

int[] t = new int[10] { 2, 3, 4, 2, 43, 54, 23, 43, 1, 67};
for(int i = 0; i < t.Length; i++)
    {
      for(int j = 0; j < t.Length-i-1; j++)
          {
              if (t[j+1] > t[j])
                 {
                   int temp = t[j+1];
                   t[j+1] = t[j];
                   t[j] = temp;
                  }
            }
     }
     for(int i = 0; i < t.Length; i++)
        {
          Console.WriteLine("{0}={1}", i, t[i]);
        }
  1. 因为只涉及到相邻的两个数的操作,所以它的空间复杂度是o(1)是原地算法排序。

  2. 因为只有当相邻的两个元素不一致的时候我们才会去交换两个元素的位置,所以这个排序也是一个稳定排序算法。

  3. 在最好的情况下就是说他们已经是有序的情况下他们的时间复杂度是o(n),在最坏的情况下就是说它们是反倒序的情况下,它的时间复杂度是o(nn);还有一种叫平均时间复杂度n(n-1)/4,一般来说我们就看做o(n*n)。

【算法】如何分析一个排序算法

排序算法的执行效率

  1. 最好情况,最坏情况,平均情况时间复杂度
    第一,有些算法会区分,为了好对比,我们最好都要做一下区分。
    第二,对于要排序的数据,有的接近有序,有的是完全无序的。有序度的不同,对于排序的执行时间肯定有影响的。

  2. 时间复杂度的系数,常数,低阶
    时间复杂度一般都是数据规模n很大的时候的一个增长趋势,所以在这个时候它就会忽略系数,常数,低阶。但是我们一般排序的数据规模10,100,1000,这样很小的规模的数,所以我们要考虑系数,常数,低阶。

  3. 比较次数和交换(或移动)次数
    基本比较的排序算法,会设计两种操作。一种是比较两个数的大小,一种是交换两个数的位置。所以我们在分析排序算法的时候这两个都要考虑。

排序算法的内存消耗

算法的内存消耗可以通过空间复杂度来衡量,但是针对排序算法的时候,我们新加了一个概念,原地排序。原地排序就是特指空间复杂度为o(1)的排序算法

排序稳定性

针对排序算法还有一个很重要的指标,就是稳定性。这个概念就是说待排序的元素中有值相等的元素,在排序之后相等元素的先后顺序不会发生改变
发生改变的排序算法叫不稳定排序算法
不发生改变的排序算法叫稳定排序算法
为什么排序的稳定性比较重要呢?一般来说我们实际使用的时候,很有可能对一组数据做两次排序。如果排序算法不稳定的话,那我们第二次排序就会覆盖掉第一次排序所产生我们需要的结果。