[Unity] 实现AssetBundle资源加载管理器

实现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日

相关推荐
牙膏上的小苏打23338 小时前
Unity Surround开关后导致获取主显示器分辨率错误
unity·主屏幕
Unity大海10 小时前
诠视科技Unity SDK开发环境配置、项目设置、apk打包。
科技·unity·游戏引擎
浅陌sss16 小时前
Unity中 粒子系统使用整理(一)
unity·游戏引擎
维度攻城狮20 小时前
实现在Unity3D中仿真汽车,而且还能使用ros2控制
python·unity·docker·汽车·ros2·rviz2
为你写首诗ge1 天前
【Unity网络编程知识】FTP学习
网络·unity
神码编程1 天前
【Unity】 HTFramework框架(六十四)SaveDataRuntime运行时保存组件参数、预制体
unity·编辑器·游戏引擎
菲fay1 天前
Unity 单例模式写法
unity·单例模式
火一线1 天前
【Framework-Client系列】UIGenerate介绍
游戏·unity
ZKY_241 天前
【工具】Json在线解析工具
unity·json
ZKY_242 天前
【Unity】处理文字显示不全的问题
unity·游戏引擎