最近用Unity做项目时遇到了需要动态加载资源的场景,所谓动态加载就是指用AB包或者Resouces.Load()之类的方法在运行时动态地加载资源(比如模型,贴图,音频,数据表等)。与之对应的就是静态加载,就是直接把资源拖到Inspector面板里,然后直接使用对应的成员变量就好了。

Resouces.Load()其实已经不建议使用了,因为打包时无法区分哪些文件需要打包进去,哪些文件不需要,到头来只能一股脑全打包进去,导致最终构建大小变得特别大。官方可能(仅仅是有可能而已)会在以后移除这个API。对于新的项目来说,建议使用纯AssetBundle的加载方案。

先介绍一下AssetBundle是什么吧。AssetBundle也叫AB包,作用很简单,就是把游戏资源(模型,图片,音频等)打包成一个单独的文件,然后在游戏的运行时去读取这个AB包文件,并加载里面的模型等资源。可以从网络加载,也可以从本地文件加载。非常适合用来打包一些游戏里经常会变的东西,比如游戏新闻或者活动内容。内容可以实时更新,而不需要让玩家重新下载一遍新版本的游戏客户端。

AB包可以把它看成是一个zip包,里面有很多被压缩好的游戏资源,当AB包被加载时,程序会自动从里面读取需要的东西。当然这样描述并不准确,仅仅是为了易于理解而已。

加载一个游戏资源实际上分两步。1.加载AssetBundle本身,2.加载AssetBundle里的游戏资源。

也就是说在AssetBundle本身加载完成之后,还需要继续加载AssetBundle里面的资源文件,里面的资源文件才是我们最终需要的东西。

AssetBundle主要的加载源一个是本地文件,一个是HTTP网络加载。既然是IO相关的逻辑,那么一定会涉及到阻塞和非阻塞的概念,我这里选用协程+非阻塞方式从本地文件进行加载。

首先写一个IEnumerator LoadAsset()协程方法,此方法接收两个形参,一个是AssetBundle的文件路径,一个是AssetBundle里具体要加载的资源的名字,先写一个空方法,然后一步步加载资源。

IEnumerator LoadAsset(string assetBundlePath, string assetName)
{

}

首先需要加载AssetBundle到内存里,可以使用APIAssetBundle.LoadFromFileAsync()来从文件异步加载。发起加载请求之后,只需要等待协程完成就可以了。

IEnumerator LoadAsset(string assetBundlePath, string assetName)
{
    // 从文件加载AssetBundle
    var loadRequest = AssetBundle.LoadFromFileAsync(assetBundlePath);
    yield return loadRequest;
}

除了傻乎乎的等以外,还可以获取加载进度,可以做成一个进度条告诉玩家当前加载了百分之多少了。这样要比直接显示“加载中”这几个字的用户体验要好得多。当然,记得加载完成后,别忘了做一下错误处理。

IEnumerator LoadAsset(string assetBundlePath, string assetName)
{
    var loadRequest = AssetBundle.LoadFromFileAsync(assetBundlePath);

    // 加载时顺便更新进度条,告诉玩家当前加载进度
    while (!loadRequest.isDone)
    {
        float progress = loadRequest.progress;
        ui.text = $"游戏正在加载中 ({(int) (progress * 100)}%)";
        yield return new WaitForSeconds(1);
    }
    
    // 检查AssetBundle是否加载成功了
    if (loadRequest.assetBundle == null)
        throw new AssetBundleFailedToBeLoadedException("AssetBundle加载失败");
}

AssetBundle加载好了以后,接着就可以从AssetBundle里加载需要的那个Asset了。加载Asset和加载AssetBundle大同小异,只需要调用AssetBundle类的LoadAssetAsync()方法就好了,此方法输入一个字符串,是具体的资源的名字。

// 从AssetBundle里加载需要的Asset(也就是游戏资源)
var assetLoadRequest = assetBundle.LoadAssetAsync(assetName);
yield return assetLoadRequest;

// 检查Asset是否加载成功了
if (assetLoadRequest.asset == null)
    throw new AssetFailedToBeLoadedException("Asset加载失败");

// 获取到最终加载好的资源
Object asset = assetLoadRequest.asset;

Asset加载完毕后,就可以获取到最终的Asset对象了。也就是游戏资源对象,这个游戏对象可能是一个预制体GameObject,也可能是一个贴图Texture。只需要将这个asset对象返回给调用LoadAsset()方法的对象就好了。这样就实现了AssetBundle和Asset在同一个协程里加载。

