项目地址GitHub - ZHOURUIH/MyFramework: Unity 商用级别开发框架,经过了多年经验沉淀.一个在unity上使用的网络游戏客户端开发框架,为unity所有使用方式提供完善的封装和管理,只需要专注于游戏逻辑的编写 · GitHubResourceManager的异步资源加载里有一个很小的细节callbackAll()它没有直接遍历原始回调列表而是先把回调列表转移到临时列表再统一执行。这个设计处理的是异步回调中的列表修改问题。一、回调列表AssetInfo中保存了两个列表protected ListAssetLoadCallback mCallback new(); // 异步加载回调列表 protected Liststring 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 时把新回调也清掉 回调顺序混乱异步资源加载里回调内部再次请求资源是很常见的情况。所以不能直接遍历原始列表。三、callbackAllMyFramework 的实现是public void callbackAll() { // 复制一份列表,避免回调中再次修改回调列表而报错 using var a new ListScope2TAssetLoadCallback, 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 ListT moveToT(this ListT sourceList, ListT 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 ListScope2TAssetLoadCallback, string(out var callbacks, out var paths);ListScope2T的作用是一次申请两个临时 Listpublic struct ListScope2TT0, T1 : IDisposable { private ListT0 mList0; // 分配的对象 private ListT1 mList1; // 分配的对象 public ListScope2T(out ListT0 list0, out ListT1 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(ListT0), stackTrace, true) as ListT0; list1 mListPool.newList(typeof(T1), typeof(ListT1), stackTrace, true) as ListT1; 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时如果又添加了Dcallbacks [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 ListScope2TAssetLoadCallback, 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 中一个很小但实用的设计点。