这是 Unity 5 [资产, 资源和资源管理系列文章] 的第五篇。(译者注:原文太长,我拆分成了两部分,这是第一部分。)
上一篇文章涵盖了 [AssetBundle 基本知识],特别是多种加载用的 API 的底层行为。这篇会讨论真实使用 AssetBundle 碰到的问题和可能的解决方案。
管理加载后的资产
在性能要求高的环境中,要特别严格地,小心翼翼地控制加载的对象的数量和大小。当对象从当前场景中移除时,Unity 不会自动的卸载他们。资产的清理是在特殊的时间触发,当然它也可以手动来触发。
AssetBundle 必须要仔细的管理。来自本地存储文件的 AssetBundle(不管是从缓存或者是通过 AssetBundle.LoadFromFile加载的) 会有最小内存开销,一般不超过 10-40 kb。当存在大量 AssetBundle 时,这种开销仍然可能成为问题。
大多数项目都允许用户对内容再次体验(比如重新玩一个关卡),知道什么时候加载和卸载 AssetBundle 是很重要的。如果一个 AssetBundle 被错误卸载,可能会引起内存中对象重复。错误卸载也可以在某些情况下引起不希望的结果,比如引起纹理丢失。要理解为什么这个会发生,请参照 [资产,对象和序列化] 章节的 内部对象引用 小结。
要理解何时去管理资产和 AssetBunle,最重要的是理解调用 AssetBundle.Unload 的不同行为,不管其参数是 true 还是 false.
这个 API 会卸载正在调用的 AssetBundle 头信息。参数意思是是否也卸载从这个 AssetBundle 加载的对象实例。如果是 true, 所有从这个 AssetBundle 实例化的对象会立即被卸载,即使它们被当前场景使用着。
例如,假设材质 M 是从 AssetBundle AB 中加载的,并且当前场景正使用着材质 M。
如果 AB.Unload(true) 被调用了,M 也会从当前场景中删除,销毁和卸载。如果 AB.Unload(false) 被调用了,AB 的头信息会被卸载,但是材质 M 依然在当前场景中并且可用。调用 AssetBundle.Unload(false) 会打破 M 和 AB 直接的关联。如果稍后再次加载 AB,则 AB 中包含的对象的新副本将被加载到内存中。
如果稍后再次加载 AB, 将会加载一个新的 AB Header 信息的副本。但是 M 不是从 AB 新副本中加载的。Unity 不会为 M 和新的 AB 拷贝间建立新的关系链接。
如果调用 AB.LoadAsset() 来重新加载 M, Unity 不会把 M 的旧副本解析为 AB 的数据的实例。所以,Unity 会重新加载一个 M 的副本,这时场景中就会有两个完全相同的 M 的副本。
大多数项目中,这种行为是不可取的。大多数项目应该使用 AssetBundle.Unload(true) 并且使用方法确保对象不会有重复副本。有两种通用的方法:
- 在应用生命周期中,临时 AssetBundle 卸载有明确定义的点,比如两个关卡之间或者加载场景的时候。这个比较简单,也是使用最多的情况
- 维护单个物体的引用计数,并当组成 AssetBundle 的对象都未被使用时卸载 AssetBundle。这允许应用卸载和重新加载对象而不会复制多余的内存。
如果应用必须使用 AssetBundle.Unload(false), 则单个对象能通过下面两种方式卸载:
- 在场景和代码中删除不需要对象的所有引用。完成之后调用 Resources.UnloadUnusedAssets
- 非增量方式加载场景。这个行为会卸载当前场景中的所有对象,然后自动的调用 Resources.UnloadUnusedAssets()
如果一个项目中有明确定义的点,那它可以用来等待对象的加载和卸载,比如游戏模式和关卡之间,那么在这些点应该尽可能多的卸载对象和加载新的对象。
最简单的方法是将项目中离散快打包到场景中,然后把场景和所有依赖打包到 AssetBundle 中。这个应用可以进入一个 “加载” 场景,在这个场景中完全卸载包含旧场景的 AssetBundle , 然后加载包含新场景的 AssetBundle。
这只是一个简单的流程,有些项目需要更复杂的 AssetBundle 管理。现在还没有通过的 AssetBundle 设计模式。每个项目的数据都是有区别的。当决定如何把对象分组进 AssetBundle 时候,通常最好的做法是打包需要同时加载或者卸载的对象进同一个 AssetBundle。
例如,对于一个角色扮演游戏。单个地图和过场动画可以按场景分组到 AssetBundle 中,但是有一些对象在大多数场景中使用。AssetBundle 可以用于提供肖像,游戏中 UI, 不同的角色模型和纹理。这些后面的对象和资产可以被分组成在启动时加载的第二组 AssetBundle 并且在应用程序的生命周期保持加载状态。
如果 Unity 必须重新从已经卸载的 AssetBundle 中加载对象,还有一种问题会出现。这种情况是, 对象加载失败,对象在 Unity 编辑器的 Hierarchy 中显示为(Missing)。
这主要发生在 Unity 丢失和拿回它的图形上下文控制权时,比如一个移动应用被挂起或者用户 PC 端锁屏。在这种情况下,Unity 必须给 GPU 重新上传纹理和 shader。如果上传的资产的源 AssetBundle 不可用,应用就会将场景中的对象显示成丢失 Shader 的洋红色的对象。
部署
有两种基本的方式可以将项目的 AssetBundle 部署到终端:跟项目一起安装或者项目安装之后下载。是一起安装还是之后安装的决策依赖于项目目标平台的能力和限制。移动端项目通常采用安装后下载来达到减少初始安装大小, 并且保持低于无线下载大小限制。控制台和 PC 项目一部是采用 AssetBundle 跟初始安装一起。
好的架构可以在跟初始安装无关的情况下,安装之后在项目中新加或者修改内容。更多的信息请参照这章中的 为 Assetbunlde 打补丁 小节。
随项目一起打包
AssetBundle 跟随项目一起打包是部署他们的最简单的方式,因为不需要额外的下载管理代码。为什么一个项目需要将 AssetBunle 打包在一起,下面有两个主要的原因:
- 减少项目构建时间和允许更简单的迭代开发。如果 AssetBundle 不需要独立于程序本身独立更新,程序可以将 AssetBundle 放置于 StreamingAssets 中。参考下面的 Streaming Assets 小结。
- 为可以更新的内容提供一个初始版本。这通常是为最终用户初始安装之后节省时间或者作为一个后期更新的基础版本。Streaming Assets 在这种情况下不是好的选择。如果写一个定制的下载和缓存系统是必须的话,那么可以更新内容的基础版本可以从 Streaming Assets 的 Unity 缓存 中加载。
Streaming Assets
在 Unity 程序安装时包括各种类型内容的最简单的方法,是在构建项目之前将内容构建入 /Assets/StreamingAssets/ 文件夹内。所有在 StreamingAssets 文件夹内的文件都会在项目构建的时候被拷贝到最终程序包里。StreamingAssets 文件夹可以用来存储最终程序包内的各种内容,而不仅仅是 AssetBundle。
StreamingAssets 文件夹在本地存储中的全路径,可以在运行期时通过属性 Application.streamingAssetsPath 得到。AssetBundle 之后在大多数平台上都可以通过 AssetBundle.LoadFromFile 来加载。
给安卓开发者: 在安卓平台上, Application.streamingAssetsPath 指向的是一个压缩的 .jar 文件,即使 AssetBundle 已经被压缩过了。在这种情况下,必须使用 WWW.LoadFromCacheOrDownload 加载每个 AssetBundle。当然可以写代码去解压 .jar file,然后将 AssetBundle 提取到一个可以读的本地存储上。
注意: StreamingAssets 在一些平台上是不可写的。如果一个项目的 AssetBundle 需要在安装之后更新,可以使用 WWW.LoadFromCacheOrDownload 或者写一个定制的下载器。更多详情请参照 Custom downloaders - storage 小结里面。
安装之后下载
将 AssetBundle 交付到移动设备上的首选方式是在应用安装完之后下载。内容可以再在应用安装后,用户不需要重新下载整个应用的情况下新加或者修改。在移动平台上,应用文件需要进过昂贵并且长时间的认证过程。所以一个安装后下载的系统是必不可少的。
最简单地交付 AssetBundle 是将它们放到一个网络服务器上,然后通过 WWW.LoadFromCacheOrDownload 或者 UnityWebRequest 来下载它们。Unity 会在本地存储上自动的缓存已下载的 AssetBundle。如果下载的 AssetBundle 是 LZMA 压缩格式,为了之后更快的加载,它会以未压缩格式存储在缓存中。如果下载的 AssetBundle 是 LZ4 压缩格式,则会保持压缩格式存储在缓存中。
如果缓存满了,Unity 会将最近最少使用的 AssetBundle 从缓存中移除。更多详情请参照 内建缓存 小节。
注意 WWW.LoadFromCacheOrDownload 是有瑕疵的。就如在加载 AssetBundle 小节中所说,WWW 对象下载的时候会消耗跟 AssetBundle 数据的大小一样的内存。这会导致意想不到的内存尖峰。有三种方法可以避免这种情况:
- 使用小尺寸 AssetBundle。AssetBundle 下载过程中,项目内存预算值决定了下载的 AssetBundle 的最大值。有 “加载中” 界面的应用可分配用来下载 AssetBundle 的内存通常会比在后台读写 AssetBundle 的多。
- 如果是 Unity 5.3 或者更新版本,使用新的 UnityWebRequest API 的 DownloadHandlerAssetBundle,这个不会引起内存尖峰
- 定制一个下载器。更多的信息,可以参照定制下载器小节
通常推荐尽可能使用 UnityWebRequest 或者在 Unity 5.2及之前版本中使用 WWW.LoadFromCacheOrDownload。只有当内置的 API 在内存消耗,缓存行为或者性能上不满足项目,或者项目必须跑平台相关代码来满足其需求时才需要定制下载器。
不适用 UnityWebRequest 或者 WWW.LoadFromCacheOrDownload 的情况实例:
- 需要对 AssetBundle 缓存做细微地控制
- 项目需要实现定制的压缩策略
- 项目希望使用平台相关的 API 去满足特定需求,比如需要在程序非激活状态下读写数据
- 比如:使用 iOS 的 Background Tasks API 去后台下载数据
- AssetBundle 需要通过在 Unity 不完全支持平台上使用 SSL,比如 PC
内置缓存
Unity 中有一个可以用来缓存通过 WWW.LoadFormCacheOrDownload 或者 UnityWebRequest API下载的软件 AssetBundle 的缓存系统。
这两个 API 都有接收 AssetBundle 版本号为参数的函数重载。这个版本号不是保存在 AssetBundle 里面,也不是由 AssetBundle 系统生成。
缓存系统会一直跟踪传递给 WWW.LoadFromCacheOrDownload 和 UnityWebRequest 的版本号。当带着版本号调用两者其中之一时,缓存系统会检查是否有缓存过的 AssetBundle。缓存系统会比较首次缓存时被传递的版本号和当前传递的版本号。如果两个版本号不匹配,或者没有缓存过的 AssetBundle,Unity 会下载一个新的副本,然后将其与新的版本号关联。
缓存系统中的 AssetBundle 只靠他们的文件名来鉴别的,并不是靠下载他们的地址。这就意味着拥有相同名字的 AssetBundle 可以存储在不同路径中。比如,一个 AssetBundle 可以放到内容分发网络中的多台服务器上。只要他们的文件名一样,缓存系统会认为它们是同一个 AssetBundle。
分配版本号给 AssetBundls 和传递这些版本号给 WWW.LoadFromCacheOrDownload 的策略完全由各个应用自己决定。大部分应用可以用 Unity 5 的 AssetBundleManifest API。这个 API 会根据 AssetBundle 的内容为其生成一个 MD5 哈希值。当 AssetBundle 改变时,这个哈希值会跟着改变,这表明这个 AssetBundle 需要被下载。
注意:Unity 内置缓存系统的实现方式的特殊,老的 AssetBundle 直到缓存被填满之后才会被删除。Unity 有意向在未来的 Release 中处理这个特殊。
更多详情参照 给 AssetBundle 打补丁 小节。
我们可以调用缓存对象上的 API 来控制 Unity 内置的缓存。Unity 缓存的行为可以通过 Caching.expirationDeplay 和 Caching.maximumAvailableDiskSpace 来控制。
Caching.expirationDelay 是 AssetBundle 被自动删除前最小需要达到的秒数。如果 AssetBundle 没有在设置的时间内访问,它将被自动删除。
Caching.maximumAvailableDiskSpace 决定了缓存在删除最少使用的 AssetBundle 前可以使用本地存储的最大空间。它通过字节来计数。当达到最大限制时,Unity 会删除最近最少打开的或者通过 Caching.MarkAsUsed 标识已使用的 AssetBundle。直到空间满足新下载的 AssetBundle 时 Unity 才会停止删除已缓存的 AssetBundle。
注意:Unity 5.3 版本中控制内置缓存的功能很不完善。不支持主动地从缓存中移除指定的 AssetBundle, 而只能当 AssetBundle 超过了时限,或者超过了磁盘空间限制,或者调用 Caching.CleanCache API。Cache.CleanCache 将会清除缓存中的所有 AssetBundle。这会给开发过程或者线上操作带来问题,比如 Unity 不会移除不再被应用使用的 AssetBundle。
填充缓存
因为 AssetBundle 是通过他们的名字还鉴别的,所以将应用附带的 AssetBundle 填充到缓存是可行的。将初始或者基础版本的 AssetBundle 放置到 /Assets/StreamingAssets/ 文件夹下可以达到这种目的。这个过程跟 [随项目一起打包] (https://unity3d.com/cn/learn/tutorials/topics/best-practices/assetbundle-usage-patterns#Shipped_with_Project) 提到的一种方式是一样的。
应用第一次运行的时候,将从 Application.streamingAssetsPath 加载的 AssetBundle 放置到缓存中。然后以后可以调用 WWW.LoadFromCacheOrDownload 或者 UnityWebRequest 加载。
定制下载器
定制一个下载器可以让应用完全控制 AssetBundle 如何下载,压缩和存储。只有当大团队需要些一些精益的应用时才推荐写下载器。写一个下载器时有四个主要的问题需要考虑:
- 怎么样下载 AssetBundle
- 将 AssetBundle 存储到哪里
- 是否需要和如何压缩 AssetBundle
- 如果给 AssetBundle 打补丁
关于如何打补丁,可以参照 给 AssetBundle 打补丁 小节。
下载
对于大多数应用,HTTP 是下载 AssetBundle 最简单的方式。但是,实现一个基于 HTTP 的下载器并不是一个简单的任务。定制的下载器需要避免过高的内存开销,过高的线程使用率和过多的线程唤醒。Unity 的 WWW 类对这些描述来说是不适合的。因为 WWW 会消耗比较高的内存,应该避免在不适用 WWW.LoadFromCacheOrDownload 的应用中使用 WWW 类。
当要写一个定制的下载器时,有 3 个选项:
- C# 的 HttpWebRequest 和 Web Client 类
- 定制的原生插件
- AssetStore packages
C# 类
如果应用不需要支持 HTTPS/SSL,C# 的 WebClient.aspx) 类提供了最简单的机制用来下载 AssetBundle。它能将任何文件异步的下载到本地存储中,不需要过多的内存分配。
使用 WebClient 下载 AssetBundle, 只要创建一个 WebClient 实例,将 AssetBundle 的下载地址和存储地址传给实例就可以。如果需要更多的控制请求的参数,可以使用 C# 的 HttpWebRequest.aspx) 类去写下载器。
平台注意: Unity C# 运行时支持 HTTPS/SSL 的平台仅有 iOS, Android 和 Windows Phone。在 PC 平台上,试图用 C# 类去访问 HTTPS 服务器的话会得到证书验证失败的错误。
Asset Store Packages
有好几个 Asset Store packages 提供 Native-code 实现的可以通过 HTTPS, HTTPS 和其他协议下载文件的功能。在为 Unity 写定制的 native-code 插件时,推荐先评估一下 Asset Store Packages。
定制原生插件
写一个定制下载器是在 Unity 下载数据方式中最耗时间和最灵活的。由于需要比较多的编程时间和技术要求,这个方式只推荐在其他方式不能满足应用需求的时候使用。比如,当应用必须要在 Unity 不支持的平台上使用 SSL 通讯时。这些平台有 Windows, OSX (mac OS) 和 Linux。
定制原生插件一般会封装目标平台上的原生下载 API. 比如 iOS 上的 NSURLConnection 和安卓平台上的 java.net.HttpURLConnection。关于这些 API 的更详细使用,请查看对于平台的原生文档。
存储
在所有平台上,Application.persistentDataPath 都指向一个可以写的路径,这个路径用来保存可以程序多次运行都不会丢失的数据。当写一个定制下载器时,强烈推荐使用 Application.persistentDataPath 的子目录去存储已下载的数据。
Application.streamingAssetPath 是只读的,是用来做 AssetBundle 缓存的一个糟糕的选择。streamingAssetPath 包括:
- OSX (mac OS): 在 .app 包内,不可以写
- Windows: 在安装目录内(一般是 Promgram Files),通常不可写
- iOS: 在 .ipa 包内,不可写
- Android: 在压缩的 .jar 文件内,不可写
资产分配策略
决定如何将项目内的资产分配到 AssetBundle 是不容易的。光使用简单的规则,比如将所有对象都放置到他们自己的 AssetBundle 中或者将所有对象都放到一个 AssetBundle 中,但是这些方案都有明显的缺点:
- AssetBundle 数量太少
- 会增加运行时内存使用
- 会增加加载时间
- 需要下载更多数据
- 有太多的 AssetBundle
- 会增加编译的时间
- 会加大开发的复杂性
- 会增加总的下载时间
关键之处的如何将对象分组到 AssetBundle 中。主要的策略有:
- 逻辑实体
- 对象类型
- 并行的内容
注意一个项目对于不同的内容分类可以将这些并且应该将这些策略混合地使用。比如一个项目可能需要将 UI 元素分组到不同平台的 AssetBundle 中,但是靠关卡或者场景来分组他们项目关联的内容。关于使用的策略,有一些好的指导去遵循:
- 相比不经常更新的内容,将经常更新的对象拆分到不同的 AssetBundle 中
- 将可能同时加载的对象分组到一起。比如模型和他的动画与纹理
- 如果一个对象被多个 AssetBundle 中的多个对象依赖,将它分配到单独的 AssetBundle 中
- 如果两个对象不太可能同时加载,比如一个纹理的高清和标清版本,可以将他们分配到不同的 AssetBundle 中
- 如果是同一个对象的不同导入设置或者数据的不同版本,考虑使用 AssetBundle 变体来替代
一旦遵循上面的指导,考虑将规定时间内小于 50% 能被加载的 AssetBundle 拆分。也可以考虑将一些小的 AssetBundle (资产数量小于 5 - 10 个) 合并。
逻辑实体分组
逻辑实体分组是一个通过项目功能来分组对象的策略。当采用这种策略时,应用不同部分会单独分组进不同的 AssetBundle 中。
例如:
- 一个 UI 屏幕中的所有纹理和布局数据打包在一起
- 一个角色的纹理、模型和动画打包在一起
- 被多个关卡共享的场景碎片的纹理和模型打包在一起
逻辑实体分组是最常用的 AssetBundle 策略,特别适用于:
- DLC (Downloadable Content)
- 实体在应用生命周期内多处被用到
例如:
- 通用的角色或者基本 UI 元素
- 完全不依赖于平台或者性能设置的实体
逻辑实体分组的优点是不需要从新下载不变内容的情况下轻松的更新实体。这就是它为什么特别适合 DLC (Downloadable Content)的原因。这个策略也是内存效率最高的,因为应用只需要加载当前使用的实体的 AssetBundle。
尽管如此,这也是最难实现的策略,因为分配对象给 AssetBundle 的开发者必须精确地熟悉单个对象是怎么样和如何被项目使用的。
类型分组
类型分组是最简单的策略。在这个策略中,相似或者相同类型的对象被放置到同一个 AssetBundle 中。比如,将不同的音轨放置到同一个 AssetBundle 或者不同的语言文件放置到同一个 AssetBundle。
这个策略简单的同时,它却经常是在编译时,加载时和升级时最低效的。它最常用的对象需要同时升级的小文件,比如本地化文件。
并行内容分组
并行内容分组是将需要同时加载和使用内容分组到同一个 AssetBundle 的策略。这种策略最常用在具有较强本地属性的内容上,也就是说内容很少或者基本不可能在应用特定的位置或者时间外出现。举个例子,关卡游戏中没一关卡都独一无二的艺术呈现,角色和声效。
实现并行内容分组的最常用的方法是通过场景来构建 AssetBundle,每个 AssetBundle 包括了场景中的几乎所有的依赖。
对没有较强本地属性的项目,和在应用声明周期内很少出现的内容,应该通过逻辑实体策略来分组。这两种都是最大化使用 AssetBundle 内容的大体策略。
这个场景的一个例子就是,一个角色在世界中随机生成的开发世界游戏。这种情况中,很难预测交个角色会同时出现,所以他们一般需要使用不同的策略。