# 前言

最开始是因为项目的一个问题:微信的 Unity 转小游戏,转出来的 XXXX.webgl.data.unityweb.bin.txt(首包资源文件) 文件极大,其中占据数量最多的是一系列的 MonoScript ,且经过对比检查,其它占用够大的 Shader、图片或冗余资源都被我一一处理过了,但是呢这个文件依然很大。

达到什么程度呢?公司其它项目转出来只有 7M,而我们的达到了 16M?!这正常吗?不正常吧!

最麻烦的一点是:经过 AssetStudio 打开看之后,排头的只有寥寥几个资源,而且都不算大:

与只有 7M 资源的对比, AssetStudio 列出的文件基本上也是一样的。

于是去查找信息,结果找到 Unity 资源管理 - 资源(Assets)、对象(Objects)和序列化 - 腾讯游戏学堂 ,然后又顺着其贴的原始文档找到了: Assets, Resources and AssetBundles - Unity Learn 文章,这篇文章看着算是官方对 Unity 的资源系统的一个比较详细的描述了,有点熟悉..... 以前是不是见过?曾经好像零零散散在某些文章看到过,但是并不齐全,于是这次就整体过了一遍。

注:本文也并不是全文翻译,而是取自己觉得重要、或都知道什么情况的知识点就暂且忽略了 (如 AssetBundle 依赖管理)。

(当然截止本文完成的时候,依然没有找出资源大小的具体原因,打算后续把 AssetStudio 源码拉下来调试一下,看是不是有什么隐藏资源)

# 资源与序列化

# Assets and Objects

Asset:指的是一个存在于磁盘上的资源文件
UnityEngine.Objects:一组序列化数据,共同描述资源的特定实例
大多数 Object 类型都是内置类型,除了两个例外:

  • ScriptableObject:提供一个方便的自定义数据类型的系统,这些类型可以由 Unity 自动序列化和反序列化,并在 Unity 编辑器窗口中进行操作。
  • MonoBehaviour:提供对 MonoScript 引用的包装,MonoScript 是 Unity 用于保存对特定程序集和命名空间中特定脚本类的引用的内部数据类型

Assets 和 Objects 之间是一对多的关系:任何给定的资产文件都包含一个或多个对象。

# 标识与引用

所有 UnityEngine.Objects 都可以引用其他 UnityEngine.Objects,被引用的 Object 可能在同一个 Asset 文件中,也可能在导入的其它 Asset 文件中

  • 在序列化后,这些引用由两部分独立的数据组成:FileGUID (标识资源位置) 和 LocalID (标识资源中的对象 [Object])
    • FileGUID 存储在 .meta 文件中
    • FileGUID 提供了文件特定位置的抽象,只要特定文件 GUID 可以与特定文件相关联,该文件在磁盘上的位置就变得无关紧要。该文件可以自由移动,而无需更新引用该文件的所有对象。
      • 由于任何给定的资产文件可能包含(或通过导入生成)多个 UnityEngine.Object 资源,因此需要 LocalID 来明确区分每个不同的对象。
    • Unity 编辑器将文件路径和 FileGUID 进行映射,每当加载或导入 Asset 时,都会记录一个映射条目。
  • 注:如果 .meta 文件在 Unity 编辑器关闭时丢失,或者 Asset 的路径发生变化而 .meta 文件没有随 Asset 一起移动,那么对该 Asset 中对象的所有引用都将被破坏。

尽管 FileGUID 和 LocalID 足够稳健,但 GUID 比较速度较慢

  • 因此 Unity 在内部维护了一份缓存(PersistentManager),它将 FileGUID 和 LocalID 转换成简单的、会话唯一(Session-unique)的整数:这些整数被称为 InstanceID,当有新的对象注册到缓存时,InstanceID 以简单的、单调递增的顺序分配
  • 在启动时,游戏会立即初始化包含所有被项目需要的对象 (例如在内置场景中引用)、以及 Resources 文件夹中包含的所有对象的 InstanceID 缓存
    • 当在运行时创建新资源 (如 new Texture2D) 以及从 AssetBundle 加载对象时,新的条目也将添加到缓存中
    • 在提供具体 FileGUID 和 LocalID 的 AssetBundle 被卸载时,会从缓存中移除 InstanceID 条目。这时原来的 InstanceID、FileGUID 和 LocalID 会被删除,如果再次加载了这个 AssetBundle,则会重新生成新的 InstanceID
    • 注:当应用程序暂停时,可能从 iOS 上的图形内存中卸载图形资源,如果这些对象来源于已卸载的 AssetBundle,Unity 将无法重新加载对象的源数据。对这些对象的任何现有引用也将无效
  • 注:当构建项目时,FileGUID 和 LocalID 会被确定性地映射到更简单的格式 (这也是不能在运行时查询 Asset 的 FileGUID 的原因),但概念仍然是相同的

