把每帧的 GC 分配降到零:一次自上而下的排查
症状很典型:平时跑得很稳,一旦触发连续的连锁反应就开始一顿一顿地卡。中端安卓机上尤其明显, 帧率曲线呈锯齿状——不是持续掉帧,是周期性地掉一大格再恢复。这个形状基本可以直接认定是 垃圾回收,而不是算法慢。
先确认是 GC,而不是逻辑耗时
两者的区别在 Profiler 里一眼可辨:逻辑慢表现为某个函数的 self time 持续偏高;
GC 卡顿表现为大部分帧都很轻松,偶尔某一帧出现一个 GC.Collect 尖峰。
打开 Profiler 的 Memory 模块,把 GC Allocated In Frame 拉出来看,
我这里的数字是每帧 40–120 KB,连锁高峰能到 300 KB 以上。
结论先行:托管堆每分配约 1 MB 就会触发一次回收。每帧 100 KB 意味着大概每 10 帧收一次, 刚好对应那个锯齿周期。所以目标不是"优化算法",而是把稳态下的分配量压到零。
元凶一:每次调用都新建临时集合
核心检测函数长这样(简化后):
List<Cell> GetMatches(Cell origin)
{
var horizontal = new List<Cell>();
var vertical = new List<Cell>();
var visited = new HashSet<Cell>();
// ... 扫描并填充
return result; // 又是一个新 List
}
写的时候完全合理:函数纯粹、没有副作用、易于测试。问题是它一帧要被调用几十次,
每次四个堆对象。HashSet 尤其贵,它的内部桶数组会随着元素增加反复扩容。
改法是把这些临时容器提成字段,调用开头 Clear() 复用:
readonly List<Cell> m_Horizontal = new List<Cell>(16);
readonly List<Cell> m_Vertical = new List<Cell>(16);
readonly HashSet<Cell> m_Visited = new HashSet<Cell>();
void GetMatches(Cell origin, List<Cell> results) // 结果也由调用方传入
{
m_Horizontal.Clear();
m_Vertical.Clear();
m_Visited.Clear();
// ...
}
List.Clear() 只把 Count 归零,容量保留——这正是我们要的,
第二次调用起就不再分配。但如果元素是引用类型,Clear 之后数组槽位仍持有旧引用,
对象不会被回收。生命周期长的复用容器要留意这一点,必要时手动置空。
这一步之后稳态分配从 100 KB 降到约 12 KB。
元凶二:小结构体被装成了类
剩下的分配集中在一个描述"可行操作"的小对象上。它只有两个坐标和一个方向枚举,
生命周期不超过一帧,却是个 class。棋盘扫描一次要产生几十个。
改成 struct 之后这部分归零。改的时候有两个地方要一起动:
- 装进
List<T>时不再有装箱开销,但用foreach遍历List<struct>会复制结构体。字段少(我这里 12 字节)时无所谓, 字段多就该用索引遍历或ref。 - 结构体默认按值传递,原来依赖"改了这个对象外面能看到"的代码会静默失效。 这类改造一定要顺着调用链把每个使用点看一遍,编译器不会报错。
元凶三:频繁创建销毁的实体对象
最后是每次消除都要销毁、每次补充都要重新创建的棋子对象。这类东西的标准解法是对象池, 没什么好说的,但实现时踩了一个值得记的坑:
池化容器的 key 一定要是精确类型,不能用基类。
我最初按基类类型入池,结果带特殊行为的子类实例被回收进了基类池, 下次取出来当普通棋子用,行为诡异且极难复现——因为取到的是哪个实例取决于回收顺序。 最终的处理是只池化精确匹配基类的实例,子类走原来的创建销毁路径:
void Release(Piece piece)
{
// 只有精确类型才入池,子类交回给引擎销毁
if (piece.GetType() == typeof(Piece))
{
piece.ResetState();
piece.gameObject.SetActive(false);
m_Pool.Push(piece);
}
else
{
Destroy(piece.gameObject);
}
}
另一个必做动作是 ResetState()。从池里取出的对象带着上一次使用的全部状态:
动画进度、协程句柄、事件订阅、材质属性。漏一个就是一个偶发 bug。
我的做法是把"重置"写成对象自己的职责,而不是散落在取出的调用点。
结果与方法论
三步做完,稳态 GC 分配为 0,连锁高峰约 2 KB(来自一处第三方 API,暂时无法避免)。 锯齿消失。
回头看,真正有用的顺序是这样的:
- 先分类:是逻辑耗时还是 GC?两者的优化方向完全不同,搞错方向会白干一天。
- 看分配量而不是猜:Profiler 的每帧分配数字是唯一可靠的进度指标, 每改一处就重新测一次,避免"感觉快了"。
- 从调用最频繁的路径改起:一帧调 60 次的函数里省 1 KB, 比一帧调一次的函数里省 50 KB 更值。
- 零分配是可达的目标,但只针对稳态。启动、切场景、加载资源时分配大量内存 是正常的,别在那些地方浪费力气。
顺带一句:值类型改造和对象池都是"能省分配但降低可读性"的手段,我只在被 Profiler
点名的热路径上用。冷路径上继续 new,代码清楚更重要。