基于 ECS 设计下的加载管理

之前在 Addressable 迁移 YooAsset 这篇文章中做了分层设计的相关介绍,本文为分层中详细的解析,以及为什么要这么设计

目标 & 背景

我们当前使用的框架,需要设计成一个基础库,以 Package 的形式进行使用,方便公司中其他项目后续的接入,所以泛用性要求很高,此外,开发者的水平可能会参差不齐,可能会有应届生刚刚接触 Unity 等现实因素的考量,因此我们需要达成如下目标

  • 任何新的设计不可对业务逻辑造成开发负担
  • 业务逻辑完全不需要知道 YooAsset 的具体内容,甚至连 dll 都不引用
  • 如果出现 AB 管理框架无法正常工作,带来灾难级问题,整体更换时,对业务逻辑影响需要降到最小
  • 加载逻辑是整体框架的基石,要保证框架内所有的加载均走同一套设计

这里框架设计最开始使用的是 Addressable,考虑到其名声比较糟糕,后续存在整体换掉的可能,所以从最开始的设计就考虑到了这个问题,这也是为什么整体迁移到 YooAsset 非常快的原因

分层设计介绍

为了解决上述所有问题,我们将框架设计成下图这样,每个组件理论上都只做一件事

AutoLoaderComponent

基于 ECS 设计下的资源释放,我们可以做到完全不需要考虑资源对象的 Release,业务逻辑仅需要 Load,而且每个 Release 是绝对精准且及时的(如果出现了资源泄露,大概是其他代码导致的异常,中断了 Release)

那么想实现这个功能就非常简单了,我们创建一个 AutoLoaderComponent,这个组件没有任何声明的变量,仅在 Dispose 时,调用一下 Release 方法,并给外部提供加载逻辑的扩展方法

此组件的职责仅仅是提供与加载的实体绑定 Dispose 生命周期,并自动释放这个实体加载的所有内容

internal class AutoLoaderComponent : Entity
{
    
}

internal class AutoLoaderComponentDestroySystem : DestroySystem<AutoLoaderComponent>
{
    public override void Destroy(AutoLoaderComponent self) { self.Destroy(); }
}

public static class AutoLoaderComponentSystem
{
    internal static void Destroy(this AutoLoaderComponent self)
    {
        YooAssetComponent.Instance.ReleaseInstance(self.GetHashCode());
        YooAssetComponent.Instance.ReleaseAsset(self.GetHashCode());
    }
    
    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    private static AutoLoaderComponent _GetOrAdd(this Entity self)
    {
        return self.GetComponent<AutoLoaderComponent>() ??  self.AddComponent<AutoLoaderComponent>(true);
    }

    internal static UniTask<GameObject> InternalInstantiate(this Entity      self,
                                                            string           path,
                                                            Transform        parent           = null,
                                                            bool             stay_world_space = false,
                                                            bool             position_to_zero = false,
                                                            IProgress<float> progress         = null,
                                                            string           lru_group        = null)
    {
        var loader = self._GetOrAdd();

        return loader._DoInstantiate(
            path,
            lru_group,
            parent,
            stay_world_space,
            position_to_zero,
            progress
        );
    }
    
    public static UniTask<T> LoadAsset<T>(this Entity self, string path, IProgress<float> progress = null)
    where T : UnityEngine.Object
    {
        var loader = self._GetOrAdd();
        return loader._DoLoadAsset<T>(path, progress);
    }
}

注意观察这里的 API 权限,针对 GameObject 加载的 API 是 internal 的,我们需要向外部提供 LRU 组的定义,因为当前代码写在 Pacakge 中,而真正的 LUR 组,需要写在业务逻辑中,所以只能用 string 来接,但是!我们并不希望业务逻辑手敲 LRU 组的名字,最终呈现必须是一个 enum

