实现Unity AssetBundle资源加载管理器
AssetBundle是实现资源热更新的重要功能,但Unity为其提供的API却十分基(jian)础(lou)。像是自动加载依赖包、重复加载缓存、解决同步/异步加载冲突,等基础功能都必须由使用者自行实现。
因此,本篇博客将会介绍如何实现一个AssetBundle管理器以解决以上问题。
1 成员定义与初始化
作为典型的"Manager"类,我们显然要让其成为一个单例对象,并且由于后续异步加载会用到协程函数,因此还需要继承MonoBehaviour
。所以,这里用到了我在Unity单例基类的实现方式中提到的Mono单例基类SingletonMono<>
。
C#
// Mono单例基类
public abstract class SingletonMono<T> : MonoBehaviour where T : MonoBehaviour
{
private static T _instance;
public static T Instance
{
get
{
if (_instance == null)
{
// 在场景中查找是否已存在该类型的实例
_instance = FindObjectOfType<T>();
// 如果场景中不存在该类型的实例,则创建一个新的GameObject并添加该组件
if (_instance == null)
{
GameObject singletonObject = new GameObject(typeof(T).Name + "(Singleton)");
DontDestroyOnLoad(singletonObject); // 保留在场景切换时不被销毁
_instance = singletonObject.AddComponent<T>();
}
}
return _instance;
}
}
}
在加载AB包时,我们一般只要求外部传入包名,但AssetBundle.LoadFromFile
是需要完整路径的,因此我们可以根据自己打包时的具体位置来修改AB_DIR
。由于我在打包时勾选了Copy to StreamingAssets
,因此这里就用Application.streamingAssetsPath + '/'
作为AB包的根目录。
C#
private static readonly string AB_DIR = ... + '/'; // AB包所在目录
AB包之间的依赖信息都存储在主包的Manifest之中,所以我们需要先设置好主包的名字。这里的MAIN_AB_NAME
的值也是根据你在打包时的参数来修改的,比如我打包的Output Path
参数是AssetBundles/PC
,那么此时主包名就是PC
。
C#
private static readonly string MAIN_AB_NAME = // 主包名
#if UNITY_IOS
"iOS";
#elif UNITY_ANDROID
"Android";
#else
"PC";
#endif
接下来就需要在Awake函数中进行初始化,唯一要做的就是读取主包的Manifest
C#
public class ABManager : SingletonMono<ABManager>
{
// ......
private AssetBundleManifest _mainManifest;
private void Awake()
{
// 加载主包的manifest
AssetBundle mainAssetBundle = AssetBundle.LoadFromFile(AB_DIR + MAIN_AB_NAME);
_mainManifest = mainAssetBundle.LoadAsset<AssetBundleManifest>("AssetBundleManifest");
mainAssetBundle.Unload(false); // 加载完manifest之后就可以释放主包
}
// ......
}
同一个AB包在被多次加载时会报错,所以我们需要声明一个字典来存储已经加载的AB包。
C#
private readonly Dictionary<string, AssetBundle> _assetBundles = new();
此外我们还要注意同步/异步冲突 与异步/异步冲突
同步/异步冲突是指,在某个AB包异步 加载的过程中,用户又对同一个AB包发起了同步加载的请求,如果我们直接进行同步加载,就会出现"同一个AB包在被多次加载"的错误。
异步/异步冲突则是,在某个AB包异步 加载的过程中,用户又对同一个AB包发起了异步 加载的请求同样会重复加载的错误,因此我们就需要让后来的异步请求进行暂停等待,直到该包在先来的异步请求中加载完成。
为此我们需要定义一组加载状态,用于解决上述冲突,并且使用字典来存储AB包当前的加载状态
C#
enum ABStatus
{
Completed, // 本包和依赖包都加载完毕
Loading, // 正在加载
NotLoaded // 未被加载
}
C#
private readonly Dictionary<string, ABStatus> _loadingStatus = new();
综上所述,我们的成员定义与初始化如下:
C#
public class ABManager : SingletonMono<ABManager>
{
private static readonly string AB_DIR = Application.streamingAssetsPath + '/'; // AB包所在目录
private static readonly string MAIN_AB_NAME = // 主包名
#if UNITY_IOS
"iOS";
#elif UNITY_ANDROID
"Android";
#else
"PC";
#endif
private AssetBundleManifest _mainManifest;
private readonly Dictionary<string, AssetBundle> _assetBundles = new();
private readonly Dictionary<string, ABStatus> _loadingStatus = new();
private void Awake()
{
// 加载主包的manifest
AssetBundle mainAssetBundle = AssetBundle.LoadFromFile(AB_DIR + MAIN_AB_NAME);
_mainManifest = mainAssetBundle.LoadAsset<AssetBundleManifest>("AssetBundleManifest");
mainAssetBundle.Unload(false); // 加载完manifest之后就可以释放主包
}
// ......
}
2 卸载AB包
接着来我们来实现最简单的AB包卸载功能。
卸载单个AB包只需要根据传入的包名,调用对应AB包的Unload
方法,然后再从_assetBundles
和_loadingStatus
中将该包名移除。
C#
public void Unload(string abName, bool unloadAllLoadedObjects = false)
{
if (!_assetBundles.ContainsKey(abName) || _assetBundles[abName] == null)
{
return;
}
_assetBundles[abName].Unload(unloadAllLoadedObjects);
_assetBundles.Remove(abName);
_loadingStatus.Remove(abName);
}
卸载所有AB包则是直接清空_assetBundles
和_loadingStatus
的记录,然后调用Unity提供的AssetBundle.UnloadAllAssetBundles
卸载所有AB包即可。
C#
public void UnloadAllAssetBundles(bool unloadAllLoadedObjects = false)
{
_assetBundles.Clear();
_loadingStatus.Clear();
AssetBundle.UnloadAllAssetBundles(unloadAllLoadedObjects);
}
3 同步加载
为了增加代码的可读性,让我们先定义以下两个函数,用于检查和设置AB包的状态。
C#
private ABStatus _checkStatus(string abName)
{
return _loadingStatus.TryGetValue(abName, out ABStatus value)
? value : ABStatus.NotLoaded;
}
private void _setStatus(string abName, ABStatus status)
{
_loadingStatus[abName] = status;
}
3.1 同步加载AB包
在加载资源之前肯定需要先加载AB包。将传入的包名作为加载队列的初值,之后遍历加载队列中的包名进行加载。
同步加载完一个AB包后,再将其所有的依赖包都加入到加载队列中,进行下一轮的加载。
由于同步加载的特性,可以保证在本次调用中完成所有AB包及其依赖的加载,因此加载状态可以直接设置为Completed
。
为了解决同步/异步冲突 ,对于正在异步中加载的包,我们可以直接调用Unload
进行卸载,这样一来就可以打断正在进行的异步加载。
C#
private void _loadAssetBundle(string abName)
{
Queue<string> loadQueue = new();
loadQueue.Enqueue(abName);
for (; loadQueue.Count > 0; loadQueue.Dequeue())
{
string name = loadQueue.Peek();
// 跳过已完成的包
if (_checkStatus(name) == ABStatus.Completed)
{
continue;
}
// 打断正在异步加载的包
if (_checkStatus(name) == ABStatus.Loading)
{
Unload(name);
}
// 同步方式加载AB包
_assetBundles[name] = AssetBundle.LoadFromFile(AB_DIR + name);
if (_assetBundles[name] == null)
{
throw new ArgumentException($"AssetBundle '{name}' 加载失败");
}
_setStatus(name, ABStatus.Completed);
// 添加依赖包到待加载列表中
foreach (var depend in _mainManifest.GetAllDependencies(name))
{
loadQueue.Enqueue(depend);
}
}
}
3.2 同步加载资源
AB包加载完成之后,就可以直接从记录中获取对应的AssetBundle对象来加载资源了。
C#
public T LoadRes<T>(string abName, string resName) where T : UnityEngine.Object
{
if (_checkStatus(abName) != ABStatus.Completed)
{
_loadAssetBundle(abName);
}
T res = _assetBundles[abName].LoadAsset<T>(resName);
if (res == null)
{
throw new ArgumentException($"无法从AssetBundle '{abName}' 中获取资源 '{resName}'。");
}
return res;
}
注意
这里不要缩写成 return res ?? throw new ArgumentException(...)的形式
因为这里的泛型T被约束为UnityEngine.Object,而Unity Object使用null合并运算符会导致意外情况
有的编辑器(比如VSCode插件)可能没有正确判断约束的上下文
没识别出T是UnityEngine.Object,从而提示使用??进行缩写,请忽略这种提示
详细情况可以参考Unity官方的说明:
https://blog.unity.com/engine-platform/custom-operator-should-we-keep-it
4 异步加载
4.1 异步加载AB包
AB包的异步加载和同步加载的策略有很大的不同。
当我们说某个AB包加载完成时,不单是指它的本体加载完毕,还需要它的依赖包也全部加载完成,而依赖包又需要"依赖包的依赖包"加载完成。
由于同步加载能够保证所有的AB包都能在本次调用中加载完毕,因此我们并不关心AB包的先后顺序。
但异步加载是分段的,所以我们必须保证其本体和所有依赖包都加载完成后,才将状态设为Completed
,而对于依赖包来说也是如此。一般我们会用递归来处理这种情况,但"协程递归"这种方案听名字就该Pass掉(bushi),这里完全可以用栈来模拟这一过程。
我们先声明一个存储二元组的栈,用于表示包名和标记位。
C#
Stack<(string name, bool needAddDepends)> loadStack = new();
对于入栈的AB包,我们先假设它还有依赖包需要加载,也就是needAddDepends
默认为true
。接着每次循环过程中,我们都查看栈顶的信息,如果标记为true
,则设为false
,然后将其所有的依赖包入栈(同样假设这些依赖包也有依赖要处理),并且需要防止重复添加包(环形依赖)导致死循环。这样就能保证在加载某个AB包前先完成其依赖包的加载。
另外,我们还需要处理异步/异步冲突 :当某个AB包处于Loading
状态时,表示有另一个协程在异步加载该AB包,这时就需要暂停等待直到该包被加载完毕。
C#
private IEnumerator _loadAssetBundleAsync(string abName)
{
HashSet<string> visitedBundles = new() { abName };
Stack<(string name, bool needAddDepends)> loadStack = new();
loadStack.Push((abName, true));
while (loadStack.Count > 0)
{
var (name, needAddDepends) = loadStack.Peek();
// 跳过已完成的包
if (_checkStatus(name) == ABStatus.Completed)
{
loadStack.Pop();
continue;
}
// 暂停等待正在加载的包
if (_checkStatus(name) == ABStatus.Loading)
{
yield return null;
continue;
}
// 先处理依赖包
if (needAddDepends)
{
loadStack.Pop();
loadStack.Push((name, false));
foreach (var depend in _mainManifest.GetAllDependencies(name))
{
if (visitedBundles.Add(depend))
{
loadStack.Push((depend, true));
}
}
continue;
}
// 异步加载AB包
AssetBundleCreateRequest abCreateRequest = AssetBundle.LoadFromFileAsync(AB_DIR + name);
_assetBundles[name] = abCreateRequest.assetBundle;
_setStatus(name, ABStatus.Loading);
if (_assetBundles[name] == null)
{
throw new ArgumentException($"AssetBundle '{name}' 加载失败");
}
yield return abCreateRequest;
// 加载完成
_setStatus(name, ABStatus.Completed);
}
}
4.2 异步加载资源
处理完AB包的加载之后就只需要发起异步资源请求并做错误处理即可。
C#
private IEnumerator _loadResAsync<T>(string abName, string resName, UnityAction<T> callBack) where T : UnityEngine.Object
{
// 等待异步加载AB包
if (_checkStatus(abName) != ABStatus.Completed)
{
yield return StartCoroutine(_loadAssetBundleAsync(abName));
}
// 异步加载资源
AssetBundleRequest abRequest = _assetBundles[abName].LoadAssetAsync<T>(resName);
yield return abRequest;
T res = abRequest.asset as T;
// 错误处理:资源不存在
if (res == null)
{
throw new ArgumentException($"无法从AssetBundle '{abName}' 中获取资源 '{resName}'。");
}
// 回调
callBack(res);
}
public void LoadResAsync<T>(string abName, string resName, UnityAction<T> callBack) where T : UnityEngine.Object
{
StartCoroutine(_loadResAsync<T>(abName, resName, callBack));
}
5 完整代码
C#
using System;
using System.Collections;
using System.Collections.Generic;
using Unity.VisualScripting;
using UnityEngine;
using UnityEngine.Events;
using Object = UnityEngine.Object;
enum ABStatus
{
Completed, // 本包和依赖包都加载完毕
Loading, // 正在加载
NotLoaded // 未被加载
}
public class ABManager : SingletonMono<ABManager>
{
private static readonly string AB_DIR = Application.streamingAssetsPath + '/'; // AB包所在目录
private static readonly string MAIN_AB_NAME = // 主包名
#if UNITY_IOS
"iOS";
#elif UNITY_ANDROID
"Android";
#else
"PC";
#endif
private AssetBundleManifest _mainManifest;
private readonly Dictionary<string, AssetBundle> _assetBundles = new();
private readonly Dictionary<string, ABStatus> _loadingStatus = new();
private void Awake()
{
// 加载主包的manifest
AssetBundle mainAssetBundle = AssetBundle.LoadFromFile(AB_DIR + MAIN_AB_NAME);
_mainManifest = mainAssetBundle.LoadAsset<AssetBundleManifest>("AssetBundleManifest");
mainAssetBundle.Unload(false); // 加载完manifest之后就可以释放主包
}
private ABStatus _checkStatus(string abName)
{
return _loadingStatus.TryGetValue(abName, out ABStatus value)
? value : ABStatus.NotLoaded;
}
private void _setStatus(string abName, ABStatus status)
{
_loadingStatus[abName] = status;
}
private void _loadAssetBundle(string abName)
{
Queue<string> loadQueue = new();
loadQueue.Enqueue(abName);
for (; loadQueue.Count > 0; loadQueue.Dequeue())
{
string name = loadQueue.Peek();
// 跳过已完成的包
if (_checkStatus(name) == ABStatus.Completed)
{
continue;
}
// 打断正在异步加载的包
if (_checkStatus(name) == ABStatus.Loading)
{
Unload(name);
}
// 同步方式加载AB包
_assetBundles[name] = AssetBundle.LoadFromFile(AB_DIR + name);
if (_assetBundles[name] == null)
{
throw new ArgumentException($"AssetBundle '{name}' 加载失败");
}
_setStatus(name, ABStatus.Completed);
// 添加依赖包到待加载列表中
foreach (var depend in _mainManifest.GetAllDependencies(name))
{
loadQueue.Enqueue(depend);
}
}
}
public T LoadRes<T>(string abName, string resName) where T : Object
{
if (_checkStatus(abName) != ABStatus.Completed)
{
_loadAssetBundle(abName);
}
T res = _assetBundles[abName].LoadAsset<T>(resName);
if (res == null)
{
throw new ArgumentException($"无法从AssetBundle '{abName}' 中获取资源 '{resName}'。");
}
return res;
}
private IEnumerator _loadAssetBundleAsync(string abName)
{
HashSet<string> visitedBundles = new() { abName };
Stack<(string name, bool needAddDepends)> loadStack = new();
loadStack.Push((abName, true));
while (loadStack.Count > 0)
{
var (name, needAddDepends) = loadStack.Peek();
// 跳过已完成的包
if (_checkStatus(name) == ABStatus.Completed)
{
loadStack.Pop();
continue;
}
// 暂停等待正在加载的包
if (_checkStatus(name) == ABStatus.Loading)
{
yield return null;
continue;
}
// 先处理依赖包
if (needAddDepends)
{
loadStack.Pop();
loadStack.Push((name, false));
foreach (var depend in _mainManifest.GetAllDependencies(name))
{
if (visitedBundles.Add(depend))
{
loadStack.Push((depend, true));
}
}
continue;
}
// 异步加载AB包
AssetBundleCreateRequest abCreateRequest = AssetBundle.LoadFromFileAsync(AB_DIR + name);
_assetBundles[name] = abCreateRequest.assetBundle;
_setStatus(name, ABStatus.Loading);
if (_assetBundles[name] == null)
{
throw new ArgumentException($"AssetBundle '{name}' 加载失败");
}
yield return abCreateRequest;
// 加载完成
_setStatus(name, ABStatus.Completed);
}
}
private IEnumerator _loadResAsync<T>(string abName, string resName, UnityAction<T> callBack) where T : Object
{
// 等待异步加载AB包
if (_checkStatus(abName) != ABStatus.Completed)
{
yield return StartCoroutine(_loadAssetBundleAsync(abName));
}
// 异步加载资源
AssetBundleRequest abRequest = _assetBundles[abName].LoadAssetAsync<T>(resName);
yield return abRequest;
T res = abRequest.asset as T;
// 错误处理:资源不存在
if (res == null)
{
throw new ArgumentException($"无法从AssetBundle '{abName}' 中获取资源 '{resName}'。");
}
// 回调
callBack(res);
}
public void LoadResAsync<T>(string abName, string resName, UnityAction<T> callBack) where T : Object
{
StartCoroutine(_loadResAsync<T>(abName, resName, callBack));
}
public void Unload(string abName, bool unloadAllLoadedObjects = false)
{
if (!_assetBundles.ContainsKey(abName) || _assetBundles[abName] == null)
{
return;
}
_assetBundles[abName].Unload(unloadAllLoadedObjects);
_assetBundles.Remove(abName);
_loadingStatus.Remove(abName);
}
public void UnloadAllAssetBundles(bool unloadAllLoadedObjects = false)
{
_assetBundles.Clear();
_loadingStatus.Clear();
AssetBundle.UnloadAllAssetBundles(unloadAllLoadedObjects);
}
}
参考资料
解决 Unity3D AssetBundle 异步加载与同步加载冲突问题
Custom == operator, should we keep it?
C#语法糖 (?) null空合并运算符对UnityEngine.Object类型不起作用
本文发布于2024年5月23日
最后编辑于2024年5月23日