引言
在上篇 Unity引擎资源管理机制介绍 中,讲解了Unity引擎在Editor下对资源的管理机制。而在游戏开发中,运行时的资源加载与管理是优化性能和提升用户体验的重要环节。Unity 的 AssetBundle 是一种强大的工具,可以让开发者在运行时动态加载资源,节省初始包体大小并提高灵活性。
然而,许多开发者在实际使用 AssetBundle 的过程中会遇到运行时加载资源失败、内存管理问题、Shader 显示错误等一系列挑战。本篇文章将聚焦于 AssetBundle 的 运行时加载,提供详细的指导和实用示例,帮助你掌握运行时加载的核心技巧。
本篇文章的假设前提是:所有需要的 AssetBundle 已经下载完成,并保存在本地或缓存中。换句话说,本文不会涉及下载或分发 AssetBundle 的内容,而是专注于运行时加载和资源管理。
谁适合阅读本篇文章?
- Unity 开发者:想优化项目的资源管理,提升加载效率。
- 技术美术:负责处理多语言、分辨率等变体问题。
- 游戏架构师:需要设计资源池和管理系统。
- 初学者:对 AssetBundle 的运行时加载机制感兴趣,并想了解其基础知识
你将学到什么?
- 运行时加载的完整流程:从加载清单到处理依赖关系。
- 内存管理技巧:如何合理使用 Unload 和资源池。
- Shader 加载与变体管理:避免洋红色问题。
- 设计资源管理系统:包括 handleBase 和池化管理。
- 常见问题解决方案:性能优化与错误排查。
Manifest(清单)
Manifest(清单): 清单文件是 AssetBundle 生态的重要组成部分,构建时生成的文件,用于描述 AssetBundle 的依赖关系 和 资源信息。它是运行时加载资源的关键数据。
因为构建方式的不同(BuiltinBuildPipeline(BBP)和ScriptBuildPipeline(SBP)),生成清单的格式也有所不同。
BuiltinBuildPipeline(BBP)清单
通过 Unity 内置的构建管线生成,文件格式和内容固定。清单分为两类:
- AssetBundleManifest:主清单文件,二进制格式。
- .manifest 文件:人类可读的文本描述文件,用于调试和查看。
内容如下图所示
AssetBundleManifest
ManifestFileVersion: 0
UnityVersion: 6000.0.32f1
CRC: 1104172007
HashAppended: 1
AssetBundleManifest:
AssetBundleInfos:
Info_0:
Name: sprite_ca70efbc38dff98f31ebe571d32cb6ea
Dependencies: {}
Info_1:
Name: prefab_0_bd6cfa2e1949600b47cd0a6d83b076b0
Dependencies:
Dependency_0: sprite_ca70efbc38dff98f31ebe571d32cb6ea
相应文件 .manifest
ManifestFileVersion: 0
UnityVersion: 6000.0.32f1
CRC: 2611737070
Hashes:
AssetFileHash:
serializedVersion: 2
Hash: bd6cfa2e1949600b47cd0a6d83b076b0
TypeTreeHash:
serializedVersion: 2
Hash: fc0fceea82710e3bd80e2ced230002f7
IncrementalBuildHash:
serializedVersion: 2
Hash: 727f0dd765e202c2e0f3ea834cc15308
HashAppended: 1
ClassTypes:
- Class: 1
Script: {instanceID: 0}
- Class: 114
Script: {fileID: 11500000, guid: fe87c0e1cc204ed48ad3b37840f39efc, type: 3}
- Class: 115
Script: {instanceID: 0}
- Class: 213
Script: {instanceID: 0}
- Class: 222
Script: {instanceID: 0}
- Class: 224
Script: {instanceID: 0}
SerializeReferenceClassIdentifiers:
- AssemblyName: UnityEngine.CoreModule
ClassName: UnityEngine.Events.PersistentCallGroup
- AssemblyName: UnityEngine.UI
ClassName: UnityEngine.UI.MaskableGraphic/CullStateChangedEvent
Assets:
- Assets/Tutorial/Prefabs/Prefab_0.prefab
Dependencies:
- D:/Unity_Project/Assetbundle/AssetBundles/Android/sprite_ca70efbc38dff98f31ebe571d32cb6ea
ManifestFileVersion: 0
UnityVersion: 6000.0.32f1
CRC: 501606143
Hashes:
AssetFileHash:
serializedVersion: 2
Hash: ca70efbc38dff98f31ebe571d32cb6ea
TypeTreeHash:
serializedVersion: 2
Hash: 9f349c36d7a6532f767333f9299b3e74
IncrementalBuildHash:
serializedVersion: 2
Hash: b67026f0a3f5d86ec485e0eacf913ba6
HashAppended: 1
ClassTypes:
- Class: 28
Script: {instanceID: 0}
- Class: 213
Script: {instanceID: 0}
SerializeReferenceClassIdentifiers: []
Assets:
- Assets/Layer Lab/GUI Pro-SurvivalClean/Preview/0_Title_Loading.png
- Assets/Layer Lab/GUI Pro-SurvivalClean/Preview/0_Title.png
Dependencies: []
运行时加载清单及操作示例
// 加载主清单 AssetBundle
string manifestPath = "Assets/Bundles/AssetBundleManifest";
AssetBundle manifestBundle = AssetBundle.LoadFromFile(manifestPath);
// 从主清单中获取 AssetBundleManifest 对象
AssetBundleManifest manifest = manifestBundle.LoadAsset<AssetBundleManifest>("AssetBundleManifest");
// 卸载清单 Bundle
manifestBundle.Unload(false);
// 获取指定资源包的依赖项
string[] dependencies = manifest.GetAllDependencies("example_bundle");
// 逐个加载依赖项
foreach (string dependency in dependencies)
{
AssetBundle.LoadFromFile($"Assets/Bundles/{dependency}");
}
ScriptBuildPipeline(SBP):
ScriptBuildPipeline 是一种自定义的构建管线,允许开发者根据项目需求定制资源打包逻辑和清单文件内容。与 Unity 的内置管线相比,SBP 的灵活性更强,适用于复杂的资源管理场景。
所以说清单的内容格式也有所不同,但依然都是描述 AssetBundle 的依赖关系 和 资源信息的重要文件。
下面为SBP下生成的Manifest
{
"FileVersion": "2.2.5",
"EnableAddressable": false,
"LocationToLower": false,
"IncludeAssetGUID": true,
"OutputNameStyle": 2,
"BuildBundleType": 2,
"BuildPipeline": "ScriptableBuildPipeline",
"PackageName": "DefaultPackage",
"PackageVersion": "0.0.0.1",
"PackageNote": "2025/1/1 20:08:27",
"AssetList": [
{
"Address": "",
"AssetPath": "Assets/Layer Lab/GUI Pro-SurvivalClean/Preview/0_Title.png",
"AssetGUID": "f05799050e9214bf8b999a1b0d6c6795",
"AssetTags": [],
"BundleID": 0
},
{
"Address": "",
"AssetPath": "Assets/Layer Lab/GUI Pro-SurvivalClean/Preview/0_Title_Loading.png",
"AssetGUID": "1f163c42d7d45401ca60c44bb4a5e1bc",
"AssetTags": [],
"BundleID": 0
},
{
"Address": "",
"AssetPath": "Assets/Tutorial/Prefabs/Prefab_0.prefab",
"AssetGUID": "2df83f2906de54f41adb83ffcacd6823",
"AssetTags": [],
"BundleID": 1
}
],
"BundleList": [
{
"BundleName": "defaultpackage_assets_layer_lab_gui_pro-survivalclean_preview.bundle",
"UnityCRC": 2649777433,
"FileHash": "e57b8f20ccc738a2404bbeb9baefb323",
"FileCRC": "df215f69",
"FileSize": 1490992,
"Encrypted": false,
"Tags": [],
"DependIDs": []
},
{
"BundleName": "defaultpackage_assets_tutorial_prefabs_prefab_0.bundle",
"UnityCRC": 1490879067,
"FileHash": "a5277bc32328f293e96584900439645f",
"FileCRC": "e3d71f7a",
"FileSize": 3865,
"Encrypted": false,
"Tags": [],
"DependIDs": [
0
]
}
]
}
加载Bundle
API:LoadFromFile 是从文件路径直接加载 AssetBundle 的 API,分为同步和异步两个版本:
- 同步加载Bundle:AssetBundle.LoadFromFile(string path)
- 异步加载Bundle:AssetBundle.LoadFromFileAsync(string path)
同步版本
public static AssetBundle LoadFromFile(string path);
string bundlePath = "Assets/Bundles/environment_bundle";
// 从文件加载 AssetBundle
AssetBundle bundle = AssetBundle.LoadFromFile(bundlePath);
if (bundle == null)
{
Debug.LogError($"Failed to load AssetBundle from path: {bundlePath}");
}
// 加载资源
GameObject prefab = bundle.LoadAsset<GameObject>("EnvironmentPrefab");
Instantiate(prefab);
// 卸载 AssetBundle,但保留加载的资源
bundle.Unload(false);
异步版本
public static AssetBundleCreateRequest LoadFromFileAsync(string path);
IEnumerator LoadBundleAsync(string bundlePath)
{
// 开始异步加载
AssetBundleCreateRequest request = AssetBundle.LoadFromFileAsync(bundlePath);
// 等待加载完成
yield return request;
AssetBundle bundle = request.assetBundle;
if (bundle == null)
{
Debug.LogError($"Failed to load AssetBundle from path: {bundlePath}");
yield break;
}
// 加载资源
GameObject prefab = bundle.LoadAsset<GameObject>("EnvironmentPrefab");
Instantiate(prefab);
// 卸载 AssetBundle,但保留加载的资源
bundle.Unload(false);
}
// 使用示例
StartCoroutine(LoadBundleAsync("Assets/Bundles/environment_bundle"));
同步 vs 异步的适用场景
特性 | 同步版本 | 异步版本 |
---|---|---|
加载过程 | 阻塞主线程 | 不阻塞主线程 |
适用场景 | 小型资源包加载,启动资源 | 大型资源包加载,动态场景切换 |
实现复杂度 | 简单 | 需要协程支持 |
用户体验 | 可能引发卡顿 | 平滑加载,提升用户体验 |
加载方式与压缩方式
- 也有其它的加载API,但从效率和实用性来看,这两个API开发中使用最多,配合LZ4的压缩方式,无脑选择就好。相关对比参数详见:Unity 优化之 移动游戏加载性能和内存管理全解析【2017年版】 中的Assetbunele部分。
三种加载AssetBundle API 对比
加载耗时
API | 较小 AB 包(6.9MB) | 较大 AB 包(116.3MB) |
---|---|---|
LoadFromFile | 0.965 毫秒 | 2.786 毫秒 |
LoadFromStream | 1.09 毫秒 | 1.633 毫秒 |
DownLoadHandlerAssetBundle | 0.721 毫秒 | 0.61 毫秒 |
内存占用(AB 包内存)
API | 较小 AB 包(6.9MB) | 较大 AB 包(116.3MB) |
---|---|---|
LoadFromFile | 4.93 kb | 12.46 kb |
LoadFromStream | 4.93 kb | 12.46 kb |
DownLoadHandlerAssetBundle | 5.00 kb | 12.53 kb |
堆内存分配
API | 堆内存分配 |
---|---|
LoadFromFile | 30.61 kb |
LoadFromStream | 64.59 kb |
DownLoadHandlerAssetBundle | 31.49 kb |
LZ4 和 LZMA
- 下面的数据来源于UWA DAY2024,对比加载 10张和100张分别以 LZ4和LZMA压缩方式 的1024纹理,所对应的耗时数据
压缩方式 | 加载耗时(10 * ASTC 4*4 1024纹理) | 加载耗时(100 * ASTC 4*4 1024纹理) |
---|---|---|
LZ4 | 0.284ms | 0.457ms |
LZMA | 73.354ms | 366.489ms |
加载资源
- AssetBundle.LoadAsset
(string name) - AssetBundle.LoadAssetAsync
(string name) - AssetBundle.LoadAllAssetsAsync
()
从AssetBundle 加载资源 API 对比
API | 特性 | 适用场景 |
---|---|---|
AssetBundle.LoadAsset<T> |
同步加载单个资源,阻塞主线程 | 小型资源加载,启动时加载少量资源 |
AssetBundle.LoadAssetAsync<T> |
异步加载单个资源,不阻塞主线程 | 动态加载场景中的关键资源,提升用户体验 |
AssetBundle.LoadAllAssetsAsync<T> |
异步加载所有资源,不阻塞主线程,减少IO操作和底层的批量处理,速度更快 | 需要批量加载的场景,适合小型资源包 |
AssetBundle 依赖关系及加载顺序
-
如果一个或多个 UnityEngine.Object 包含对位于另一个 AssetBundle 中的 UnityEngine.Object 的引用,则该 AssetBundle 会依赖于另一个 AssetBundle。如果 UnityEngine.Object 包含对未包含在任何 AssetBundle 中的 UnityEngine.Object 的引用,则不会形成依赖关系。在这种情况下,构建 AssetBundle 时,所依赖的对象会被复制到当前 AssetBundle 中。
-
如果多个 AssetBundle 中的多个对象引用了同一个未分配到 AssetBundle 的对象,则每个 AssetBundle 都会为该对象创建自己的副本,并将其打包到生成的 AssetBundle 中。
-
当 AssetBundle 包含依赖关系时,必须在加载尝试实例化的对象之前,先加载包含这些依赖项的 AssetBundle。Unity 不会自动加载这些依赖项。
例如,一个材质(Material)位于 Bundle 1 中,而它引用的纹理(Texture)位于 Bundle 2 中:
在这个例子中,必须在加载 Bundle 1 中的材质之前,将 Bundle 2 加载到内存中。加载 Bundle 1 和 Bundle 2 的顺序并不重要,关键是在加载材质之前,Bundle 2 已加载。
结论:当加载资源时,所需要的bundle已经在内存中即可。
ThreadPriority
Application.backgroundLoadingPriority 属性用于设置后台加载线程的优先级,从而控制异步加载数据所需的时间,以及在后台加载时对游戏性能的影响。
UNITY 文档
异步加载函数(如 Resources.LoadAsync、AssetBundle.LoadAssetAsync、AssetBundle.LoadAllAssetsAsync、SceneManager.LoadSceneAsync)会在独立的后台加载线程上执行数据读取和反序列化操作,并在主线程上进行对象集成。
为避免游戏卡顿,Unity 会根据 backgroundLoadingPriority
的值限制主线程在单帧内用于对象集成操作的时间:
优先级 | 单帧主线程最大占用时间 |
---|---|
ThreadPriority.Low |
2 毫秒 |
ThreadPriority.BelowNormal |
4 毫秒 |
ThreadPriority.Normal |
10 毫秒 |
ThreadPriority.High |
50 毫秒 |
这意味着,设置较高的优先级(如 ThreadPriority.High)时,异步加载操作在主线程每帧可占用的时间更长,可能加快加载速度,但也可能对游戏的实时性能产生更大影响。
相反,设置较低的优先级(如 ThreadPriority.Low)时,异步加载操作在主线程每帧占用的时间较短,减少对游戏性能的影响,但可能延长加载时间
加载API性能对比
变体收集
关注的内存变化三项
Unload(true)和Unload(false)区别
var assetBundleA = AssetBundle.LoadFromFile(path);
var spriteA = assetBundleA.LoadAsset<Texture2D>("spriteA");
var spriteA = assetBundleA.LoadAsset<Texture2D>("SpriteA");
另外,如果在此状态下再次加载相同的资源,则会返回对已加载到内存中的资源的引用。
- 无磁盘IO发生
- 资产在内存中不重复
assetBundleA.Unload(false);
如果在此状态下调用AssetBundle.Unload(false),AssetBundle的元信息将被卸载。
从现在开始,将不再可能从 assetBundleA 加载资产。
在Texture2D的引用过期后,可以通过调用Resources.UnloadUnusedAssets来释放剩余的资源。
assetBundleA.Unload(true);
如果是调用Unload(true) ,即使加载的资源也会被强制释放。
这种情况下,游戏中显示的纹理将会处于缺失状态。
如果不是用loadFromFile 配个LZ4或者未压缩的时候,就不是进行按需加载到内存中,会一次性全部加载。
在Editor情况下会全部加载,但对于Editor的情况下,性能并不是主要关注的问题。详见:AssetBundle 压缩和缓存
- 注意:Assetbundle没有卸载时重复加载同一AssetBundle,会出现报错。
Assetbundle 中的注意事项
AssetBundle Meta的回收
- AssetBundle 的 Meta 信息如果没有调用 Unload 函数,是不会被自动卸载的,Meta信息在UnityNative端,所以即使主动调用
System.GC.Collect()
,它也不会被释放,而且由于它与资源的处理方式不同,调用Resources.UnloadUnusedAssets,它也不会被释放。
在这种情况下,释放它的唯一方法是使用AssetBundle.UnloadAllAssetBundles卸载所有Meta信息。
纹理变成洋红色
- 纹理丢失
- 对应平台的shader或变体没有收集
- 当前shader不支持对应的平台
注:关于AssetBundle变体收集详见:Unity Shader变体管理流程 和 Shader:优化破解变体的“影分身”之术
触发shader函数
- Shader.Parse 和 Shader.CreateGPUProgram 是每次加载Bundle的时候都会重新触发,所以需要cache
CRC 完整性检查
CRC(Cyclic Redundancy Check)用于对 Asset Bundles 进行校验和验证,以确保传送到游戏中的内容与预期完全一致。CRC 是基于 Bundle 的未压缩内容计算的。
在主机平台上,Asset Bundles 通常作为本地存储上的内容安装的一部分或作为 DLC 下载,因此,不需要进行 CRC 检查。在其他平台(如 PC 或移动设备)上,对从 CDN 下载的 Bundle 进行 CRC 检查就非常重要。这是为了确保文件没有损坏或被截断,从而避免潜在的崩溃,同时也为了防止潜在的篡改。
CRC 检查在 CPU 使用方面相当昂贵,尤其是在主机和移动设备上。出于这些原因,通常一个较好的折衷方案是在本地和缓存的 Bundle 上禁用 CRC 检查,只对非缓存的远程 Bundle 启用 CRC 检查。
CRC 与 Hash计算的对比
- CRC计算速度比Hash算法(如SHA-256)快约10倍或更多。
- CRC适用于实时和大数据量的校验,而Hash适用于安全性和唯一性更高的场景。
特性 | CRC | Hash计算 |
---|---|---|
用途 | 错误检测 | 数据完整性与安全性 |
数学基础 | 多项式运算 | 哈希函数 |
结果长度 | 较短(8/16/32位) | 较长(128/160/256位等) |
抗碰撞性 | 较弱 | 较强 |
速度 | 快 | 相对较慢 |
是否可逆 | 不可逆,但易伪造 | 不可逆,难以伪造 |
典型应用场景 | 网络通信、文件传输 | 密码学、文件完整性校验 |
加载缓存
加载缓存(Loading cache)是一个共享的页面池,Unity 用它来存储最近访问的 Asset Bundles 数据。这个缓存是全局的,因此你游戏中所有的 Asset Bundles 都可共享这个缓存。
这个功能是最近(大约是在 Unity 2021.3 版本)引入的,然后移植回 2019.4 版本。在此之前,Unity 为每个 Asset Bundle 使用单独的缓存,这导致运行时内存使用显著增加(在下文的“序列化文件缓冲区”部分会讨论)。
默认情况下,这个缓存大小设置为 1MB,但可以通过设置 AssetBundle.memoryBudgetKB 来改变。
默认的缓存大小在大多数情况下应该是足够的,虽然在某些场景下,改变它可能给你的游戏带来好处。例如,如果你的 Bundle 中有很多小对象,增加缓存大小可能会导致更多的缓存命中,从而提高游戏性能。
预加载表
预加载表( Preload Table)列出了 Bundle 中每个资产的依赖项。Unity 用它来正确加载和构建资产。
如果 Bundle 中的资产有很多显式和隐式的依赖项,以及来自其他 Bundle 的级联依赖项,这张表可能会变得相当大。因此(以及其他许多原因),设计 Bundle 时最好尽量减少依赖链。
AssetBundle 变体
总结思考
- 基于上述的信息,我们应该如何设计运行时关于AssetBundle 和 Asset 的管理系统?
参考链接
- 充分理解AssetBundle
- AssetBundle 的解析方法及其应对措施
- Unity Asset Bundles,不可不知的使用技巧和误区
- AssetBundle 下载完整性和安全性
- 揭开AssetBundle庐山真面目(二)
- 你应该知道的AssetBundle管理机制【旧版】
- Using AssetBundles Natively
- AssetBundle 压缩和缓存
- AssetBundle Dependencies
- AssetBundle的原理及最佳实践
- Assets, Resources and AssetBundles
工具
- MemoryProfiler
- AssetRipper