如何优化代码适应托管内存?

C#的自动内存管理相比于**C++**等其他编程语言,减少了内存泄漏和其他编程错误的风险,后者需要手动追踪和释放所有分配的内存。

目录

避免重复字符串拼接

三层递进

TMP_Text.SetText(char\[\])零分配

对比两种接口底层差异

[① SetText(string)(常规写法)](#① SetText(string)(常规写法))

[② SetText(char\[\])(你说的复用数组方案)](#② SetText(char[])(你说的复用数组方案))

为什么说这是「真正零分配」(限定范围)

[为什么普通 UGUI Text 做不到这点?](#为什么普通 UGUI Text 做不到这点?)

[额外小知识点:还有 SetText(NativeArray )](#额外小知识点:还有 SetText(NativeArray ))

避免闭包和匿名方法

[避免值类型转引用类型 / 装箱(Boxing)](#避免值类型转引用类型 / 装箱(Boxing))

[避免使用 params 修饰符(Avoid the params modifier)](#避免使用 params 修饰符(Avoid the params modifier))

对象池与对象复用

对象池的本质

[ObjectPool :Unity 官方池实现](#ObjectPool :Unity 官方池实现)

取出:Get()

归还:Release()

安全检查:collectionCheck

状态重置:对象池最容易翻车的地方

子弹示例

逐段拆解

子弹侧关键点

炮台侧关键点

场景卸载时的池化处理

集合类型的池化

[思路一:提升 + Clear 复用](#思路一:提升 + Clear 复用)

思路二:官方集合池

对象池的边界:什么时候不该用

数组优化

[把数组作为参数传给方法(Pass arrays as parameters)](#把数组作为参数传给方法(Pass arrays as parameters))

[避免重复访问"返回数组的 Unity API"](#避免重复访问"返回数组的 Unity API")

[无分配替代 API(Alternative non-allocating APIs)](#无分配替代 API(Alternative non-allocating APIs))

零长度数组用静态实例

[大数组用 Native 容器](#大数组用 Native 容器)


避免重复字符串拼接

现象:

  • C# 的 string不可变引用类型。一旦创建就不能改。
  • Unity 把引用类型分配在托管堆上,受 GC 管辖。
  • 因此 += 拼接时,每一次循环都会丢弃上一版字符串、新建一个更长的字符串。

输入 stringArray = { "A", "B", "C", "D", "E" }

堆上依次产生:

"A" → "AB" → "ABC" → "ABCD" → "ABCDE"

只有最后一个有用,前四个全是垃圾。输入越长,产生的垃圾越多,而且长度递增(分配量是 O(n²) 级别的)

改进方案:StringBuilder

private StringBuilder _sb = new StringBuilder(16);

string ConcatExample(string\[\] stringArray)

{

_sb.Clear();

for (int i = 0; i < stringArray.Length; i++)

_sb.Append(stringArrayi);

return _sb.ToString();

}

1.new StringBuilder(16) 作为成员变量

StringBuilder 内部持有一块 char\[\] 缓冲区。

初始化指定容量 16:内部直接 new char 16,只分配一次;

如果不写容量,默认很小,Append 不断变长时,内部 char \[\] 会扩容:新建更大数组 + 拷贝旧内容,旧数组变成垃圾触发 GC;

成员复用:_sb 实例本身和它内部的 char \[\]全程只创建一次。Clear() 不会销毁内部 char 数组,只是把 Length 标记置 0,数组内存保留,后续 Append 直接覆写。

好处:循环 Append 拼接的时候,不会生成一堆中间临时 string。 如果不用 StringBuilder,直接 a+b+c+d,C# 编译器会生成多个临时字符串,堆分配爆炸。

误区纠正:Clear() 不会释放内部 char \[\],只是重置长度。这是复用的关键。

2.致命点:_sb.ToString()

StringBuilder.ToString() 的源码逻辑:

// 简化伪代码

public string ToString()

{

string s = new string(m_ChunkChars, 0, m_Length);

return s;

}

new string(char[], start, length) 会在托管堆上创建全新 string 对象,把 StringBuilder 内部 char 数组里的字符拷贝进去。

string 在 C# 是不可变类型 ,一旦创建不能修改。 StringBuilder 的内部 char \[\] 是可变缓冲区,CLR 不允许直接把这块可变内存包装成 string 返回(安全问题:外部修改 char \[\] 会破坏 string 不可变契约)。 所以必须拷贝一份字符,新建 string

所以这套方案的 GC:

  • 多次调用 ConcatExample每次调用都会产生 1 个 string 垃圾
  • 对比直接 string.Concat:少了大量中间字符串,但是最终仍然每次 1 个 string 分配

三层递进

以下示例每次调用时分配新字符串,并生成垃圾回收器必须处理的连续对象流:Update

public class ExampleScript : MonoBehaviour {

public Text scoreBoard;

public int score;

void Update() {

string scoreText = "Score: " + score.ToString();

scoreBoard.text = scoreText;

}

}
public class ExampleScript : MonoBehaviour {

public Text scoreBoard;

public string scoreText;

public int score;

public int oldScore;

void Update() {

if (score != oldScore) {

scoreText = "Score: " + score.ToString();

scoreBoard.text = scoreText;

oldScore = score;

}

}

}
public class ExampleScript : MonoBehaviour {

public Text scoreBoardTitle;

public Text scoreBoardDisplay;

public string scoreText;

public int score;

public int oldScore;

void Start() {

scoreBoardTitle.text = "Score: ";

}

void Update() {

if (score != oldScore) {

scoreText = score.ToString();

scoreBoardDisplay.text = scoreText;

oldScore = score;

}

}

}

版本 做法 每帧分配
Bad string scoreText = "Score: " + score.ToString(); 每帧 1~2 个字符串
Better if (score != oldScore) 才更新 分数不变时 0 分配,变化时才有
Best 标题和数值拆成两个 Text,只更新数值 变化时只做 1 次 ToString(),无拼接

思路演进是:减少次数 → 消除拼接 → 用 API 彻底绕开字符串

UGUI 的 TMPro.TMP_TextSetText(char[]),可以复用一个 char[] 逐位拼数字,连 ToString() 的分配都省掉。这是真正的"零分配"方案。

TMP_Text.SetText(char[])零分配

TMP_Text.SetText(char[]) 传入预先分配好的 char 数组 ,直接把字符内存交给 TMPro,完全不产生字符串对象(string ;而普通写法 text.SetText(number.ToString()) 一定会创建临时 string,造成 GC 分配。这就是它被叫做零分配方案的根本原因。

注意:是调用这一次 SetText 本身不分配托管内存,不是绝对全局零分配

对比两种接口底层差异

SetText(string)(常规写法)

复制代码
tmpText.SetText(12345.ToString());
  1. int.ToString()新建一个 string 对象,string 内部就是 char \[\],托管堆分配。
  2. SetText(string) 内部:读取 string 的 char buffer,拷贝到 TMPro 的内部字符缓冲区。
  3. 这个临时 string 用完没人引用,等待 GC 回收;频繁更新(比如帧率刷新的伤害数字、计分)就会持续堆内存分配,触发 GC。

SetText(char[])(你说的复用数组方案)

复制代码
// 预先在 Awake 一次性分配,全程复用这一个数组
private char[] _charBuffer = new char[16];

void Update()
{
    int score = 12345;
    int len = WriteIntToCharArray(score, _charBuffer); // 自己写逻辑,把数字写入char数组,返回有效字符长度
    tmpText.SetText(_charBuffer, 0, len);
}

TMP_Text.SetText(char[] chars, int start, int length) 签名:

把传入 char 数组指定区间的字符,拷贝进 TMPro 的内部渲染缓冲区。

关键点:

  • _charBuffer 只在初始化分配一次,之后反复覆盖里面的字符 ,不会新建任何 string
  • 没有 ToString(),不会生成临时字符串,本次调用没有托管堆分配
  • 数组只是一块 char 内存,你反复覆盖数组元素,数组引用本身不变。

C# 里 string 是**不可变(immutable)**的,一旦创建就不能修改;只要生成字符串就必然分配。而 char \[\] 是可变的,原地覆写。这是这套方案成立的基础。

为什么说这是「真正零分配」(限定范围)

零分配的范围:更新文本这一步,不产生新托管对象

  • 没有临时 string
  • 不新建 char[],复用同一块数组内存
  • 没有装箱、没有值类型转引用类型的分配

但是有两个容易被忽略的 "不是零分配" 的坑,很多人踩:

  1. TMPro 内部本身会有缓冲区内存 :TMPro 会维护自己渲染用的字符网格数据(TMP_MeshInfo 等)。
    • 如果文本长度变化很大,TMPro 内部缓冲区扩容时依然会产生一次堆分配
    • 固定长度数字(比如分数永远最多 6 位),缓冲区稳定后就不再扩容,后续就真的没有托管 GC 分配。
  2. 自己写数字转 char \[\] 的逻辑如果写烂了,照样分配:不能在写入函数里临时 new 数组、创建 string。必须纯原地写字符。

示例:原地把数字写入 char 数组(无分配)

复制代码
// 把数字写入buffer,返回有效字符长度,不产生任何GC
public static int WriteInt(int value, char[] buffer)
{
    if (value == 0)
    {
        buffer[0] = '0';
        return 1;
    }
    int idx = buffer.Length;
    while (value > 0)
    {
        idx--;
        buffer[idx] = (char)('0' + value % 10);
        value /= 10;
    }
    int len = buffer.Length - idx;
    // 可选:把字符挪到数组头部,方便SetText从0开始读
    Array.Copy(buffer, idx, buffer, 0, len);
    return len;
}

为什么普通 UGUI Text 做不到这点?

原生 UnityEngine.UI.Text 没有 SetText(char[], start, length) 重载,它只能接收 string。 原生 Text 只能传字符串,只要更新文本就绕不开 string,没法用 char \[\] 直接喂。这也是 TMPro 这个重载在游戏开发里做实时数值(血条、分数、FPS 计数器)优化非常香的原因。

额外小知识点:还有 SetText(NativeArray<char>)

新版本 TMPro 还支持 NativeArray<char>,适合和 Job/Burst 搭配,连托管 char \[\] 都不用,完全非托管内存,进一步优化,但普通 UI 计分场景 char \[\] 复用方案已经足够。

避免闭包和匿名方法

  • C# 的方法引用(委托)本身是引用类型 → 上堆。
  • 传方法引用当参数,不管传的是匿名方法还是已有方法,都可能产生临时分配。
  • 一旦匿名方法变成闭包,内存开销会显著变大。

关键对比:匿名方法 ≠ 闭包

复制代码
// 好:纯粹的匿名方法,不捕获外部变量
listOfNumbers.Sort((x, y) => (int)x.CompareTo((int)(y / 2)));

编译器会把这个 lambda 缓存成一个静态委托实例,Sort 不产生垃圾

复制代码
// 坏:捕获了局部变量 desiredDivisor → 变成闭包
int desiredDivisor = getDesiredDivisor();
listOfNumbers.Sort((x, y) => (int)x.CompareTo((int)(y / desiredDivisor)));

原因:lambda 要访问作用域外的变量,编译器必须:

  1. 生成一个匿名类(display class),把 desiredDivisor 变成它的字段;
  2. 每次执行到这行时 new 一个该类的实例;
  3. desiredDivisor 的当前值初始化它。

类都是引用类型​ → 托管堆分配 → GC。

结论:

  • 一次性初始化、偶尔调用的 lambda,随便用,不用焦虑;
  • Update / 每帧循环 / 高频路径里的 lambda,只要捕获了外部变量,就是每帧一个垃圾对象
  • 这类场景改成手写 for 循环,或把需要的状态封装成可复用的比较器实例。

避免值类型转引用类型 / 装箱(Boxing)

复制代码
int x = 1;
object y = new object();
y.Equals(x);   // ← x 被装箱了

int 是值类型,object.Equals(object) 要求参数必须是 object,于是 CLR 在堆上包一个盒子把 x 装进去。

**为什么这在 Unity 特别致命?**​ 一个很精彩的解释:

C# 的设计假设是:小临时分配由分代式 GC ​ 高效回收,所以编译器和 IDE 根本不会为装箱发警告

Unity 的 GC 不是分代式的(Boehm GC,非分代、非压缩),它扫不掉这种"小而频繁"的临时垃圾。

这是本段的技术核心:同一个写法,在普通 .NET 服务端可能是无害的,在 Unity 里却会积累成 GC 尖峰。

(补充一点背景:Unity 后来引入了 Incremental GC (增量式 GC),把一次长暂停摊到多帧,缓解了卡顿,但它并没有减少分配本身------所以"别装箱"这条建议依然成立。)

**如何定位装箱?**​ 文档给了两条路:

  • CPU Profiler / Deep Profile 里看调用栈,出现这类名字:

<类名>::Box(...)
Box(...)
<类名>_Box(...)

取决于脚本后端(Mono / IL2CPP)命名格式不同。

  • 反编译看 IL :用 ReSharper 内置 IL Viewer 或 dotPeek,搜 box 指令。

常见装箱雷区(实战高频):

  • string.Format / $"..." 插值里塞 intenumstruct
  • enum 当字典 Key(Dictionary<SomeEnum, T>EqualityComparer 旧版本会装箱);
  • ArrayListHashtable 等非泛型集合;
  • struct 实现接口后,用接口类型变量接收它。

避免使用 params 修饰符(Avoid the params modifier)

复制代码
void Log(string fmt, params object[] args);   // 每次调用都 new 一个 object[]
Log("a", 1, 2.0f);

编译器会在调用点隐式 new object[]{...} 来装参数。如果方法提供了不带 params 的重载 (比如 Log(string)Log(string, object)),优先用重载,就能避开这次数组分配。

顺带一提,params object[]双重打击:数组本身要分配,装箱进去的值类型还要再分配一次。

params 修饰符 让方法可以接受任意个数的同一类型实参,调用方不必手动创建数组。

对象池与对象复用

高频创建的对象该怎么复用?

别反复 new / Instantiate + Destroy,把用完的对象还回池子里下次接着用,从而砍掉分配与回收的开销,压住 GC。

对象池的本质

文档定义:对象池是一种把频繁使用的实例归还到池中、需要时再取出复用的编程模式。

它换来三件事:

收益 说明
降低反复实例化/销毁的开销 Instantiate 涉及反序列化、组件构造、Awake/OnEnable 链,远比"取出来 SetActive(true)"贵
限制分配与释放的总量 堆上对象数量趋于稳定,不再持续增长
减少 GC 与 CPU 压力 垃圾少了 → GC 触发频率下降 → 帧时间不再周期性抖动

关键认知 :对象池不是让分配变快,而是让分配不再发生。它把 N 次分配压缩成"首次预热 + 后续零分配"。

文档还特别指出能池化的不只是 GameObject,数组、List、Dictionary 这些集合类型同样可以池化------这一条最容易被忽略,但实操中收益极高。

UnityEngine.Pool 系列 API 不是线程安全的,只能在主线程调用。

ObjectPool<T>:Unity 官方池实现

命名空间 UnityEngine.Pool(Unity 2021+ 提供)。核心类 ObjectPool<T>,T 就是你要池化的对象类型。

new ObjectPool<T>(
createFunc, // 1. 池空时如何造新的
actionOnGet, // 2. 取出时回调(激活/初始化)
actionOnRelease, // 3. 归还时回调(失活/重置)
actionOnDestroy, // 4. 池满时归还 → 销毁
collectionCheck, // 5. 是否做重复归还检查
defaultCapacity, // 6. 初始容量
maxSize // 7. 最大容量
);

取出:Get()

复制代码
var bullet = objectPool.Get();          // 常规
objectPool.Get(out PooledObject<T> p);  // 重载:p 被 dispose 时自动归还

Get(out PooledObject<T>) 这个重载值得记住------PooledObject<T> 实现了 IDisposable,配合 using 可以在作用域结束时自动归还,适合短生命周期场景,能避免"忘记 Release"这个最常见的池化 bug。

归还:Release()

  • 池没满 → 触发 actionOnRelease,对象回池等待复用;
  • 池已满 → 触发 actionOnDestroy 直接销毁 。这点很重要:池不是无限容器,超出 maxSize 的部分会被真的 Destroy,所以 maxSize 本质上是内存上限保护

安全检查:collectionCheck

true 时,如果试图归还一个已经在池里的对象(double-release),Unity 会抛异常。

只在编辑器生效,真机上没有。它有额外开销,因此建议开发期开、发布时按需关。这也意味着------真机上重复归还不会报错,但会静默污染池(同一个对象被取出两次),务必在开发期把这开关打开跑一遍。

状态重置:对象池最容易翻车的地方

这是全文技术含量最高、也最容易被低估的一节。

池化对象在使用中会累积状态:位置、血量、动画状态、物理速度、协程、事件订阅......如果不重置,下次取出来就是"带着上辈子的记忆重生"。

文档给出的重置时机策略:

时机 适合重置什么
actionOnGet 每次使用都要设的值(位置、朝向、速度、血量)
actionOnRelease 收尾清理(停协程、退订事件、清物理速度、停粒子、停动画)
对象自身的 Deactivate() 复杂对象自己封装失活逻辑,由上面两个回调调用

文档列举的 release 时常见清理项,堪称一份 checklist:

  • 停止协程(最高频的泄漏源
  • 退订事件(OnEnable+= 的,release 时必须 -=
  • 重置物理状态(linearVelocity / angularVelocity 归零,否则下次取出来带着旧速度飞走)
  • 清理动画状态
  • 停止粒子系统
  • SetActive(false) ------ 不关掉的话,池里的对象仍在接收 Update 调用、

子弹示例

cs 复制代码
using UnityEngine;
using UnityEngine.Pool;
using UnityEngine.Events;

public class RevisedGun : MonoBehaviour
{
    [Tooltip("要发射的子弹预制体")]
    [SerializeField] private RevisedProjectile projectilePrefab;

    [Tooltip("子弹初速度")]
    [SerializeField] private float muzzleVelocity = 1500f;

    [Tooltip("枪口位置,子弹从此处生成射出")]
    [SerializeField] private Transform muzzlePosition;

    [Tooltip("射击冷却间隔,数值越小射速越高")]
    [SerializeField] private float cooldownWindow = 0.1f;

    [SerializeField] private UnityEvent m_GunFired; // 开枪事件,可在Inspector绑定音效、特效等

    // Unity2021及以上版本提供的基于栈实现的对象池
    private IObjectPool<RevisedProjectile> objectPool;

    [Tooltip("安全校验:如果归还一个已经在池内的物体,是否抛出异常,用于排查逻辑Bug")]
    [SerializeField] private bool collectionCheck = true;

    [Tooltip("对象池初始容量,预先实例化这么多个子弹")]
    [SerializeField] private int defaultCapacity = 20;

    [Tooltip("对象池最大容量;超出上限时新生成的子弹不会进池,直接销毁")]
    [SerializeField] private int maxSize = 100;

    private float nextTimeToShoot; // 记录下一次允许射击的时间点

    private void Awake()
    {
        // 初始化对象池,传入4个回调 + 配置参数
        objectPool = new ObjectPool<RevisedProjectile>(
            CreateProjectile,        // 创建新物体回调
            OnGetFromPool,           // 从池中取出物体回调
            OnReleaseToPool,         // 物体归还池子回调
            OnDestroyPooledObject,   // 销毁池内物体回调
            collectionCheck,
            defaultCapacity,
            maxSize);
    }
    /// <summary>
    /// 对象池内部调用:池子需要新物体时,创建子弹实例
    /// </summary>
    private RevisedProjectile CreateProjectile()
    {
        RevisedProjectile projectileInstance = Instantiate(projectilePrefab);
        // 给子弹脚本传入自身所属对象池引用,子弹才能调用Release归还自己
        projectileInstance.ObjectPool = objectPool;
        return projectileInstance;
    }
    /// <summary>
    /// 物体归还到对象池时执行:直接关闭GameObject,不Destroy
    /// </summary>
    private void OnReleaseToPool(RevisedProjectile pooledObject)
    {
        pooledObject.gameObject.SetActive(false);
    }

    /// <summary>
    /// 从对象池取出物体时执行:激活物体
    /// </summary>
    private void OnGetFromPool(RevisedProjectile pooledObject)
    {
        pooledObject.gameObject.SetActive(true);
    }
    /// <summary>
    /// 对象池已满(达到maxSize上限),多余物体直接销毁
    /// </summary>
    private void OnDestroyPooledObject(RevisedProjectile pooledObject)
    {
        Destroy(pooledObject.gameObject);
    }
    private void FixedUpdate()
    {
        // 按住开火键、已经过冷却时间、对象池有效,才允许射击
        if (Input.GetButton("Fire1") && Time.time > nextTimeToShoot && objectPool != null)
        {
            Shoot();
        }
    }
    private void Shoot()
    {
        // 从对象池获取子弹,替代 Instantiate
        RevisedProjectile bulletObject = objectPool.Get();
        if (bulletObject == null)
            return;

        // 将子弹位置旋转对齐枪口
        bulletObject.transform.SetPositionAndRotation(muzzlePosition.position, muzzlePosition.rotation);

        // 给子弹刚体施加向前的力
        bulletObject.GetComponent<Rigidbody>().AddForce(bulletObject.transform.forward * muzzleVelocity, ForceMode.Acceleration);

        // 启动子弹延时回收协程,超时后归还对象池
        bulletObject.Deactivate();

        // 更新下次可射击时间
        nextTimeToShoot = Time.time + cooldownWindow;

        // 触发开枪事件(音效、枪口火花等外部逻辑挂这里)
        m_GunFired.Invoke();
    }
}
cs 复制代码
using System.Collections;
using UnityEngine;
using UnityEngine.Pool;

public class RevisedProjectile : MonoBehaviour
{
    [SerializeField]private float timeoutDelay = 3f;
    private IObjectPool<RevisedProjectile> objectPool;
    public IObjectPool<RevisedProjectile> ObjectPool { set => objectPool = value; }
    /// <summary>
    /// 发起延时回收流程,外部调用这个方法
    /// </summary>
    public void Deactivate()
    {
        StartCoroutine(DeactivateRoutine(timeoutDelay));
    }
    IEnumerator DeactivateRoutine(float delay)
    {
        yield return new WaitForSeconds(delay);
        Rigidbody rBody = GetComponent<Rigidbody>();
        rBody.angularVelocity = new Vector3(0f, 0f, 0f);
        // 将自身释放归还到对象池,而不是Destroy(gameObject)
        objectPool.Release(this);
    }
}

逐段拆解

文档给了一套完整代码:RevisedProjectile(子弹自身)+ RevisedGun(炮台/池管理者)。

子弹侧关键点

复制代码
private IObjectPool<RevisedProjectile> objectPool;
public IObjectPool<RevisedProjectile> ObjectPool { set => objectPool = value; }

这是一个非常漂亮的技巧 :让池化对象持有自己所属池的引用 ,于是它能"自杀式归还"(objectPool.Release(this)),而不需要外部管理者知道它什么时候该回收。

字段类型用 IObjectPool<T> 而非 ObjectPool<T>,是面向接口编程------方便替换实现或做单元测试 mock。

DeactivateRoutine 里做的正是上一节说的物理重置:

复制代码
rBody.linearVelocity = Vector3.zero;
rBody.angularVelocity = Vector3.zero;
objectPool.Release(this);

注:linearVelocity 是 Unity 6 的新命名(旧版叫 velocity)。如果你在 2022 LTS 或更早版本,要改回 velocity,否则编译不过。

炮台侧关键点

复制代码
objectPool = new ObjectPool<RevisedProjectile>(
    CreateProjectile, OnGetFromPool, OnReleaseToPool, OnDestroyPooledObject,
    collectionCheck, defaultCapacity, maxSize);

四个回调职责极其清晰:

CreateProjectile() → Instantiate(prefab) + 把池引用塞给它
OnGetFromPool(obj) → SetActive(true)
OnReleaseToPool(obj) → SetActive(false)
OnDestroyPooledObject() → Destroy(gameObject)

Shoot() 的流程是标准范式:

Get() → 设置位置/朝向 → 施加力 → 启动超时回收 → 冷却计时

场景卸载时的池化处理

默认行为 :对象池绑定在创建它的场景上。场景卸载时,Unity 会销毁该场景所有 GameObject,包括池本身和所有池化实例。

于是文档给了四条对策:

问题 对策
想让池跨场景存活 把池管理器挂在调用 DontDestroyOnLoad 的根物体下
池化实例会被场景连带销毁 归还时 transform.SetParent 重新挂回池根节点
卸载时仍有"活跃"实例在外 OnDisable / OnDestroy 里自我归还,或注册 SceneManager.activeSceneChanged 回调统一回收
关卡之间要清干净 ObjectPool<T>.Clear() 销毁所有非活跃实例

额外原则:不要长期持有非活跃池化实例的引用------既阻碍回收又可能误操作

集合类型的池化

思路一:提升 + Clear 复用

文档先给了一个反例------每帧 new 一个 List:

复制代码
// Bad:每帧分配一个新 List
void Update() {
    List<float> nearestNeighbors = new List<float>();
    findDistancesToNearestNeighbors(nearestNeighbors);
    nearestNeighbors.Sort();
}

改成成员变量复用:

复制代码
// Good:每帧复用同一个 List
List<float> m_NearestNeighbors = new List<float>();

void Update() {
    m_NearestNeighbors.Clear();      // 清内容,不清容量
    findDistancesToNearestNeighbors(m_NearestNeighbors);
    m_NearestNeighbors.Sort();
}

核心原理:Clear() 只移除元素,不释放已分配的内存(Capacity 保留)。所以只有当元素数量超过历史峰值时才会扩容,稳态下零分配。

思路二:官方集合池

UnityEngine.Pool 直接提供了现成的集合池,用法与 ObjectPool<T> 同构(Get / Release):

池类 对应集合
ListPool<T> List<T>
HashSetPool<T> HashSet<T>
DictionaryPool<TKey, TValue> Dictionary<TKey,TValue>
CollectionPool<TCollection, TItem> 通用基类

典型写法:

cs 复制代码
var list = ListPool<float>.Get();
try 
{
    // 使用 list
} 
finally 
{
    ListPool<float>.Release(list);
}

务必记得 Release,否则池化就退化成了"更慢的 new"------忘记归还的对象既不会被复用,也占着池的账。

对象池的边界:什么时候不该用

  1. 低频创建的对象没必要池化------池化本身引入了状态管理复杂度,得不偿失。典型适用对象是子弹、粒子特效、伤害数字、UI 列表项、寻路结果 List。
  2. 对象池不降低峰值内存 ,恰恰相反,它会让内存常驻。这是"用空间换 GC 平稳"的交易。
  3. SetActive(true) 并非零成本 ------它触发 OnEnable、重注册变换层级。池化收益远大于这点开销时才划算。
  4. 池化不解决所有分配------上一节的装箱、字符串拼接、闭包,得靠那节的手段解决,池化救不了。
  5. 预热(warm-up) :文档说 defaultCapacity 是初始容量,但真正的"开局 Instantiate 一批"需要在 Awake 里手动 Get 一堆再 Release 回去,否则第一次战斗高峰仍会触发批量创建。

数组优化

数组这种"体积大、频率高、且 Unity API 里到处都是"的对象,具体怎么优化?

把数组作为参数传给方法(Pass arrays as parameters)

问题写法

复制代码
float[] RandomList(int numElements) {
    var result = new float[numElements];   // 每次调用都 new
    for (int i = 0; i < numElements; i++)
        result[i] = Random.value;
    return result;
}

一个方法"内部造数组 → 填值 → 返回",写起来很顺手。但每调用一次就分配一次 ,在 Update 里就是每帧一个数组。

改进写法

复制代码
void RandomList(float[] arrayToFill) {
    for (int i = 0; i < arrayToFill.Length; i++)
        arrayToFill[i] = Random.value;
}

原理 :数组是引用类型,方法内部对形参数组的修改,会直接反映到调用方的那个数组上。

代价是分配责任转移给了调用方------但调用方可以把数组提升为成员变量缓存复用 ,于是稳态下零分配

这其实就是上文的 对象池 / List.Clear() 复用 思想在数组上的直接应用:把"每次 new"变成"一次 new,反复填"

避免重复访问"返回数组的 Unity API"

所有返回数组的 Unity API,每次访问都会创建一份新的数组副本。

mesh.vertices 看起来像个字段,实际是个属性(property),getter 内部会复制整个顶点数组再返回。

反例剖析------为什么是"每轮循环 4 份副本":

复制代码
for (int i = 0; i < mesh.vertices.Length; i++) {   // 第 1 次访问
    x = mesh.vertices[i].x;                        // 第 2 次
    y = mesh.vertices[i].y;                        // 第 3 次
    z = mesh.vertices[i].z;                        // 第 4 次
}

到底发生了什么?

i < mesh.vertices.Length
// → 调用get,完整拷贝整个顶点数组 → 拿Length,数组直接变成垃圾GC

x = mesh.verticesi.x
// → 再调用get,再完整拷贝一份顶点数组,取第i个的x,数组丢弃

y = mesh.verticesi.y
// → 又拷贝一份完整数组!

z = mesh.verticesi.z
// → 又拷贝一份完整数组!

循环每一轮迭代,执行 4 次 mesh.vertices get,每一次都复制整个顶点缓冲区,生成全新托管数组,循环跑完产生大量 GC 垃圾,性能灾难。

不是只读取元素!属性 getter 内部做了完整内存拷贝 。 Unity 很多原生组件属性都是这个行为:mesh.normalsmesh.uvmesh.triangles全部同理。

一个 1 万顶点的网格,每帧循环就是 4 万次顶点复制 + 4 万个临时数组 。这不是"小垃圾",这是每帧几十 MB 级的堆压力

Better:提到循环外,只取一次

cs 复制代码
var vertices = mesh.vertices;   
// 【仅1次】调用getter,拷贝顶点数组,得到一份本地副本数组引用

for (int i = 0; i < vertices.Length; i++)
{
    var v = vertices[i];
    x = v.x;
    y = v.y;
    z = v.z;
}
  • 只做1 次完整数组拷贝
  • 循环内部访问的是 C# 本地数组vertices,普通数组直接内存寻址,没有任何拷贝、没有 GC 分配;
  • vertices[i] 就是普通数组索引,开销极低。

Unity Mesh 数组属性:get = 拷贝副本,不是内部原生数组引用。循环前一定要缓存到局部变量。

Best :跨帧缓存 + GetVertices 填充

cs 复制代码
List<Vector3> m_vertices = new List<Vector3>();
void Update()
{
    mesh.GetVertices(m_vertices);   //复用已有List
    // 直接用 m_vertices[i] 读取数据
}

这就是上一节"集合复用"的思路:GetVertices(List<Vector3>)无分配版本------它往你给的容器里写,内部保留 Capacity,稳态零分配。

  • mesh.verticesGet‑Accessor 返回全新 Vector3[] 数组 ,每次调用都 new 托管数组 → 产生 GC 垃圾。用完就丢,Update 每帧调用就每帧堆分配。
  • mesh.GetVertices(List<T> list)不新建集合,把网格顶点数据拷贝到你传入的已有 List 内部缓冲区。

GetVertices 内部行为细节

  1. 如果传入的 list.Capacity ≥ 需要的元素数量:
    • 不会分配新内存 ,复用 List 内部已有的 _items char/Vector3 \[\] 缓冲区;
    • 先执行 list.Clear()(只修改Size不释放内部数组);
    • 引擎把顶点数据拷贝进 List 的内部缓冲区;
    • 设置 list.Count = 顶点数量。✅零 GC 分配
  2. 如果 Capacity 不足:
    • List 会自动扩容,新建更大的内部数组,旧数组变成垃圾(仅此一次分配;扩容之后后续不再分配)。

最佳实践:初始化时直接预分配足够容量 m_vertices = new List<Vector3>(maxVertexCount);,彻底消除运行时扩容分配。

Input.touches 同理(移动端高频雷区):

复制代码
// Bad:循环条件每轮都访问一次 touches → 每轮一份数组
for (int i = 0; i < Input.touches.Length; i++) {
    Touch touch = Input.touches[i];   // 又一次复制
}

// Better:提出来,只访问一次
Touch[] touches = Input.touches;

// Best:完全无分配
int touchCount = Input.touchCount;              // 不分配
for (int i = 0; i < touchCount; i++) {
    Touch touch = Input.GetTouch(i);            // 不分配
}

文档最后那句 Note 很细致:

Input.touchCount 的访问也要放在循环外,省掉反复调用 property getter 的 CPU 开销。

注意这里区分了两种成本:

  • 分配成本 (堆内存 / GC)→ 靠 GetTouch 消除;
  • CPU 调用成本(property getter)→ 靠"提出循环"消除。

很多人只优化前者,忘了后者。

数据拷贝依然存在 !三种方案都免不了 CPU 内存拷贝(引擎非托管→C# 托管内存);GetVertices优化的是托管堆对象分配 GC,不能消除内存拷贝开销。拷贝耗时还在,只是没有垃圾产生,不会触发 GC 停顿。

思考:

mesh.GetVertices(m_vertices) + 预先设置足够 Capacity,托管0分配为什么?

全程不会 new 任何托管数组 / 对象,所以没有托管堆分配、不产生 GC 垃圾。

1. List<T> 的底层结构

List<T> 本身只是一个包装类,内部持有一个 T[] _items 数组(托管数组):

  • Capacity_items 数组的总长度(底层缓冲区大小)
  • Count:当前 List 里有效元素数量
  • Clear():只把 Count = 0不会销毁、不会重新 new _items数组,底层内存保留。

2. mesh.GetVertices (list) 内部执行逻辑(Unity 源码行为)

cs 复制代码
//伪代码
void GetVertices(List<Vector3> list)
{
    list.Clear();  // Count置0,底层_items数组不变
    int vertexCount = mesh.vertexCount;

    // 判断:底层数组容量够不够放下全部顶点
    if (list.Capacity < vertexCount)
    {
        // List自动扩容:new更大数组,拷贝旧元素,旧数组变成 垃圾这里会分配一次
        list.Capacity = vertexCount;
    }

    // 引擎C++层:把Mesh非托管顶点内存直接拷贝进 list._items 这个已有数组
    CopyMeshVertexDataToManagedBuffer(list._items, vertexCount);

    list.Count = vertexCount; // 设置有效元素数量
}

当你提前预分配足够 Capacity:

复制代码
// 初始化一次性分配,只跑1次
List<Vector3> m_vertices = new List<Vector3>(maxVertexCount);

此时:

  1. list.Clear():仅修改 Count,不碰底层_items数组
  2. Capacity >= vertexCount不会触发 List 扩容,不会 new 新 Vector3 \[\]
  3. 引擎直接把顶点数据拷贝进已经存在的_items数组(原地覆写数组元素,数组引用不变)
  4. 修改list.Count,完事

整个GetVertices调用期间:没有 new Vector3[],没有 new 任何托管对象 。 所以叫:0 托管分配

3.和 mesh.vertices 做对比,理解差距

Vector3[] arr = mesh.vertices

  • getter 内部:每次调用直接 new Vector3 \[\],把顶点拷贝进这个全新数组,返回给你。
  • 每帧调用就每帧新建数组,用完丢弃,Profiler 持续看到 GC Alloc。

mesh.GetVertices(m_vertices)(Capacity 充足)

  • 底层Vector3[]只在初始化时 new 一次;后续反复覆写里面元素,数组实例一直复用。
  • 没有新建托管数组,所以没有 GC 分配。

4. 两个极易混淆的关键点

① "0 托管分配" ≠ 没有内存拷贝

不管哪种 API,都要把引擎 C++ 里的网格数据拷贝到 C# 托管内存 。 这个是 CPU 内存拷贝,不是托管堆分配,不会产生 GC 垃圾。

GC 只关心:有没有新建托管对象。单纯往已有数组覆写元素,不算分配。

② 只有 Capacity 不足的时候才会分配

如果网格顶点数量偶尔超过 List 的 Capacity:

  • List 自动扩容 → new 更大的Vector3[],旧数组变成垃圾(这一次会产生 GC
  • 扩容完成后,后面只要顶点数不再突破新 Capacity,又回到 0 分配。

所以最佳实践:初始化 Capacity = 你的网格最大顶点数量,彻底规避扩容分配。

5. 补充:为什么 Unity 要提供这个重载?

.vertices属性的设计缺陷:每次返回全新数组,高频读取 GC 爆炸。 GetVertices(List<T>) 专门用来把缓冲区控制权交给开发者:由你预先创建一块托管缓冲区反复复用,不再由 API 偷偷帮你 new 数组。

无分配替代 API(Alternative non-allocating APIs)

文档给了一张对照表,这是实战中最该背下来的部分:

会分配的 API 无分配替代
Physics.RaycastAll Physics.RaycastNonAlloc
Animator.parameters Animator.parameterCount + Animator.GetParameter
Renderer.sharedMaterials Renderer.GetSharedMaterials

通用规律(文档那句金句):

如果一个方法返回数组,通常就存在一个让你"传入数组"的无分配版本。

即命名上往往带 NonAllocGet...,签名特征是接受一个数组/List 作为输出参数

三个典型用法补充:

// 射线:预分配 RaycastHit 数组,复用
private readonly RaycastHit\[\] _hits = new RaycastHit32;
int count = Physics.RaycastNonAlloc(origin, dir, _hits, maxDist);
for (int i = 0; i < count; i++) { /* 只有前 count 个有效 */ }

// Animator:按索引取,不生成数组
for (int i = 0; i < animator.parameterCount; i++)
var p = animator.GetParameter(i);

// Renderer:填入已有 List
List<Material> mats = new List<Material>();
renderer.GetSharedMaterials(mats);

特别注意 RaycastNonAlloc 的返回值语义 :返回的是实际命中数量 ,不是数组长度。必须用它作为循环上界,否则会遍历到上一次残留的旧数据------这是极高频的 bug。

零长度数组用静态实例

背景 :有些团队约定"方法返回空集合时,返回空数组而不是 null",避免调用方到处判空。

但如果每次都 return new T[0];,就又产生了分配(虽然是 0 长度,对象头照样占堆)。

正确做法

复制代码
private static readonly float[] EmptyArray = new float[0];
// 或直接用 BCL 提供的全局单例
private static readonly float[] EmptyArray = Array.Empty<float>();

.NET 的 Array.Empty<T>() 会为每种类型缓存一个全局共享的零长度数组实例,天然就是最优解,优先用它。

这条收益量级很小,但成本也几乎为零------属于顺手就做对的 hygiene 级优化。

大数组用 Native 容器

这是原理最深的部分,讲的是托管堆的结构性问题。

问题根源 :Unity 的 GC 是保守式(conservative)/ 启发式 垃圾回收器(Boehm 风格),它无法准确区分"一个指针大小的字段到底是不是指针"------于是它把任何看起来像指针的值都当成指针去追踪。

当一个数组超过约 1 万个元素时,会引发三重问题:

1. 扫描开销爆炸

GC 会把这个大数组当成"一大串待检查的指针"逐个检查,扫描时间随元素数量线性增长 → GC 变慢、卡顿变长。

2. 误判保留(false positive)

数组越大、占的堆地址区间越宽,某个随机的整数值碰巧落在"看起来像有效堆地址"范围内的概率就越高 。一旦误判,GC 就认为"这块内存还被引用着"→ 本该回收的内存被永久占用,这是 Unity 项目内存莫名增长却查不到引用的经典原因之一。

3. 堆碎片化

托管 VM 必须为这些大数组分配大块连续堆空间。反复分配/释放不同尺寸的大块 → 托管堆碎片化 → 后续即使总空闲内存够,也可能因为找不到连续空间而被迫扩容。

解决方案 :改用 Unity.Collections 命名空间下的原生容器,文档明确点名 NativeArray

复制代码
using Unity.Collections;
NativeArray<float> data = new NativeArray<float>(100000, Allocator.Persistent);
// ... 使用 ...
data.Dispose();   // 必须手动释放

三重收益

收益 说明
绕开托管堆 分配在非托管内存,GC 完全不扫描它​ → 消除上面三个问题
消除碎片 由原生分配器管理
打通 DOTS Job System ​ 和 Burst 编译器兼容,可多线程 + 向量化加速

使用要点

  • 必须手动 Dispose() ,否则是真·内存泄漏(没有 GC 兜底)。常用 Allocator.Temp(一帧内)、TempJob(4 帧内)、Persistent(长期)。
  • 安全检查:编辑器/开发版会检测数据竞争、越界、use-after-dispose;发布版关闭以提速。
  • 只能存非托管类型(unmanaged / blittable) ,不能放 string、普通类对象。
  • 配合 IJob / IJobParallelFor + [BurstCompile] 才能发挥最大价值。
相关推荐
humors2211 小时前
支付宝游戏灵画师简易步骤整理
游戏·技巧·手游·攻略·灵画师
河南花仙子科技1 小时前
企业定制小游戏助力品牌软性传播
大数据·科技·游戏·小程序
liulilittle5 小时前
为什么采用全局管理缓存及状态:麻将客户端状态管理
服务器·网络·游戏·客户端·异步·mahjong·麻将
狂人开飞机8 小时前
21、游戏架构设计模式
游戏·游戏引擎·godot
bj_bluewei_tech1 天前
拯救者游戏本维修卡 logo 无限重启,确认显卡虚焊故障
游戏·电脑
新的瑞拉公主1 天前
Unity游戏发布微信小游戏:从构建到提审
unity·webgl·微信小游戏·开放数据域
Jae den2 天前
网络延迟为什么会影响游戏和实时业务?
网络·游戏
思盛iOS签名上架2 天前
ipa企业签名闪退是什么原因?
游戏·ios·testfight
狂人开飞机2 天前
14、游戏手感与视觉打磨
游戏·游戏引擎·godot