所以这个 API 必须是 internal 权限,同时,我们在这个域中提供一个友元扩展,比如 View.Base.Friend,我们在业务逻辑这个域中二次扩展这里的 InternalInstantiate 注意!此时 API 就变了,从 string lru_group 变成了 ResGroupType lru_group,而这个 ResGroupType 就是业务逻辑定义的枚举

手敲 string 对我来说就像写 lua 一样,所以必须考虑如何解决这个问题

public static class AutoLoaderEx
{
    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    public static async UniTask<GameObject> Instantiate(this Entity      self,
                                                        string           path,
                                                        Transform        parent           = null,
                                                        ResGroupType     lru_group        = ResGroupType.None,
                                                        bool             stay_world_space = false,
                                                        bool             position_to_zero = false,
                                                        IProgress<float> progress         = null)
    {
        string group = lru_group == ResGroupType.None ? "" : lru_group.ToString();

        return await self.InternalInstantiate(
            path,
            parent,
            stay_world_space,
            position_to_zero,
            progress,
            group
        );
    }

    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    public static void RemoveAutoLoader(this Entity self) { self.RemoveComponent<AutoLoaderComponent>(); }
}

最终在业务逻辑中就存在两个 View 的域,一个是真正的显示层,另一个是对底层 AutoLoaderComponent internal 方法有访问权限的 View.Base.Friend 友元层,因为业务逻辑连 AutoLoaderComponent 的访问权限都没有,所以还需要提供一个移除的扩展

基于这样的设计,我们几乎达成了上面所有的目标,甚至做到了业务逻辑连加载组件都看不到的结果

当然,如果你项目不需要这么细致,不用友元,甚至不用 Package 也无妨

YooAssetComponent

这个组件需要做的就是提供一套 LRU 加载规则,并且因为很多时候不同机器因为性能不一样,我们还需要对组进行最大激活数量的限制,同时为了方便上面 AutoLoaderComponent 加载还需要提供 hash 和资源之间的映射关系

public enum ResGroupType
{
    [LruCount(100), MaxActive(100)]
    Strike
}

这里我们需要先从业务逻辑的组定义开始看起,上面的 ResGroupType 中有一个受击特效的组,在这个 enum 上,绑定了两个 Attribute,一个标记 LRU 最大数量,另一个标记 最大特效激活数量

那么为了方便外部传入配置,我们需要在底层定义 YooAssetComponent 的配置,也可以理解为初始化参数,具体定义如下

public class YooAssetComponentConfig
    {
#if UNITY_EDITOR

        public const string EDITOR_PLAYMODE_KEY = "YooAsset_PlayMode";

#endif

        /// <summary>
        /// lru 组 size
        /// </summary>
        public readonly Dictionary<string, int> lru_name_and_size;

        /// <summary>
        /// 组最大激活数量
        /// </summary>
        public readonly Dictionary<string, int> group_max_active;

        public readonly int max_download;
        public readonly int retry;
        public readonly int time_out;

        public readonly YooAssets.EPlayMode play_mode;
        public readonly ILocationServices   location_services;
        public readonly IDecryptionServices decryption_services;
        public readonly bool                clear_cache_when_dirty;
        public readonly bool                enable_lru_log;

        public YooAssetComponentConfig(Dictionary<string, int> lru_name_and_size,
                                       Dictionary<string, int> group_max_active,
                                       int                     max_download = 30,
                                       int                     time_out = 30,
                                       int                     retry = 3,
                                       YooAssets.EPlayMode     play_mode = YooAssets.EPlayMode.HostPlayMode,
                                       ILocationServices       location_services = null,
                                       IDecryptionServices     decryption_services = null,
                                       bool                    clear_cache_when_dirty = false,
                                       bool                    enable_lru_log = false)
        {
            this.lru_name_and_size = lru_name_and_size;
            this.group_max_active  = group_max_active;
            this.max_download      = max_download;
            this.time_out          = time_out;
            this.retry             = retry;

#if UNITY_EDITOR
            play_mode = (YooAssets.EPlayMode) UnityEditor.EditorPrefs.GetInt(EDITOR_PLAYMODE_KEY, 0);
#endif
            this.play_mode              =   play_mode;
            this.location_services      =   location_services;
            this.decryption_services    =   decryption_services;
            this.clear_cache_when_dirty =   clear_cache_when_dirty;
            this.location_services      ??= new AddressLocationServices();
            this.enable_lru_log         =   enable_lru_log;
        }
    }

