[转] AssetBundle 使用模式

AssetBundle 使用模式

原文地址:https://zhuanlan.zhihu.com/p/33268074


原文:https://unity3d.com/cn/learn/tutorials/temas/best-practices/assetbundle-usage-patterns
译文参考: http://blog.shuiguzi.com/2017/04/18/AssetBundle_usage_pattern_1/

这是 Unity 5 资源、Resources目录和资源管理 系列文章的第五篇。

上一篇文章涵盖了 AssetBundle 基础知识,特别是各种加载资源 API 的底层行为。这篇会讨论实际使用 AssetBundle 碰到的问题和可能的解决方案。

4.1 管理已加载的资源

在高性能要求的环境中,要特别严格地,小心翼翼地控制加载的对象的数量和大小。当对象从当前场景中移除时,Unity 不会自动的卸载他们。资源的清理在特殊的时间触发,当然它也可以手动来触发。

AssetBundle 必须要被仔细地管理,来自本地存储文件的 AssetBundle(不管是从缓存或者是通过 AssetBundle.LoadFromFile加载的) 一般来说很少会超过10-40 kb的额外内存开销,所以它的开销是 最小 的。然而当存在大量 AssetBundle 时,这种开销仍然可能成为问题。

对大部分项目来说,一般允许用户对游戏内容重新体验(例如重玩关卡),那么考虑在什么时候加载和卸载AssetBundle就显得很重要了。如果一个 AssetBundle 没有被正确卸载,可能会引起内存中对象重复。 不正确的卸载资源也在某些情况下也可能引起不希望的结果,比如引起纹理丢失。要理解这其中的缘由,可以参考 “资源、对象与序列化(Assets, Objects and serialization)- 1.2 对象之间的引用(Inter-Object references)” 章节。

要了解AssetBundle管理资源的关键,是要了解在调用 AssetBundle.Unload 函数时参数 true 或是 false 时的不同行为。

这个 API 会卸载调用的 AssetBundle 的信息头,参数意思是是否也卸载从这个 AssetBundle 加载的对象实例。如果是 true, 所有从这个 AssetBundle 实例化的对象会立即被卸载,即便是它们被当前场景正在被使用。

例如,假设材质 M 是从 AssetBundle AB 中加载的,并且当前场景正使用着材质 M:

img

如果调用了 AB.Unload(true),那么 M 会从当前场景中删除,同时销毁和卸载。如果调用了 AB.Unload(false),那么 AB 的信息头会被卸载,但是材质 M 依然在当前场景中并且可用。调用 AssetBundle.Unload(false) 会打破 M 和 AB 直接的关联关系,如果稍后再次加载 AB,则 AB 中包含的对象的新副本将被加载到内存中:

img

如果稍后再次加载 AB, 将会加载一个新的 AB 信息头的副本,但是 M 不是从 AB 新副本中加载的,所以Unity 不会为 M 和新的 AB 拷贝间建立新的关联关系:

img

如果调用了 AB.LoadAsset() 来重新加载 M,Unity 不会把 M 的旧副本解析为 AB 的数据的实例,所以,Unity 会重新加载一个 M 的副本,这时场景中就会有 两个 完全一摸一样的 M 的副本。

img

对大多数项目而言,这种行为是不可取的,大多数项目应该使用 AssetBundle.Unload(true) 并且使用额外方法来确保对象不会有重复副本,一般常用的方法有:

  1. 在应用生命周期中,在明确定义的时间点对临时的AssetBundle 卸载,比如两个关卡之间或者加载场景的时候。这个比较简单,也是使用最多的情况。
  2. 维护单个物体的引用计数,并当组成 AssetBundle 的对象都未被使用时卸载 AssetBundle。这允许应用卸载和重新加载对象而不会复制多余的内存。
    如果应用必须使用 AssetBundle.Unload(false)来卸载资源, 则单个对象只能通过下面两种方式卸载:

如果一个项目中有明确定义的时间点,那它可以用来等待对象的加载和卸载,比如游戏模式和关卡之间,那么在这些点应该尽可能多的卸载对象和加载新的对象。

最简单的方法是将项目中离散块打包到场景中,然后把场景和所有依赖打包到 AssetBundle 中。这个应用可以进入一个 “加载” 场景,在这个场景中完全卸载包含旧场景的 AssetBundle , 然后加载包含新场景的 AssetBundle。

然而这只是一个简单的流程,有些项目需要更复杂的 AssetBundle 管理,并没有放之四海皆准的AssetBundle设计模式,每个项目的具体情况都不同。当考虑如何对资源进行AssetBundle分组时,如果一些资源通常需要被一块加载或者更新的时候,那么把它们打包到一个AssetBundle中会是一个不错的选择。

