UGUI 性能优化系列:第一篇——基础优化与资源管理
UGUI 性能优化系列:第二篇——Canvas 与 UI 元素管理
UGUI 性能优化系列:第三篇——渲染与像素填充率优化
UGUI 性能优化系列:第四篇——高级优化与注意事项
在前面的三篇文章中,我们从 UGUI 的基础渲染管线、资源管理,到 Canvas 的重建机制、UI 元素管理,再到渲染与像素填充率优化,逐步深入地探讨了 UGUI 性能优化的核心策略。现在,我们将进入本系列的最终章,涵盖一些更为深入和广泛的优化领域,包括 动画优化、Shader 优化、内存优化,以及如何 深度使用性能分析工具 和遵循 编码实践与最佳实践。
UI 动画是提升用户体验的重要组成部分,但如果处理不当,也可能成为性能瓶颈。
Unity 提供了强大的 Animator 系统来制作动画,而像 DOTween 这样的第三方动画库也广受欢迎。
Rect Transform
属性(尤其是 anchoredPosition
、sizeDelta
、scale
),并且该 UI 元素所在的 Canvas 规模较大且频繁触发重建,那么 Animator 可能会间接导致 Canvas 重建的性能开销。Update Mode
属性,可以设置为 Normal
(每帧更新)、Animate Physics
(在 FixedUpdate 中更新,不适合 UI) 或 Unscaled Time
(不受 Time.timeScale 影响)。对于 UI 动画,通常使用 Normal
或 Unscaled Time
。Rect Transform
的修改:
Canvas Group
进行 Alpha
动画: 如果只是做 UI 的淡入淡出效果,不要直接修改 Image
或 Text
的颜色 Alpha
,而是给其父级添加 Canvas Group
组件,然后动画 Canvas Group
的 alpha
属性。Canvas Group
的 alpha
变化通常不会触发 Canvas 重建,因为它只影响渲染时的颜色混合。// 禁用 Animator
myAnimator.enabled = false;
// 启用 Animator
myAnimator.enabled = true;
.OnComplete()
等回调函数来执行逻辑,而不是在 Update
中进行轮询。.SetRecyclable(true)
: 确保 DOTween 的 Tween 对象被池化,以减少 GC Alloc。这是 UI 动画优化的核心,前面已经在 Canvas 重建部分提及,这里再次强调:
Rect Transform
的 anchoredPosition
、sizeDelta
、pivot
、anchorMin/Max
。 这些属性的修改最容易导致 Canvas 的布局重建。Canvas Group
的 alpha
属性进行淡入淡出动画。Transform.localPosition
或 Transform.position
: 对于 Screen Space - Overlay
和 Screen Space - Camera
模式下的 Canvas,修改 Rect Transform
继承自 Transform
的 localPosition
或 position
通常不会触发布局重建,因为它只是改变了 UI 元素的最终渲染位置,而没有改变其在 Canvas 布局系统中的相对位置和大小计算。但是,仍然要避免每帧都做这种修改,因为这仍然会导致脏标记,最终导致 UI.Canvas.SendWillRenderCanvases
的执行。Image
的 sprite
,这会触发 Mesh 重建。尽量将这种切换控制在关键帧或只在动画的开始/结束时发生。Canvas Group
进行 Alpha 动画前面已多次提及 Canvas Group
的重要性,这里做个小结:
Canvas Group
组件可以控制其所有子 UI 元素的 alpha
(透明度)、interactable
(是否可交互)和 blocksRaycasts
(是否拦截射线)。Canvas Group
的 alpha
时,它通常不会触发 Canvas 的 Mesh 或布局重建。它会在渲染阶段将 alpha
值传递给 Shader,从而以更高效的方式实现整个组的透明度变化。interactable = false
和 blocksRaycasts = false
)。alpha
变化不触发重建,但设置为半透明仍然会增加 Overdraw,因为 GPU 需要进行混合操作。因此,在 alpha
变为 0 时,最好同时设置 blocksRaycasts = false
和 interactable = false
,甚至考虑 SetActive(false)
,以完全移除渲染和交互开销。UI Shader 对 UGUI 的渲染性能也有重要影响。
UI/Default
, UI/Lit
)UI/Default
:
Image
, Text
)默认使用的 Shader。UI/Lit
:
UI/Default
。UI/Lit
。 对于绝大多数 2D UI 界面,光照是不必要的。如果你需要自定义 UI Shader 来实现特殊效果(如辉光、径向模糊等),请务必注意 Shader 的复杂度。
pow
, exp
, sin
, cos
等)会增加计算量。half
或 fixed
)而不是全精度(float
),尤其是在移动平台上,这可以显著提高性能。例如,float4
可以用于位置,但颜色和 UV 可以用 half4
。Shader Graph
进行自定义 Shader 优化Unity 的 Shader Graph 是一个可视化 Shader 编辑器,可以帮助你创建自定义 Shader 而无需编写代码。
UI 资源,特别是图片和字体,是游戏内存占用的主要部分。
Memory Usage
模块: 可以查看纹理占用的内存大小。Window > Analysis > Memory Profiler
: 这是一个强大的独立工具,可以详细分析运行时内存分配,包括纹理。Read/Write Enabled
: 除非必要,否则务必禁用纹理的 Read/Write Enabled
选项,这会使纹理内存占用减半。Generate Mip Maps
: 对于 UI 纹理,禁用 Mip Maps
可以节省 33% 的内存。Resources.Load
加载的资源,可以使用 Resources.UnloadUnusedAssets()
来卸载不再引用的资源。注意: Resources.UnloadUnusedAssets()
是一个耗时操作,通常在场景切换或加载屏幕时执行,而不是在游戏运行时频繁调用。内存泄漏是指程序在不再需要某个对象时,仍然持有对它的引用,导致垃圾回收器无法回收该对象及其占用的内存。在 UGUI 开发中,事件订阅是一个常见的内存泄漏源。
Destroy
了,全局事件的发布者仍然持有对这个已销毁 UI 元素的引用,导致其无法被 GC 回收。OnDestroy()
中取消订阅: 始终在 UI 组件的 OnDestroy()
生命周期方法中取消所有事件订阅。public class MyUIComponent : MonoBehaviour
{
void OnEnable()
{
// 订阅事件
GlobalEventManager.OnSomethingHappened += OnSomethingHappenedHandler;
}
void OnDisable()
{
// **重要:在禁用或销毁时取消订阅**
GlobalEventManager.OnSomethingHappened -= OnSomethingHappenedHandler;
}
void OnDestroy()
{
// 确保在销毁时也取消订阅,防止 OnDisable 之前组件被直接销毁
GlobalEventManager.OnSomethingHappened -= OnSomethingHappenedHandler;
}
private void OnSomethingHappenedHandler()
{
// ...
}
}
+=
和 -=
成对出现: 每次 +=
订阅事件,就必须有对应的 -=
取消订阅。Resources.UnloadUnusedAssets
的使用时机Resources.UnloadUnusedAssets()
会卸载所有不再被任何场景或对象引用的资源。
Resources.Load
加载的图片、预制件),并在使用完成后不再需要它们时,可以调用此函数来释放内存。Resources.UnloadUnusedAssets()
是一个 CPU 密集型 操作,因为它需要遍历所有已加载的资源并检查其引用计数。因此,不要在游戏运行时频繁调用它,也不要在 Update 函数中调用。Resources.UnloadUnusedAssets()
更高效和灵活。掌握 Unity 提供的性能分析工具,是优化任何性能问题的基石。
Profiler 是你的性能侦探工具箱。
CPU Usage
(CPU 使用率):
UI.Canvas.SendWillRenderCanvases
-> Canvas.BuildBatch
和 Layout.PerformCalculations
:高耗时意味着 Canvas 重建问题。Animator.Update
:检查是否有不必要的动画更新或复杂动画。GraphicRaycaster.Raycast
:检查射线检测是否过于频繁或遍历元素过多。GC.Alloc
:追踪每一帧的内存分配,任何非零的 GC Alloc 都可能导致 GC 发生,从而引起卡顿。目标是每一帧都尽量为 0 GC Alloc。Update()
、LateUpdate()
、FixedUpdate()
函数:检查是否有耗时操作。Deep Profile
:勾选后可以查看更详细的函数调用栈,但会显著增加 Profiler 开销。在定位特定问题时使用。Call Stacks
:查看某个函数的调用栈,找出其来源。Hierarchy
和 Timeline
视图:Hierarchy
显示函数耗时的层级关系,Timeline
显示函数在时间轴上的分布。GPU Usage
(GPU 使用率):
Draw Calls
:数量越少越好。过高通常是合批问题。Batches
:合批的数量。Triangles
/ Vertices
:渲染的三角形和顶点数量。UI 的 Mesh 越复杂,这些值越高。Overdraw
:在 Scene View 中配合使用。Render.OpaqueGeometry
和 Render.TransparentGeometry
:UI 通常属于 TransparentGeometry
。Frame Debugger
逐个分析 Draw Call。Memory Usage
(内存使用率):
Textures
:纹理是 UI 内存大户。检查图集和散图的占用。Meshes
:UI 的 Mesh 占用。Fonts
:字体图集占用。Other
/ ManagedHeap
:ManagedHeap 是 C# 堆内存,关注这里的增长,特别是 GC Alloc。Take Sample
按钮,可以拍摄当前内存快照,然后与之前的快照进行对比,找出内存增长的原因。Memory Profiler
(独立工具) 进行更详细的内存分析。Rendering
(渲染):
Frame Debugger
是分析 Draw Call 问题的终极工具,它能让你像外科医生一样剖析每一帧的渲染过程。
State Change Reason
),例如 Material changed
、Shader changed
、Texture changed
、Blend State Changed
等。UI-Batch
开头。State Change Reason
是关键。例如,如果看到 Material changed
,说明是材质不同导致合批失败;如果看到 Blend State changed
,通常是因为透明和不透明对象交错渲染。良好的编码习惯和架构设计,能从根本上避免许多性能问题。
这是最常见也最致命的性能问题之一。
GetComponent
。Text.text
或 Image.sprite
。Rect Transform
属性。Update
中进行复杂的字符串拼接。Update
中频繁进行资源加载。// ❌ Bad: Every frame updates gold text
// void Update() { goldText.text = PlayerData.Gold.ToString(); }
// ✅ Good: Only update when gold changes
private int _currentGold;
void Start() { _currentGold = PlayerData.Gold; UpdateGoldText(); }
void Update() {
if (_currentGold != PlayerData.Gold) {
_currentGold = PlayerData.Gold;
UpdateGoldText();
}
}
void UpdateGoldText() { goldText.text = _currentGold.ToString(); }
GetComponent()
是一个耗时操作,尤其是在 Update
或循环中。Awake()
或 Start()
方法中获取组件引用并缓存起来。public class MyUIController : MonoBehaviour
{
[SerializeField] private TextMeshProUGUI scoreText; // Inspector 赋值
private Button myButton; // 代码获取
void Awake()
{
// 通过 GetComponent 获取引用并缓存
myButton = GetComponent<Button>();
}
void Start()
{
// 使用缓存的引用
scoreText.text = "0";
myButton.onClick.AddListener(OnButtonClick);
}
}
OnEnable()
中订阅,OnDisable()
和 OnDestroy()
中取消订阅。// ❌ Bad: Cannot unsubscribe this anonymous method
// myButton.onClick.AddListener(() => Debug.Log("Clicked!"));
// ✅ Good: Use a named method for easy unsubscribe
myButton.onClick.AddListener(OnMyButtonClicked);
// In OnDisable/OnDestroy:
// myButton.onClick.RemoveListener(OnMyButtonClicked);
+
运算符拼接字符串会创建大量的临时字符串对象,导致大量的 GC Alloc 和内存碎片。StringBuilder
来构建字符串。using System.Text;
private StringBuilder sb = new StringBuilder();
public TextMeshProUGUI timerText;
void UpdateTimer(float timeRemaining)
{
sb.Clear();
sb.Append("Time: ");
sb.AppendFormat("{0:0.00}", timeRemaining);
timerText.text = sb.ToString();
}
object
类型或接口参数传递时,会被装箱成引用类型,导致 GC Alloc。string.Format("{0}", intValue)
:intValue
会被装箱。Debug.Log(intValue)
:intValue
会被装箱。enum
类型作为 object
或接口参数传递。Debug.Log
,可以使用 Debug.Log("Value: " + intValue);
(虽然有字符串拼接开销,但通常比装箱小,或者直接写 Debug.Log(intValue.ToString());
) 或者使用字符串插值 Debug.Log($"Value: {intValue}");
。string.Format
,如果支持 C# 6.0+,使用字符串插值。TextMeshPro
的 SetText
方法,它有针对数字和格式化字符串的无 GC Alloc 版本。// TextMeshProUGUI
timerText.SetText("Time: {0:0.00}", timeRemaining); // 无 GC Alloc
foreach
循环在某些情况下会产生额外的 GC Alloc(例如,迭代器分配)。Update
或频繁调用的函数中,尽量避免使用 Linq。foreach
,如果可以,使用传统的 for
循环迭代数组或 List。foreach
,并且其迭代的是集合接口(IEnumerable
),那么可能存在迭代器装箱问题。但对于 List
或数组,现代 Unity 版本通常已经优化得很好,不会产生 GC Alloc。性能优化不是一劳永逸的事情,新的功能和美术资源都可能引入新的性能问题。因此,建立一套 UI 性能测试和回归机制至关重要。
从基础到高级,我们已经全面覆盖了 UGUI 性能优化的方方面面。
回顾一下我们的旅程:
最重要的核心思想:
UI 性能优化是一个复杂但回报丰厚的领域。希望这个系列文章能为我们提供一份坚实可靠的指南。作为一名 Unity 开发者,掌握这些技能将使我们在构建高性能、用户体验良好的游戏方面更上一层楼~
UGUI 性能优化系列:第一篇——基础优化与资源管理
UGUI 性能优化系列:第二篇——Canvas 与 UI 元素管理
UGUI 性能优化系列:第三篇——渲染与像素填充率优化
UGUI 性能优化系列:第四篇——高级优化与注意事项