MyFramework:Unity SafeList 如何支持遍历中修改

项目地址:

github.com/ZHOURUIH/My...

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

下一次遍历再同步删除。

没有遍历时,可以直接清空 mModifyListmUpdateList


九、嵌套遍历

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 主线程框架系统。

相关推荐
SmalBox7 小时前
【节点】[SmoothWave节点]原理解析与实际应用
unity3d·游戏开发·图形学
SmalBox1 天前
【节点】[RoundedRectangle节点]原理解析与实际应用
unity3d·游戏开发·图形学
_zhourui_h_1 天前
MyFramework:AssetBundle 延迟卸载与依赖保护
unity3d
_zhourui_h_2 天前
MyFramework:safe() 扩展函数的空集合设计
unity3d·游戏开发
SmalBox2 天前
【节点】[RoundedPolygon节点]原理解析与实际应用
unity3d·游戏开发·图形学
SmalBox3 天前
【节点】[Rectangle节点]原理解析与实际应用
unity3d·游戏开发·图形学
SmalBox4 天前
【节点】[Polygon节点]原理解析与实际应用
unity3d·游戏开发·图形学
_zhourui_h_4 天前
MyFramework:整体代码结构与热更新分层解析
unity3d·游戏开发
SmalBox5 天前
【节点】[Houndstooth节点]原理解析与实际应用
unity3d·游戏开发·图形学