《Unity3D高级编程之进阶主程》第五章,资源的加载与释放

我们在计算机上编程,始终逃不过计算机的体系范围,其实对于编程来说不过是进程,线程,CPU,CPU缓存,内存,硬盘,GPU,GPU显存,我们无非就是围绕着这个几个关键点在做文章。从比较宏观的角度上来看,计算机本身的内容就这么些,假如我们暂时不去细想具体的逻辑细节,我们可以从大体上明白我们需要做的工作与这些内容有多大的关系。

我们制作的软件运行在进程上,进程是我们的载体,线程是进程的员工可以分担进程的负担,主线程是进程的一号员工,还有二号线程、三号线程等,这些线程有利于我们更大限度的利用多核CPU,这样就有可以让不只一个内核为我们工作。

无论在PC还是手机设备上,CPU都负担着多个进程的计算请求,它们不断的以时间片切换的概念来使用CPU,其进程中大部分都是由运算、硬盘的读写即IO、内存读写、分配与回收内存消耗着CPU的算力。因此当我们对程序做性能优化时大部分都是围绕着,如何减少运算量、如何减少硬盘读写、如何加速内存读写、如何减少内存分配与回收、以及如何更多的利用多核CPU加速运算等,这几个关键点来做的。

除了上述中普通的这几个概念,CPU缓存的存在加速了CPU执行效率,它让数据离CPU跟接近使CPU执行效率更高。只是CPU只认机器码由1和0组成的数据与指令,于是在机器码之上又有了汇编这种语言做助记符,使得我们能够不用去记住0和1的世界。只是这助记符还需要自己操作寄存器等直面硬件的事务让人们觉得太繁琐,对于现代越来越复杂和庞大的软件系统来说人类难以承受如此的复杂度,于是就有了更高级的语言+编译器来让编程变得更加简单,编译器它翻译了我们能更容易运用的各种语言包括C++、Java、C#等,其中C++被直接编译成机器语言,Java和C#则先翻译成中间语言再由虚拟机VM将中间语言翻译成的二进制码被CPU识别。

这些高级语言能够让人类更加专注于编写复杂和庞大的软件系统,从而解放了我们直面2进制指令和重新编写高级语言结构的痛苦。Unity3D引擎从这层意义上来说也是做了同样的事情,它将大部分对OpenGL/DirectX等图形接口底层的调用都封装在了引擎中,只需要我们了解业务层面的事务即可快速构建项目,解放了我们需要学习枯燥复杂底层的时间,可以将更多的注意力放在对业务的探索上。只是我们在制作过程中始终绕不过去的是底层的工作原理与流程,如果我们想要编写更加优秀的程序,就得学习和理解这些底层原理,也只有这样才能明白应该如何改善我们自己编写的框架逻辑使得它们在计算机中运行的效率比较高。

内存已经是除了CPU缓存外最快的数据存取地点了,所以要想更快的取得内容就要借助内存空间,但也需要适度使用内存。比如我们的移动设备中内存还不是那么廉价或者说容量还不足以可以肆无忌惮的任意使用,即使在PC机上内存已经足够大的情况下也要考虑其他软件进程的内存消耗,给PC机留出更多可用空间以支撑其他操作。

硬盘在现代已经是很廉价了,硬盘占用的大小已经很少被大家所诟病,不过背后还需要考虑网络宽带和IO读写问题。虽然硬盘廉价但宽带并没有那么多,大部分磁盘文件都需要从网络上下载下来,这也使得宽带的占用量是紧张的,我们经常需要控制文件资源大小,约束项目对硬盘的占用量使得我们制作的产品能更快的被用户下载到。由于硬盘是读写数据最慢的部件,因此我们在程序中也应该注意尽量减少对磁盘的读写操作,特别是日志这块内容,特别容易疏忽,大部分IO都是日志造成的,我们应该尽量想办法避免大量或频繁的操作日志文件。

GPU对CPU的优势是在处理运算上,例如在图形图像的计算上就得到了很好的体现,因此我们也常常将部分CPU运算转移到GPU去处理以此来分担CPU的负担。即使是现代显卡普及的情况下,GPU的好坏也参差不齐,我们仍然要尽最大的努力去学习和理解GPU的运作原理,以尽可能得发挥其最大优势。虽然移动设备架构中,GPU并没有附带显存,但显存的原理与系统内存是一样的,都是为了能让它让内核读取数据变得效率更高,更何况还有GPU缓存的存在,和CPU缓存都是异曲同工之妙,不同的是GPU缓存分的更细,它让每个内部单元都拥有自己的缓存,这也使得并行计算更高效。