例如,考虑一个角色扮演游戏,单个地图和过场动画可以按场景分组到 AssetBundle 中,但是有一些对象在大多数场景中都要被用到,例如AssetBundle 可以用于加载肖像,游戏中 UI, 不同的角色模型和纹理。这些用到的对象和资源可以被分组到另一套AssetBundle里,这样可以在游戏启动的时候加载,并且运行时一直保留在内存里。

如果 Unity 必须重新从已经卸载的 AssetBundle 中加载对象,那么这时对象加载会失败,对象在 Unity 编辑器的 Hierarchy 中显示为(Missing)。

这种情况通常发生在Unity丢失了图形上下文(graphics context)然后又重新获得控制权的时候,例如一个移动应用被挂起或者用户 PC 端锁屏。在这种情况下,Unity 必须向 GPU 重新上传纹理和 shader。如果上传的资源的 AssetBundle 不可用,那么应用的场景中的对象就会 “丢失Shader” 而显示成的洋红色。

4.2 部署

有两种基本的方式可以将项目的 AssetBundle 部署到客户端上:跟项目一起安装或者项目安装之后下载。是一起安装还是安装之后下载的决定依赖于项目目标平台的能力和限制:移动端项目通常采用安装后下载来达到减少初始安装大小, 并且大小不超过无线网络下载限制。主机和 PC 项目一般是采用 AssetBundle 跟初始安装一起分发。

优良架构可以在不管AssetBundle最开始如何分发的情况下,进行内容的更新和修正。更多的信息请参照这章中的 “4.4 为Assetbunlde 打补丁” 章节。

4.2.1 随工程分发

AssetBundle 跟随项目一起分发是部署他们的最简单的方式,因为不需要额外的下载管理代码。将AssetBundle在安装的时候就包含进去最主要有两点原因:

  • 减少项目构建时间和允许更快的迭代开发。如果 AssetBundle 不需要独立于程序本身独立更新,程序可以将 AssetBundle 放置于 StreamingAssets 中。参考下面的 “4.2.1.1 Streaming Assets 目录” 小结。
  • 为可以更新的内容提供一个初始版本。这通常是为最终用户初始安装之后节省时间或者作为一个后期更新的基础版本。Streaming Assets 在这种情况下不是好的选择。如果不想写一个定制的下载器和缓存系统的话,那么可以更新内容的基础版本可以从 “4.2.3.1 初始缓存(Cache Priming)” 中加载。

4.2.1.1 Streaming Assets目录

在构建项目之前将内容放到 /Assets/StreamingAssets/ 文件夹里,是 Unity 程序安装时就包含各种各样的内容的最简便的方法。所有在 StreamingAssets 文件夹内的文件都会在项目构建的时候被拷贝到最终程序包里,StreamingAssets 文件夹可以用来存储最终程序包内的各种内容,而不仅仅是 AssetBundle。

在运行时要想取得StreamingAssets 文件夹在本地存储中的全路径,可以通过属性 Application.streamingAssetsPath 得到,AssetBundle 在大多数平台上都可以通过 AssetBundle.LoadFromFile来加载。

致Android开发者: 在安卓平台上, Application.streamingAssetsPath 指向的是一个压缩的 .jar 文件,即便 AssetBundle 已经被压缩过了。在这种情况下,必须使用 WWW.LoadFromCacheOrDownload 加载每个 AssetBundle。当然可以写代码去解压 .jar file,然后将 AssetBundle 提取到一个可以读的本地存储上。
[ 译注: 这里文档有误,Android平台上通过AssetBundle.LoadFromFile(Async)加载StreammingAssets目录里的AssetBundle可行的,可以参考 这里]

注意: StreamingAssets目录 在一些平台上是不可写的。如果一个项目的 AssetBundle 需要在安装之后更新,可以使用 WWW.LoadFromCacheOrDownload 或者写一个定制的下载器,更多详情请参照 “4.2.3.2 存储” 小结。

4.2.2 安装之后下载

将 AssetBundle 交付到移动设备上的首选方式是在应用安装完之后下载,内容可以再在应用安装后,用户不需要重新下载整个应用的情况下新加或者修改。在移动平台上,应用文件需要进昂贵并且长时间的认证审核过程,所以一个安装后下载的系统是必不可少的。

最简单地分发 AssetBundle 是将它们放到一个网络服务器上,然后通过 WWW.LoadFromCacheOrDownload或者 UnityWebRequest 来下载它们。Unity 会在本地存储上自动的缓存已下载的 AssetBundle。如果下载的 AssetBundle 是 LZMA 压缩格式,为了之后更快的加载,它会以未压缩格式存储在缓存中。如果下载的 AssetBundle 是 LZ4 压缩格式,则会保持压缩格式存储在缓存中。

