Unity 垃圾回收(GC)最佳实践

目录

前言

一、减少临时内存分配(Temporary Allocations)

1. 临时分配的危害

2. 优化目标与原则

二、使用可复用对象池(Reusable Object Pools)

1. 对象池核心逻辑

2. 基础对象池实现代码

三、避免重复字符串拼接(Repeated String Concatenation)

1. 问题示例:循环拼接的危害

2. 优化方案 1:使用StringBuilder

3. 优化方案 2:减少不必要的字符串更新

四、优化 “返回数组的方法”(Method Returning an Array Value)

1. 问题示例:每次调用创建新数组

2. 优化方案:传入数组并复用

五、集合与数组复用(Collection and Array Reuse)

1. 问题示例:每帧新建集合

2. 优化方案:复用类成员集合

六、慎用闭包与匿名方法(Closures and Anonymous Methods)

1. 问题示例:闭包导致的分配

2. 优化方案:避免闭包或提前处理变量

七、避免装箱操作(Boxing)

1. 装箱示例

2. 如何识别装箱

3. 优化方案

八、优化 Unity 数组类 API(Array-valued Unity APIs)

1. 问题示例:频繁访问 API 产生分配

2. 优化方案 1:缓存数组再使用

3. 优化方案 2:使用非分配 API

示例:优化触摸输入代码

九、空数组复用(Empty Array Reuse)

示例:复用静态空数组

总结


前言

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 字节
  • 关键原则:避免在UpdateFixedUpdate等高频调用方法中创建新引用类型(如new List<>()new Class())。

二、使用可复用对象池(Reusable Object Pools)

对于频繁创建 / 销毁的对象(如子弹、敌人、UI 弹窗),直接复用已有对象而非反复新建,可彻底消除这类对象的 GC 分配。

1. 对象池核心逻辑

以 “子弹(Projectile)” 为例:

  1. 预分配:关卡加载时,根据 “同时存在的最大子弹数” 预实例化一批子弹,初始设为非激活状态;
  2. 复用激活:发射子弹时,从池中找到第一个非激活对象,重置位置后激活;
  3. 回收休眠:子弹销毁时(如命中目标),仅设为非激活状态,不销毁对象,放回池中等待下次复用。

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)

ListDictionary等集合类,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.verticesInput.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.RaycastAllPhysics.RaycastNonAlloc
Animator.parametersAnimator.parameterCount + Animator.GetParameter
Renderer.sharedMaterialsRenderer.GetSharedMaterials
Input.touchesInput.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 优化不是 “一次性操作”,而是贯穿开发全流程的习惯,只有持续关注每处可能的分配,才能实现稳定流畅的应用性能。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

小李也疯狂

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值