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

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

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

项目地址:

github.com/ZHOURUIH/My…

一、普通集合的问题

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

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);
}
}
}

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

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

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();
}

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

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

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

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

二、使用 SafeList

将普通列表替换为:

protected SafeList<SkillBullet> mBulletList = new();

遍历时创建 SafeListReader

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);
}
}
}

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

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 内部维护了三份数据:

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

开始遍历时:

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

遍历过程中调用:

mBulletList.add(bullet);
mBulletList.remove(bullet);

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

因此当前循环不会失效。

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

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

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

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

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

mBulletList.add(newBullet);

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

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

mBulletList.remove(bullet);

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

可以简单理解为:

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

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

五、为什么使用 Reader

也可以直接调用:

List<SkillBullet> readList = mBulletList.startForeach();

foreach (SkillBullet bullet in readList)
{
}

mBulletList.endForeach();

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

因此更推荐:

using var a = new SafeListReader<SkillBullet>(mBulletList);

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

mBulletList.endForeach();

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

六、使用 SafeDictionary

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

protected SafeDictionary<long, CharacterMonster> mMonsterList = new();

安全遍历:

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);
}
}
}

添加和删除:

public void addMonster(CharacterMonster monster)
{
mMonsterList.add(monster.getGUID(), monster);
}

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

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

CharacterMonster monster =
mMonsterList.get(guid);

bool exist =
mMonsterList.containsKey(guid);

int count =
mMonsterList.count();

七、使用 SafeHashSet

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

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

protected SafeHashSet<int> mVisibleTiles = new();

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

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 提供:

mBulletList.getMainList();

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

它返回实时主集合。

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

foreach (SkillBullet bullet in mBulletList.getMainList())
{
bullet.refreshRender();
}

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

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

using var a = new SafeListReader<SkillBullet>(mBulletList);

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

bullet.update()
skill.breakSkill()
character.destroy()
事件回调
命令调用

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

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

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

using var a = new SafeListReader<SkillBullet>(mBulletList);

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

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

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

using var a = new SafeListReader<SkillBullet>(mBulletList);

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

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

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

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”指的是:

可以在遍历过程中进行增删

并不是:

允许多个线程同时访问

SafeListSafeDictionarySafeHashSet 都不是线程安全集合。

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

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

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

适合使用:

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

继续使用普通集合:

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

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

十二、总结

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

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

使用方式基本一致:

using var a = new SafeListReader<T>(safeList);

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

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

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