这部分主要讨论了AssetBundle的如下知识:
- AssetBundle的基础知识
- 使用AssetBundle的核心API
- AssetBundle自身的加载和卸载
- AssetBundle中的Asset和Object的加载和卸载
- AssetBundle之间的依赖
对于更多的关于AssetBundle使用的实践知识,参照下一节。
1. 概述
AssetBundle系统是Unity提供用来存储一个或者多个文件的压缩格式,利用这种格式Unity可以索引到其中的资源。设计的主要目的是提供一种和Unity序列化系统兼容的数据分发方式。AssetBundle是用来安装应用包之后用来分发和更新非代码资源的最佳方式。这种机制允许开发者减少安装包中的Asset体积,减少运行内存的压力,而且可以根据设备和平台针对性加载资源。
对于针对移动设备开发的Unity项目而言,理解AssetBundle的工作原理尤其重要。
2. AssetBundle是什么?
一个AssetBundle包含两个部分的内容:头和数据段。
头是在AssetBundle创建的时候由Unity生成的,主要包括AssetBundle的标识符、是否被压缩和清单(manifest)信息。
清单包括一个以AssetBundle中Object名字作为key的查找表,查找表中的每条记录包括了Object在AssetBundle数据段中的字节偏移量。这个查找表是通过STL的std::multimap实现的。虽然在不同的平台上,STL的实现方式不尽相同,但是大部分平台上是通过平衡查找树实现的,Windows和基于OSX的平台(包括iOS)是用红黑树。由此可以知道,构建清单的时间复杂度是O(Nlog(N)),N代表AssetBundle中Object的数目。
数据段是根据AssetBundle中Asset的原始数据序列化之后生成的。如果数据段被压缩,那么先执行序列化操作,然后再执行压缩过程。
在Unity 5.3 之前的版本,AssetBundle内的Object是不能被独立压缩的。导致的结果就是,如果从压缩后的AssetBundle中读取某个Object的时候,就需要将整个AssetBundle进行解压操作。通常情况下,Unity就会缓存一个AssetBundle的解压副本放在内存,用来改进后面对同一个AssetBundle读取的性能。
Unity 5.3 加入了LZ4的压缩选项。如果使用LZ4压缩选项创建AssetBundle,Unity会针对AssetBundle中的每一个Object进行独立压缩,Unity会在磁盘上存储压缩后的AssetBundle。这样的话,Unity就可以独立解压AssetBundle中的某个Object,而不用对整个AssetBundle进行处理。
3. AssetBundle管理器
Unity在Bitbucket上开源了一个AssetBundle管理器,其中用到了很多本节讨论到的API,这个代码库是一个很好的参考例子。
值得关注的特性有“模拟模式”。当在Unity编辑器中开启这个选项的时候,对AssetBundle的加载请求就会重定位到/Assets/的原始文件夹中,方便开发者不用重新生成AssetBundle。
AssetBundle管理器的地址链接为:
https://bitbucket.org/Unity-Technologies/assetbundledemo
AssetBundle的加载
在Unity 5版本中,AssetBundle可以使用四个API进行调用。这四个API使用的主要差别在:
- AssetBundle是否被压缩以及被压缩的格式(LZMA、LZ4)
- AssetBundle被加载的平台
这四个API分别如下:
- AssetBundle.LoadFromMemoryAsync
- AssetBundle.LoadFromFile
- WWW.LoadFromCacheOrDownload
- UnityWebRequest.DownloadHandlerAssetBundle
4.1 AssetBundle.LoadFromMemoryAsync
Unity不推荐使用这个API。
在Unity5.3.3之前的版本中,API的名字是AssetBundle.CreateFromMemory。
AssetBundle.LoadFromMemoryAsync从托管代码的字节数组中(如C#的byte[])加载AssetBundle。这个方法通常从字节数组中加载原始数据并且拷贝到新分配的连续内存中。如果AssetBundle采用LZMA压缩,会在复制的时候执行解压操作。未采用压缩或者使用LZ4算法压缩的AssetBundle会被完整拷贝。
这个API消耗的内存量是AssetBundle体积的两倍:API创建的内存中的副本,还有传入API的托管代码中的字节数组副本。从这个API创建的AssetBundle中加载的Asset会在内存中重复三次:一份在托管代码字节数组中,一份是AssetBundle在内存中的拷贝,第三份是在GPU或者Asset自己占据的系统内存中。
4.2 AssetBundle.LoadFromFile
在Unity 5.3. 之前的版本中叫做AssetBundle.CreateFromFile。
AssetBundle.LoadFromFile被设计成从存储器,如硬盘或者SD卡中加载未被压缩的AssetBundle的高效API。
如果AssetBundle没有被压缩或者使用LZ4压缩,API工作流程如下:
移动设备:API只会加载AssetBundle的头部,将剩下的数据放在磁盘。当加载方法被调用的时候(如AssetBundle.Load),或者这些Object的Instance ID被引用到的时候,AssetBundle中的Object才会按需加载。这种场景下没有额外的内存消耗。
Unity编辑器:API会将整个AssetBundle加载进内存,所有的自己都会从磁盘中读取出来,类似于AssetBundle.LoadFromMemoryAsync方法。这个API会导致剖析器中出现性能高峰,但是应该不会影响到真实设备上的性能,在正式发布之前可以利用真机调试确认。
注意:,在Android设备上,Unity 5.3和之前的版本中,API尝试从StreamingAsset加载AssetBundle的时候会失败,因为这个路径下的内容属于一个压缩的.jar
文件。在Unity 5.4之后的版本中,这个问题已经被修复。
注意:,AssetBundle.LoadFromFile对于使用LZMA压缩的AssetBundle会失败。这个问题已经在Unity 5.3.7f1,Unity5.4.3f1之后的版本中被修复。
4.3 WWW.LoadFromCacheOrDownload
WWW.LoadFromCacheOrDownload对于从远程服务器和从本地存储器中加载Object都有效。本地文件可以通过file://加载,如果AssetBundle已经在Unity缓存中,方法和AssetBundle.LoadFromFile完全一致。
如果AssetBundle没有被缓存,WWW.LoadFromCacheOrDownload从源头读取AssetBundle。如果AssetBundle被压缩,就会开启子线程用来对AssetBundle解压并且写入到缓存中,否则就有子线程直接写入缓存。
一旦AssetBundle被缓存,WWW.LoadFromCacheOrDownload会从缓存的非压缩AssetBundle中加载头部信息,这时候就和AssetBundle.LoadFromFile完全一致了。
注意:当数据使用固定大小的缓冲区解压并且写入缓存的时候,WWW对象会在内存中保持一个完整的AssetBundle字节的副本。这个额外的副本用来支持对WWW.bytes属性的获取。
基于WWW对象中缓存AssetBundle字节造成的字节负担,推荐在使用WWW.LoadFromCacheOrDownload的时候,确保AssetBundle的大小在几MB内。而且,在内存比较敏感的时候,确保某段时间只会加载一个AssetBundle,防止内存出现压力。
注意:,每次调用这个API的时候,都会产生一个新的子线程。当多次调用的时候,需要注意会创建很多的子线程,最好在代码中防止出现并行处理多个AssetBundle下载的情况。
4.4 AssetBundleDownloadHandler
在Unity 5.3之后的版本中,针对移动设备平台,UnityWebRequest提供了比WWW更为灵活的处理方法。UnityWebRequest允许开发者指定Unity如何处理下载的数据来去掉不必要的内存使用。使用UnityWebRequest最简单的方法就是调用UnityWebRequest.GetAssetBundle。
而对于本节来讲,最值得关注的API则是DownloadHandlerAssetBundle。这个API使用方式和WWW.LoadFromCacheOrDownload类似。使用一个子线程,将下载的数据放入固定大小的缓冲区,然后将缓冲数据放入临时存储或者AssetBundle缓存中,这取决于DownloadHandler被定义的方式。LZMA压缩过的AssetBundle在下载之后会被解压,将解压后的内存存入到缓存中。
所有的这些操作都发生在原生代码中,所以没有增大堆内存的风险。而且,DownloadHandler也不会保存所有下载字节的原始代码的副本,所以减少了下载AssetBundle的内存使用。
当下载完成之后,DownloadHandler中的AssetBundle属性提供了获取AssetBundle的接口,类似于调用AssetBundle.LoadFromFile方法。
UnityWebRequest同时提供了和WWW.LoadFromCacheOrDownload相同的缓存方法。如果提供一个缓存信息给UnityWebRequest对象,而且这个对象已经存在Unity的缓存之中,那么对应的AssetBundle能够立即被获取到,和调用AssetBundle.LoadFromFile具有相同的效果。
注意:Unity的AssetBundle缓存在WWW.LoadFromCacheOrDownload和UnityWebRequest之间是共享的。任何由其中一个API创建的缓存都可以被另外一个API获取到。
注意:和WWW不同的是,UnityWebRequest系统会管理一个内部的线程池和一个内部的线程系统来确保不会同时产生很多线程同时下载的情况。就目前而言,线程池的大小是不可以配置的。
4.5 建议
通常而言,在条件允许的情况下,应该使用AssetBundle.LoadFromFile接口。这个API无论是从运行时间,磁盘空间还是运行内存而言都是最佳选择。
对于必须要下载或者通过补丁获取AssetBundle的话,对于Unity 5.3版本之后的工程则推荐使用UnityWebRequest,对于Unity 5.2和以前的版本而言,使用WWW.LoadFromCacheOrDownload。
使用WWW.LoadFromCacheOrDownload的时候,强烈建议工程的AssetBundle使用量不要太高,防止出现内存泄漏造成应用闪退。
对于大部分工程而言,AssetBundle文件大小不应该超过5MB,而且不应该出现两个AssetBundle同时下载的情况。
当使用WWW.LoadFromCacheOrDownload或者UnityWebRequest的时候,确保下载代码在处理完加载AssetBundle之后调用了Dispose方法。另外,C#中的using语句能够确保WWW或者UnityWebRequest的对象被安全销毁。
而对于大型的工程团队和有着特殊需求的团队而言,使用一个定制化的下载器是必需的。写一个自定义的下载器并不是一个非常复杂的工程任务,而且要确保定制化的下载器和AssetBundle.LoadFromFile兼容。
5. 从AssetBundle中加载Asset
从AssetBundle中加载UnityEngine.Object有三个不同的API,LoadAsset
,LoadAllAssets
,LoadAssetWithSubAssets
,这三个API有着对应的异步版本,加上Async后缀即可。
同步调用的API通常比异步方法至少要快一帧。对于Unity 5.2之前的版本都存在这个问题,在Unity 5.2之前的版本中,所有异步API每帧至多加载一个Object。这就意味着LoadAllAssetsAsync和LoadAssetWithSubAssetAsync会比对应的同步方法要慢一些,但是在Unity 5.2之后的版本中已经被修复。异步加载也可以在每帧加载多个Object,只要不超过每帧分配的时间即可。
LoadAllAssets应该在需要加载多个独立的Object时候使用,只有当AssetBundle中的主Object和所有的Object需要被加载的时候使用。和其他两个API相比,LoadAllAssets比多次调用LoadAsset要快一些,因此,如果需要被加载的Asset很多,而且AssetBundle中不超过2/3的内容需要同时被加载,考虑将AssetBundle拆分成小的AssetBundle,并且使用LoadAllAssets方法。
LoadAssetWithSubAssets应该在需要加载某个包含很多内嵌Object的复合Asset的时候使用,例如某个FBX带有很多动画,或者一个包含着很多小图的图集文件。只有需要的Object都来自同一个Asset,而且和其他很多不相关的Object存放在同一个AssetBundle的时候,使用这个API。
对于其他的情况,使用LoadAsset和LoadAssetAsync可以满足。
5.1 加载细节
UnityEngine.Object的加载是在主线程之外的子线程中完成的。Unity系统中的非线程敏感部分都放到子线程中完成了。如从网格中创建VBO,压缩纹理操作等。
在Unity 5.3之前的版本中,连续加载Object和某些特定的Object加载只能在主线程中完成,这个操作过程称为“整合”。当子线程完成加载Object的数据之后,就会暂停,等到新载入的Object并入到主线程之后才会继续工作。
从Unity 5.3版本之后,Object的加载也可以并行处理了。多个Object可以在子线程上进行反序列化,处理和整合。当Object完成加载之后,Awake回调被执行,Unity引擎可以在下一帧可以获取到这个Object。
同步版本的AssetBundle.Load方法会暂停主线程直到Object加载完成。在Unity 5.3版本之前,异步版本的Asset.LoadAsync方法只有当需要将Object整合到主线程的时候才会暂停主线程。加载过程也是按照时间进行分片处理,所以Object的操作只会占到某一帧时间的几毫秒到几十毫秒。消耗的毫秒数可以通过Application.backgroundLoadingPriority指定:
- ThreadPriority.High :每帧最多消耗50ms
- ThreadPriority.Normal :每帧最多消耗10ms
- ThreadPriority.BelowNormal:每帧最多消耗4ms
- ThreadPriority.Low:每帧最多消耗2ms
在Unity 5.1之前的版本,异步的API每帧只能整合一个Object。这个BUG已经在Unity 5.2的版本中被解决,可以在一帧之中加载并整合多个Object,只要不超过设置的时间片即可。AssetBundle.LoadAsync通常比同步方法消耗的时间长一点,因为发出LoadAsync调用到Object可以被Unity获取到至少会有一帧的延迟。
使用真实的工程进行测试的结果为,在Unity 5.2之前的版本中,在低端设备上加载一个比较大的纹理,使用同步方法消耗为7ms,使用一步步方法则需要70ms。在Unity 5.2之后的版本中,两者之间几乎没有差异。
5.2 AssetBundle之间的依赖关系
在Unity 5.2之后的AssetBundle系统中,AssetBundle之间的依赖关系可以通过两个不同的API进行追踪,针对使用的平台选择使用。在Unity编辑器中,AssetBundle之间的引用关系可以通过AssetDatabase进行查询。AssetBundle分配和依赖可以通过AssetImporter API进行获取和更改。
在运行阶段,Unity提供了额外的API来记载AssetBundle生成的时候产生的以来信息,通过基于ScriptableObject的API:AssetBundleManifest。
当某个AssetBundle中的Object引用到了另外一个AssetBundle的Object的时候,就认为这个AssetBundle引用到了另一个AssetBundle。
AssetBundle存放着Object的原始数据,通过文件GUID和局部ID来定位到AssetBundle中的每一个Object。
当Object被加载之后,实例ID就会被引用到,而且当Object对应的AssetBundle被加载之后,Object就会指定一个合法的实例ID,至于AssetBundle被加载的先后顺序并不重要。然而,在加载Object之前必须要加载这个Object引用到的其他Object所在的AssetBundle。Unity并不会加载某个父AssetBundle之后自动去加载所有的子AssetBundle。
例子:
假设材质A引用到了纹理B。材质A被打包到了AssetBundle 1中,而纹理B则在AssetBundle 2中。
在这种情况下,AssetBundle 2必须要在加载AssetBundle 1中的材质A之前加载进内存。
注意,这并不意味着AssetBundle 2必须要在AssetBundle 1之前被加载,或者纹理B一定要显式从AssetBundle 2中被加载。
当AssetBundle 1被加载之后,Unity并不会主动去加载AssetBundle 2。这个过程必须通过脚本去实现。至于具体使用哪个API去加载则没有什么关系,上面提到的API都可以来完成。
5.3 AssetBundle清单
当使用BuildPipeline.BuildAssetBundles执行AssetBundle打包过程的时候,Unity将每个AssetBundle的依赖信息也会序列化成一个对象。这部分的数据被存放在一个独立的AssetBundle中,包含一个AssetBundleManifest类型的对象。
这个Asset会存放在AssetBundle建立的同一个文件夹下,名字和文件夹名称相同。如果工程将AssetBundle打包放在(projectroot)/build/Client下面,那么这个AssetBundle的路径就是(projectroot)/build/Client/Client.manifest.
包含了manifest的AssetBundle和其他的AssetBundle完全一致,可以一样被加载,被缓存,被卸载。
AssetBundleManifest提供了GetAllAssetBundles接口来获取所有打包的AssetBundle以及两个方法查询特定AssetBundle之间的依赖关系。
AssetBundleManifest.GetAllDependencies返回一个AssetBundle的所有引用,包括这个AssetBundle的直接引用,以及引用的引用。
AssetBundleManifest.GetDirectDependencies只会返回AssetBundle的直接引用。
注意,这两个API都会分配字符串数组。请保守使用这两个API,不要在性能很敏感的生命周期方法中执行。
5.4 推荐
当用户进入到性能敏感的部分的时候,例如关卡或者世界地图中,请尽可能加载只会被用到的Object。对于移动平台而言更是这样,因为移动设备访问内存的速度偏慢,而且对内存的操作更容易触发GC操作。