这里你会注意到有一个 EditorPrefs 混入了配置中,这是因为我希望提供一个可以在 Editor 快速切换到底用哪种 YooAssets.EPlayMode 来加载的 GUI 工具

这个工具是 git 上开源的仓库 unity-toolbar-extender 具体不做介绍,感兴趣可以自行了解

基于现有设计,我们就可以按照下发的过程进行实现

  • 对 hash 进行分组,存放、查询、删除等功能
  • 对当前 LRU 组,检查最大激活数量,如果超过限制,直接给一个 null
  • 对当前 LRU 组,进行维护,超出 LRU 最大数量的,交由下一次进行 Release

更具体的代码这里就不贴了,可以自己搜搜 LRU 的具体实现,下图为暴露在外部的 API 参考

YooAssetsShim

这个组件是对现有 YooAsset API 的垫片实现,因为个人比较喜欢 Addressable 中直接 Release(Object) 实现对具体 handle 的释放,以及针对 SA 图集加载中,子图集的配置方式 Assets/Res/xxx.spriteatlas[1]

这种图集配置方式,Luban 中 path 校验器也是支持的!

那么这个组件的职责就非常简单了,提供 Objecthandle 的索引关系,以及上述 SA 加载的解析,这里的代码在 Addressable 迁移 YooAsset 贴了一次,下面再重复贴一遍

using System;
using System.Collections.Generic;
using System.Text.RegularExpressions;
using Cysharp.Threading.Tasks;
using UnityEngine;
using UnityEngine.U2D;
using Object = UnityEngine.Object;

namespace YooAsset
{
    public static class YooAssetsShim
    {
        private static readonly Dictionary<Object, OperationHandleBase> _OBJ_2_HANDLES = new();

        private static readonly Dictionary<GameObject, Object> _GO_2_OBJ = new();

        private static readonly Regex _SA_MATCH = new("[*.\\w/]+");

        private static PatchDownloaderOperation _DOWNLOADER;

        public static UniTask InitializeAsync(YooAssets.EPlayMode play_mode,
                                              string              cdn_url,
                                              ILocationServices   location_services,
                                              IDecryptionServices decryption_services    = null,
                                              bool                clear_cache_when_dirty = false)
        {
            YooAssets.CreateParameters parameters = play_mode switch
            {
                YooAssets.EPlayMode.EditorSimulateMode => new YooAssets.EditorSimulateModeParameters(),
                YooAssets.EPlayMode.OfflinePlayMode    => new YooAssets.OfflinePlayModeParameters(),
                YooAssets.EPlayMode.HostPlayMode => new YooAssets.HostPlayModeParameters
                {
                    LocationServices    = location_services,
                    DecryptionServices  = decryption_services,
                    ClearCacheWhenDirty = clear_cache_when_dirty,
                    DefaultHostServer   = cdn_url,
                    FallbackHostServer  = cdn_url
                },
                _ => throw new ArgumentOutOfRangeException(nameof(play_mode), play_mode, null)
            };

            parameters.LocationServices = location_services;

            return YooAssets.InitializeAsync(parameters).ToUniTask();
        }

        public static async UniTask<int> UpdateStaticVersion(int time_out = 30)
        {
            var operation = YooAssets.UpdateStaticVersionAsync(time_out);

            await operation.ToUniTask();

            if(operation.Status != EOperationStatus.Succeed)
            {
                return-1;
            }

            return operation.ResourceVersion;
        }

        public static async UniTask<bool> UpdateManifest(int resource_version, int time_out = 30)
        {
            var operation = YooAssets.UpdateManifestAsync(resource_version, time_out);

            await operation.ToUniTask();

            return operation.Status == EOperationStatus.Succeed;
        }