前面的章节说了这么多关于算法、框架、结构、数学、图形学等,我希望能用更大的角度来观察总结我们所天天需要接触的编程工作。每条语句,每个结构,每个编码都能知道我们现在所要围绕的是哪个节点,是否能通过对节点的优化来让当前程序的执行更加高效,让程序跑在设备上时更加流畅。
说了这些关于计算机本身的事,其意图是想从宏观的角度看问题,抛开架构、系统逻辑、框架结构等细节来看看我们所面对的工作到底是个怎样的世界。从根本上看我所说这些并不是什么特别具体底层的东西,但是也可以从另一个角度了解我们所面对的编程工作,对我们未来的技术方向兴许会有些帮助。

前面讲了很多关于计算机本质的东西,这节我们主要来讲一下关于资源的内容。

资源加载的多种方式

资源的数据格式其实有很多种并不一定要依照引擎来,但如果自己另开辟一种格式来做为自定义资源格式确实耗时耗力,性价比太低。也有极少项目的那种资源保密性要求很高的,自己会去定制资源的格式,但既然使用了Unity3D商业引擎来提高开发效率,加快迭代速度,完全可以借助Unity3D自身的机制来完成加密工作。这里我们主要还是来说说以Unity3D自身资源格式为基础的加载方式。

我们可以把资源加载分为阻塞式和非阻塞式。到底什么是阻塞式什么是非阻塞式呢?简单来说,阻塞是当前资源文件加载完了才能执行下一条语句,非阻塞则是开启另一个线程加载资源文件,而主线程则可以继续执行下面的程序,当加载完毕时再通知主线程。

下面我们来介绍下在Unity3D中阻塞式的加载方式。

1.Resource.Load

Resource.Load是Unity3D中最传统最古老的资源加载方式,Unity3D以Resources这个名字的文件夹作为根目录来加载资源其下面的资源文件。

当Unity3D的项目被Build构建时,Unity3D打包了Resources文件夹的所有资源文件成为1个或几个资源文件(将资源文件合并成了1个或几个资源包文件)放入包内。当我们在程序中调用Resource.Load时则从这几个资源文件中查找并从中提取数据作为资源放入内存。

这个资源包文件会被Unity3D在打包时压缩,这使得包体的大小会适度的减少,压缩的另一面是解压,因此在当我们通过调用Resource.Load加载资源时也增加了解压的算力损耗。这也是很多项目不乐意使用Resources的缘由,解压消耗带给他们不必要的开销,CPU算力资源比硬盘资源珍贵的多。

2.File read + AssetBundle.CreateFromMemory + AssetBundle.Load

我们也可以先通过文件操作加载资源文件,再通过AssetBundle.CreateFromMemory的方式把byte数据转换成AssetBundle格式,再通过AssetBundle.Load从AssetBundle中加载某个资源。

这种方式虽然费时费力,几乎消耗了2倍的内存以及1.3倍左右的算力,但是这种方式可以让我们加入些许自定义功能。比如能在加载AssetBundle前做加解密操作,由于加载AssetBundle前自主加载了文件,使得文件数据在变为Assetbundle实例前可以自主的把控,我们可以先用文件操作获得的数据解密,再转换成AssetBundle实例,最后交给资源控制程序处理。只是这种获得加解密AssetBundle的能力,是需要付出代价的,代价就是内存和GC(内存的分配与销毁)。

由于用文件操作时完全读入了整个资源文件的数据,导致当前还不需要的资源也一并读入了内存,增大了内存消耗,另外转换成AssetBundle后的byte数据也不再有用处,只能等待GC处理掉,这大大增加了内存分配和销毁的CPU负荷。

3.AssetBundle.CreateFromFile + AssetBundle.Load

我们还可以使用通过直接加载文件变成AssetBundle的方式,再通过AssetBundle.Load接口来获得资源。这也是项目中常用的方式,它即没有压缩与解压,也可以不用一下子将所有内容加载到内存,能够做到按需加载。

这种加载方式最大的好处是能够按需分配内存。AssetBundle.CreateFromFile接口并不会把资源文件整个加载进内存中,而是先加载文件中的数据头,通过数据头中的数据去识别各个资源在文件中的偏移位置。当我们调用AssetBundle.Load时,先从数据头中的数据查找对数据应偏移量,根据数据头中对应资源偏移量的记录,找到对应的资源位置,从而将数据加载到内存,因此我们可以说它是按需加载,更合理的利用了内存节省了CPU消耗。

除了阻塞式的加载,在Unity3D中我们还有非阻塞式的加载方式:

	1.AssetBundle.CreateFromFile + AssetBundle.LoadAsync
	2.WWW + AssetBundle.Load
	3.WWW + AssetBundle.LoadAsync
	4.File Read all + AssetBundle.CreateFromMemory + AssetBundle.Load
	5.File Read all + AssetBundle.CreateFromMemory + AssetBundle.LoadAsync
	6.File Read async + AssetBundle.CreateFromMemory + AssetBundle.Load
	7.File Read async + AssetBundle.CreateFromMemory + AssetBundle.Load
	8.File Read async + AssetBundle.CreateFromMemory + AssetBundle.LoadAsync

