List 和 Dictionary<TKey,TValue> 在 Unity 中的性能调优实战
系列 :C# 与常用数据结构源码剖析 · Unity 实战篇
阅读时间 :约 60 分钟
前置知识 :
List<T>连续数组、Dictionary<TKey,TValue>桶与 Entry、Unity Profiler 基础结论边界:本文区分 Editor/Player、Mono/IL2CPP、Development/Release 与目标设备。不把某个 Unity 版本的 GC 或桌面 .NET 基准当成所有项目的普遍事实。
一、调优对象不是集合 API,而是整条帧时间路径
List<T> 和 Dictionary<TKey,TValue> 本身并不"慢"。它们是 Unity 项目中最常用的两种可变容器,很多问题来自使用模式:在帧循环中反复扩容,为一次查询临时建字典,每帧清空后又重建数万元素,使用不稳定或昂贵的键,在遍历时修改集合,以及为了"零 GC"无限保留峰值容量。
游戏帧预算是有上限的。60 FPS 的理论每帧时间约 16.67 ms,120 FPS 约 8.33 ms,但脚本代码只能使用其中一部分。一个容器问题可能表现为 CPU 尖峰、托管堆分配、GC 停顿、峰值内存、缓存未命中,也可能表现为错误的生命周期导致脏数据。只看 GC.Alloc 一列,无法回答这些问题。
可靠的调优要先定义场景:同屏实体数、每帧读写次数、创建销毁频率、典型容量和峰值容量。然后在目标设备的 Player 中录制可重复的玩法片段,定位真正的占用者。最后才修改容量、索引、数据布局或生命周期,用同一片段复测。
二、先建成本模型:List 和 Dictionary 在付费什么
List<T> 的核心是一个 T[]、当前元素数 _size 与用于枚举修改检测的 _version。按索引读写是 O(1),尾部添加的均摊复杂度是 O(1),中间插入或删除要搬移后续元素,最坏是 O(n)。当 Count == Capacity 时需要分配更大的数组并复制旧元素。对引用类型元素,Clear 还需要清掉有效区间内的引用,使对象可被 GC 回收。
Dictionary<TKey,TValue> 使用桶数组定位冲突链的起点,再在 Entry 数组中保存哈希、下一个冲突位置、键和值。在哈希分布良好时,查找、插入和删除的期望复杂度是 O(1),但常数成本包含计算哈希、定位桶、追踪冲突链和执行相等性比较。扩容要分配新内部数组并重建桶索引。
因此两者的核心选型差异是:List<T> 优先为紧凑遍历和序号访问服务,Dictionary<TKey,TValue> 用更多内存和更复杂的访存换取按键快速查找。对 16 个以内的元素,线性扫描可能因连续内存和低管理成本而更快;对一万个且每帧做大量按 ID 查找的元素,字典通常更合理。转折点取决于键类型、数据布局、硬件与访问分布,不是一个固定数字。
三、Capacity 不是越大越好:在扩容尖峰与驻留内存之间取舍
已知元素数时,构造时传入容量,或在批量写入前调用 EnsureCapacity,可以避免中途多次分配和复制。如果是从已知集合复制,优先使用能一次获取数量的构造函数或 AddRange,但仍要检查当前 Unity 运行时提供的 API。
public static void CollectVisible(
IReadOnlyList<Enemy> all,
List<Enemy> output)
{
output.Clear();
output.EnsureCapacity(all.Count);
for (int i = 0; i < all.Count; i++)
{
Enemy enemy = all[i];
if (enemy.IsVisible)
output.Add(enemy);
}
}
上例的 all.Count 是安全上界,但如果全局有十万敌人而可见者通常只有两百,每个输出列表都预留十万会浪费大量内存。更好的估算来自历史高分位、场景上限或分区结构。容量是预算,不是越大越好的标签。
Clear() 将 Count 归零,但通常保留底层容量。这对下一帧重复使用很有价值,但也意味着一次偶发峰值可能让大数组长期驻留。TrimExcess 会通过新分配和复制缩容,不应每帧调用。可以在场景切换、关卡结束或记录到长时间低使用后做有节制的缩容,也可以直接丢弃过大容器,让池重建一个常规尺寸的实例。
四、List 的热点操作:搬移、顺序与删除策略
在帧循环中从头部删除 List<T> 是典型的二次复杂度陷阱。每次 RemoveAt(0) 都需要把后续元素向前搬移,连续删除 n 个元素会累积大量复制。如果语义是 FIFO,应使用 Queue<T> 或自己的环形缓冲区。如果元素顺序不重要,可以用末元素覆盖待删位置,再删除末尾,把搬移降为 O(1)。
static void RemoveAtSwapBack<T>(List<T> list, int index)
{
int last = list.Count - 1;
if ((uint)index > (uint)last)
throw new ArgumentOutOfRangeException(nameof(index));
list[index] = list[last];
list.RemoveAt(last);
}
但 swap-back 会改变顺序,不能用于 UI 列表、时间线、确定性回放或任何依赖稳定顺序的系统。优化不可以偷偷更改数据结构的语义。
批量过滤时,可以使用一次稳定压缩:用读指针扫描全部元素,用写指针保留需要的元素,最后一次删除尾部。RemoveAll(predicate) 已经提供类似思路,但 predicate 是否捕获外部变量、当前运行时如何处理委托,仍需用实际版本验证。如果这一路径并不热,清晰的 predicate 比手写复杂循环更值得保留。
五、遍历与修改:foreach 不等于必然分配
List<T>.Enumerator 是值类型。直接对 List<T> 使用 foreach 在常见编译路径下不需要堆分配,不应传播"foreach 一定产生 GC"的过时结论。但如果将容器作为非泛型 IEnumerable 处理,枚举器可能装箱;接口分派、扩展方法、LINQ 和特定运行时又会引入不同成本。结论必须精确到静态类型和调用路径。
遍历期间调用 Add、Remove 或其他会改变结构的方法,通常会使枚举器的版本检查失败。不要为了绕过异常而改用不受约束的索引循环;要先确定语义。删除当前元素可以倒序遍历,添加待处理任务可以写入另一个队列,并发修改则应通过命令缓冲区在安全阶段合并。
对值类型元素,list[i] 返回的是值,修改局部副本不会自动写回列表。必须显式赋回 list[i],或在运行时支持且确有证据时使用 ref/Span 类 API。后者可以绕过常规版本管理与封装,不能在可能扩容或修改集合的期间保留引用。
六、Dictionary 的键设计:稳定性比"用 enum 一定快"更重要
一个正确的字典键必须在进入字典后保持与哈希和相等性有关的状态不变。如果把可变坐标、可修改名字或整个组件对象作为键,修改后它可能仍然位于旧哈希对应的桶中,使后续查找失败。这是正确性错误,不只是性能问题。
字符串可以是合理键:它不可变,语义清楚,而且框架提供专门的相等性实现。问题不在"string 必然慢",而在是否为每次查找动态构造字符串,是否使用了错误的大小写规则,以及键长度和查找频率是否已进入热点。配置字段名可以在加载边界转换为稳定整型 ID,但要保留冲突检测与调试时的反向映射。
public readonly struct EntityId : IEquatable<EntityId>
{
public EntityId(int value) => Value = value;
public int Value { get; }
public bool Equals(EntityId other) => Value == other.Value;
public override bool Equals(object? obj) => obj is EntityId other && Equals(other);
public override int GetHashCode() => Value;
}
小型不可变值类型可以防止把角色 ID 与道具 ID 混用,但不应在 GetHashCode 中使用不稳定数据。自定义 IEqualityComparer<TKey> 时,Equals(a,b) 为真必须推出哈希相同,否则字典可能把相等键放入不同桶。哈希相同则不要求键相等,冲突本来就由后续相等性比较处理。
七、避免重复查找,但不要为微优化牺牲边界
ContainsKey(key) 后再读取 dictionary[key] 会做两次查找,可以用 TryGetValue 合并。累加计数时也应避免在一个迭代中做多次哈希路径。
if (damageByAttacker.TryGetValue(attackerId, out int damage))
damageByAttacker[attackerId] = damage + amount;
else
damageByAttacker.Add(attackerId, amount);
这仍然会在赋值时再查找一次。现代 .NET 提供 CollectionsMarshal.GetValueRefOrAddDefault 类似的 ref API,能直接获得 Entry 中值的引用,但 Unity 的可用性取决于具体运行时,而且在字典后续修改或扩容后保留该 ref 非常危险。只有采样证明这一处是热点,团队又能审查其生命周期时,才值得引入。
删除并重新添加键不应被用来"整理"字典。字典的枚举顺序不应被当作持久化、回放或网络协议的契约,即使某些运行时版本在常见操作下呈现稳定顺序。需要确定性时,应显式对键排序,或使用定义了顺序语义的结构,并把排序成本纳入预算。
八、双结构索引:同时需要顺序遍历和按 ID 查找时
实体系统经常同时需要两种访问:每帧顺序遍历所有活动实体,以及收到网络消息时按 ID 定位实体。只用字典遍历可能失去紧凑性,只用列表则让按 ID 查找变成 O(n)。一种常见方案是维护紧凑列表和 ID -> index 字典。
public sealed class EntityStore
{
private readonly List<Entity> _items = new();
private readonly Dictionary<EntityId, int> _indexById = new();
public bool Remove(EntityId id)
{
if (!_indexById.TryGetValue(id, out int index))
return false;
int last = _items.Count - 1;
Entity moved = _items[last];
_items[index] = moved;
_items.RemoveAt(last);
_indexById.Remove(id);
if (index != last)
_indexById[moved.Id] = index;
return true;
}
}
这是 swap-back 删除的双结构版本。其不变式是:每个列表元素的 ID 在字典中恰好对应其当前索引。任何增、删、替换操作都必须同时维护两边,否则性能优化会变成难以复现的错误。可以在 Development Build 或测试中添加 O(n) 不变式校验,发布版再移除或降频。
如果顺序必须稳定,不能使用 swap-back。可以接受删除搬移并修正后续索引,但代价是 O(n);也可以给实体分配稳定槽位并维护空闲列表,代价是空洞与更复杂的遍历。没有一种方案同时满足所有语义,必须由顺序、查找、删除和内存预算决定。
九、池化集合:复用的是容量,同时也复用了风险
临时列表池能避免反复分配容器与底层数组,但必须定义清晰所有权。借出后只能由借用者使用,归还后任何引用都必须视为失效。不能把池化列表交给异步回调后立即归还,也不能让多个系统把它当成自己的缓存。
List<Enemy> rented = enemyListPool.Get();
try
{
CollectVisible(allEnemies, rented);
ProcessVisible(rented);
}
finally
{
rented.Clear();
enemyListPool.Release(rented);
}
finally 保证异常路径也归还,但 Unity 实时循环中是否使用异常还要服从项目规范。列表中若存放引用类型,归还前 Clear 可以避免池间接保活整张对象图。但对超大列表,清空有扫描成本,且底层数组仍驻留。池应设置最大保留容量:归还过大容器时直接丢弃,而不是永久污染池。
字典池还要注意 comparer。一个使用大小写不敏感 comparer 的字典不能被当成默认比较字典重用。池化键值容器时,类型参数与 comparer 都属于池的身份。为了降低误用,可以用作用域包装器封装 Get/Release,或把池限制在单个模块内,而不是建立全局"万能集合池"。
十、分配与 GC:不要用一句过时标签描述所有 Unity
Unity 的托管内存回收行为受 Editor 版本、脚本后端、平台、增量 GC 选项与玩家构建配置影响。把它一概写成"Boehm 保守式不分代 GC,每次都扫全堆"不足以支持现代 Unity 项目的精确结论。工程上更稳妥的规则是:热循环中可避免的持续分配会增加回收工作量和帧时不确定性,应通过目标版本的 Profiler 确认。
new List<T>() 创建列表对象,底层数组可能在首次写入或指定容量时分配。每次扩容会让旧数组等待回收。new Dictionary<TKey,TValue>() 也会在需要存储时建立内部数组。预分配或复用可以把这些成本移出游戏进行中的关键帧,但会提高驻留内存。对低内存移动设备,预分配过度可能比偶发扩容更糟。
要把"每帧分配字节数"和"一次性启动分配"分开。一个只在进入关卡时生成的字典,可能根本不值得池化;一个每帧只分配 200 B 但贯穿整场 40 分钟战斗的查询,却可能值得修复。时间维度比单帧截图更重要。
十一、一个完整案例:战斗伤害统计从帧尖峰到可预测
假设战斗系统每帧接收伤害事件,UI 每 0.2 秒需要显示输出最高的若干个角色。初版实现在每次刷新时用 LINQ GroupBy、ToDictionary、OrderByDescending和 ToList 串起整条路径。这很易读,但在大量事件下会创建多层中间对象与容器,并且每次都重新聚合全部历史。
重构的第一步不是换成手写循环,而是修改生命周期:在伤害事件到达时,增量更新 Dictionary<EntityId, DamageTotal>;UI 刷新时只复制当前聚合值到复用列表并排序。如果 UI 只需前 10,在实体非常多时可使用大小为 10 的最小堆或选择算法,避免对全量 O(n log n) 排序。在实体只有几十个时,直接排序通常更简单,也可能更快。
第二步是预算容量。字典容量可取同局最大参战实体数,排行列表可使用上一次的容量。战斗结束后不要立即在资源释放峰值帧对所有容器做缩容;可以在转场加载阶段统一重置。
第三步是正确性。伤害事件需要唯一序号防止重复结算,实体 ID 不能在复活时被错误复用,字典清理要与战斗世代绑定。如果只优化分配却让旧一局数据混入新局,这不是成功的调优。
第四步是证据。记录优化前后 UI 刷新 Marker 的主线程时间、分配字节、参战实体数、伤害事件数和峰值驻留容量。如果优化只在 Editor 中测得,它还不是玩家端结论。
十二、证据链:如何证明修改真的有效
首先用 Profiler Marker 把业务阶段分开,例如"收集"、"建立索引"、"排序"与"提交 UI"。一个大 Marker 只能说明整个方法慢,不能说明是扩容、哈希、比较器还是 UI 实例化所致。然后在 Timeline 中看尖峰帧,在 Hierarchy 中看累积占比,并同时记录输入数量。
其次将 Deep Profile 当成定位手段,而不是最终测量环境。它的插桩可以改变方法成本和时序。需要精确分配调用栈时可以短时启用,找到可疑路径后关闭,用自定义 Marker 重测。
再次比较发布条件。Editor 有额外的引擎与检查器开销,Development Build 有调试与 Profiler 开销,Mono 与 IL2CPP 的代码生成不同,桌面 CPU 与移动 SoC 的缓存和散热条件也不同。最终结论至少要在一台最低档目标设备上成立。
最后做回归。性能测试要固定场景、随机种子、实体数、采样时长、热身阶段和设备状态,同时保留功能断言。一个容器重构若改变了顺序,可能导致回放 Hash 不同、浮点累加顺序不同或 UI 闪烁。平均帧时下降不能抵消确定性回归。
十三、选型与审查清单
在代码评审中,可以用下列问题快速检查:
- 容器的典型容量、峰值容量和生命周期是什么?
- 主要操作是顺序遍历、按索引访问、按键查找,还是中间插删?
- 顺序是否是业务契约?能否使用 swap-back?
- 键在存活期间是否不变,
Equals与GetHashCode是否一致? - 是否在做双重字典查找、每帧扩容、头部删除或反复构建中间集合?
- 复用集合能否改变数据生命周期,而不是只加一个全局池?
- 归还池时是否清除引用,是否丢弃超大实例,是否可能二次归还?
- 数据是否跨线程?
List<T>和普通Dictionary不因为只读"看起来安全"就自动具备发布与可见性保证。 - 证据来自 Editor,还是目标设备 Player?采样时的输入规模是否代表真实压力?
- 优化后是否检查帧时分位数、分配、驻留内存和功能不变式?
十四、本篇结论
List<T> 适合紧凑的顺序存储与遍历,Dictionary<TKey,TValue> 适合稳定键上的高频查找。调优的核心不是把前者全部换成后者,而是让容器的数据布局、操作复杂度、容量和生命周期与真实访问模式对齐。
预分配能消除扩容尖峰,但增加驻留内存;池化能复用容量,但引入所有权、脏数据和峰值污染风险;双结构索引能同时提供遍历与查找性能,但需要维护更强的不变式。每一项收益都有对应代价。
最终标准不是代码中出现了多少 Capacity、对象池或手写循环,而是在目标 Unity 版本、后端、设备和典型玩法下,帧时尖峰、分配与内存峰值都进入预算,同时顺序、唯一性、回放与生命周期保持正确。
建议实验 :在目标设备上对比"每帧新建"、"复用但不预留"、"复用并按 P95 预留"三种列表策略,同时记录 CPU、GC.Alloc 与驻留容量。
下一篇:Span、foreach 与 LINQ 的分配和热路径边界。