Unity 的 C# 代码由托管内存系统管理对象。自动管理降低了开发负担,但高频分配与回收可能造成额外 CPU 开销和帧时间波动。有效优化应从实际采样出发,而不是把每个看起来方便的语言特性都当成问题。本文参考 Unity 6.0 文档。

先区分分配与垃圾回收

Profiler 的 GC.Alloc 表示采样位置发生了托管分配,不代表垃圾回收恰好在这里触发,也不涵盖所有原生内存与 GPU 资源。分析时应观察分配频率、累计量、对象寿命以及垃圾回收造成的实际时间。

常见来源包括循环内创建临时集合、字符串拼接、装箱、捕获变量的闭包,以及部分返回新数组的 API。LINQ 或枚举写法是否分配,取决于具体类型、调用链和后端,不能断言所有 foreach 都会产生 GC。

某个配置步骤在场景初始化时分配一次,和每帧给大量角色分配一次,通常有完全不同的优先级。应优先处理稳定运行时的高频热点,并通过目标平台的采样验证收益。

复用集合和查询缓冲区

对于高频使用的列表,可以保留集合并在使用前 Clear(),必要时预估容量。清空列表通常保留内部容量,因此减少再次增长的机会,也意味着内存不会因为清空就全部归还。

一些 Unity API 提供写入调用方缓冲区的版本,例如下面的物理查询片段。数组在对象初始化时分配并重复使用,调用方依据返回数量访问结果。

private readonly Collider[] results = new Collider[32];

private int ScanNearby()
{
    return Physics.OverlapSphereNonAlloc(
        transform.position, 5f, results);
}

这段代码有明确的容量限制:当返回数量达到数组长度时,不能假定已经取得所有命中对象。应按玩法需求选择容量或处理饱和情况,且不要遍历超出本次有效数量的旧槽位。减少分配不能以丢失必要结果为代价。

对象池的关键是生命周期

对象池通过复用对象,减少频繁创建和销毁的成本。Unity 的 UnityEngine.Pool.ObjectPool<T> 提供创建、取出、归还和销毁回调,适合把生命周期规则集中起来。它本身不支持从后台线程安全地并发调用。

  1. 创建:建立对象并完成长期有效的依赖初始化。
  2. 取出:重置本轮状态,例如位置、速度、血量、计时器和视觉效果。
  3. 归还:停止协程或其他异步活动,解除本轮事件订阅,并清理对外部对象的引用。
  4. 销毁:池不再保留对象时,正确释放它持有的资源。

还需要避免重复归还、归还后继续使用,以及回调晚到时修改已经开始下一轮生命周期的对象。开发阶段的 collection checks 有助于发现部分误用,但业务状态和异步回调的正确性仍由应用负责。

容量上限与优化代价

池会保留对象,因此可能提高常驻内存。设置 maxSize 主要限制池可保留的非活动对象数量,并不自动限制同时处于使用状态的对象总数;如果玩法需要并发数量上限,应另行实现业务限制。

不要为了追求某个分配计数而无限扩大缓冲区或池。比较修改前后的分配频率、回收时间、内存占用与长帧表现,尤其是在内存较紧的移动设备上。增量 GC 可以分摊部分回收工作,但不会消除托管分配和回收的总成本。

参考资料

本文根据以下资料整理撰写,详细规范与版本行为请以原文为准。