这几种方式分别是由文件读取和AssetBundle异步加载的接口组合而成。这8种组合中前2种为主流的异步加载方式,其中第1种用的比较多,因为大多数资源文件都会在游戏开始前进行比对和下载,所以没必要使用WWW的形式从本地读取或从网络下载。

我们在实际项目中常常会困扰是否要使用非阻塞加载的问题。常有人说阻塞式加载这么好用,为什么还要用非阻塞式。首先我们不要为了异步而异步,有人会觉得异步更高级,如果只是为了异步而做异步并没有意义。大部分情况下我们在使用阻塞式接口加载资源时,都会遇到一个问题,在某一帧加载的资源很多,加载完毕后需要实例化的资源也很多,从而导致画面在这一帧耗时特别长,画面卡顿现象特别严重,这种情况用运营同学们的话说“对用户来说不友好”。通常我们为了能更好更平滑的过度场景,我们需要把要加载和实例化的时间跨度拉长,这样在每帧时间中消耗的CPU被分配的均匀些,也就不会集中在某一帧处理所有资源问题,虽然增加了些许等待时间,却能平滑过渡到最终我们需要的画面。

具体怎么做呢,其实并不复杂的,可以先获取所有需要加载的资源,放入队列中,每次加载限制N个(N可以根据实际情况调整),如果已经加载过的就直接通知逻辑程序实例化,不曾被加载的则调用加载程序并将调用后的加载信息(AssetBundleRequest)放入‘加载中’队列,不开协程而是用Update帧更新去判断‘加载中’队列中是否有完成的,每加载完毕一个资源先从‘加载中’队列里移除,再通知逻辑程序再进行实例化,在实例化期间我们也要注意为每帧分配数量合适的实例化,如果实例化太多也容易造成集中消耗CPU算力的现象导致卡顿。就这样直到队列中的请求加载完毕为止,继续下一个N个加载请求。当然这里也需要做些判断,例如已经在加载队列里的资源不重复加载等一些避免重复加载的判断。

AssetBundle的引用计数方式卸载

Assetbundle在加载后我们需要寻求释放,只有加载没有释放内存只会不断攀升。该怎么释放就成了问题,因为资源使用的地方太多,太庞杂,所以为了能更好的知道什么时候该释放资源,我们需要制定一个规则,在遵守这个规则的前提下,我们就能知道什么时候资源没有被再使用了,有多少个地方仍然在使用。

引用计数就是判断这种释放依据很好的技巧,具体方式为如下:

我们先对AssetBundle包装一个计数器(是个整数),当需要某个AssetBundle时先加载所有依赖的AssetBundle,每加载一个AssetBundle就为该AssetBundle的引用计数加1。

我们在调用加载AssetBundle时其UnityEngine.Object则通常会通过Instantiate进行实例化,每次实例化时对该AssetBundle引用计数都必须加1处理。用UnityEngine.Object实例化时做引用计数加1的手法,又消耗了些许我们的注意力而且容易遗漏,所以我们通常选择封装接口,不让UnityEngine.Object暴露在外面,引用计算的操作只在封装的接口中进行,这样就节省了人额外的注意力,少一点主意力的消耗,就少一些遗漏。

如果是Texture贴图这种不需要进行实例化的资源则最好不要被再次被引用,因为被再次引用会导致引用计数的错乱,我们可以选择每次当需要Texture时通过查看AssetBundle是否加载,有则直接取,没有则加载后再取,每次取资源时都对相应的AssetBundle计数加1。

当Destroy销毁实例或者不需要用资源时,则统一调用某个自定义的Unload(假设这个接口名字是自定义类AssetBundleMrg.Unload)接口并附上加载时的关键字(为了能更快的找到AssetBundle实例),从而将对应的AssetBundle的引用计数减1。卸载资源时会减少引用计数,当该AssetBundle引用计数为0,则认为可以进行AssetBundle卸载,此时可以立即卸载。

不过问题又来了,及时的卸载也会有问题,因为每次都卸载后又会有需要该资源的时候,这样我们又会需要再次加载Assetbundle,这样就重复消耗了IO和许多CPU算力,为了避免这种情况的频繁发生,我们可以通过增加空置倒计时时间来给卸载AssetBundle一个预留时间。当需要卸载时,让AssetBundle进入倒计时,比如5秒,5秒内仍然没有任何程序使用这个资源则立即进行卸载,如果5秒内又有程序加载该AssetBundle资源则继续使用引用计数来判断是否需要进入卸载倒计时。

