Unity MyFramework 用法说明(十一):使用 SafeList 在遍历过程中安全增删

游戏中的子弹、状态、怪物和可见区域等集合,经常需要一边遍历,一边创建或销毁其中的元素。普通 ListDictionaryHashSet 在遍历过程中被修改时,通常会直接抛出异常。

MyFramework 提供了 SafeListSafeDictionarySafeHashSet,用于处理这种高频场景。

项目地址:

github.com/ZHOURUIH/My...

一、普通集合的问题

以子弹管理器为例,每帧更新全部子弹,并销毁已经结束的子弹:

bash 复制代码
protected List<SkillBullet> mBulletList = new();

public override void update(float elapsedTime)
{
	base.update(elapsedTime);

	foreach (SkillBullet bullet in mBulletList)
	{
		bullet.update(elapsedTime);

		if (bullet.getState() == BULLET_STATE.DESTROY)
		{
			mBulletList.Remove(bullet);
		}
	}
}

这段代码会在运行时抛出集合已被修改的异常。

常见处理方式是先记录待删除对象:

bash 复制代码
protected List<SkillBullet> mDestroyList = new();

public override void update(float elapsedTime)
{
	base.update(elapsedTime);

	foreach (SkillBullet bullet in mBulletList)
	{
		bullet.update(elapsedTime);

		if (bullet.getState() == BULLET_STATE.DESTROY)
		{
			mDestroyList.Add(bullet);
		}
	}

	foreach (SkillBullet bullet in mDestroyList)
	{
		mBulletList.Remove(bullet);
	}

	mDestroyList.Clear();
}

虽然能够正常运行,但每个管理器都要重复维护一套延迟删除逻辑。

而且实际调用关系可能更复杂:

bash 复制代码
管理器遍历子弹
    ↓
子弹执行 update
    ↓
技能结束
    ↓
技能要求销毁自己的所有子弹
    ↓
子弹集合在调用链内部被修改

修改集合的位置不一定就在当前循环中,因此很难保证所有地方都手动延迟处理。

二、使用 SafeList

将普通列表替换为:

bash 复制代码
protected SafeList<SkillBullet> mBulletList = new();

遍历时创建 SafeListReader

bash 复制代码
public override void update(float elapsedTime)
{
	base.update(elapsedTime);

	using var a = new SafeListReader<SkillBullet>(mBulletList);
	foreach (SkillBullet bullet in a.mReadList)
	{
		bullet.update(elapsedTime);

		if (bullet.getState() == BULLET_STATE.DESTROY)
		{
			destroyBullet(bullet);
		}
	}
}

子弹的创建和销毁仍然可以直接修改集合:

bash 复制代码
public SkillBullet createBullet(
	Type bulletType,
	CharacterGame character,
	CharacterSkill skill)
{
	SkillBullet bullet = CLASS<SkillBullet>(bulletType);
	bullet.setSkill(skill);
	bullet.setCharacter(character);
	bullet.init();

	mBulletList.add(bullet);
	return bullet;
}

public void destroyBullet(SkillBullet bullet)
{
	mBulletList.remove(bullet);
	UN_CLASS(ref bullet);
}

不需要额外维护待添加列表和待删除列表。

三、SafeList 的工作方式

SafeList 内部维护了三份数据:

bash 复制代码
mMainList      当前实时数据
mUpdateList    当前用于遍历的数据
mModifyList    两次遍历之间发生的修改

开始遍历时:

bash 复制代码
将 mMainList 的变化同步到 mUpdateList
        ↓
清空修改记录
        ↓
返回稳定的 mUpdateList 进行遍历

遍历过程中调用:

bash 复制代码
mBulletList.add(bullet);
mBulletList.remove(bullet);

会立即修改 mMainList,同时将操作记录到 mModifyList,但不会修改当前正在遍历的 mUpdateList

因此当前循环不会失效。

下一次开始遍历时,框架再把这些操作同步到 mUpdateList

四、当前遍历中的数据变化