        public static long GetDownloadSize(int downloading_max_num, int retry)
        {
            _DOWNLOADER = YooAssets.CreatePatchDownloader(downloading_max_num, retry);

            return _DOWNLOADER.TotalDownloadCount == 0 ? 0 : _DOWNLOADER.TotalDownloadBytes;
        }

        public static async UniTask<bool> Download(IProgress<float> progress = null)
        {
            if(_DOWNLOADER is null)
            {
                return false;
            }

            _DOWNLOADER.BeginDownload();

            await _DOWNLOADER.ToUniTask(progress);

            return _DOWNLOADER.Status == EOperationStatus.Succeed;
        }

        public static async UniTask<GameObject> InstantiateAsync(string           location,
                                                                 Transform        parent_transform = null,
                                                                 bool             stay_world_space = false,
                                                                 IProgress<float> progress         = null)
        {
            var handle = YooAssets.LoadAssetAsync<GameObject>(location);

            await handle.ToUniTask(progress);

            if(!handle.IsValid)
            {
                throw new Exception($"[YooAssetsShim] Failed to load asset: {location}");
            }

            _OBJ_2_HANDLES.TryAdd(handle.AssetObject, handle);

            if(Object.Instantiate(handle.AssetObject, parent_transform, stay_world_space) is not GameObject go)
            {
                Release(handle.AssetObject);
                throw new Exception($"[YooAssetsShim] Failed to instantiate asset: {location}");
            }

            _GO_2_OBJ.Add(go, handle.AssetObject);

            return go;
        }

        public static async UniTask<T> LoadAssetAsync<T>(string location, IProgress<float> progress = null)
            where T : Object
        {
            if(typeof(T) == typeof(Sprite))
            {
                var matches = _SA_MATCH.Matches(location);

                if(matches.Count == 2)
                {
                    var sa_handle = YooAssets.LoadAssetAsync<SpriteAtlas>(matches[0].Value);

                    await sa_handle.ToUniTask(progress);

                    if(!sa_handle.IsValid)
                    {
                        throw new Exception($"[YooAssetsShim] Failed to load sprite atlas: {matches[0].Value}");
                    }

                    if(sa_handle.AssetObject is not SpriteAtlas sa)
                    {
                        sa_handle.Release();
                        throw new Exception($"[YooAssetsShim] Failed to load sprite atlas: {matches[0].Value}");
                    }

                    var sprite = sa.GetSprite(matches[1].Value);

                    if(sprite is null)
                    {
                        sa_handle.Release();
                        throw new Exception($"[YooAssetsShim] Failed to load sprite: {location}");
                    }

                    _OBJ_2_HANDLES.TryAdd(sprite, sa_handle);

                    return sprite as T;
                }
            }

            var handle = YooAssets.LoadAssetAsync<T>(location);

            await handle.ToUniTask(progress);

            if(!handle.IsValid)
            {
                throw new Exception($"[YooAssetsShim] Failed to load asset: {location}");
            }

            _OBJ_2_HANDLES.TryAdd(handle.AssetObject, handle);

            return handle.AssetObject as T;
        }

        public static void ReleaseInstance(GameObject go)
        {
            if(go is null)
            {
                return;
            }

            Object.Destroy(go);

            _GO_2_OBJ.Remove(go, out Object obj);

            Release(obj);
        }

        public static void Release(Object obj)
        {
            if(obj is null)
            {
                return;
            }

            _OBJ_2_HANDLES.Remove(obj, out OperationHandleBase handle);

            handle?.ReleaseInternal();
        }
    }
}

最后

就像 一些关于代码积累的记录 这篇文章中,我希望传达的 重要的永远不是怎么做,而是为什么要这么做 一样,这里的代码没有任何高深的东西,底层框架设计更多的是思考如何降低理解成本,以及如何降低开发成本

而这些是无法通过复制代码学到的