# MonoScript

其重点是 MonoBehaviour 引用 MonoScript (MonoBehaviour 就是 MonoScript 的包装器),而 MonoScripts 只包含定位特定脚本类所需的信息,两种类型的对象都不包含脚本类的可执行代码。

  • MonoScript 包含三个字符串:程序集名称、类名称和命名空间。

当构建项目时,Unity 将 Assets 文件夹中所有松散的脚本文件编译成 Mono 程序集 (它们也是 MonoScript 引用的程序集)

  • Plugins 子文件夹外的 C# 脚本被放入 Assembly-CSharp.dll
  • Plugins 子文件夹中的脚本被放入 Assembly-CSharp-firstpass.dll
  • 注:还可以自定义程序集

与其他资源不同,Unity 应用程序中包含的所有程序集都在应用程序启动时加载
这允许不同的 MonoBehaviours 引用特定的共享类,即使 MonoBehaviours 在不同的 AssetBundle 中。

# 资源加载

# 加载方式

在以下情况下会自动加载对象:

  • 映射到该对象的 InstanceID 被间接引用
  • 该对象当前未加载到内存中
  • 可以定位 (找到) 对象的源数据

对象也可以在代码中显式加载,比如直接代码创建或调用资源加载 API (例如 AssetBundle.LoadAsset)
加载一个对象时,Unity 会尝试通过由每个被引用对象的 FileGUID 和 LocalID 转换而成的 InstanceID 来解析任何引用。
对象被间接引用而导致按需加载的条件:

  • InstanceID 引用当前未加载的对象
  • InstanceID 具有在缓存中注册的有效 FileGUID 和 LocalID

注 1:可以通过调用 Resources.UnloadAsset API 显式卸载来自 Resources 文件夹的对象,不过这些对象的 InstanceID 仍然有效,并且仍将包含有效的 FileGUID 和 LocalID 条目,只要被任何活动对象间接引用时,就会重新加载该对象
注 2:注意任何标有 HideFlags.DontUnloadUnusedAsset 和 HideFlags.HideAndDontSave 的东西都不会被卸载

# 大资源加载消耗

创建任何 GameObject 层次结构时,CPU 时间主要花费在以下几种方式上:

  • 读取源数据(从存储、AssetBundle、另一个 GameObject 等)
  • 设置新 Transform 之间的父子关系
  • 实例化新的游戏对象和组件
  • 在主线程上唤醒新的游戏对象和组件

后三种时间成本通常是不变的,无论层次结构是从现有层次结构中克隆还是从存储中加载。然而,读取源数据的时间随着序列化到层次结构中的组件和游戏对象的数量线性增加,并且还乘以数据源的速度。

  • 在所有当前平台上,从内存中的其他位置读取数据比从存储设备加载数据要快得多,加载操作的成本与存储 I/O 时间有关。

如前所述,在序列化单体预制件时,每个 GameObject 和组件的数据都是单独序列化的,这可能会重复数据。

  • 例如,具有 30 个相同元素的 UI 屏幕会将相同元素序列化 30 次,从而产生大量二进制数据。
  • 在加载时,必须从磁盘读取这 30 个重复元素中每一个的所有游戏对象和组件的数据,然后再传输到新实例化的对象。此文件读取时间是实例化大型预制件的总成本的主要消耗。
  • 因此,大型层次结构对象应该在模块化中实例化,然后在运行时结合在一起。

注:在实例化一个立即会重新设置为另一个对象子节点的新游戏对象时,请使用接受父对象参数的 GameObject.Instantiate 重载变体。使用此重载可避免为新游戏对象分配根变换层次结构。在测试中,这将实例化操作所需的时间加快了大约 5-10%。

# Resources 目录

不要使用
缺点很明显:

  • 使用 Resources 文件夹使细粒度的内存管理更加困难
  • 资源文件夹使用不当会增加应用程序启动时间和构建时间
  • Resources 系统降低了项目向特定平台交付自定义内容的能力,并消除了增量内容升级的可能性
    • AssetBundle Variants 可以用于根据每个设备调整内容

