目录
一、减少临时内存分配(Temporary Allocations)
二、使用可复用对象池(Reusable Object Pools)
三、避免重复字符串拼接(Repeated String Concatenation)
四、优化 “返回数组的方法”(Method Returning an Array Value)
五、集合与数组复用(Collection and Array Reuse)
六、慎用闭包与匿名方法(Closures and Anonymous Methods)
八、优化 Unity 数组类 API(Array-valued Unity APIs)
前言
Unity 的自动内存管理(GC)虽降低了内存泄漏风险,简化了开发流程,但 GC 运行时的 CPU 开销可能成为性能瓶颈 —— 尤其是每帧频繁触发 GC 时,极易导致帧率波动。想要优化 GC 性能,核心思路是减少不必要的托管堆分配,从根源避免 GC 频繁执行。
本文将围绕 9 个高频 GC 问题场景,结合代码示例提供可直接落地的优化方案,覆盖临时分配、对象复用、API 选择等关键环节,帮助开发者将每帧 GC 分配控制在接近 0 字节的理想状态。
一、减少临时内存分配(Temporary Allocations)
每帧在托管堆上分配临时数据,是导致 GC 频繁触发的首要原因。这类分配看似单次量小,但累积效应会显著增加 GC 负担。
1. 临时分配的危害
以 60 帧 / 秒的应用为例:
- 若每帧分配 1KB 临时内存,每秒将产生 60KB 分配,1 分钟累积达 3.6MB;
- 若 GC 每秒触发 1 次,会造成频繁卡顿;若 1 分钟触发 1 次,则需清理数千个分散的小对象,单次 GC 耗时激增;
- 资源加载时若生成大量临时对象,托管堆会被迫扩展,即使后续对象被释放,堆内存也难以及时回收。
2. 优化目标与原则
- 核心目标:将每帧托管堆分配量降至0 字节或接近 0 字节;
- 关键原则:避免在
Update、FixedUpdate等高频调用方法中创建新引用类型(如new List<>()、new Class())。
二、使用可复用对象池(Reusable Object Pools)
对于频繁创建 / 销毁的对象(如子弹、敌人、UI 弹窗),直接复用已有对象而非反复新建,可彻底消除这类对象的 GC 分配。
1. 对象池核心逻辑
以 “子弹(Projectile)” 为例:
- 预分配:关卡加载时,根据 “同时存在的最大子弹数” 预实例化一批子弹,初始设为非激活状态;
- 复用激活:发射子弹时,从池中找到第一个非激活对象,重置位置后激活;
- 回收休眠:子弹销毁时(如命中目标),仅设为非激活状态,不销毁对象,放回池中等待下次复用。
2. 基础对象池实现代码
Unity 提供ObjectPool类(需导入UnityEngine.Pool命名空间),若使用旧版本 Unity,可参考以下自定义栈式对象池:
csharp
using System.Collections.Generic;
using UnityEngine;
public class ExampleObjectPool : MonoBehaviour
{
[Header("对象池配置")]
public GameObject PrefabToPool; // 待池化的预制体
public int MaxPoolSize = 10; // 池最大容量
private Stack<GameObject> _inactiveObjects = new Stack<GameObject>(); // 存储非激活对象的栈
void Start()
{
// 预实例化对象并加入池
if (PrefabToPool != null)
{
for (int i = 0; i < MaxPoolSize; i++)
{
GameObject newObj = Instantiate(PrefabToPool);
newObj.SetActive(false); // 初始非激活
_inactiveObjects.Push(newObj);
}
}
}
/// <summary>
/// 从池中获取对象
/// </summary>
public GameObject GetObjectFromPool()
{
while (_inactiveObjects.Count > 0)
{
GameObject obj = _inactiveObjects.Pop();
if (obj != null)
{
obj.SetActive(true);
return obj;
}
// 若池中有null对象(可能被外部销毁),打印警告
Debug.LogWarning("对象池中存在null对象,是否被外部代码销毁?");
}
// 池已满且无可用对象时打印错误
Debug.LogError("所有池化对象均在使用中或已被销毁");
return null;
}
/// <summary>
/// 将对象放回池中
/// </summary>
public void ReturnObjectToPool(GameObject objectToDeactivate)
{
if (objectToDeactivate != null)
{
objectToDeactivate.SetActive(false);
_inactiveObjects.Push(objectToDeactivate);
}
}
}
三、避免重复字符串拼接(Repeated String Concatenation)
C# 字符串是不可变引用类型—— 任何修改(如拼接、替换)都会创建新字符串,导致大量临时分配。需通过StringBuilder或逻辑优化减少分配。
1. 问题示例:循环拼接的危害
以下代码每循环一次就创建一个新字符串,若输入数组有 5 个元素,会生成 4 个临时字符串(“A”“AB”“ABC”“ABCD”):
csharp
// 糟糕的C#脚本示例:重复拼接产生大量临时字符串
using UnityEngine;
public class ExampleScript : MonoBehaviour
{
// 拼接字符串数组
string ConcatExample(string[] stringArray)
{
string result = "";
for (int i = 0; i < stringArray.Length; i++)
{
result += stringArray[i]; // 每次 += 都创建新字符串
}
return result;
}
}
2. 优化方案 1:使用StringBuilder
StringBuilder通过预分配缓冲区减少临时分配,仅在最终调用ToString()时创建一个字符串:
csharp
// 优秀的C#脚本示例:StringBuilder避免临时分配
using UnityEngine;
using System.Text;
public class ExampleScript : MonoBehaviour
{
// 将StringBuilder作为类成员变量,避免每帧新建
private StringBuilder _sb = new StringBuilder(16); // 初始容量16,可根据需求调整
string ConcatExample(string[] stringArray)
{
_sb.Clear(); // 清空内容,复用缓冲区
for (int i = 0; i < stringArray.Length; i++)
{
_sb.Append(stringArray[i]); // 仅操作缓冲区,不创建新字符串
}
return _sb.ToString(); // 仅此处创建最终字符串
}
}
3. 优化方案 2:减少不必要的字符串更新
如 UI 分数显示,无需每帧更新,仅在分数变化时更新:
csharp
// 最佳的C#脚本示例:分数变化时才更新字符串
using UnityEngine;
using UnityEngine.UI;
public class ScoreDisplay : MonoBehaviour
{
public Text scoreBoardTitle; // 显示“Score: ”的文本框
public Text scoreBoardDisplay; // 显示分数数字的文本框
public int score;
private int _oldScore; // 记录上一帧分数
void Start()
{
// 仅初始化时设置一次标题
scoreBoardTitle.text = "Score: ";
}
void Update()
{
// 仅当分数变化时才更新
if (score != _oldScore)
{
scoreBoardDisplay.text = score.ToString(); // 仅此处产生分配
_oldScore = score;
}
}
}
四、优化 “返回数组的方法”(Method Returning an Array Value)
若方法频繁被调用且每次返回新数组,会导致大量重复分配。可改为 “传入数组参数并修改”,复用已有数组。
1. 问题示例:每次调用创建新数组
csharp
// 糟糕的C#脚本示例:每次调用都新建数组
using UnityEngine;
public class ExampleScript : MonoBehaviour
{
// 生成包含随机数的数组,每次调用分配新内存
float[] RandomList(int numElements)
{
float[] result = new float[numElements]; // 每次调用新建数组
for (int i = 0; i < numElements; i++)
{
result[i] = Random.value;
}
return result;
}
}
2. 优化方案:传入数组并复用
csharp
// 优秀的C#脚本示例:传入数组并修改,无新分配
using UnityEngine;
public class ExampleScript : MonoBehaviour
{
// 接收外部传入的数组,修改其内容(无新分配)
void RandomList(float[] arrayToFill)
{
// 确保传入数组长度足够(可选校验)
if (arrayToFill == null)
{
Debug.LogError("传入的数组不可为null");
return;
}
for (int i = 0; i < arrayToFill.Length; i++)
{
arrayToFill[i] = Random.value; // 仅修改已有数组内容
}
}
// 调用示例:复用类成员数组
private float[] _reusableArray = new float[10]; // 预分配的复用数组
void UseRandomList()
{
RandomList(_reusableArray); // 传入复用数组,无新分配
// 使用_reusableArray...
}
}
五、集合与数组复用(Collection and Array Reuse)
List、Dictionary等集合类,Clear()方法仅清空内容,不释放内存 —— 可将集合作为类成员变量,避免每帧新建。
1. 问题示例:每帧新建集合
csharp
// 糟糕的C#脚本示例:Update中每帧新建List
using UnityEngine;
using System.Collections.Generic;
public class ExampleScript : MonoBehaviour
{
void Update()
{
// 每帧新建List,产生GC分配
List<float> nearestNeighbors = new List<float>();
FindDistancesToNearestNeighbors(nearestNeighbors);
nearestNeighbors.Sort();
// 使用列表...
}
void FindDistancesToNearestNeighbors(List<float> distances)
{
// 向列表添加数据...
}
}
2. 优化方案:复用类成员集合
csharp
// 优秀的C#脚本示例:复用类成员List,无每帧分配
using UnityEngine;
using System.Collections.Generic;
public class ExampleScript : MonoBehaviour
{
// 将List作为类成员,仅初始化一次
private List<float> _nearestNeighbors = new List<float>();
void Update()
{
_nearestNeighbors.Clear(); // 清空内容,复用内存
FindDistancesToNearestNeighbors(_nearestNeighbors);
_nearestNeighbors.Sort();
// 使用列表...
}
void FindDistancesToNearestNeighbors(List<float> distances)
{
// 向列表添加数据...
}
}
六、慎用闭包与匿名方法(Closures and Anonymous Methods)
闭包(引用外部变量的匿名方法)会生成匿名类实例,导致托管堆分配;即使是普通方法引用,也属于引用类型,会产生分配。
1. 问题示例:闭包导致的分配
匿名方法引用外部变量desiredDivisor,C# 会自动生成匿名类存储该变量,每次调用Sort()都会创建匿名类实例:
csharp
// 糟糕的C#脚本示例:闭包产生内存分配
using UnityEngine;
using System.Collections.Generic;
public class ExampleScript : MonoBehaviour
{
void SortList()
{
List<float> listOfNumbers = GetListOfRandomNumbers();
int desiredDivisor = GetDesiredDivisor(); // 外部变量
// 匿名方法引用desiredDivisor,成为闭包,产生分配
listOfNumbers.Sort((x, y) => (int)x.CompareTo((int)(y / desiredDivisor)));
}
List<float> GetListOfRandomNumbers() { /* 生成随机数列表 */ return new List<float>(); }
int GetDesiredDivisor() { /* 获取除数 */ return 2; }
}
2. 优化方案:避免闭包或提前处理变量
若逻辑允许,可将外部变量转为常量或提前计算,避免闭包;若必须使用,确保仅在非高频逻辑(如初始化)中调用:
csharp
// 优秀的C#脚本示例:无闭包,无分配
using UnityEngine;
using System.Collections.Generic;
public class ExampleScript : MonoBehaviour
{
void SortList()
{
List<float> listOfNumbers = GetListOfRandomNumbers();
const int desiredDivisor = 2; // 改为常量,避免闭包
// 匿名方法无外部引用,不产生分配
listOfNumbers.Sort((x, y) => (int)x.CompareTo((int)(y / desiredDivisor)));
}
List<float> GetListOfRandomNumbers() { /* 生成随机数列表 */ return new List<float>(); }
}
七、避免装箱操作(Boxing)
装箱是指值类型(如 int、float)自动转为引用类型(如 object) 的过程,会在托管堆创建临时对象,是高频隐性分配源。
1. 装箱示例
csharp
using UnityEngine;
public class ExampleScript : MonoBehaviour
{
void BoxingExample()
{
int x = 1; // 值类型
object y = new object();
// x(值类型)转为object(引用类型),发生装箱,产生分配
y.Equals(x);
}
}
2. 如何识别装箱
- Profiler 定位:在 CPU 追踪中搜索
Box(...)、<类名>::Box(...)等方法调用; - IL 代码查看:通过 ReSharper、dotPeek 等工具查看 IL 代码,含
box指令即存在装箱。
3. 优化方案
- 优先使用泛型方法(如
EqualityComparer<int>.Default.Equals(x, x)),避免值类型转 object; - 方法参数尽量使用具体值类型(如
void DoSomething(int value)),而非object。
八、优化 Unity 数组类 API(Array-valued Unity APIs)
许多 Unity API(如mesh.vertices、Input.touches)每次调用都会返回新数组副本,导致重复分配。需使用 “先缓存数组” 或 “非分配 API” 优化。
1. 问题示例:频繁访问 API 产生分配
csharp
// 糟糕的C#脚本示例:每帧多次访问mesh.vertices,产生4次分配
using UnityEngine;
public class ExampleScript : MonoBehaviour
{
public Mesh mesh;
void Update()
{
// 每次访问mesh.vertices都创建新数组副本
for (int i = 0; i < mesh.vertices.Length; i++)
{
float x = mesh.vertices[i].x;
float y = mesh.vertices[i].y;
float z = mesh.vertices[i].z;
DoSomething(x, y, z);
}
}
void DoSomething(float x, float y, float z) { /* 处理逻辑 */ }
}
2. 优化方案 1:缓存数组再使用
csharp
// 优秀的C#脚本示例:仅获取一次数组,无重复分配
using UnityEngine;
public class ExampleScript : MonoBehaviour
{
public Mesh mesh;
private List<Vector3> _vertices = new List<Vector3>(); // 复用List
void Update()
{
// 用非分配API获取顶点数据,无新数组创建
mesh.GetVertices(_vertices);
for (int i = 0; i < _vertices.Count; i++)
{
float x = _vertices[i].x;
float y = _vertices[i].y;
float z = _vertices[i].z;
DoSomething(x, y, z);
}
}
void DoSomething(float x, float y, float z) { /* 处理逻辑 */ }
}
3. 优化方案 2:使用非分配 API
Unity 为部分分配型 API 提供了非分配替代版本,以下是常见示例:
| 分配型 API | 非分配型 API 替代方案 |
|---|---|
Physics.RaycastAll | Physics.RaycastNonAlloc |
Animator.parameters | Animator.parameterCount + Animator.GetParameter |
Renderer.sharedMaterials | Renderer.GetSharedMaterials |
Input.touches | Input.touchCount + Input.GetTouch |
示例:优化触摸输入代码
csharp
// 糟糕的C#脚本示例:Input.touches每次访问都创建新数组
using UnityEngine;
public class ExampleScript : MonoBehaviour
{
void Update()
{
// 每帧访问Input.touches两次,产生两次分配
for (int i = 0; i < Input.touches.Length; i++)
{
Touch touch = Input.touches[i];
// 处理触摸逻辑...
}
}
}
// 最佳的C#脚本示例:使用非分配API,无任何分配
public class ExampleScript : MonoBehaviour
{
void Update()
{
// 仅获取一次触摸数量,无分配
int touchCount = Input.touchCount;
for (int i = 0; i < touchCount; i++)
{
// Input.GetTouch无分配,直接获取触摸数据
Touch touch = Input.GetTouch(i);
// 处理触摸逻辑...
}
}
}
九、空数组复用(Empty Array Reuse)
若方法需返回空数组(而非 null),反复创建空数组会产生冗余分配。可预分配一个静态空数组,复用该实例。
示例:复用静态空数组
csharp
using UnityEngine;
using System;
public class ExampleScript : MonoBehaviour
{
// 预分配静态空数组,仅初始化一次
private static readonly int[] _emptyIntArray = Array.Empty<int>();
// 返回空数组时,复用静态实例
int[] GetEmptyArray()
{
// 无需新建空数组,直接返回预分配实例
return _emptyIntArray;
}
}
总结
Unity GC 优化的核心是 “减少托管堆的不必要分配”—— 通过对象池复用、集合缓存、非分配 API 选择等手段,从根源降低 GC 触发频率。实际开发中,需结合 Profiler(如 CPU Usage 模块的 GC Alloc 列、Memory Profiler 包)定位分配热点,再针对性应用本文方案。记住:GC 优化不是 “一次性操作”,而是贯穿开发全流程的习惯,只有持续关注每处可能的分配,才能实现稳定流畅的应用性能。
557

被折叠的 条评论
为什么被折叠?