如果缓存满了,Unity 会将最近未使用的 AssetBundle 从缓存中移除。更多详情请参考 “4.2.3 内置缓存” 小节。

注意 WWW.LoadFromCacheOrDownload 是有瑕疵的。就如在“3.4 加载AssetBundle” 小节中所说,WWW 对象下载的时候会消耗跟 AssetBundle 数据的大小一样的内存,这会导致意想不到的内存峰值。有三种方法可以避免这种情况:

  • 使用小尺寸 AssetBundle,AssetBundle 下载过程中,项目内存预算值决定了下载的 AssetBundle 的最大值。有 “加载” 界面的应用可分配用来下载 AssetBundle 的内存通常会比在后台读写 AssetBundle 的多。
  • 如果是 Unity 5.3 或者更新版本,使用新的 UnityWebRequest API 的 DownloadHandlerAssetBundle,这个不会引起内存峰值。
  • 定制一个下载器。更多的信息,可以参考“4.2.3 自定义下载器”章节。

通常推荐尽可能使用 UnityWebRequest 或者在 Unity 5.2及之前版本中使用 WWW.LoadFromCacheOrDownload。只有当内置的 API 在内存消耗,缓存行为或者性能上不满足项目需求,或者项目必须跑平台相关代码来满足其需求时才需要定制下载器。

不适用 UnityWebRequest 或者 WWW.LoadFromCacheOrDownload 的情况有:

  • 需要对 AssetBundle 缓存做细微地控制
  • 项目需要实现定制的压缩策略
  • 项目希望使用平台相关的 API 去满足特定需求,比如需要在程序非激活状态下保持数据流(stream data),例如:使用 iOS 的 Background Tasks API 去后台下载数据。
  • AssetBundle 需要通过在 Unity 不完全支持平台上使用 SSL,比如 PC。

4.2.3 内置缓存

Unity 中有一个可以用来缓存通过 WWW.LoadFormCacheOrDownload 或者 UnityWebRequest API下载的 AssetBundle 的内置缓存系统。

这两个 API 都有接收 AssetBundle 版本号为参数的函数重载,这个版本号既不是保存在 AssetBundle 里面,也不是由 AssetBundle 系统生成。

缓存系统会一直记录传递给 WWW.LoadFromCacheOrDownload 和 UnityWebRequest 的版本号。当带版本号任一API被调用时,缓存系统会检查是否有缓存过的 AssetBundle。缓存系统会比较首次缓存时被传递的版本号和当前传递的版本号。如果两个版本号不匹配,或者没有 AssetBundle没有被缓存过,Unity 会下载一个新的副本,然后将其与新的版本号关联。

缓存系统中的 AssetBundle 只靠他们的文件名来区分的,而不是靠下载他们的地址。这就意味着拥有相同名字的 AssetBundle 可以存储在不同路径中。比如,一个 AssetBundle 可以放到内容分发网络(CDN)中的多台服务器上。只要他们的文件名一样,缓存系统会认为它们是同一个 AssetBundle。

分配版本号给 AssetBundls 和传递这些版本号给 WWW.LoadFromCacheOrDownload 的策略完全由各个应用自己决定。大部分应用可以用 Unity 5 的 AssetBundleManifest API。这个 API 会根据 AssetBundle 的内容为其生成一个 MD5 哈希值。当 AssetBundle 改变时,这个哈希值会跟着改变,这表明这个 AssetBundle 需要被下载。

注意: Unity 内置缓存系统的实现方式的特殊,旧的 AssetBundle 直到缓存被填满之后才会被删除。Unity 有意向在未来的发行版中解决这个问题。

更多信息请参考 “4.4 给AssetBundle打补丁” 小节。

我们可以调用缓存对象上的 API 来控制 Unity 内置的缓存。Unity 缓存行为可以通过 Caching.expirationDeplayCaching.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。

4.2.3.1 初始缓存(Cache Priming)

因为 AssetBundle 是通过他们的名字来区别的,所以应用程序时可以“初始化(prime)”缓存的。通过将基础版本的 AssetBundle 放置到 /Assets/StreamingAssets/ 文件夹下可以达到这种目的。这个过程跟 “4.2.1 随工程分发”提到的一种方式是一样的。

应用第一次运行的时候,可以从 Application.streamingAssetsPath 加载一次 AssetBundle,这样就放置到缓存中。然后以后可以调用 WWW.LoadFromCacheOrDownload 或者 UnityWebRequest 加载。

4.2.3 自定义下载器