总之,官方是非常不推荐使用的,也许快速原型阶段可以使用,但当项目进入全面生产阶段时,应取消使用 Resources 文件夹
构建项目时,所有名为 Resources 的文件夹中的资源和对象都合并到一个序列化文件中。该文件还包含元数据和索引信息,类似于 AssetBundle。如 AssetBundle 文档中所述,该索引包括一个序列化查找树,用于将给定对象的名称解析为其适当的 FileGUID 和 LocalID。它还用于在序列化文件主体中的特定字节偏移处定位对象。

  • 在应用程序启动时,也会进行 Resources 资源数据结构的构建
  • 在大多数平台上,查找数据结构是一个平衡的搜索树,其构建时间以 O (n log (n)) 的速度增长。随着资源文件夹中对象数量的增加,这种增长也会导致索引的加载时间以超线性方式增长。

# AssetBundle

AssetBundle 由两部分组成:标头和数据段
标头

  • 包含有关 AssetBundle 的信息,例如其标识符、压缩类型和清单。清单是一个以对象名称为关键字的查找表。每个条目都提供一个字节索引,指示在 AssetBundle 的数据段中可以找到给定对象的位置。在大多数平台上,此查找表被实现为平衡搜索树。具体来说,Windows 和 OSX 衍生平台(包括 iOS)采用红黑树。因此,随着 AssetBundle 中资产数量的增长,构建清单所需的时间将超过线性增长。

数据段

  • 数据段包含序列化 AssetBundle 中的 Assets 生成的原始数据。如果将 LZMA 指定为压缩方案,则会压缩所有序列化资产的完整字节数组。如果改为指定 LZ4,则单独压缩单独资产的字节。如果不使用压缩,数据段将保持为原始字节流。

通常,Unity 会缓存一份 AssetBundle 的解压副本,以提高后续对同一个 AssetBundle 的加载请求的加载性能。

# 加载 AssetBundle 方式

# AssetBundle.LoadFromMemory(Async)