不过还是会有个小问题,如果大量资源在同一时间卸载,就会造成大量资源同一时间进入倒计时,倒计时完毕同时进行卸载,也会带来1帧消耗过大的问题,毕竟资源的卸载时内存的消耗,大量的内存在同一时间销毁会带来大量的CPU消耗。为了避免大量资源同一时间卸载,我们可以对倒计时进行随机,在2-5秒的时间内随机一个值,让卸载分散在这个时间段内,让卸载的消耗分散在不同时间点上让CPU消耗更加平滑。

AssetBundle的打包与颗粒度大小

Unity3D对AssetBundle的封装做的很好,当我们在打包AssetBundle时Unity3D会自动去计算AssetBundle与AssetBundle之间的依赖关系,所以我们能很轻松的将资源打的很细(贴图,网格,Shader,Prefab,每个资源可以独立存在于文件)。

这使得我们能很轻松得让一个AssetBundle文件只装一个资源并且控制起来也很顺手,只要在加载时读取存有依赖关系的AssetBundle(AssetBundleManifest实例数据)就能得到AssetBundle之间的依赖关系数据,根据取得的依赖关系数据我们就能轻松的加载相关的AssetBundle。

在Unity3D中既然AssetBundle颗粒度可以很容易的缩放,那么我们就需要考虑颗粒度的大小到底对项目产生多大的影响。我们说说左右两种极端状态下的表现。

一种为颗粒度极粗状态,所有资源集合起来只打成一个AssetBundle包,所有逻辑程序要的资源都从这个仅有的AssetBundle里取。引用计数,在这里已经完全没有了用处,由于只有一个AssetBundle已经完全没有卸载的可能了。这导致了内存只会逐步增大,而绝不会因为不再需要某资源而卸载AssetBundle(当前AssetBundle的卸载机制中没有只销毁某部分资源的功能)。

我们来看看整个过程,一个很大的资源文件从网络上下载下来,解压后成为仅有的一个AssetBundle文件,然后我们读取它并从中获得资源。从这个过程来看只有一个AssetBundle的极限状态下,更新资源非常方便,文件操作的次数极低,读取AssetBundle文件信息完全没有障碍,解压的IO效率也非常高,甚至解压时不需要创建很多文件从IO上会相对比较快些,同时由于只有一个文件内容所以打包的压缩率也是最大化的。我们看到除了不能不能更好的管理内存,热更新资源时会更费流量外,其他方面还是比较方便的。

另一种为颗粒度极细状态,所有贴图、网格、动画、Shader、Prefab都各自打自己的一份AssetBundle(一份AssetBundle只带一个资源)。为了能更有效的控制内存,AssetBundle之间的依赖关系和引用计数在这里的用处会非常大。通过引用计数和依赖关系,我们能很有效的控制逻辑系统中需要的资源和内存中的资源是一致的。

我们来看看整个过程,根据资源文件列表从网上下载下来所有AssetBundle资源文件,对每个压缩过的资源文件进行解压,当需要某个资源时从AssetBundle读取资源并且读取前先根据依赖关系读取需要的AssetBundle资源,并且对所有加载过的AssetBundle引用计数加1。当我们调用卸载接口时,对当前卸载的AssetBundle引用计数减1,并且对存有需求上依赖关系的其他AssetBundle也相应减1(由于当前资源卸载后对其他依赖资源不再引用),如果引用计数为0则启动卸载程序。

我们从这个过程看来,一个极限细分颗粒度状态下的AssetBundle机制,文件操作数量会很大,IO操作的时间会因为文件增多的增大许多,导致下载时间拉长,下载完毕后解压的总时间也同样会更长,打包时由于每个文件单独打包压缩因此压缩比率会降低,压缩时间更长。当然这些‘坏处’都是次要的,最重要的是我们很好的控制了内存,当我们需要热更新资源时会更小巧和方便。

大多数项目都选择中间状态,颗粒度会选择使用prefab和material两种形式打包,很少会细化到贴图和网格(除了UI上的Icon),但也不少会将动画另外拎出来打包,因为这样就可以让动画按需加载了,或者以模块化的形式打包将某一个模块下的资源统一打包成一个资源文件,等各个项目都有所不同。太细化的打包方式也会让路径查找变得困难,但也不用太担心,因为可以我们可以想出更好地解决方案,比如自动生成代码,将资源以枚举方式生成数据结构,让查找变得更高效。

上述分析了两种极限状态下的利弊,我们可以根据自己项目的需求来定制AssetBundle打包机制。

· 书籍著作, Unity3D, 前端技术

感谢您的耐心阅读

Thanks for your reading

  • 版权申明

    本文为博主原创文章,未经允许不得转载:

    《Unity3D高级编程之进阶主程》第五章,资源的加载与释放

    Copyright attention

    Please don't reprint without authorize.

  • 微信公众号,文章同步推送,致力于分享一个资深程序员在北上广深拼搏中对世界的理解

    QQ交流群: 777859752 (高级程序书友会)