当然在实际的项目中需要考虑更多的事情,比如一个AssetBundle加载到内存里以后,就不能再重复加载一遍了。这就需要将加载好的AssetBundle对象缓存起来,等到再次需要读取这个AssetBundle里的资源时,就先查询缓存,缓存没有再去真正地加载AssetBundle,如果缓存里有则直接使用。对于Asset也是同样的道理,不能多次加载,也要最对应的缓存机制。

不仅如此,还需要考虑同时对一个AssetBundle发起两个并发加载请求时,只能有其中一个协程进行实际加载,另一个协程需要进行等待。这个场景在开发中很常见,比如游戏初始化阶段时,可能背包类和菜单类就会同时加载同一个AssetBundle里的同一个Asset(一般是窗口背景贴图之类的共用资源),如果不进行并发处理,就会导致重复加载而报错。

这些稳健性相关的代码每个人都有自己的实现风格,并没有特别统一的规范。这里列举一下我自己写的AssetBundle加载管理类吧,可以供大家参考。(当然直接复制使用也是没有问题的)

using System;
using System.Collections;
using System.Collections.Generic;
using System.IO;
using UnityEditor;
using UnityEngine;
using Object = UnityEngine.Object;

/// <summary>
/// 负责AB包和包内资源的加载和管理的类
/// </summary>
public class AssetBundleManager : MonoBehaviour
{
    // 单例实例
    public static AssetBundleManager ins;
    
    // 从哪个目录加载AssetBundle
    static readonly string assetBundleFolderPath = Path.Combine(Application.streamingAssetsPath, "AssetBundles/StandaloneWindows");
    
    [SerializeField]
    private SerializableMap<string, StatableAssetBundle> assetBundlesLoaded = new(false);
    
    [SerializeField]
    private SerializableMap<string, StatableAsset> assetsLoaded = new(false);

    void Awake()
    {
        ins = this;
    }

    public LoadAssetstruction<TAsset> GetAsset<TAsset>(string assetBundlePath, string assetName) where TAsset : Object
    {
        return new LoadAssetstruction<TAsset>(this, assetBundlePath, assetName);
    }
    
    StatableAssetBundle GetAssetBundleInCache(string assetBundlePath)
    {
        return assetBundlesLoaded.ContainsKey(assetBundlePath) 
            ? assetBundlesLoaded[assetBundlePath] : null;
    }
    
    StatableAsset GetAssetInCache(string assetBundlePath, string assetName)
    {
        string cacheKey = GetCacheKey(assetBundlePath, assetName);
        
        return GetAssetBundleInCache(assetBundlePath) == null 
            ? null : (assetsLoaded.ContainsKey(cacheKey) ? assetsLoaded[cacheKey] : null);
    }

    void RegisterAssetBundleLoading(string assetBundlePath)
    {
        assetBundlesLoaded[assetBundlePath] = new StatableAssetBundle();
    }
    
    void RegisterAssetLoading(string assetBundlePath, string assetName)
    {
        assetsLoaded[GetCacheKey(assetBundlePath, assetName)] = new StatableAsset();
    }

    void CacheAssetBundleLoaded(string assetBundlePath, AssetBundle assetBundle)
    {
        StatableAssetBundle sab = GetAssetBundleInCache(assetBundlePath);
        sab.FinishLoad(assetBundle);
    }
    
    void CacheAssetLoaded(string assetBundlePath, string assetName, Object asset)
    {
        StatableAsset sa = GetAssetInCache(assetBundlePath, assetName);
        sa.FinishLoad(asset);
    }

    static string GetAssetBundleAbsolutePath(string path)
    {
        return Path.Combine(assetBundleFolderPath, path);
    }
    
    string GetCacheKey(string assetBundlePath, string assetName)
    {
        return $"{assetBundlePath}|{assetName}";
    }
    
    public class LoadAssetstruction<TAsset> : CustomCoroutineInstruction where TAsset : Object
    {
        public TAsset asset = null;
        
        public string assetBundlePath;
        public string assetName;
        
        AssetBundleManager parent;
        AssetBundle assetBundle = null;
        
        public LoadAssetstruction(AssetBundleManager parent, string assetBundlePath, string assetName)
        {
            this.assetBundlePath = assetBundlePath;
            this.assetName = assetName;
            this.parent = parent;
        }

        public override IEnumerator Coroutine()
        {
            yield return MakesureAssetBundleLoaded();
            yield return MakesureAssetLoaded();
        }

