[转] AssetBundle 基本知识

AssetBundle 基本知识。

原文地址:http://blog.shuiguzi.com/2017/01/08/AssetBundle_Fundamentals/


这是 Unity 5 [资产, 资源和资源管理系列文章] 的第四篇。

这篇将会探讨 AssetBundle,并介绍构建 AssetBundle 的基本系统和与它交互的核心 API。这篇文章也会特别地介绍 AssetBundle 和 AssetBundle 内的资产与对象的加载和卸载。

更多关于 AssetBundle 的使用模式和最佳实践,请看本系列文章中的下一篇。

概况

AssetBundle 系统提供了一种可以把一个或者多个文件存储到一个能被 Unity 识别的存档的方法。这个系统的目的是提供一个可以和 Unity 的序列化系统兼容的数据推送方法。AssetBundle 是 Unity 用来在应用安装后推送或者更新非代码内容的主力工具。开发者能够用它来减少资产的体积,缩短运行时内存压力,以及在不同的用户终端有选择的加载内容。

理解 AssetBundle 的工作原理是开发一个成功的移动设备项目必不可少的。

什么是 AssetBundle

一个 AssetBundle 有两部分组成:一个 Header 和一个数据段。

Header 是 Unity 构建 AssetBundle 时创建的。它包含了一些 AssetBundle 的信息,比如 AssetBundle 的识别符,AssetBundle 是否压缩和一个清单。

清单是一个由对象名称当做关键词的查找表格。每个条目都提供了用来标识对应对象在 AssetBundle 数据段中位置的字节索引。在大部分平台上,这个操作表是通过 C++ STL 中的 std:multimap 来实现的。虽然这个算法在不同的平台上有实现有差异,但是大部分都是平衡搜索树的变种。比如 Windows 和 OSX 衍生的平台(包括 iOS)使用的是红黑树。随着 AssetBundles 内资产数量的增长,构建清单的时间将大于会线性的增长。

AssetBundle 数据段包含了 AssetBundle 中资产序列化后的源数据。如果数据被压缩,则 LZMA 算法已被应用于串行化字节的集合序列 - 也就是说,所有资产被串行化,然后完整的字节阵列被压缩。

在 Unity 5.3 之前,对象不能被单独的压缩到 AssetBundle 内。所以在 5.3 之前的版本的 Unity 如果想从一个压缩过之后的 AssetBundle 内读取一个或者多个对象,Unity 必须解压整个 AssetBundle。平常,Unity 会缓存解压后的 AssetBundle 来提高之后有加载请求时性能。

Unity 5.3 加入了一个 LZ4 压缩选项。选择了 LZ4 压缩后,AssetBundle 内对象会被单独压缩,这样 Unity 就可以把压缩后的 AssetBundle 存储到硬盘上, 并且解压单个对象而不需要解压整个 AssetBundle。

AssetBundle Manager

Unity 开发并维护了一个 AssetBundle Manager 的参考实现,并把放到了 Bitbucket 网站上。这个 Manager 用到了这篇文章中的很多概念和 API 的细节,并且为任何一个必须集成 AssetBundle 作为它的资产管理流程的项目提供给了一个好的起点。

这个 Manager 值得注意的功能是引入了 “模拟模式”。当这个模式开起的时候,这个模式会透明地把对 AssetBundle 内资产的请求重定向到对 Project Assets 文件夹下中的原资产。这可以让开发者开发过程中不需要重新打包 AssetBundle。

AssetBundle Manager 是开源项目,可以在 这里 找到。

加载 AssetBundle

在 Unity 5 中,AssetBundle 可以被 4 中不同 API 加载。这 4 个的行为基于下面两个条件:

  1. AssetBundle 是否开启 LZMA 或者 LZ4 压缩
  2. 加载 AssetBundle 的平台

这 4 个 API 分别是:

AssetBundle.LoadFromMemoryAsync

Unity 不推荐使用这个 API。

Unity 5.3.3 更新: 这个 API 在 5.3.3 被重命名,在之前的版本中叫 AssetBundle.CreateFromMemory, 但是功能并没有改变。