定制一个下载器可以让应用全权控制 AssetBundle 如何下载,解压和存储。只有当大团队需要些一些精益的应用时才推荐写下载器。写一个下载器时有四个主要的问题需要考虑:

  • 怎么样下载 AssetBundle
  • 将 AssetBundle 存储到哪里
  • 是否需要和如何压缩 AssetBundle
  • 如果给 AssetBundle 打补丁

关于如何打补丁,可以参照 给 “4.4 AssetBundle 打补丁” 小节。

4.2.3.1 下载

对于大多数应用来说,HTTP 是下载 AssetBundle 最简单的方式。但是,实现一个基于 HTTP 的下载器并不是一个简单的任务。定制的下载器需要避免过高的内存开销,过高的线程使用率和过多的线程唤醒。Unity 的 WWW 类根据 “AssetBundle基础 - 3.4.3 WWW.LoadFromCacheOrDownload”章节里的描述,是特别不适合这项任务的。因为 WWW 会消耗比较高的内存,即使不使用 WWW.LoadFromCacheOrDownload 的情况下也不要使用 WWW 类。

当要写一个定制的下载器时,有 3 个选择:

  • C# 的 HttpWebRequest 和 Web Client 类
  • 定制原生插件
  • AssetStore 插件

4.2.3.1.1 C#类

如果应用不需要支持 HTTPS/SSL,C# 的 WebClient 类提供了最简单的机制用来下载 AssetBundle。它能将任何文件异步的下载到本地存储中,不需要过多的内存分配。

使用 WebClient 下载 AssetBundle, 只要创建一个 WebClient 实例,将 AssetBundle 的下载地址和存储地址传给实例就可以。如果需要更多的控制请求的参数,可以使用 C# 的 HttpWebRequest 类去写下载器:

  1. HttpWebResponse.GetResponseStream 获取一个字节流(byte stream)。
  2. 从堆内存上分配一个固定长度的字节buffer。
  3. 将字节从流中读到 buffer 里。
  4. 将 Buffer 里的数据写到硬盘中,可以使用 C# http://File.IO API或者其它流式IO系统。

平台相关注意: Unity C# 运行时支持 HTTPS/SSL 的平台仅有 iOS, Android 和 Windows Phone。在 PC 平台上,试图用 C# 类去访问 HTTPS 服务器的话会得到证书验证失败的错误提示。

4.2.3.1.2 AssetStore 插件

有好几个 Asset Store 插件通过原生代码(native-code)提供了实现的可以通过 HTTP, HTTPS 和其他协议下载文件的功能。在为 Unity 写定制的原生代码插件前,推荐先评估一下 Asset Store插件。

4.2.3.1.3 定制原生插件

写一个定制原生插件是在 Unity 下载数据方式中最耗时但又最灵活的。由于需要比较多的编程时间和技术要求,这个方式只推荐在其他方式不能满足应用需求的时候使用。比如,当应用必须要在 Unity 不支持的平台上使用 SSL 通讯时。这些平台有 Windows, OSX (mac OS) 和 Linux。

定制原生插件一般会封装目标平台上的原生下载 API. 比如 iOS 上的 NSURLConnection 和安卓平台上的 java.net.HttpURLConnection。关于这些 API 的更详细使用,请查看对于平台的原生文档。

4.2.3.2 存储

在所有平台上,Application.persistentDataPath 都指向了一个可以写的路径,这个路径用来保存可以程序多次运行持久化的数据。当写一个定制下载器时,强烈推荐使用 Application.persistentDataPath 的子目录去存储已下载的数据。

Application.streamingAssetPath 是只读的,是用来做 AssetBundle 缓存的一个糟糕的选择。streamingAssetPath 其中:

  • OSX: 在 .app 包内,不可以写
  • Windows: 在安装目录内(一般是 Promgram Files),通常不可写
  • iOS: 在 .ipa 包内,不可写
  • Android: 在压缩的 .jar 文件内,不可写

4.3 资源分配策略

决定如何将项目内的资源分配到 AssetBundle 是不容易的。简单的规则都很有诱惑性,比如将所有对象都放置到他们自己的 AssetBundle 中或者将所有对象都放到一个 AssetBundle 中,但是这些方案都有明显的缺点:

  • AssetBundle 数量太少:

    • 会增加运行时内存使用
    • 会增加加载时间
    • 需要下载大量数据
  • 有太多的 AssetBundle:

    • 会增加构建的时间
    • 会加大开发的复杂性
    • 会增加总的下载时间

关键之处在于如何将对象分组到 AssetBundle 中。主要的策略有:

  • 逻辑实体
  • 对象类型
  • 内容不相干(Concurrent content)