        IEnumerator MakesureAssetBundleLoaded()
        {
            var sab = parent.GetAssetBundleInCache(assetBundlePath);

            // AssetBundle还未开始加载
            if (sab == null)
            {
                parent.RegisterAssetBundleLoading(assetBundlePath);
                
                string absolutePath = GetAssetBundleAbsolutePath(assetBundlePath);
                var loadRequest = AssetBundle.LoadFromFileAsync(absolutePath);
                yield return loadRequest;

                if (loadRequest.assetBundle == null)
                    throw new AssetBundleFailedToBeLoadedException(absolutePath);
                
                parent.CacheAssetBundleLoaded(assetBundlePath, loadRequest.assetBundle);
                
                assetBundle = loadRequest.assetBundle;
            } else if (sab.isLoading) { // AssetBundle已经在加载过程中
                // 等待加载完成
                yield return new WaitWhile(() => sab.isLoading);

                assetBundle = sab.assetBundle;
            } else { // AssetBundle已经在缓存里
                assetBundle = sab.assetBundle;
            }
        }

        IEnumerator MakesureAssetLoaded()
        {
            var sa = parent.GetAssetInCache(assetBundlePath, assetName);
            
            // Asset还未开始加载
            if (sa == null)
            {
                parent.RegisterAssetLoading(assetBundlePath, assetName);
                
                var loadRequest = assetBundle.LoadAssetAsync<TAsset>(assetName);
                yield return loadRequest;

                if (loadRequest.asset == null)
                    throw new AssetFailedToBeLoadedException(assetBundlePath, assetName);

                parent.CacheAssetLoaded(assetBundlePath, assetName, loadRequest.asset);
                
                asset = loadRequest.asset as TAsset;
            } else if (sa.isLoading) { // Asset已经在加载过程中
                // 等待加载完成
                yield return new WaitWhile(() => sa.isLoading);

                asset = (TAsset) sa.asset;
            } else { // Asset已经在缓存里
                asset = (TAsset) sa.asset;
            }
        }

        public class AssetBundleFailedToBeLoadedException : Exception
        {
            public AssetBundleFailedToBeLoadedException(string path)
                : base($"failed to load the AssetBundle ({path})") { }
        }
        
        public class AssetFailedToBeLoadedException : Exception
        {
            public AssetFailedToBeLoadedException(string assetBundle, string asset) 
                : base($"failed to load a Asset ({asset}) in AssetBundle ({assetBundle})") { }
        }
    }

    [Serializable]
    public class StatableAssetBundle : ISerializationCallbackReceiver
    {
        public bool isLoading { get; private set; } = true;
        public AssetBundle assetBundle { get; private set; } = null;

        public void FinishLoad(AssetBundle assetBundleLoaded)
        {
            isLoading = false;
            assetBundle = assetBundleLoaded;
        }

        [SerializeField]
        string serialization;

        public void OnBeforeSerialize()
        {
            serialization = assetBundle.name + (isLoading ? " (Loading)" : "");
        }

        public void OnAfterDeserialize() { }
    }
    
    [Serializable]
    public class StatableAsset : ISerializationCallbackReceiver
    {
        public bool isLoading { get; private set; } = true;
        public Object asset { get; private set; }

        public void FinishLoad(Object assetLoaded)
        {
            isLoading = false;
            asset = assetLoaded;
        }
        
        [SerializeField]
        Object serialization;
        
        public void OnBeforeSerialize() { serialization = asset; }

        public void OnAfterDeserialize() { }
    }
}

#if UNITY_EDITOR
[CustomPropertyDrawer(typeof(AssetBundleManager.StatableAssetBundle))]
public class StatableAssetBundleDrawer : PropertyDrawer
{
    public override void OnGUI(Rect position, SerializedProperty property, GUIContent label)
    {   
        var assetBundleName = property.FindPropertyRelative("serialization");
        EditorGUI.LabelField(position, new GUIContent(assetBundleName.stringValue));
    }

    public override float GetPropertyHeight(SerializedProperty property, GUIContent label)
    {
        return 18;
    }
}

[CustomPropertyDrawer(typeof(AssetBundleManager.StatableAsset))]
public class StatableAssetDrawer : PropertyDrawer
{
    public override void OnGUI(Rect position, SerializedProperty property, GUIContent label)
    {   
        var asset = property.FindPropertyRelative("serialization");
        if (asset.objectReferenceValue != null)
        {
            EditorGUI.ObjectField(position, asset, GUIContent.none);
        } else {
            EditorGUI.LabelField(position, new GUIContent("Loading"));
        }
    }

    public override float GetPropertyHeight(SerializedProperty property, GUIContent label)
    {
        return 18;
    }
}
#endif