AssetBundle.LoadFromMemoryAsync 从托管代码字节数组(C# 中的 Btye[])中加载 AssetBundle。它总是会从本地内存中开辟一段连续内存,然后从托管代码的字节数组中拷贝源数据到这段新分配的内存中。如果 AssetBundle 是 LZMA 压缩格式的,拷贝过程中 AssetBundle 会被解压。而 LZ4 压缩格式的 AssetBundle 会原原本本的拷贝过去。

这个 API 消耗内存的峰值最少是 AssetBundle 大小的两倍:一个是 API 创建的本地内存,一个是传递给 API 的托管代码数组。 利用这个 API 加载资产之后,这个加载资产将会在内存中出现 3 份拷贝:一个是托管代码字节数组,一个是 AssetBundle 的本地内存,第三个是在 GPU 或者系统内存中的资产本身。

AssetBundle.LoadFromFile

Unity 5.3 更新: 这个 API 在 Unity 5.3.3 被重命名。在之前的版本中叫做 AssetBundle.CreateFromFile。它的功能并没有改变。

AssetBundle.LoadFromFile 能从本地存储中(如硬盘和 SDK 卡)高效的加载未压缩的 AssetBundle。如果 AssetBundle 未压缩或者使用 LZ4 压缩,这个 API 有如下表现。

移动设备: API 只会加载 AssetBundle 的 Header, 其他的数据保留在磁盘中。当调用加载的方法(也就是 AssetBundle.Load)或者他们的实例 Id 被间接引用时对象会被按需加载。在这种情况下没有额外的内存开销。

Unity 编辑器: 这个 API 会讲整个 AssetBundle 加载进内存,而不像从磁盘上读取所有字节,使用 AssetBundle.LoadFromMemoryAsync。如果在 Unity Editor 中监视内存,当 AssetBundle 加载时,监视器上内存会出现一个尖峰。这些尖峰并不影响设备上性能,而且在做调整措施之前必须要在设备上重新测试。

注意: 在 Unity 5.3 或者之前的安卓设备上,从 StreamingAssets 目录下加载 AssetBundle 会失败。这是因为 StreamingAssets 下的内容会被打包到一个压缩的 .jar 文件中。更详细的内容,请参照 AssetBundle 使用模式项目部署 小结。这个问题在 Unity 5.4 中已经修复。Unity 5.4 或者以后版本编译的游戏可以使用这个 API 来从 StreamingAssets 里面加载 AssetBundle。

注意: 对 LZMA 压缩的 AssetBundle 调用 AssetBundle.LoadFromFile 会永远不成功。

WWW.LoadFromCacheOrDownload

WWW.LoadFromCacheOrDownload 对于从远端服务器和本地存储中加载对象很有用。可以使用 file:// 链接地址从本地加载文件。如果 AssetBundle 已经在 Unity 的缓存中存在,则它会表现的跟 AssetBundle.LoadFromFile 一样。

如果 AssetBundle 没有被缓存,WWW.LoadFromCacheOrDownload 会从 AssetBundle 的源地址读取它。如果 AssetBundle 是压缩格式,它会使用一个 worker 线程来解压 AssetBundle 并且写入到缓存当中。如果不是压缩格式,worker 线程会直接将它写入缓存中。

一旦 AssetBundle 被缓存了,WWW.LoadFromCacheOrDownload 会从缓存中加载 Header 信息和未压缩的 AssetBundle。之后这个 API 表现就跟 AssetBundle.LoadFromFile 一样了。

注意: 当数据被解压并写到缓存的同时,WWW 对象会在本地内存中保留一份 AssetBundle 字节的完整拷贝。这个 AssetBundle 的额外的拷贝是用来支持 WWW.bytes 属性的。

由于 WWW 对象会缓存 AssetBundle 的数据,这里推荐开发者使用 WWW.LoadFromCacheOrDownload 来确保 AssetBundle 保持最小(最多几 M 大小。也推荐开发在内存有限平台上,如移动设备,确保他们的代码在运行时只下载一个 AssetBundle 来避免内存尖峰现象。关于 AssetBundle 的大小,请参照 AssetBundle 使用模式 章节中的 资产分配策略 小结。

注意: 每调用一次这个 API 都会产生出一个新的 worker 线程。要当心多次调用这个 API 的时候多产生多个线程。如果有 5 到 10 个 AssetBundle 需要下载,建议代码只让少数几个 AssetBundle 同时下载。

AssetBundleDownloadHandler

在 Unity 5.3 的移动平台上,Unity 引入了 UnityWebRequest API,它比 Unity 的 WWW API 更灵活。UnityWebRequest 可以让开发者指定 Unity 怎么样处理数据和排除不需要的内存开销。使用 UnityWebRequest 去下载一个 AssetBundle 的最简单的方式就是调用 UnityWebRequest.GetAssetBundle API。

这篇文章中,我们感兴趣的类是 DownloadHandlerAssetBundle。使用时,这里的行为跟 WWW.LoadFromCacheOrDownload 类似。它使用 worker 线程去下载数据到固定大小 Buffer 中,然后把 Buffer 中的数据写到临时存储或者 AssetBundle 缓存中,存储在哪这依赖于 Doanload Hanlder 怎么设置。LZMA 格式压缩的 AssetBudnle 会在下载和缓存过程中被解压。

为了不增加托管堆大小,所有的操作都是发生在 Native code 中。这个 Download Handler 也没有下载数据的的本地拷贝,进一步的减少了下载 AssetBundle 过程中的内存开支。

当下载完成之后,Doanload Handler 的 assetBundle 属性提供给了对下载的 AssetBundle 的访问,就像对下载后的 AssetBundle 执行了 AssetBundle.LoadFromFile 一样。

UnityWebRequeset 也支持像 WWW.LoadFromCacheOrDownload 一样缓存机制。如果给 UnityWebRequest 对象提供给了缓存信息,并且请求的 AssetBundle 已经在 Unity 的缓存中,AssetBundle 会马上有效并这个 API 会像 AssetBundle.LoadFromFile 一样操作它。

注意: Unity AssetBundle 缓存在 WWW.LoadFromCacheOrDownloadUnityWebReuqest 见是共享的,任何下载过的 AssetBundle 都对这两个 API 管用。

注意: 不像 WWW, UnityWebRequest 系统拥有一个内部的 worker 线程池,和内部的人物系统去确保开发者不会开始多个线程去同步下载。当前这个池的大小是不可控制的。

推荐

一般的,应该尽可能的使用 AssetBundle.LoadFromFile。这个 API 在速度,磁盘使用率和运行期内存方面都最高效。

对于需要下载 AssetBundle 或者给 AssetBundle 打补丁的项目,强烈推荐在 Unity 5.3 或更新版本中使用 UnityWebRequest ,在 Unity 5.2 或者更老版本中使用 WWW.LoadFromCacheOrDownload。就像在下一章的 部署 小结中提到,可以在项目安装器将 AssetBundle 缓存先准备好。

当使用 WWW.LoadFromCacheOrDownload 时,强烈推荐确保 AssetBundle 保持在项目最大内存使用的 2-3%,为了避免内存尖峰引起的程序终止。对于大多数项目,AssetBundle 最好不要大于 5M 的文件大小,并且不大于 2 个 AssetBundle 同时下载。

当使用 WWW.LoadFromCacheOrDownload 或者 UnityWebRequest 是,确保下载的代码在加载完 AssetBundle 后正确的调用 Dispose。C# 的 using 是确保 WWW 或者 UnityWebRequest 被安全 Dispose 的最方便的做法。

对于一个有持续交互的工程师团队和有唯一缓存和下载需求的项目,自定义的下载器是有必要的。写一个自定义的下载器不是一个非凡的任务,而这个这个下载器不需要要和 AssetBundle.LoadFromFile 兼容。更多详情,查看下章节中的 部署 小结。

在 AssetBundle 中加载资产

可以使用 3 种不同 AssetBundle 的 API 来从 AssetBundle 中加载 UnityEngine.Objects:LoadAssetLoadAllAssetsLoadAssetWithSubAssets。这些 API 都有带有 -Async 后缀的异步方法:LoadAssetAsyncLoadAllAssetsAsyncLoadAssetWithSubAssetsAsync

同步类型的 API 总是比异步 API 快,至少在一帧中是这样。在 Unity 5.1 及其更老版本中尤其是这样。在 Unity 5.2 之前,所有的异步在每帧中最多加载一个对象,这就意味着 LoadAllAssetsAsync 和 LoadAssetWithSubAssetAsync 会明显比其同步的 API 慢。这个行为在 Unity 5.2 之后被纠正 了。现在异步 API 可以在每帧中加载多个对象,这依赖于时间片的限制。关于这种行为的底层技术原因和时间片的详细描述,请参照 Low-level loading details

当加载多个独立的 UnityEngine.Objects 时,应该要使用 LoadAllAssets。它应该用在需要加载的主要对象(或者多个对象)是在同一个 AssetBundle 时。相比其他两个 API, LoadAllAssets 比多次调用 LoadAssets 更快一些。如果需要再加的对象数目比较多,并且在同一时间需要加载的数量小于 AssetBundle 内数量的 2/3,可以考虑将这个 AssetBundle 切分为跟小的 AssetBundle, 然后使用 LoadAllAssets

LoadAssetWithSubAssets 应该用于需要加载一个包含多个嵌入对象资产,比如包含动画的 FBX 模型或者包含多个精灵的精灵图集。如果需要再加的对象都来自同一个资产,但是他们存储在一个拥有很多其他不相关的对象的 AssetBundle 中,可以使用这个 API。

其他情况,请使用 LoadAsset 或者 LoadAsstsAsync

底层加载细节

UnityEngine.Object 的加载不是在主线程中执行:一个对象的数据是 worker 从存储中读取的。其他一切 Unity 系统中不涉及线程的部分(脚本,图形)将会在 worker 线程中被转换。比如,从网格中创建 VBO(Vertex Buffer Object),解压纹理等。

在 Unity 5.3 之前的版本中,对象加载是串行的,并且对象的加载过程的某些部分只能在主线程中执行。这个部分这叫做 “integration”。当 workter 线程完成对象数据的加载,这部分就会暂停,并把新加载的对象集成进主线程,并且保持(而不是加载下一个对象)到主线程集成完成。

从 Unity 5.3 开始,对象加载可以是并行的。worker 线程中可以反序列化,处理和集成多个对象。当一个对象的加载完成后,它的 Awake 回调函数会被调用,并且从下帧开始,这个对象会在 Unity 引擎中变成可用。

同步类型的方法 AssetBundle.Load 方法会暂停主线程,直到对象加载结束为止。在 Unity 5.3 之前,异步方法 AssetBundle.LoadAsync 在它需要将对象集成到主线程之前,它不会暂停主线程。它们会将对象加载按时间分片,使对象集成不会超过一定毫秒数量的帧时间。这个毫秒的数量是靠下面这个属性来设置的。

Application.backgroundLoadingPriority

  • ThreadPriority.High: 每帧最多 50 毫秒
  • ThreadPriority.Normal: 每帧最多 10 毫秒
  • ThreadPriority.BelowNormal: 每帧最多 4 毫秒
  • ThreadPriority.Low: 每帧组多 2 毫秒

在 Unity 5.1 之前,异步 API 在每一帧中只集成一个对象。这个可以说是个 Bug, 其已经在 Unity 5.2 中被修复了。从 Unity 5.2 开始,可以加载多个对象直到超过了帧中加载时间的最大上限。假设其他条件都一样的情况下,AssetBundle.LoadAsync 总是比比的同步 API 需要更多的时间,因为从调用 LoadAsync 到对象在引擎中可用期间有最小帧延迟。

可以现实中对资产和对象的测试来演示他们的不同。Unity 5.2 之前,在一个低端设备上加载一个大纹理,同步方式会消耗 7ms, 异步会消耗 70ms. 在 Unity 5.2 之后,两种方式之间差别几乎为零。

AssetBundle 依赖

Unity 5 的 AssetBundle 系统中,AssetBunle 间的依赖是靠两种不同的 API 来自动跟踪的,这依赖于运行时环境。在 Unity 编辑器中,AssetBundle 依赖是通过 AssetDatabase API 查询的。AssetBundle 的分配和依赖可以通过 AssetImporter 访问和改变。在运行时,Unity 提供了可以选择的 API 去 加载基于 ScriptableObject 的 AssetBundleManifest API 构建 AssetBundle 过程中生成的 AssetBundle 依赖信息。

一个 AssetBundle 是否是依赖的取决于另外一个 AssetBundle,当父 AssetBundle 的一个或者多个 UnityEngine.Objects 引用其他 AssetBundle 中的一个或者多个 UnityEngine.Objects。更多关于信息可以参考 资产,对象和序列化 文章中的 内部引用 小结。

就像在那片文章中的 序列化和实例 小结中提到,AssetBundle 提供 AssetBundle 内每个靠文件 GUID 和 本地 ID 来鉴别的对象的源数据。

因为当对象的实例 ID 第一次被间接引用时加载,并且对象在 AssetBundle 加载后被分配了一个有效的实例 ID,所以对象在 AssetBundle 中的顺序并不是很重要。反而重要的是加载对象之前,加载这个对象所有依赖的 AssetBundle。Unity 不会在加载完父 AssetBundle 只有去自动加载子 AssetBundle。

例子:

假设材质 A 引用了纹理 B。材质 A 被打包进 AssetBundle 1, 而纹理 B 被打包进 AssetBundle 2

description

在这种情况下,AssetBundle 2 必须在加载来自 AssetBundle 1 的材质 A 之前加载。

这不是在暗示 AssetBundle 2 必须要在 AssetBundle 1 之前加载,或者或者纹理 B 必须在 AssetBundle 2 中显式的加载。在加载来自 AssetBundle 1 中的材质 A 之前加载 AssetBundle 2 就够了。

Unity 不会在加载完 AssetBundle 1 之后自动的加载 AssetBundle 2。它必须手动的通过脚本加载。加载 AssetBundle 1 和 AssetBundle 2 时使用的 API并不重要。 使用 WWW.LoadFromCacheOrDownload 加载的 AssetBundle 可以和被 AssetBundle.LoadFromFile 或者 AssetBundle.LoadFromMemoryAsync 加载的 AssetBundle 混合使用。

AssetBundle 清单

当通过 BuildPipeline.BuildAssetBundles API 来构建 AssetBundle 时,Unity 会序列化一个包含 AssetBundle 依赖信息的对象。这个对象的数据被存储在一个单独的,包含一个 AssetBundleManifest 类型的对象的 AssetBundle 中。

这个资产会存储在一个 AssetBundle 中,这个 AssetBundle 的名字跟 AssetBundle 所在的目录的名字一样。如果一个项目编译它的 AssetBundle 到 (Projectroot)/build/Client/ 中,这个 AssetBundle 的清单文件会被保存为 (projectroot)/build/Client/Client.mainifest。

AssetBundle 包含的清单可以被加载,缓存和卸载,就像其他的 AssetBundle 一样。

AssetBundleManifest 对象提供了 GetAllAssetBundles API 来列出所有跟清单一起编译出来的 AssetBundle 和两个查询 AssetBundle 依赖的方法。

AssetBundleManifest.GetAllDependencies 返回一个 AssetBundle 的所有依赖,包括 AssetBundle 所有依赖以及依赖的依赖等等。

AssetBundleManifest.GetDirectDependencies 只返回一个 AssetBundle 的直接依赖。

需要注意的一点,这两个 API 都会产生字符串数组。要尽量少使用它们,不要用在应用运行期中对性能敏感的部分。

推荐

在用户进入应用的性能要求高的部分前尽可能多的加载所需的对象被认为是一个好的方案,比如用户进入主要的游戏关卡或者场景。这对移动平台特别关键,移动平台对存储的访问慢并且在运行时加载和卸载对象对内存的消耗会触发垃圾回收器。

对于在应用程序交互时必须加载和卸载对象的项目,请参照 AssetBundle usage patterns 章节的 Managing loaded assets 小结关于卸载对象和 AssetBundle 的信息。

英文原文地址:https://unity3d.com/learn/tutorials/topics/best-practices/asset-bundle-fundamentals