注意一个项目对于不同的内容分类可以将这些并且应该将这些策略混合地使用。比如一个项目可能需要将 UI 元素分组到不同平台的 AssetBundle 中,但是靠关卡或者场景来分组他们项目相关的内容。关于使用的策略,可以遵循下面这些指导:

  • 相比不经常更新的内容,将经常更新的对象拆分到不同的 AssetBundle 中

  • 将可能同时加载的对象分组到一起。比如模型和他的动画与纹理

    • 如果一个对象被多个 AssetBundle 中的多个对象依赖,将它分配到单独的 AssetBundle 中
  • 如果两个对象不太可能同时加载,比如一个纹理的高清和标清版本,可以将他们分配到不同的 AssetBundle 中
  • 如果是同一个对象的不同导入设置或者数据的不同版本,考虑使用 AssetBundle 变体(AssetBundle Variants)来替代

一旦遵循上面的指导,考虑将任意时刻内小于 50% 能被加载的 AssetBundle 拆分。也可以考虑将一些小的(资产数量小于 5 - 10 个)、会同时加载的 AssetBundle 合并。

4.3.1 逻辑实体分组

逻辑实体分组是一个通过项目功能来分组对象的策略。当采用这种策略时,应用中的不同部分会单独分组进不同的 AssetBundle 中。

例如:

  • 一个 UI 屏幕中的所有纹理和布局数据打包在一起
  • 一个角色的纹理、模型和动画打包在一起
  • 被多个关卡共享的场景碎片的纹理和模型打包在一起

逻辑实体分组是最常用的 AssetBundle 策略,特别适用于:

  • DLC (Downloadable Content)

  • 实体(Entity)在应用生命周期内多处被用到
    例如:

    • 通用的角色或者基本 UI 元素
    • 实体(Entity)仅仅是平台或者性能不同而有差异

逻辑实体分组的优点是不需要从新下载不变内容的情况下轻松的更新实体。这就是它为什么特别适合 DLC (Downloadable Content)的原因。这个策略也是内存效率最高的,因为应用只需要加载当前使用的实体的 AssetBundle。

尽管如此,这也是最难实现的策略,因为分配对象给 AssetBundle 的开发者必须精确地熟悉单个对象是怎样被项目使用的。

4.3.2 类型分组

类型分组是最简单的策略。在这个策略中,相似或者相同类型的对象被放置到同一个 AssetBundle 中。比如,将不同的音轨放置到同一个 AssetBundle 或者不同的语言文件放置到同一个 AssetBundle。

这个策略简单的同时,它却经常是在编译时,加载时和升级时最低效的。它常常被用作小文件对象的同时升级,比如本地化文件。

4.3.3 不相干(concurrent)内容分组

不相干内容分组是将需要同时加载和使用内容分组到同一个 AssetBundle 的策略。这种策略最常用在强本地相关属性的内容上,也就是说内容很少或者基本不可能在应用特定的位置或者时间之外出现。举个例子,关卡游戏中每一关卡都独一无二的艺术效果,角色和声效。

实现不相干内容分组的最常用的方法是通过场景来构建 AssetBundle,每个 AssetBundle 包括了场景中的几乎所有的依赖。
对没有强本地属性的项目,和在应用生命周期内很少出现的内容,应该通过逻辑实体策略来分组。这两种都是最优化使用 AssetBundle 内容的大体策略。

一个例子就是,一个角色在世界中随机生成的开发世界游戏。这种情况中,很难预测几个角色会同时出现,所以它们一般需要使用不同的策略。

4.4 为Assetbunlde 打补丁

给 AssetBundle 打补丁就如简单地下载一个新的 AssetBundle 然后替换已存在的。如果 WWW.LoadFromCacheOrDownload 或者 UnityWebRequest 被用来管理应用已缓存的 AssetBundle,这个过程就是给所选的 API 传递不同的版本号一样简单。(可以参考上面的脚本参考链接来查看更多详情。)

更困难的问题是解决补丁系统检测哪个 AssetBundle 需要被替换。一个补丁系统需要两个信息列表:

  • 当前已下载的 AssetBundle 和它们的版本信息列表
  • 远程服务器上的 AssetBundle 和它们的版本信息列表

修补程序需要下载服务器端的 AssetBundle 列表和然后比较这个列表。AssetBundle 丢失或者 AssetBundle 的版本信息改变了,都需要重新下载。

Unity 5 的 AssetBundle 系统会在构建完成后创建一个额外的 AssetBundle。这个额外的 AssetBundle 包括一个 AssetBundleManifest 对象。这个清单对象包含 AssetBundle 和它们的哈希值,可以用来给客户端传递有效的 AssetBundle 下载列表和版本信息。关于 AssetBundle manifest bundle,请参照 Unity手册