如果需要直接复制使用的话,还需要顺带复制一个我自己写的使用类,SerializeMap,这个类是一个可序列化版本的Dictionary字典类。主要用于在Inspector面板调试使用。能写注释的地方,我会尽量写上详细的注释。

using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
#if UNITY_EDITOR
using UnityEditor;
#endif

#pragma warning disable 414

/// <summary>
/// SerializableSDictionary,可序列化的Dictionary类
/// </summary>
[Serializable]
public class SerializableMap<TKey, TValue> : Dictionary<TKey, TValue>, ISerializationCallbackReceiver
{
    /// <summary>
    /// 是否允许在Inspector里编辑Map的内容<br/>
    /// (此变量可以从Inspector里修改)
    /// </summary>
    [SerializeField]
    bool editable = false;
    
    /// <summary>
    /// 是否禁止在Inspector里修改'editable'字段(俗称锁定可编辑状态)<br/>
    /// (此变量无法从Inspector里修改,只能由代码修改)
    /// </summary>
    [SerializeField]
    bool lockEditableState = true;
    
    /// <summary>
    /// 用于存储Dictionary数据的列表
    /// </summary>
    [SerializeField]
    List<Pair> pairs = new List<Pair>();

    public SerializableMap(bool? forceEditableState)
    {
        if (forceEditableState != null)
        {
            lockEditableState = true;
            editable = forceEditableState.Value;
        }
    }
    
    public void OnAfterDeserialize()
    {
        if (!editable)
            return;
        
        Clear();
        
        foreach (var el in pairs)
            Add(el.key, el.value);
    }

    public void OnBeforeSerialize()
    {
        pairs.Clear();

        foreach (var kv in this)
            pairs.Add(new Pair { key = kv.Key, value = kv.Value });
    }
    
    [Serializable]
    public struct Pair
    {
        public TKey key;
        public TValue value;
    }
}

#if UNITY_EDITOR
[CustomPropertyDrawer(typeof(SerializableMap<,>.Pair))]
public class PairDrawer : PropertyDrawer
{
    public override void OnGUI(Rect position, SerializedProperty property, GUIContent label)
    {
        var key = property.FindPropertyRelative("key");
        var value = property.FindPropertyRelative("value");

        var (keyRect, valueRect) = position.Indent(0, 0, 0, 2).Split(position.width * 0.45f, 4, false);

        EditorGUI.PropertyField(keyRect, key, GUIContent.none);
        EditorGUI.PropertyField(valueRect, value, GUIContent.none);
    }

    public override float GetPropertyHeight(SerializedProperty property, GUIContent label)
    {
        var key = EditorGUI.GetPropertyHeight(property.FindPropertyRelative("key"), true);
        var value = EditorGUI.GetPropertyHeight(property.FindPropertyRelative("value"), true);
        return Mathf.Max(key, value);
    }
}    
#endif

#if UNITY_EDITOR
[CustomPropertyDrawer(typeof(SerializableMap<,>))]
public class SerializableMapDrawer : PropertyDrawer
{
    protected const float IndentPixel = 15;
    const float HeaderHeight = 18;

    public override void OnGUI(Rect rect, SerializedProperty property, GUIContent label)
    {
        var stateLock = property.FindPropertyRelative("lockEditableState");
        var editable = property.FindPropertyRelative("editable");
        var pairs = property.FindPropertyRelative("pairs");

        Rect mainRect;
        Rect sideRect;

        if (!stateLock.boolValue)
        {
            (mainRect, sideRect) = rect.Split(rect.width - 18, 2, false);
        } else {
            mainRect = rect;
            sideRect = Rect.zero;
        }
        
        // 绘制Dictionary内容
        var fieldLabel = new GUIContent(
            label.text, 
            editable.boolValue || !pairs.isExpanded ? null : Icons.NotEditable.Value, 
            label.tooltip
        );
        EditorGUI.PropertyField(mainRect, pairs, fieldLabel, true);

        // 绘制editable按钮
        if (!stateLock.boolValue)
        {
            Vector2 pivot = sideRect.position;
            float angle = 90;
            GUIUtility.RotateAroundPivot(angle, pivot);
            {
                var rotated = sideRect.Transpose();
                rotated.y -= 18;
                editable.boolValue = GUI.Toggle(rotated, editable.boolValue, new GUIContent("Editable", "Editable In Inspector"));
            }
            GUIUtility.RotateAroundPivot(-angle, pivot);
        }
    }

    public override float GetPropertyHeight(SerializedProperty property, GUIContent label)
    {
        var pairs = property.FindPropertyRelative("pairs");
        return EditorGUI.GetPropertyHeight(pairs);
    }
}
#endif