需要理解 SafeList 的一个重要规则:

SafeList 保证当前遍历过程稳定,但不会让当前遍历列表实时变化。

例如在遍历过程中创建一颗新子弹:

bash 复制代码
mBulletList.add(newBullet);

新子弹已经存在于主列表中,但不会加入当前的 a.mReadList,要到下一次遍历才会被更新。

在遍历过程中删除一个对象:

bash 复制代码
mBulletList.remove(bullet);

对象会立即从主列表移除,但当前读取列表中的快照不会被改变。

可以简单理解为:

bash 复制代码
增删操作立即影响真实集合
当前遍历使用开始时的稳定数据
下一次遍历再同步最新结果

这种行为非常适合逐帧更新的游戏对象。

五、为什么使用 Reader

也可以直接调用:

bash 复制代码
List<SkillBullet> readList = mBulletList.startForeach();

foreach (SkillBullet bullet in readList)
{
}

mBulletList.endForeach();

但只要中间发生提前返回或异常,就可能忘记调用 endForeach()

因此更推荐:

bash 复制代码
using var a = new SafeListReader<SkillBullet>(mBulletList);

离开当前作用域时,会自动调用:

bash 复制代码
mBulletList.endForeach();

即使中途 return,也能正常结束遍历状态。

六、使用 SafeDictionary

怪物通常通过 GUID 管理,可以使用:

bash 复制代码
protected SafeDictionary<long, CharacterMonster> mMonsterList = new();

安全遍历:

bash 复制代码
public void updateMonster(float elapsedTime)
{
	using var a =
		new SafeDictionaryReader<long, CharacterMonster>(
			mMonsterList);

	foreach (var item in a.mReadList)
	{
		CharacterMonster monster = item.Value;
		monster.update(elapsedTime);

		if (monster.isDead())
		{
			destroyMonster(item.Key, monster);
		}
	}
}

添加和删除:

bash 复制代码
public void addMonster(CharacterMonster monster)
{
	mMonsterList.add(monster.getGUID(), monster);
}

public void destroyMonster(
	long guid,
	CharacterMonster monster)
{
	mMonsterList.remove(guid);
	UN_CLASS(ref monster);
}

查询仍然读取实时主集合:

bash 复制代码
CharacterMonster monster =
	mMonsterList.get(guid);

bool exist =
	mMonsterList.containsKey(guid);

int count =
	mMonsterList.count();

七、使用 SafeHashSet

当数据不允许重复,也不需要通过索引访问时,可以使用 SafeHashSet

例如地图当前可见的格子:

bash 复制代码
protected SafeHashSet<int> mVisibleTiles = new();

遍历并根据新的视野移除格子:

bash 复制代码
public void updateVisibleTiles(
	HashSet<int> newVisibleTiles)
{
	using var a =
		new SafeHashSetReader<int>(mVisibleTiles);

	foreach (int tileIndex in a.mReadList)
	{
		if (!newVisibleTiles.Contains(tileIndex))
		{
			mVisibleTiles.remove(tileIndex);
			hideTile(tileIndex);
		}
	}

	foreach (int tileIndex in newVisibleTiles)
	{
		if (mVisibleTiles.add(tileIndex))
		{
			showTile(tileIndex);
		}
	}
}

add()remove() 都会返回是否真正发生了变化,因此可以避免重复执行显示或隐藏逻辑。

八、什么时候可以使用 getMainList

SafeList 提供:

bash 复制代码
mBulletList.getMainList();

SafeDictionarySafeHashSet 也提供相同用途的接口。

它返回实时主集合。

当能够确定遍历过程中绝对不会修改集合时,可以直接访问:

bash 复制代码
foreach (SkillBullet bullet in mBulletList.getMainList())
{
	bullet.refreshRender();
}

这样不需要启动安全遍历。

但只要循环中的函数可能间接修改集合,就应该使用 Reader:

bash 复制代码
using var a = new SafeListReader<SkillBullet>(mBulletList);