我们也可以写一个定制的系统来检测 AssetBundle 的改变。大部分写开发者写的系统都选择使用行业标准的数据结构作为他们的 AssetBundle 文件列表,比如 JSON,还有用来计算 checksum 的标准 C# 类,比如 MD5。

4.4.1 差异更新

在 Unity 5 中,Unity 可以将数据按确定的顺序编译出 AssetBundle。这就允许定制下载器实现差异更新。要让 AssetBunle 按确定的布局构建,需要将 BuildAssetBundleOptions.DeterministicAssetBundle 标签传递给 BuildAssetBundles 函数。(更多详情请参照脚本参考的链接。)

Unity 没有提供内置的差异更新的机制,WWW.LoadFromCacheOrDownload 和 UnityWebRequest 使用内置的缓存系统也没有实现差异更新。如果一个系统中,差异更新是必须的,那么必须要写一个定制的下载器。

4.4.2 iOS 中的按需加载资源

按需加载资源 是 Apple 为 iOS 和 TVOS 设备提供内容的 API,在iOS 9以上的系统中可用。它在 App Store 发布 iOS app 不是必须的需求,但是在 TVOS app 中是必须的。

关于 Apple 的按需加载资源系统的资料可以从 Apple 开发者 找到。

Unity 5.2.1 中对 Apple 的应用分割和按需资源支持都是在 Apple 的另外一个叫Asset Catalogs系统上构建的。在 Unity 编辑器中注册回调函数之后,iOS 的构建管道会给出被自动放到 Asset Catalogs 中并分配了按需资源标签的文件集合。

新的 API UnityEngine.iOS.OnDemandResources 提供了运行时获取和缓存按需资源文件的支持。一旦资源通过按需资源系统加载,它就可以通过标准的 API AssetBundle.LoadFromFile 加载进 Unity。
示例和更多的细节可以参照 这篇帖子

4.5 常见的坑

这小节讲述了使用 AssetBundle 的项目常出现的几个问题。

4.5.1 资源重复

通过AssetDatabase,Unity 5 的 AssetBundle 系统可以找出打包进 AssetBundle 的对象的所有依赖。依赖信息决定了打包进 AssetBundle 的对象集合。

对象被显式的分配到某个 AssetBundle 后它们只会被打包到那个 AssetBundle 中。对象被 “显式分配” 是对象的 AssetImpoter 的 assetBundleName 属性被设置为了非空字符串。这个行为可以在对象 Inspector 中选择 AssetBundle 或者通过编辑器脚本完成。

没有被显式分配到 AssetBundle 的对象会被打包到拥有一个或者多个没有标签的对象的 AssetBundle 中。
如果两个不同的对象被分配到不同的 AssetBundle ,而它们都引用了共同依赖对象,然后这个共同的对象会被拷贝到每个 AssetBundle 中。重复的依赖对象会被实例化,这意味着这些依赖对象的拷贝会被认为是拥有不同标识不同对象。这会增加应用的 AssetBundle 的总大小。这也让加载这两个不同对象所在的 AssetBundle 时,它们会被加载进内存中。

有几种方式来应付这种问题:

  • 确保被打包进 AssetBundle 中的不同对象不会有同样的依赖。任何跟其他对象没有共同依赖的对象都会打包到 AssetBundle 中,而不同重复拷贝依赖。

    • 这种方法对有很多共享依赖的项目不太合适。它会产生的巨大的 AssetBunle,而且这个 AssetBunle 必须频繁地重建和下载,既不方便又很低效。
  • AssetBundle 分片,这样就不会同时有两个有共同依赖的 AssetBundle 会被加载

    • 这个方法可能只对某些项目管用,比如基于关卡的游戏。但是它会给项目增加不必要的 AssetBundle 大小和增加编译与加载时间。
  • 把所有的依赖都打包到依赖他们的 AssetBundle 中。这完全地排除了冗余资产的风险,但是它也引入了复杂性。应用程序必须 AssetBundle 间的依赖,来确保在调用 AssetBundle.LoadAsset API 前加载了正确的 AssetBundle。

Unity 5 中,对象的依赖是通过 AssetDatabase API 来记录的,这些 API 位于 UnityEditor 命名空间。就行命名空间名字所表明意思一样,这些 API 只有在 Unity 编辑器中可用,在运行时不可用。AssetDatabase.GetDependencies 可用来得到特定对象或者资源当前的依赖。注意得到的依赖可能还有它们自己的依赖。另外的,AssetImporter API 可用来查询 AssetBundle 被指派到了那些指定的对象上。