建议是不要使用这个 API

  • 这个 API 会从托管代码字节数组(C# 中的 byte [])加载 AssetBundle。它总是将源数据从托管代码字节数组复制到新分配的、连续的本机内存块中。如果 AssetBundle 是 LZMA 压缩的,它会在复制时解压 AssetBundle。未压缩和 LZ4 压缩的 AssetBundle 将被逐字复制。

此 API 消耗的内存峰值量将至少是 AssetBundle 大小的两倍:一份在 API 创建的本机内存中,一份在传递给 API 的托管字节数组中。

  • 因此,从通过此 API 创建的 AssetBundle 加载的资源将在内存中复制三次:一次在托管代码字节数组中,一次在 AssetBundle 的本机内存副本中,第三次在资源本身的 GPU 或系统内存中

# AssetBundle.LoadFromFile(Async)

这是一个高效的 API,用于从本地存储(如硬盘或 SD 卡)加载 AssetBundle。

  • 在桌面、控制台和移动平台上,API 只会加载 AssetBundle 的标头,剩余数据依然保留在磁盘上。
    • AssetBundle 的对象将在调用加载方法(例如 AssetBundle.Load)或取消引用它们的 InstanceID 时按需加载。在这种情况下不会消耗过多的内存。
  • 在 Unity Editor 中,API 会将整个 AssetBundle 加载到内存中,就像从磁盘读取字节并使用 AssetBundle.LoadFromMemoryAsync 一样。如果在 Unity 编辑器中分析项目,此 API 可能会导致在 AssetBundle 加载期间出现内存峰值。这不影响真机性能,在采取修复措施之前应在真机设备上重新测试这些内存峰值情况。

# UnityWebRequest.GetAssetBundle

这个 API 会使用工作线程,将下载的数据流式传输到固定大小的缓冲区,然后将缓冲的数据保存到临时存储或 AssetBundle 缓存,具体取决于下载处理程序的配置方式。

  • 所有这些操作都发生在本机代码中,从而消除了扩展托管堆的风险。
  • 此外,此下载处理程序不会保留所有下载字节的本机代码副本,从而进一步减少了下载 AssetBundle 的内存开销

注:LZMA 压缩的 AssetBundle 将在下载期间解压缩并使用 LZ4 压缩进行缓存。可以通过设置 Caching.CompressionEnabled 来更改此行为。

  • 如果缓存已满,Unity 将从缓存中删除最近最少使用的 AssetBundle
  • 注:缓存系统中的 AssetBundle 仅由文件名标识 (而不是下载的完整 URL)

下载完成后,AssetBundle 属性提供的对下载的 AssetBundle 的访问,与已在下载的 AssetBundle 上调用 AssetBundle.LoadFromFile 一样。

  • 如果向 UnityWebRequest 对象提供缓存信息,并且请求的 AssetBundle 已存在于 Unity 的缓存中,则此 API 的操作与 AssetBundle.LoadFromFile 相同。

注 1:请确保下载程序代码在加载 AssetBundle 后正确调用 Dispose (或使用 using 语句)
注 2:更多缓存机制详情参考原文缓存机制部分
注 3:官方推荐若想自己实现细粒度资源下载管理,使用 C# HttpWebRequest 或自定义本机代码,官方强烈建议使用 Application.persistentDataPath 作为持久存储位置

# WWW.LoadFromCacheOrDownload

从 Unity 2017.1 开始,这个 API 只是对 UnityWebRequest 的包装,以后可能会直接弃用。

# 总结

总之,应尽可能使用 AssetBundle.LoadFromFile,这个 API 在速度、磁盘使用和运行时内存使用方面都是最有效的。
对于必须下载或修补 AssetBundles 的项目,建议使用 UnityWebRequest。
对于需要独特、特定缓存或下载要求的项目,可以考虑使用自定义下载器。任何自定义下载器都应该与 AssetBundle.LoadFromFile 兼容。

# 加载 AssetBundle 中的资源

可以使用三个不同的 API 从 AssetBundle 加载,这些 API 都属于 AssetBundle 对象的实例方法 (并且可选同步或异步):

  • LoadAsset (LoadAssetAsync)
  • LoadAllAssets (LoadAllAssetsAsync)
  • LoadAssetWithSubAssets (LoadAssetWithSubAssetsAsync)

这些 API 的同步版本总是比异步版本快至少一帧。

  • 异步加载将每帧加载多个对象,直到它们达到时间片限制 (参考底层加载细节)。

LoadAllAssets

  • 应该在加载多个独立的 UnityEngine.Objects 时使用。仅当需要加载 AssetBundle 中的大部分或所有对象时才应使用它。与其他两个 API 相比,LoadAllAssets 比多次单独调用 LoadAssets 稍微快一些。
  • 因此,如果要加载的资源数量较多,但一次需要加载的 AssetBundle 不到 66%,可以考虑将 AssetBundle 拆分成多个更小的 bundle,使用 LoadAllAssets。

LoadAssetWithSubAssets

  • 应该在加载包含多个嵌入式对象的复合资产时使用,例如带有嵌入式动画的 FBX 模型或其中嵌入了多个精灵的精灵图集。
  • 如果需要加载的 Object 都来自同一个 Asset,但是和很多其他不相关的 Object 存储在一个 AssetBundle 中,那么就使用这个 API。

对于任何其他情况,使用 LoadAsset 或 LoadAssetAsync

# 底层加载细节

UnityEngine.Object 加载是在主线程之外执行的:对象的数据使用工作线程从磁盘读取。

  • 任何不涉及 Unity 系统的线程敏感部分(脚本、图形)的内容都将在工作线程上进行转换。
  • 例如,VBO 将从网格创建,纹理将被解压等。

从 Unity 5.3 开始,对象加载已经并行化。多个对象在工作线程上被反序列化、处理和集成。当一个对象完成加载时,将调用其 Awake 回调,并且该对象将在下一帧期间可供 Unity 引擎的其余部分使用。
同步 AssetBundle.Load 方法将暂停主线程,直到对象加载完成。它们还将对对象加载进行时间切片,限制每帧的占用的总的加载时间不会超过超过一定毫秒数。毫秒数由属性 Application.backgroundLoadingPriority 设置:

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

假设所有其他因素都相同,资产加载 API 的异步版本将始终比对应同步版本花费更长的时间来完成,因为发出异步调用和对象对引擎可用之间的至少也有最小一帧的延迟。

# AssetBundle 依赖

根据运行时环境,可以使用两个 API 自动跟踪 AssetBundle 之间的依赖关系:

  • 在 UnityEditor 中,可以通过 AssetDatabase API 查询 AssetBundle 依赖项
  • 在运行时,Unity 提供了一个可选的 API :AssetBundleManifest,可以加载在 AssetBundle 构建期间生成的依赖信息。

如之前的序列化和实例部分所述,AssetBundle 充当其中包含的每个对象的 FileGUID 和 LocalID 标识的源数据的来源。
因为一个对象是在它的 InstanceID 第一次被间接引用时加载的,并且在加载一个 AssetBundle 时其中对象会被分配一个有效的 InstanceID,因此加载 AssetBundle 的顺序并不重要。

  • 相反,重要的是在加载对象本身之前加载所有包含对象依赖项的 AssetBundle (但不必显式去加载依赖资源)。
  • 注:Unity 不会尝试自动加载任何被依赖的 AssetBundle 包本身

# AssetBundle manifests

当使用 BuildPipeline.BuildAssetBundles API 执行 AssetBundle 构建时,Unity 会序列化一个包含每个 AssetBundle 的依赖信息的对象。

  • 此数据存储在单独的,与构建 AssetBundle 的父目录同名的 AssetBundle 中,其中包含一个 AssetBundleManifest 类型的对象。
  • 例如构建目的目录为:
    • (projectroot)/build/Client/
    • 则包含清单的 AssetBundle 将被保存为:(projectroot)/build/Client/Client.manifest

包含清单的 AssetBundle 可以像任何其他 AssetBundle 一样加载、缓存和卸载:

  • AssetBundle bundle =AssetBundle.LoadFromFile((projectroot)/build/Client/Client)
  • AssetBundleManifest manifest =bundle.LoadAsset("AssetBundleManifest");

AssetBundleManifest 对象本身提供了 GetAllAssetBundles API 来列出与清单同时构建的所有 AssetBundle,并提供两种方法来查询特定 AssetBundle 的依赖项:

  • AssetBundleManifest.GetAllDependencies 返回 AssetBundle 的所有层次依赖关系,包括 AssetBundle 的直接子项、其子项的子项等的依赖项
  • AssetBundleManifest.GetDirectDependencies 仅返回 AssetBundle 的直接子级依赖

注意:这两个 API 都分配字符串数组。因此它们应该被谨慎使用,而不是在应用程序生命周期的性能敏感部分使用。

# 卸载 AssetBundle 方式

切换场景 (即当 SceneManager.LoadScene 被非附加地调用时)

  • 将销毁当前场景中的所有对象并自动调用 Resources.UnloadUnusedAssets

脚本调用 Resources.UnloadUnusedAssets 时

  • 此过程仅卸载未引用的对象:仅当没有 Mono 变量持有对对象的引用并且没有其他活动对象持有对对象的引用时,才会卸载对象

调用 AssetBundle.Unload (true) API 时,源自 AssetBundle 的对象会自动并立即卸载

  • 这会使对象实例 ID 的文件 GUID 和本地 ID 无效,并且对已卸载对象的任何实时引用都将成为 Missing 引用
  • 在 C# 脚本中,尝试访问已卸载对象的方法或属性将产生 NullReferenceException
  • 如果调用 AssetBundle.Unload (false)-- 不推荐使用,来自已卸载 AssetBundle 的活动对象将不会被销毁,但 Unity 将使其 FileGUID 和 LocalID 引用无效。如果稍后从内存中卸载这些对象并且对卸载对象的实时引用仍然存在,Unity 将不可能重新加载这些对象。

注:当 Unity 失去对其图形上下文的控制时,对象在运行时会从显存中移除。这可能发生在移动应用程序暂停并且应用程序被迫进入后台时。在这种情况下,移动操作系统通常会从 GPU 内存中逐出所有图形资源。当应用程序返回前台时,Unity 必须在场景渲染恢复之前将所有需要的纹理、着色器和网格重新加载到 GPU (如果 AssetBundle 已经被卸载,就会导致问题)

# 注意 - 安卓

安卓平台:

  • 在 Android 上,StreamingAssets 文件夹中的资源存储在 APK 中,如果它们被压缩可能需要更多时间来加载,因为存储在 APK 中的文件可以使用不同的存储算法。
  • 使用 7-zip 等归档程序打开 APK 可以确定文件是否被压缩,如果是则 AssetBundle.LoadFromFile 肯定执行得更慢,这种情况下可以使用 UnityWebRequest.GetAssetBundle 作为解决方法来检索缓存的版本。通过使用 UnityWebRequest,AssetBundle 将在第一次运行期间被解压缩和缓存,从而使后续执行速度更快。但是将占用更多存储空间,因为 AssetBundle 将被复制到缓存中。
  • 或者可以编辑 build.gradle 文件并将该扩展名添加到 noCompress 部分,这样使用 AssetBundle.LoadFromFile () 也能无需消耗额外解压缩成本。

# 总结

多数情况下,最好在玩家进入程序的性能关键区域(例如主游戏关卡或世界)之前加载尽可能多的所需对象。这在移动平台上尤为重要,因为在移动平台上访问本地存储很慢,并且在运行时加载和卸载对象的内存波动会触发 GC
使用 AssetBundle.LoadFromFile 加载资源并采用 AssetBundle.Unload (true) 释放资源。

# 参考文档

Unity 资源管理 - 资源(Assets)、对象(Objects)和序列化 - 腾讯游戏学堂
Assets, Resources and AssetBundles - Unity Learn