不要只看当前函数中是否写了 add()remove(),还要考虑内部调用:

bash 复制代码
bullet.update()
skill.breakSkill()
character.destroy()
事件回调
命令调用

这些逻辑都可能间接修改管理器中的集合。

九、不能重复遍历同一个 SafeList

同一个 SafeList 同时只能存在一个 Reader:

bash 复制代码
using var a = new SafeListReader<SkillBullet>(mBulletList);

// 这里不能再次创建 mBulletList 的 Reader
using var b = new SafeListReader<SkillBullet>(mBulletList);

框架会提示当前列表正在遍历,并记录上一次开始遍历的位置。

调用其他函数时也需要注意:

bash 复制代码
using var a = new SafeListReader<SkillBullet>(mBulletList);

foreach (SkillBullet bullet in a.mReadList)
{
	checkAllBullet();
}

如果 checkAllBullet() 内部再次遍历同一个 mBulletList,就会形成重复遍历。

不同的安全集合可以正常嵌套:

bash 复制代码
using var a =
	new SafeDictionaryReader<long, CharacterMonster>(
		mMonsterList);

foreach (var item in a.mReadList)
{
	using var b =
		new SafeListReader<CharacterState>(
			item.Value.getStateList());

	foreach (CharacterState state in b.mReadList)
	{
	}
}

十、SafeList 不是线程安全集合

SafeList 中的"Safe"指的是:

bash 复制代码
可以在遍历过程中进行增删

并不是:

bash 复制代码
允许多个线程同时访问

SafeListSafeDictionarySafeHashSet 都不是线程安全集合。

子线程与主线程之间传递数据时,仍然需要使用锁、双缓冲或其他线程同步方式。

十一、不要把所有集合都替换掉

SafeList 内部需要维护读取列表和修改记录,因此并不是所有场景都应该使用。

适合使用:

bash 复制代码
每帧更新的游戏对象
对象更新时可能销毁自己
事件或命令可能修改当前集合
调用链较深,难以保证集合不被修改

继续使用普通集合:

bash 复制代码
初始化后不再变化的配置
只查询、不在遍历中修改的数据
局部函数中的临时列表
能够明确控制增删时机的简单集合

它解决的是"遍历过程中可能修改集合"这一具体问题,而不是普通集合的替代品。

十二、总结

MyFramework 提供了三种常用安全集合:

bash 复制代码
SafeList          有顺序、允许重复
SafeDictionary    通过 Key 查找
SafeHashSet       不允许重复

使用方式基本一致:

bash 复制代码
using var a = new SafeListReader<T>(safeList);

foreach (T item in a.mReadList)
{
	// 遍历过程中可以调用 safeList.add/remove
}

它们通过实时主集合、稳定读取集合和修改记录,将"当前业务数据"和"当前遍历数据"分离。

这样游戏对象可以在自己的更新逻辑中创建或销毁其他对象,而不需要每个管理器都重复维护延迟添加、延迟删除和临时复制列表。

相关推荐
SmalBox2 天前
【节点】[EvaluateTipThickness节点]原理解析与实际应用
unity3d·游戏开发·图形学
_zhourui_h_2 天前
Unity MyFramework 用法说明(十):使用 ClassObject 和 ClassScope 管理可复用对象
unity3d
SmalBox3 天前
【节点】[EvaluateSimulationDisplacement节点]原理解析与实际应用
unity3d·游戏开发·图形学
_zhourui_h_4 天前
Unity MyFramework 用法说明(九):使用 EventSystem 处理不确定的业务响应
unity3d
SmalBox4 天前
【节点】[EvaluateSimulationCaustics节点]原理解析与实际应用
unity3d·游戏开发·图形学
SmalBox5 天前
【节点】[EvaluateSimulationAdditionalData节点]原理解析与实际应用
unity3d·游戏开发·图形学
_zhourui_h_5 天前
Unity MyFramework 用法说明(八):逻辑就是对象,使用 Command 封装跨系统业务
unity3d