MyFramework:异步加载回调为什么要先转移再执行
项目地址:
GitHub - ZHOURUIH/MyFramework: Unity 商用级别开发框架,经过了多年经验沉淀.一个在unity上使用的网络游戏客户端开发框架,为unity所有使用方式提供完善的封装和管理,只需要专注于游戏逻辑的编写 · GitHub
ResourceManager的异步资源加载里,有一个很小的细节:
callbackAll()它没有直接遍历原始回调列表,而是先把回调列表转移到临时列表,再统一执行。
这个设计处理的是异步回调中的列表修改问题。
一、回调列表
AssetInfo中保存了两个列表:
protected List<AssetLoadCallback> mCallback = new(); // 异步加载回调列表 protected List<string> mLoadPath = new(); // 加载资源时使用的路径mCallback保存回调函数。
mLoadPath保存每个回调对应的加载路径。
添加回调时,两个列表同步追加:
public void addCallback(AssetLoadCallback callback, string loadPath) { if (callback == null) { return; } mCallback.Add(callback); mLoadPath.Add(loadPath); }这里没有创建单独的结构体保存回调和路径,而是用两个并行列表。
执行时按相同下标读取。
二、普通写法的问题
最直接的写法是这样:
foreach (AssetLoadCallback callback in mCallback) { callback(asset, subAssets, bytes, loadPath); } mCallback.Clear(); mLoadPath.Clear();这种写法有风险。
回调执行过程中,业务逻辑可能再次发起同一个资源的异步加载。
这时又会调用:
addCallback(callback, loadPath);也就是在遍历mCallback的过程中修改mCallback。
结果可能是:
foreach 报错 新回调被本轮错误执行 Clear 时把新回调也清掉 回调顺序混乱异步资源加载里,回调内部再次请求资源是很常见的情况。
所以不能直接遍历原始列表。
三、callbackAll
MyFramework 的实现是:
public void callbackAll() { // 复制一份列表,避免回调中再次修改回调列表而报错 using var a = new ListScope2T<AssetLoadCallback, string>(out var callbacks, out var paths); mCallback.moveTo(callbacks); mLoadPath.moveTo(paths); int callbackCount = callbacks.Count; for (int i = 0; i < callbackCount; ++i) { callbacks[i](mSubAssets.get(0), mSubAssets, null, paths[i]); } }它分三步。
1. 从 ListPool 中申请两个临时列表 2. 把原始列表内容转移到临时列表 3. 遍历临时列表执行回调原始列表在回调执行前已经清空。
所以回调执行过程中,即使再次调用addCallback(),新增回调也只会进入新的原始列表,不会影响当前这一轮。
四、moveTo
列表转移使用的是moveTo()扩展函数:
// 将sourceList中的所有元素添加到targetList中,并清空sourceList,返回targetList public static List<T> moveTo<T>(this List<T> sourceList, List<T> targetList) { if (sourceList.isEmpty()) { return targetList; } targetList.AddRange(sourceList); sourceList.Clear(); return targetList; }它不是复制后保留原列表。
它是转移:
sourceList -> targetList sourceList.Clear()转移完成后:
mCallback 变为空列表 callbacks 保存本轮需要执行的回调这样当前回调和新加入的回调被分成了两批。
五、ListScope2T
临时列表通过ListScope2T获取:
using var a = new ListScope2T<AssetLoadCallback, string>(out var callbacks, out var paths);ListScope2T的作用是一次申请两个临时 List:
public struct ListScope2T<T0, T1> : IDisposable { private List<T0> mList0; // 分配的对象 private List<T1> mList1; // 分配的对象 public ListScope2T(out List<T0> list0, out List<T1> list1) { if (GameEntryBase.getInstance() == null || mListPool == null) { list0 = new(); list1 = new(); mList0 = null; mList1 = null; return; } string stackTrace = GameEntryBase.getInstance().mFrameworkParam.mEnablePoolStackTrace ? getStackTrace() : EMPTY; list0 = mListPool.newList(typeof(T0), typeof(List<T0>), stackTrace, true) as List<T0>; list1 = mListPool.newList(typeof(T1), typeof(List<T1>), stackTrace, true) as List<T1>; mList0 = list0; mList1 = list1; } public void Dispose() { mListPool?.destroyList(ref mList0, typeof(T0)); mListPool?.destroyList(ref mList1, typeof(T1)); } }using结束时,两个临时列表自动归还到对象池。
这避免了每次资源回调都创建新的List。
六、并行列表
callbackAll()中必须同时转移两个列表:
mCallback.moveTo(callbacks); mLoadPath.moveTo(paths);然后通过相同下标执行:
callbacks[i](mSubAssets.get(0), mSubAssets, null, paths[i]);这要求两个列表始终数量一致。
添加回调时:
mCallback.Add(callback); mLoadPath.Add(loadPath);执行回调时:
callbacks[i] paths[i]这种写法比创建一个临时结构对象更省。
代价是必须保证两个列表同步维护。
在这个场景中,addCallback()是唯一入口,所以同步关系比较容易保证。
七、本轮和下一轮
这个设计最关键的是区分两类回调:
本轮已经准备执行的回调 回调执行过程中新增的回调转移前:
mCallback = [A, B, C]转移后:
callbacks = [A, B, C] mCallback = []执行A时,如果又添加了D:
callbacks = [A, B, C] mCallback = [D]D不会插入当前遍历。
D会留给下一次加载流程处理。
这样回调执行顺序更稳定。
八、避免 Clear 误删
如果不使用转移,而是遍历后清空:
foreach (...) { callback(); } mCallback.Clear();回调中新增的内容也可能被最后的Clear()清掉。
这类 Bug 很隐蔽。
表现是:
回调已经注册 资源也加载完成 但回调没有执行moveTo()先清空原列表,可以避免这个问题。
当前批次和新批次不会混在一起。
九、适用位置
这种写法适合所有“执行回调时可能再次修改回调列表”的场景。
例如:
资源异步加载 AssetBundle 异步加载 事件分发 命令完成回调 网络消息回调 UI 动画完成回调条件是:
当前批次执行期间 允许产生下一批回调 但不希望下一批影响当前批次这类场景都可以使用“转移列表再执行”的方式。
十、和 SafeList 的区别
SafeList适合遍历中允许增删列表。
callbackAll()的需求不同。
它不需要让新增回调参与当前遍历。
它需要把当前批次固定下来。
所以这里没有用 SafeList,而是使用:
moveTo + 临时列表这比 SafeList 更直接。
当前批次被完整保存。
原列表立即空出来。
新增内容自然进入下一批。
十一、设计价值
这个函数的价值不在复杂。
它解决的是一个高频细节:
回调执行时,回调列表可能被再次修改MyFramework 的处理方式是:
先转移 再执行 执行期间允许原列表继续接收新回调 临时列表用完自动归还对象池这让异步资源回调更稳定。
总结
callbackAll()的核心逻辑很短:
using var a = new ListScope2T<AssetLoadCallback, string>(out var callbacks, out var paths); mCallback.moveTo(callbacks); mLoadPath.moveTo(paths); for (int i = 0; i < callbacks.Count; ++i) { callbacks[i](mSubAssets.get(0), mSubAssets, null, paths[i]); }它做了三件事:
固定当前回调批次 避免遍历中修改原列表 避免回调中新增内容被 Clear 误删再配合ListScope2T,临时列表也不会频繁产生 GC。
这是 MyFramework 中一个很小但实用的设计点。
