C#的自动内存管理相比于**C++**等其他编程语言,减少了内存泄漏和其他编程错误的风险,后者需要手动追踪和释放所有分配的内存。
目录
[① 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 官方池实现)
[思路一:提升 + 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_Text 有 SetText(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());
int.ToString():新建一个string对象,string 内部就是 char \[\],托管堆分配。SetText(string)内部:读取 string 的 char buffer,拷贝到 TMPro 的内部字符缓冲区。- 这个临时 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[],复用同一块数组内存 - 没有装箱、没有值类型转引用类型的分配
但是有两个容易被忽略的 "不是零分配" 的坑,很多人踩:
- TMPro 内部本身会有缓冲区内存 :TMPro 会维护自己渲染用的字符网格数据(
TMP_MeshInfo等)。- 如果文本长度变化很大,TMPro 内部缓冲区扩容时依然会产生一次堆分配;
- 固定长度数字(比如分数永远最多 6 位),缓冲区稳定后就不再扩容,后续就真的没有托管 GC 分配。
- 自己写数字转 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 要访问作用域外的变量,编译器必须:
- 生成一个匿名类(display class),把
desiredDivisor变成它的字段; - 每次执行到这行时
new一个该类的实例; - 用
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/$"..."插值里塞int、enum、struct;- 把
enum当字典 Key(Dictionary<SomeEnum, T>的EqualityComparer旧版本会装箱); ArrayList、Hashtable等非泛型集合;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"------忘记归还的对象既不会被复用,也占着池的账。
对象池的边界:什么时候不该用
- 低频创建的对象没必要池化------池化本身引入了状态管理复杂度,得不偿失。典型适用对象是子弹、粒子特效、伤害数字、UI 列表项、寻路结果 List。
- 对象池不降低峰值内存 ,恰恰相反,它会让内存常驻。这是"用空间换 GC 平稳"的交易。
SetActive(true)并非零成本 ------它触发OnEnable、重注册变换层级。池化收益远大于这点开销时才划算。- 池化不解决所有分配------上一节的装箱、字符串拼接、闭包,得靠那节的手段解决,池化救不了。
- 预热(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,数组直接变成垃圾GCx = mesh.verticesi.x
// → 再调用get,再完整拷贝一份顶点数组,取第i个的x,数组丢弃y = mesh.verticesi.y
// → 又拷贝一份完整数组!z = mesh.verticesi.z
// → 又拷贝一份完整数组!循环每一轮迭代,执行 4 次
mesh.verticesget,每一次都复制整个顶点缓冲区,生成全新托管数组,循环跑完产生大量 GC 垃圾,性能灾难。不是只读取元素!属性 getter 内部做了完整内存拷贝 。 Unity 很多原生组件属性都是这个行为:
mesh.normals、mesh.uv、mesh.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.vertices:Get‑Accessor 返回全新Vector3[]数组 ,每次调用都new托管数组 → 产生 GC 垃圾。用完就丢,Update 每帧调用就每帧堆分配。mesh.GetVertices(List<T> list):不新建集合,把网格顶点数据拷贝到你传入的已有 List 内部缓冲区。
GetVertices 内部行为细节
- 如果传入的
list.Capacity≥ 需要的元素数量:- 不会分配新内存 ,复用 List 内部已有的
_itemschar/Vector3 \[\] 缓冲区; - 先执行
list.Clear()(只修改Size,不释放内部数组); - 引擎把顶点数据拷贝进 List 的内部缓冲区;
- 设置
list.Count = 顶点数量。✅零 GC 分配
- 不会分配新内存 ,复用 List 内部已有的
- 如果
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);此时:
list.Clear():仅修改 Count,不碰底层_items数组Capacity >= vertexCount→ 不会触发 List 扩容,不会 new 新 Vector3 \[\]- 引擎直接把顶点数据拷贝进已经存在的
_items数组(原地覆写数组元素,数组引用不变)- 修改
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 |
通用规律(文档那句金句):
如果一个方法返回数组,通常就存在一个让你"传入数组"的无分配版本。
即命名上往往带 NonAlloc 或 Get...,签名特征是接受一个数组/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]才能发挥最大价值。