通过 AssetDatabase 和 AssetImporter API 的组合使用,用脚本用来确保一个 AssetBundle 的所有直接或者间接的依赖都指派到了同一个 AssetBundle 上,或者不存在没有指派到 AssetBundle 的依赖被两个 AssetBundle 共享是可行的。出于对重复资源内存消耗考虑,建议所有的项目都有这样的编辑器脚本。

4.5.2 图集的冗余

下面小结介绍了 Unity 5 的资源依赖计算代码中,用于自动生成的图集的一种缺陷。Unity 5.2.4p4 和 Unity 5.3 已经对这个行为打了补丁。

Unity 5.2.2p4,5.3 和之后的版本
所有自动生成的精灵图集所有对象都会指派到包含它们的 AssetBundle 中。如果这些精灵对象被指派到多个 AssetBunle,那么图集就不会指派给一个AssetBundle,这就会导致重复。如果这些精灵对象没有指派给AssetBundle,那么图集也不会指派给一个AssetBundle。

为了确保精灵图集不会冗余,确保所有标在同一个图集中的精灵都指派到了同一个 AssetBundle 中。

Unity5.2.2p3 以及老的版本
在这些版本中,自动生成的精灵图集永远不会指派到 AssetBundle 中。因为这样,包含有组成这个图集的精灵对象的 AssetBundle 和引用了组成这个图集的精灵对象的 AssetBundle 都会包含这个图集。

因为这个问题,建议所有使用 Unity 的 Sprite Packer 的版本都生升级到 Uinty 5.2.2p4,5.4 或者更新的 Unity 版本。

对于不能升级的项目,有两种临时解决方案:

  • 简单的方案:避免使用 Unity 的内置 Sprite Packer. 外部的打包工具生成的精灵图集是造正常的资源,可以正确的指派到 AssetBundle 中。

  • 难的方案:将所有使用了自动打图集的精灵的对象都指派到和精灵同一个 AssetBundle 中

    • 这可以确保生成的精灵图集对于非直接的依赖的 AssetBundle 不可见,也不会有冗余
    • 这个方案解决了使用 Unity Sprite Packer 的简单流程,但是它让开发者不能将分配资源到不同的 AssetBundle 中,并且引用了这个图集的组件上的任何数据变动都要强制重新下载整个精灵图集,即使图集自己没有任何变动。

4.5.3 安卓纹理

由于安卓生态中设备的碎片化特别严重,常常我们需要将纹理压缩成好几种格式。所有的安卓设备都支持 ETC1, 而 ETC1 不支持 alpha 通道。在不是必须要求使用 OpenGL ES 2 支持的设备上,最彻底简便的解决这个问题方法就是使用 ETC2 格式,这个格式被所有使用 OpenGL ES 3 的安卓设备支持。

如果很多应用程序需要在不支持 ETC2 的老设备支持。一个解决这个问题的方法就是使用 Unity 5 的 AssetBundle 变体(Variants)。(更多设置详情请查看 Unity 的安卓优化指导。)

为了使用 AssetBundle 变体(Variants),所有不能使用 ETC1 的纹理多要被隔离到只包含纹理的 AssetBundle 中。然后为非 ETC2 设备创建主变体,并使用第三方纹理压缩格式,比如 DXT5, PVRTC 和 ATITC。对于每个 AssetBundle 变体,改变包含的纹理的 TextureImporter 设置来改变为变体对应的格式。

在运行时,不同纹理压缩格式可以用 SystemInfo.SupportsTextureFormat API 来检测。这个信息应该用来选择和加载其所支持纹理格式的AssetBundle 变体。

更多Android图片压缩格式的信息可以参考 这里

4.5.4 过度使用iOS文件句柄

下面描述的问题已经在 Unity 5.3.2p2 中修复,当前版本的 Unity 并不受其影响。

在 Unity 5.3.2p2 之前,Unity 会在整个周期内保持对已加载过的 AssetBundle 的文件句柄的占用。这个对大多数平台不算大问题。但是 iOS 限制了并行的文件句柄数量最大为 255。 如果超过这个数量时加载 AssetBundle, 加载的调用会失败,并抛出 “打开文件句柄太多” 错误。

这对那些把内容拆分到上百甚至上千个 AssetBundle 的项目算是比较常见的问题。

对于没法升级 Unty 补丁版本的 Unity, 有两个临时解决方案可以用:

  • 合并相关的 AssetBundle 来减少 AssetBundle 的数量
  • 使用 AssetBundle.Unload(false) 关闭文件句柄,并手动的管理加载的对象的生命周期

4.6 AssetBunle 变体

