项目地址:
Unity 项目里,经常会遇到一种情况:
bash
正在遍历列表
遍历过程中又要添加或删除元素
普通 List<T> 不能直接这样用。
如果在 foreach 中修改原列表,会触发异常。
如果改成倒序遍历,只能解决部分删除场景。
如果复制一份列表再遍历,每次都会有额外开销。
MyFramework 中的 SafeList<T> 解决的就是这个问题。
一、结构
SafeList<T> 内部有三个列表:
bash
protected List<SafeListModify<T>> mModifyList = new(); // 记录操作的列表,按顺序存储所有的操作
protected List<T> mUpdateList = new(); // 用于遍历更新的列表
protected List<T> mMainList = new(); // 用于存储实时数据的列表
三个列表的职责不同:
bash
mMainList
实时数据
mUpdateList
遍历快照
mModifyList
从上次同步后发生的增删记录
mMainList 始终保存最新数据。
mUpdateList 只在开始遍历时同步。
mModifyList 记录两次遍历之间发生的修改。
二、遍历入口
遍历入口是 startForeach():
bash
// 获取用于更新的列表,会自动从主列表同步,遍历结束时需要调用endForeach
// 搭配SafeListScope使用,using var a = new SafeListScope<T>(safeList);然后遍历a.mReadList
public List<T> startForeach(string fileName = null)
{
if (mForeaching)
{
logError("当前列表正在遍历中,无法再次开始遍历, 上一次开始遍历的地方:" + (mLastFileName ?? "") + ", 当前遍历的地方:" + fileName);
return null;
}
mLastFileName = fileName;
mForeaching = true;
int mainCount = mMainList.Count;
if (mainCount == 0)
{
mUpdateList.Clear();
}
else
{
if (mModifyList.Count < mainCount)
{
foreach (var value in mModifyList)
{
if (value.mAdd)
{
mUpdateList.Add(value.mValue);
}
else
{
if (isEditor() && !equal(value.mValue, mUpdateList[value.mRemoveIndex]))
{
logError("同步列表数据错误");
}
mUpdateList.RemoveAt(value.mRemoveIndex);
}
}
}
else
{
mUpdateList.setRange(mMainList);
}
}
if (mUpdateList.Count != mMainList.Count)
{
logError("同步失败");
}
mModifyList.Clear();
return mUpdateList;
}
开始遍历时,SafeList 会把 mMainList 同步到 mUpdateList。
外部遍历的是 mUpdateList,不是 mMainList。
三、修改入口
添加元素:
bash
public T add(T value)
{
mMainList.Add(value);
mModifyList.Add(new(value, true, -1));
return value;
}
删除元素:
bash
public bool remove(T value)
{
int index = mMainList.IndexOf(value);
if (index < 0 || index >= mMainList.Count)
{
return false;
}
mMainList.RemoveAt(index);
mModifyList.Add(new(value, false, index));
return true;
}
删除指定下标:
bash
public T removeAt(int index)
{
if (index < 0 || index >= mMainList.Count)
{
return default;
}
T value = mMainList.removeAt(index);
mModifyList.Add(new(value, false, index));
return value;
}
每次修改都做两件事:
所以 mMainList 始终是实时数据。
当前正在遍历的 mUpdateList 不会被直接修改。
四、修改记录
修改记录结构是:
bash
// 因为即使继承了IEquatable,也会因为本身是带T模板的,无法在重写的Equals中完全避免装箱和拆箱,所以不继承IEquatable
// 而且实际使用时也不会调用此类型的比较函数
public struct SafeListModify<T>
{
public T mValue;
public int mRemoveIndex;
public bool mAdd;
public SafeListModify(T value, bool add, int removeIndex)
{
mValue = value;
mAdd = add;
mRemoveIndex = removeIndex;
}
}
添加只需要记录值。
删除需要记录值和删除下标。
删除时记录下标,是为了后面同步 mUpdateList 时可以按同样位置删除。
编辑器下还会检查删除位置是否一致:
bash
if (isEditor() && !equal(value.mValue, mUpdateList[value.mRemoveIndex]))
{
logError("同步列表数据错误");
}
这可以提前发现同步错误。
五、增量同步
startForeach() 中最关键的判断是:
bash
if (mModifyList.Count < mainCount)
{
foreach (var value in mModifyList)
{
...
}
}
else
{
mUpdateList.setRange(mMainList);
}
这里没有每次都完整复制 mMainList。
它会根据修改数量做选择。
修改少时:
bash
根据 mModifyList 增量同步
修改多时:
bash
直接把 mMainList 全量同步到 mUpdateList
这个判断很实用。
如果列表有 1000 个元素,两次遍历之间只新增 1 个元素,就没必要复制整个列表。
如果修改记录已经很多,逐条同步反而不划算,直接全量复制更简单。
六、遍历快照
使用方式是:
bash
using (var reader = new SafeListReader<int>(list))
{
foreach (int value in reader.mReadList)
{
// 遍历期间可以修改 list
}
}
SafeListReader<T> 很简单:
bash
public struct SafeListReader<T> : IDisposable
{
private SafeList<T> mSafeList;
public List<T> mReadList;
public SafeListReader(SafeList<T> list)
{
mSafeList = list;
mReadList = mSafeList.startForeach();
}
public void Dispose()
{
mSafeList.endForeach();
}
}
构造时调用 startForeach()。
Dispose() 时调用 endForeach()。
using 可以避免忘记结束遍历。
七、遍历中修改
假设初始数据是:
bash
mMainList = [1, 2, 3]
mUpdateList = [1, 2, 3]
开始遍历后,外部遍历的是 mUpdateList。
遍历过程中执行:
bash
list.add(4);
list.remove(1);
这时:
bash
mMainList = [2, 3, 4]
mUpdateList = [1, 2, 3]
mModifyList = [+4, -1]
当前遍历不会被影响。
下一次 startForeach() 时,mModifyList 会同步到 mUpdateList。
同步后:
bash
mMainList = [2, 3, 4]
mUpdateList = [2, 3, 4]
mModifyList = []
当前遍历稳定。
下一次遍历更新。
八、clear 处理
clear() 也分两种情况:
bash
// 清空所有数据
public void clear()
{
if (mForeaching)
{
int count = mMainList.Count;
for (int i = 0; i < count; ++i)
{
mModifyList.Add(new(mMainList[i], false, i));
}
}
else
{
mModifyList.Clear();
mUpdateList.Clear();
}
mMainList.Clear();
}
正在遍历时,不能直接清空 mUpdateList。
所以它会把 mMainList 中的每个元素都记录成删除操作。
这样当前遍历还能继续使用旧的 mUpdateList。
下一次遍历再同步删除。
没有遍历时,可以直接清空 mModifyList 和 mUpdateList。
九、嵌套遍历
SafeList<T> 不支持嵌套遍历。
开始遍历时会判断:
bash
if (mForeaching)
{
logError("当前列表正在遍历中,无法再次开始遍历, 上一次开始遍历的地方:" + (mLastFileName ?? "") + ", 当前遍历的地方:" + fileName);
return null;
}
这里还记录了上一次开始遍历的文件名:
bash
protected string mLastFileName;
这个限制让实现更简单。
如果需要嵌套遍历,可以单独复制快照,或者调整调用结构。
SafeList<T> 主要解决的是遍历期间增删,不是多层读者同时遍历。
嵌套遍历需要使用DeepSafeList,但是实现方式是每一次遍历都复制一个列表出来,所以性能比较低.
十、MainList
getMainList() 可以直接拿到实时列表:
bash
// 获取主列表,存储着当前实时的数据列表,所有的删除和新增都会立即更新此列表
// 如果确保在遍历过程中不会对列表进行修改,则可以使用MainList
// 如果可能会对列表进行修改,则应该使用startForeach
public List<T> getMainList()
{
return mMainList;
}
使用边界很明确:
bash
不会修改列表
可以直接用 mMainList
遍历中可能修改列表
使用 startForeach
SafeList<T> 没有禁止访问实时列表。
它只是把安全遍历入口单独区分出来。
十一、和 callbackAll 的区别
上一篇写过 callbackAll()。
它使用的是:
bash
moveTo + 临时列表
callbackAll() 的目标是固定当前回调批次。
新增回调进入下一批。
SafeList<T> 的目标不同。
它需要长期维护一个实时列表,同时提供一个可安全遍历的列表。
所以它用了:
bash
mMainList
mUpdateList
mModifyList
callbackAll() 是一次性批次转移。
SafeList<T> 是长期列表同步。
十二、适用场景
SafeList<T> 适合这些场景:
bash
事件监听列表
命令接收者列表
Update 对象列表
状态列表
组件列表
UI 子窗口列表
可能在遍历中增删元素的运行时列表
这些列表都有一个共同点:
bash
遍历期间可能触发新增或删除
普通 List<T> 在这种场景中不稳定。
每次复制列表又有额外成本。
SafeList<T> 用增量同步减少了不必要的复制。
十三、设计取舍
优点:
bash
遍历期间可以增删元素
mMainList 保持实时
mUpdateList 保持当前遍历稳定
修改少时只做增量同步
修改多时自动全量同步
using 方式自动结束遍历
限制:
bash
非线程安全
不支持嵌套遍历
需要通过 startForeach 获取遍历列表
mUpdateList 不是实时列表
修改操作必须走 SafeList 接口
这个设计适合主线程运行时系统。
它不是通用并发容器。
总结
SafeList<T> 的核心设计是三个列表:
bash
mMainList
实时数据
mUpdateList
遍历快照
mModifyList
增删记录
修改时立即更新 mMainList,并记录到 mModifyList。
遍历时从 mMainList 同步到 mUpdateList,然后遍历 mUpdateList。
同步时根据修改数量选择增量同步或全量复制。
这个设计让 MyFramework 中很多运行时列表可以安全处理遍历中修改的问题。
它不是复杂容器,但很适合 Unity 主线程框架系统。
