[Unity] 實現AssetBundle資源載入管理器

千松發表於2024-05-23

實現Unity AssetBundle資源載入管理器

AssetBundle是實現資源熱更新的重要功能,但Unity為其提供的API卻十分基(jian)礎(lou)。像是自動載入依賴包、重複載入快取、解決同步/非同步載入衝突,等基礎功能都必須由使用者自行實現。

因此,本篇部落格將會介紹如何實現一個AssetBundle管理器以解決以上問題。

1 成員定義與初始化

作為典型的"Manager"類,我們顯然要讓其成為一個單例物件,並且由於後續非同步載入會用到協程函式,因此還需要繼承MonoBehaviour。所以,這裡用到了我在Unity單例基類的實現方式中提到的Mono單例基類SingletonMono<>

// 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包的根目錄。

private static readonly string AB_DIR = ... + '/';    // AB包所在目錄

AB包之間的依賴資訊都儲存在主包的Manifest之中,所以我們需要先設定好主包的名字。這裡的MAIN_AB_NAME的值也是根據你在打包時的引數來修改的,比如我打包的Output Path引數是AssetBundles/PC,那麼此時主包名就是PC

private static readonly string MAIN_AB_NAME =   // 主包名
#if UNITY_IOS
        "iOS";
#elif UNITY_ANDROID
        "Android";
#else
        "PC";
#endif

接下來就需要在Awake函式中進行初始化,唯一要做的就是讀取主包的Manifest

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包。

private readonly Dictionary<string, AssetBundle> _assetBundles = new();

此外我們還要注意同步/非同步衝突非同步/非同步衝突

同步/非同步衝突是指,在某個AB包非同步載入的過程中,使用者又對同一個AB包發起了同步載入的請求,如果我們直接進行同步載入,就會出現“同一個AB包在被多次載入”的錯誤。

非同步/非同步衝突則是,在某個AB包非同步載入的過程中,使用者又對同一個AB包發起了非同步載入的請求同樣會重複載入的錯誤,因此我們就需要讓後來的非同步請求進行暫停等待,直到該包在先來的非同步請求中載入完成。

為此我們需要定義一組載入狀態,用於解決上述衝突,並且使用字典來儲存AB包當前的載入狀態

enum ABStatus
{
    Completed,  // 本包和依賴包都載入完畢
    Loading,    // 正在載入
    NotLoaded   // 未被載入
}
private readonly Dictionary<string, ABStatus> _loadingStatus = new();

綜上所述,我們的成員定義與初始化如下:

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中將該包名移除。

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包即可。

public void UnloadAllAssetBundles(bool unloadAllLoadedObjects = false)
{
    _assetBundles.Clear();
    _loadingStatus.Clear();
    AssetBundle.UnloadAllAssetBundles(unloadAllLoadedObjects);
}

3 同步載入

為了增加程式碼的可讀性,讓我們先定義以下兩個函式,用於檢查和設定AB包的狀態。

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進行解除安裝,這樣一來就可以打斷正在進行的非同步載入

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物件來載入資源了。

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),這裡完全可以用來模擬這一過程。

我們先宣告一個儲存二元組的棧,用於表示包名和標記位。

Stack<(string name, bool needAddDepends)> loadStack = new();

對於入棧的AB包,我們先假設它還有依賴包需要載入,也就是needAddDepends預設為true。接著每次迴圈過程中,我們都檢視棧頂的資訊,如果標記為true,則設為false,然後將其所有的依賴包入棧(同樣假設這些依賴包也有依賴要處理),並且需要防止重複新增包(環形依賴)導致死迴圈。這樣就能保證在載入某個AB包前先完成其依賴包的載入。

另外,我們還需要處理非同步/非同步衝突:當某個AB包處於Loading狀態時,表示有另一個協程在非同步載入該AB包,這時就需要暫停等待直到該包被載入完畢。

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包的載入之後就只需要發起非同步資源請求並做錯誤處理即可。

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 完整程式碼

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日

相關文章