Unity 5 AssetBundle 系统的一个关键功能就是引入了 AssetBundle 变体(Variants)。变体的目的是允许应用根据运行环境来调整它们的内容。它可以让不同 AssetBundle 中的不同 UnityEngine.Objects 对象在加载和引用处理的时候表现的像 “同一个” 对象一样。从概念上来讲,它允许两个不同的 UnityEngine.Objects 对象表现为共享了相同的文件 GUID 和 本地 ID,然后通过变体 ID 来断定实际要加载的 UnityEngine.Object 对象。

这个系统有两个主要的用途:

  1. 变体简化了平台对应 AssetBundle 的加载。
  • 例子:构建系统可能创建了一个适用于 DirectX11 Windows 独立运行程序 的AssetBundle, 它包含高分辨率纹理和复杂的 Shader,然后另外一个给 Android 平台的低质量的 AssetBundle。在运行期,项目资源加载代码可以根据运行的平台加载对应的 AssetBundle 变体,而不用改变传递给 AssetBundle.Load API 的县官代码。
  1. 变体可以让应用在相同平台上为不同的硬件加载不同的内容。
  • 这是支持大范围移动设备的关键。在真实世界中应用 iPhone4 显示 iPhone6 质量的内容会比较吃力
  • 在 Android 上,AssetBundle 变体可以用来处理不同设备间屏幕分辨率和 DPI 碎片化严重的问题

[译注: AssetBunle 变体这功能一般不常用]

4.6.1 AssetBundle 变体限制

AssetBunble 变体系统的主要限制是要求变体需要从不同的资源中编译出来。即使只有导入设置的改变也受这个限制影响。如果一个纹理需要打包进变体 A 和变体 B 中,两个变体中这个纹理的唯一差别是导入设置中的压缩算法不同,这种情况下,变体 A 和变体 B 必须要求是完全不同的资产,意味着必须是磁盘上独立分开的文件。
这个限制会导致在版本管理中同一个资产可能会有多份拷贝,增加了大项目的管理复杂度。而且如果开发者要更新资源的内容时,所有这些拷贝也要更新。
针对这个问题,现在还没有内置的临时解决方案。
大部分团队会有他们自己的 AssetBundle 变种方式。通常靠将定义好的后缀加入到编译的 AssetBundle 文件名中,用来区分不同的变体。然后用代码在打包这些 AssetBundle 时修改资源的导入设置。也有些开发会扩展他们定制的系统,让其能修改预设上的组件的参数。

4.7 压缩还是不压缩

是否要压缩 AssetBundle 需要仔细的考虑,重点有下面几个方面:

  • 加载时间 是不是这个 AssetBundle 的主要因数?从磁盘或缓存中加载没有压缩的 AssetBundle 会比压缩的 AssetBundle 快很多。但是通常从服务器上下载一个压缩的文件会比一个未压缩的 AssetBundle 快。
  • 编译时间 是不是这个 AssetBundle 的主要因数?LZMA 和 LZ4 在压缩式很慢,并且 Unity 编辑器会序列化 AssetBundle。有很多 AssetBundle 的项目会在压缩上花费很长的时间。
  • 程序大小 是不是主要因数?如果 AssetBundle 是跟程序一起发布,压缩他们会减少包体的大小。另外 AssetBundle 可以在程序安装后下载。
  • 内存使用 是不是主要因数?在 Unity 5.3 之前,所有 Unity 的解压机制都要求解压前将整个 AssetBundle 都加载到内存中。如果内存使用率比较重要,请使用 LZ4 压缩 AssetBundles 或者不压缩 AssetBundle。
  • 下载时间 是不是主要因数?压缩仅在 AssetBundle 比较大或者用户在带宽有限的环境中才需要,比如移动端通过 3G 下载或者在低速连接中。如果只有几十 M 的数据需要传输到 PC 或者在高速连接中,可以把压缩去掉。

4.8 AssetBundle 和 WebGL

Unity 强烈推荐开发者在 WebGL 项目上不压缩 AssetBundle。

Unity 5.3 中所有的 AssetBundle 解压和加载在 WebGL 项目中都发生在主线程之上。这是因为 Unity 5.3 的 WebGL 导出选项不支持工作线程。(AssetBundle 的下载通过 Javascript 的 XMLHttpRequest API 代理给了浏览器,不是在主线程中。)这意味着在 WebGL 中加载压缩的 AssetBundle 开销比较昂贵。

知道了这些之后,你能会想到使用解压很高效的 LZ4 压缩来避免 LZMA 压缩格式。如果你需要传输比 LZ4 更小的压缩大小,你可以通过配置 Web 服务器在 Http 协议层中使用 gzip 压缩文件(在 LZ4 压缩之上使用)。