游戏中的子弹、状态、怪物和可见区域等集合,经常需要一边遍历,一边创建或销毁其中的元素。普通 List、Dictionary 和 HashSet 在遍历过程中被修改时,通常会直接抛出异常。
MyFramework 提供了 SafeList、SafeDictionary 和 SafeHashSet,用于处理这种高频场景。
项目地址:
一、普通集合的问题
以子弹管理器为例,每帧更新全部子弹,并销毁已经结束的子弹:
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();
SafeDictionary 和 SafeHashSet 也提供相同用途的接口。
它返回实时主集合。
当能够确定遍历过程中绝对不会修改集合时,可以直接访问:
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
允许多个线程同时访问
SafeList、SafeDictionary 和 SafeHashSet 都不是线程安全集合。
子线程与主线程之间传递数据时,仍然需要使用锁、双缓冲或其他线程同步方式。
十一、不要把所有集合都替换掉
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
}
它们通过实时主集合、稳定读取集合和修改记录,将"当前业务数据"和"当前遍历数据"分离。
这样游戏对象可以在自己的更新逻辑中创建或销毁其他对象,而不需要每个管理器都重复维护延迟添加、延迟删除和临时复制列表。