基于 ECS 设计下的加载管理
基于 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 校验器也是支持的!
那么这个组件的职责就非常简单了,提供 Object
和 handle
的索引关系,以及上述 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();
}
}
}
最后
就像 一些关于代码积累的记录 这篇文章中,我希望传达的 重要的永远不是怎么做,而是为什么要这么做 一样,这里的代码没有任何高深的东西,底层框架设计更多的是思考如何降低理解成本,以及如何降低开发成本
而这些是无法通过复制代码学到的
- 感谢你赐予我前进的力量