1. UGUI渲染体系的基石:Graphic组件全景拆解
做过Unity UI开发的人多少都经历过这样的场景:明明只改了一个按钮的颜色,结果整个Canvas下的文字都重新刷新了一遍;或者列表滚动时帧率骤降,Profiler里UI重建的耗时高得离谱。这些问题的根源,往往都指向同一个东西——Graphic。它是UGUI渲染体系里所有可见UI元素的基类,Text、Image、RawImage、MaskableGraphic这些我们天天打交道的组件,全都从它派生而来。换句话说,只要你在Unity里画出一个像素的UI,背后就一定有Graphic在干活。
Graphic这个类本身并不复杂,代码量也不算大,但它承担的责任却相当关键:它负责把UI元素的数据组织成可供底层渲染的网格,管理材质和纹理,处理Canvas的更新时机,还要响应各种重建请求。很多开发者用了几年UGUI,对RectTransform、LayoutGroup、Canvas Scaler这些外围组件了如指掌,却从来没打开过Graphic的源码看一眼。这就像开车开了十年却不知道发动机怎么工作——平时没问题,一旦遇到性能瓶颈或者诡异Bug,就完全无从下手。
这篇文章适合所有使用Unity做UI开发的同行,不管你是刚接触UGUI的新手,还是已经能熟练搭建复杂UI框架的老手,理解Graphic的运作机制都能让你在性能优化和问题排查时更有底气。我会从设计思路、核心机制、实操要点、常见问题几个维度,把Graphic这个组件彻底拆开讲清楚,尽量用大白话把底层逻辑说明白,同时给出可以直接参考的代码和配置方案。
2. Graphic的设计思路与核心机制
2.1 为什么UGUI要抽象出Graphic这一层
要理解Graphic的设计,得先回到UGUI诞生时的技术背景。在UGUI之前,Unity开发者做UI主要靠NGUI或者自己基于Mesh手搓。NGUI的核心思路是每个UI元素自己管理Mesh和DrawCall,灵活但容易失控,一个复杂界面动辄几百个DrawCall是家常便饭。UGUI的设计目标很明确:把UI元素的渲染数据统一收集起来,由Canvas统一合并批次,尽可能减少DrawCall。
在这个架构下,Graphic扮演的是“数据提供者”的角色。它不直接负责渲染,而是负责回答几个问题:我需要画哪些顶点?这些顶点用什么材质?纹理是哪张?我在Canvas的层级里处于什么位置?Canvas拿到这些信息后,统一做合批和渲染。这种职责分离的设计,让UGUI在保持灵活性的同时,把合批优化的控制权收归到了Canvas层面。
从类继承关系看,Graphic继承自UIBehaviour,实现了ICanvasElement接口。ICanvasElement是Graphic和Canvas之间沟通的桥梁,它定义了几个关键方法:Rebuild、LayoutComplete、GraphicUpdateComplete、IsDestroyed等。Canvas通过持有所有ICanvasElement的引用来管理整个UI的更新流程。这个设计很巧妙——Canvas不需要知道具体是Text还是Image,它只关心“你需要重建吗”“你重建完了吗”,具体的重建逻辑由各个Graphic子类自己实现。
2.2 Graphic的生命周期与Canvas更新流程
Graphic的生命周期和Canvas的更新流程是深度绑定的。当你修改了一个Image的Color属性,或者替换了Text的字体,Graphic会调用SetVerticesDirty或SetMaterialDirty来标记自己需要重建。这两个方法最终会调用CanvasUpdateRegistry的RegisterCanvasElementForGraphicRebuild或RegisterCanvasElementForLayoutRebuild,把自己注册到对应的重建队列里。
Canvas的更新分三个主要阶段,顺序不能乱:
- Layout阶段:处理LayoutGroup、ContentSizeFitter这类布局组件的计算。这个阶段会调用ILayoutElement的CalculateLayoutInputHorizontal和CalculateLayoutInputVertical,确定每个元素的实际尺寸和位置。
- PreRender阶段:处理Graphic的几何重建。这个阶段会调用Graphic的Rebuild方法,根据当前的脏标记类型,分别执行UpdateGeometry或UpdateMaterial。
- Render阶段:Canvas把收集到的所有Graphic数据合并成批次,提交给底层渲染。
这里有个很容易踩的坑:Layout阶段和Graphic重建阶段是分开的,但Layout的变化往往会触发Graphic重建。比如你改了一个Text的文字内容,Text的preferredWidth变了,如果它在一个HorizontalLayoutGroup里,整个布局会重新计算,然后所有受影响的Graphic都会重建。这就是为什么改一个文字可能导致整个界面刷新的原因。
注意:CanvasUpdateRegistry的更新是在Canvas.willRenderCanvases回调里执行的,这个回调每帧在渲染前触发。如果你在Update里修改UI属性,它会在当帧的渲染前被处理;如果你在LateUpdate里修改,可能就要等到下一帧了。
2.3 顶点数据的组织方式
Graphic最核心的数据结构是VertexHelper。这个类封装了顶点位置、UV、颜色、法线、切线等信息,并提供了一系列操作方法:AddVert、AddTriangle、AddUIVertexQuad、FillMesh等。Graphic的子类通过重写OnPopulateMesh(VertexHelper vh)方法来填充顶点数据。
VertexHelper内部用List 存储顶点,用List 存储三角形索引。UIVertex是一个结构体,包含position、normal、tangent、color、uv0、uv1、uv2、uv3这些字段。对于大多数UI元素来说,normal和tangent用不到,但Unity为了兼容性还是保留了这些字段。这也是为什么UI顶点数据的内存占用比想象中要大——每个UIVertex大约占56字节,一个简单的Image就有4个顶点,复杂一点的Text可能有几百个顶点。
OnPopulateMesh的调用时机很关键。它只在Graphic被标记为VerticesDirty并且Canvas执行到PreRender阶段时才会被调用。如果你在OnPopulateMesh里做了复杂的计算,比如动态生成大量顶点,这个开销会直接体现在UI重建的耗时上。我见过不少项目在OnPopulateMesh里做字符串解析、曲线细分这类重计算,结果就是UI一刷新就卡顿。
2.4 材质与纹理的管理策略
Graphic通过material和materialForRendering两个属性来管理材质。material返回的是你直接设置的材质,而materialForRendering会考虑IMaterialModifier接口的修改。Mask、RectMask2D这些组件就是通过实现IMaterialModifier来修改材质的,比如Mask会给材质加一个裁剪用的Shader关键字和参数。
默认情况下,Graphic使用的材质是Graphic.defaultGraphicMaterial,这是一个内置的UI/Default Shader材质。纹理则通过mainTexture属性获取,默认返回s_WhiteTexture(一张1x1的白色纹理)。Image和RawImage会重写mainTexture来返回自己设置的Sprite或Texture。
这里有个性能优化的关键点:相同材质的Graphic才能合批。如果你给每个Image都设置不同的材质,即使它们用的是同一张纹理,也无法合批。UGUI的合批规则是:材质相同、纹理相同、且层级连续(中间没有插入不同材质的元素)的Graphic可以合并到一个DrawCall里。所以,尽量让UI元素共用材质和纹理图集,是减少DrawCall最有效的手段。
3. 核心细节解析与实操要点
3.1 Rebuild方法的内部逻辑
Graphic的Rebuild方法是整个重建流程的入口,它的逻辑值得仔细拆解:
public virtual void Rebuild(CanvasUpdate update) { if (canvasRenderer == null || canvasRenderer.cull) return; switch (update) { case CanvasUpdate.PreRender: if (m_VertsDirty) { UpdateGeometry(); m_VertsDirty = false; } if (m_MaterialDirty) { UpdateMaterial(); m_MaterialDirty = false; } break; } }这段代码看起来简单,但有几个细节值得注意。首先,canvasRenderer.cull为true时会直接返回,这意味着被裁剪掉的UI元素不会执行重建。这个判断在列表滚动场景下非常重要——滚出可视区域的元素如果被正确裁剪,就不会产生重建开销。其次,顶点重建和材质重建是分开判断的,只有对应的脏标记为true时才执行。这避免了不必要的重复计算。
UpdateGeometry方法会调用DoMeshGeneration,后者最终调用OnPopulateMesh。在调用OnPopulateMesh之前,它会先清空VertexHelper,然后设置一些默认状态。UpdateMaterial则会调用canvasRenderer.SetMaterial和SetTexture,把材质和纹理同步给CanvasRenderer。
实操心得:如果你自定义Graphic子类,重写OnPopulateMesh时一定要先调用vh.Clear(),否则顶点数据会不断累积,导致内存泄漏和渲染异常。我早期写自定义Graphic时就犯过这个错误,表现为UI越刷新越卡,最后查了半天才发现是顶点没清空。
3.2 SetVerticesDirty与SetMaterialDirty的触发时机
这两个方法是Graphic提供给子类和外部调用的脏标记接口。理解它们的触发时机,是优化UI性能的关键。
SetVerticesDirty会在以下情况被调用:
- 修改Color属性
- 修改RectTransform的尺寸(通过OnRectTransformDimensionsChange)
- 修改Text的文字内容、字体、字号
- 修改Image的Sprite、Type、FillAmount
- 启用或禁用Graphic组件
SetMaterialDirty会在以下情况被调用:
- 修改Material属性
- 修改MaskableGraphic的maskable属性
- Mask组件的状态变化
这两个方法内部都会检查enabled和canvas的存在,然后调用CanvasUpdateRegistry注册重建。值得注意的是,SetVerticesDirty和SetMaterialDirty并不会立即执行重建,它们只是打个标记,真正的重建发生在当前帧的Canvas更新阶段。这意味着在同一帧内多次修改属性,只会触发一次重建。
// 这样写只会触发一次重建 image.color = Color.red; image.color = Color.green; image.color = Color.blue; // 这样写会触发三次重建(如果中间有Canvas更新的话) image.color = Color.red; Canvas.ForceUpdateCanvases(); image.color = Color.green; Canvas.ForceUpdateCanvases(); image.color = Color.blue;3.3 自定义Graphic的正确姿势
写自定义Graphic是很多项目的常见需求,比如画圆角矩形、画曲线、画自定义图表。但很多人写出来的自定义Graphic性能很差,问题往往出在几个地方。
第一,OnPopulateMesh里的计算要尽量轻量。如果你需要生成大量顶点,考虑把计算结果缓存起来,只在参数变化时重新计算。比如画一个圆形,如果半径和分段数没变,就没必要每帧重新计算所有顶点的位置。
第二,合理使用VertexHelper的批量操作方法。AddUIVertexQuad比手动AddVert+AddTriangle效率高,因为它内部做了优化。如果需要添加大量相同结构的顶点,可以先用数组准备好数据,再批量添加。
第三,注意材质的设置。如果你的自定义Graphic需要特殊Shader,记得在Awake或OnEnable里设置material,而不是在OnPopulateMesh里。OnPopulateMesh应该只负责顶点数据,不应该做材质相关的操作。
public class CustomCircleGraphic : MaskableGraphic { [SerializeField] private float radius = 50f; [SerializeField] private int segments = 32; protected override void OnPopulateMesh(VertexHelper vh) { vh.Clear(); // 圆心顶点 UIVertex center = UIVertex.simpleVert; center.color = color; center.position = Vector3.zero; vh.AddVert(center); // 圆周顶点 float angleDelta = 2f * Mathf.PI / segments; for (int i = 0; i <= segments; i++) { float angle = i * angleDelta; UIVertex vert = UIVertex.simpleVert; vert.color = color; vert.position = new Vector3(Mathf.Cos(angle) * radius, Mathf.Sin(angle) * radius, 0); vh.AddVert(vert); } // 三角形索引 for (int i = 1; i <= segments; i++) { vh.AddTriangle(0, i, i + 1); } } }这段代码画了一个简单的圆形。注意几个细节:UIVertex.simpleVert提供了一个默认的顶点结构,省去了手动设置normal和tangent的麻烦;顶点数量是segments+2(圆心+圆周+重复的第一个圆周点);三角形数量是segments个。
3.4 MaskableGraphic的裁剪机制
MaskableGraphic继承自Graphic,增加了maskable属性和裁剪相关的逻辑。当maskable为true时,它会查找最近的Mask或RectMask2D组件,并根据裁剪方式修改自己的材质或顶点数据。
Mask组件通过IMaterialModifier接口修改材质,给Shader传入裁剪参数。RectMask2D则通过IClippable接口直接修改顶点数据,把超出裁剪区域的顶点剔除或调整。这两种方式的性能特征不同:Mask需要额外的DrawCall来渲染遮罩,而且会打断合批;RectMask2D不打断合批,但只支持矩形裁剪。
注意事项:如果一个UI元素不需要被裁剪,把maskable设为false可以避免不必要的裁剪计算。在列表滚动场景下,这个优化能带来明显的性能提升。我实测过一个包含200个元素的滚动列表,把所有元素的maskable设为false后,滚动帧率从45提升到了60。
4. 实操过程与核心环节实现
4.1 搭建一个可观测的Graphic调试环境
要深入理解Graphic的行为,光看源码不够,最好能实际观测它的重建过程。我通常会在项目里搭一个简单的调试环境,用来观察Graphic的各种状态变化。
首先,创建一个自定义的Graphic子类,在关键方法里打日志:
public class DebugGraphic : MaskableGraphic { protected override void OnPopulateMesh(VertexHelper vh) { Debug.Log($"[DebugGraphic] OnPopulateMesh called on {name}, frame {Time.frameCount}"); vh.Clear(); var rect = GetPixelAdjustedRect(); UIVertex vert = UIVertex.simpleVert; vert.color = color; vert.position = new Vector3(rect.xMin, rect.yMin); vh.AddVert(vert); vert.position = new Vector3(rect.xMin, rect.yMax); vh.AddVert(vert); vert.position = new Vector3(rect.xMax, rect.yMax); vh.AddVert(vert); vert.position = new Vector3(rect.xMax, rect.yMin); vh.AddVert(vert); vh.AddTriangle(0, 1, 2); vh.AddTriangle(2, 3, 0); } public override void SetVerticesDirty() { Debug.Log($"[DebugGraphic] SetVerticesDirty on {name}, frame {Time.frameCount}"); base.SetVerticesDirty(); } public override void SetMaterialDirty() { Debug.Log($"[DebugGraphic] SetMaterialDirty on {name}, frame {Time.frameCount}"); base.SetMaterialDirty(); } }把这个组件挂到场景里的UI元素上,然后在运行时修改它的Color、RectTransform尺寸、父级LayoutGroup等属性,观察日志输出。你会直观地看到哪些操作触发了重建,重建发生在哪一帧,以及重建的频率。
这个调试环境帮我排查过很多问题。有一次我发现某个界面的UI重建频率异常高,用这个工具一查,发现是一个Text的ContentSizeFitter在每帧触发布局重建,进而导致整个界面刷新。后来把ContentSizeFitter改成只在文字变化时手动调用SetLayoutDirty,性能问题就解决了。
4.2 用Profiler定位Graphic重建热点
Unity Profiler是分析UI性能的利器。在Profiler里,UI相关的开销主要分布在几个地方:Canvas.BuildBatch、Canvas.SendWillRenderCanvases、Graphic.Rebuild、Layout.Rebuild等。
打开Profiler,切换到Hierarchy视图,展开Canvas相关的调用。你会看到Canvas.SendWillRenderCanvases下面挂着LayoutRebuilder和GraphicRebuildQueue的执行。如果Graphic.Rebuild的耗时很高,说明有大量Graphic在重建;如果LayoutRebuilder的耗时高,说明布局计算是瓶颈。
我常用的一个技巧是在Profiler里给关键方法打自定义采样标记:
using UnityEngine.Profiling; protected override void OnPopulateMesh(VertexHelper vh) { Profiler.BeginSample("CustomGraphic.OnPopulateMesh"); // ... 顶点生成逻辑 Profiler.EndSample(); }这样在Profiler里就能看到自定义Graphic的具体耗时,方便定位是哪个组件的顶点生成拖慢了整体性能。
4.3 优化Graphic重建的实战方案
基于对Graphic机制的理解,我总结了几套优化重建性能的实战方案,按优先级从高到低排列:
方案一:减少重建频率。最有效的优化是让Graphic尽量少重建。具体做法包括:避免在Update里修改UI属性;把频繁变化的UI元素和静态UI元素分开放在不同的Canvas下;对于文字内容频繁变化的Text,考虑用TextMeshPro替代传统Text,TMP的重建开销更可控。
方案二:拆分Canvas。一个Canvas下的任何Graphic重建,都会导致整个Canvas的批次重新构建。把动态元素和静态元素拆到不同Canvas下,可以避免静态元素被牵连。我通常会把UI分成三层:背景层(几乎不变)、内容层(偶尔变化)、特效层(频繁变化),每层一个Canvas。
方案三:控制顶点数量。顶点数量直接影响重建耗时和内存占用。对于Text,减少不必要的富文本标签、避免过长的文字内容;对于Image,尽量使用简单的Sprite,避免九宫格拉伸产生过多顶点;对于自定义Graphic,合理控制分段数和细节层次。
方案四:使用对象池。对于频繁创建销毁的UI元素(如列表项),使用对象池复用GameObject,避免反复的初始化和销毁开销。对象池配合SetActive切换,比Destroy+Instantiate高效得多。
| 优化方案 | 适用场景 | 预期收益 | 实施难度 |
|---|---|---|---|
| 减少重建频率 | 所有场景 | 高 | 低 |
| 拆分Canvas | 动静混合的复杂界面 | 高 | 中 |
| 控制顶点数量 | 文字多、图形复杂的界面 | 中 | 中 |
| 使用对象池 | 列表、弹窗等动态元素 | 中 | 中 |
| 关闭不需要的Maskable | 不需要裁剪的元素 | 低到中 | 低 |
4.4 一个完整的自定义Graphic案例:圆角矩形
圆角矩形是UI开发中最常见的自定义需求之一。Unity内置的Image不支持圆角,很多人用九宫格Sprite来实现,但九宫格在圆角半径变化时不灵活。用自定义Graphic画圆角矩形是更好的方案。
public class RoundedRectGraphic : MaskableGraphic { [SerializeField] private float cornerRadius = 10f; [SerializeField] private int cornerSegments = 4; public float CornerRadius { get => cornerRadius; set { cornerRadius = value; SetVerticesDirty(); } } protected override void OnPopulateMesh(VertexHelper vh) { vh.Clear(); Rect rect = GetPixelAdjustedRect(); float radius = Mathf.Min(cornerRadius, Mathf.Min(rect.width, rect.height) * 0.5f); // 中心点 UIVertex center = UIVertex.simpleVert; center.color = color; center.position = new Vector3(rect.center.x, rect.center.y, 0); vh.AddVert(center); // 生成圆角矩形的轮廓顶点 List<Vector2> outline = new List<Vector2>(); // 四个圆角的圆心 Vector2[] corners = new Vector2[] { new Vector2(rect.xMin + radius, rect.yMin + radius), new Vector2(rect.xMax - radius, rect.yMin + radius), new Vector2(rect.xMax - radius, rect.yMax - radius), new Vector2(rect.xMin + radius, rect.yMax - radius) }; // 每个圆角的角度范围 float[] startAngles = { 180f, 270f, 0f, 90f }; for (int c = 0; c < 4; c++) { for (int i = 0; i <= cornerSegments; i++) { float angle = (startAngles[c] + (float)i / cornerSegments * 90f) * Mathf.Deg2Rad; float x = corners[c].x + Mathf.Cos(angle) * radius; float y = corners[c].y + Mathf.Sin(angle) * radius; outline.Add(new Vector2(x, y)); } } // 添加轮廓顶点 for (int i = 0; i < outline.Count; i++) { UIVertex vert = UIVertex.simpleVert; vert.color = color; vert.position = new Vector3(outline[i].x, outline[i].y, 0); vh.AddVert(vert); } // 生成三角形 int vertCount = outline.Count; for (int i = 0; i < vertCount; i++) { int next = (i + 1) % vertCount; vh.AddTriangle(0, i + 1, next + 1); } } }这个实现有几个关键点:圆角半径会被限制在矩形短边的一半以内,避免圆角重叠;每个圆角用cornerSegments+1个顶点来近似,段数越多越平滑但顶点也越多;所有三角形都以中心点为第一个顶点,形成扇形三角剖分。
实操心得:cornerSegments设为4时,一个圆角矩形大约有20个顶点,视觉效果已经足够平滑。如果UI元素很小(比如小于50像素),可以降到2甚至1。我做过测试,在移动端上,把cornerSegments从8降到4,UI重建耗时减少了约30%,而视觉差异几乎看不出来。
5. 常见问题与排查技巧实录
5.1 Graphic不显示或显示异常的排查思路
Graphic不显示是最常见的问题之一,原因可能有很多。我通常按以下顺序排查:
第一步,检查CanvasRenderer的状态。在Inspector里选中UI元素,看CanvasRenderer组件的Cull属性是否为true。如果为true,说明元素被裁剪了,检查父级是否有Mask或RectMask2D,以及元素是否在裁剪区域内。
第二步,检查顶点数据。如果CanvasRenderer的顶点数为0,说明OnPopulateMesh没有生成顶点,或者生成的顶点被清空了。检查自定义Graphic的OnPopulateMesh实现,确认vh.Clear()之后有AddVert和AddTriangle调用。
第三步,检查材质和纹理。如果顶点数正常但看不到东西,可能是材质或Shader的问题。检查material是否为空,Shader是否支持UI渲染,纹理是否正确设置。有时候材质的RenderQueue设置不对,会导致UI被其他物体遮挡。
第四步,检查RectTransform。尺寸为0或者位置在屏幕外的元素自然看不到。检查RectTransform的width、height、anchoredPosition、localScale等属性。
第五步,检查Color的Alpha。这个看似简单,但我见过不少案例是Color的Alpha被设成了0,或者父级的CanvasGroup的Alpha为0。
5.2 UI重建导致的卡顿问题排查
UI重建卡顿通常表现为:界面刷新时帧率骤降,Profiler里Canvas.SendWillRenderCanvases耗时很高。排查这类问题,我有一套固定的流程。
首先,用Profiler的Deep Profile模式,定位到具体的Graphic.Rebuild调用。看看是哪个组件的重建耗时最高。如果是Text,检查文字内容是否过长、是否使用了复杂的富文本;如果是Image,检查Sprite是否过于复杂、是否开启了Type为Tiled或Filled的模式。
其次,检查重建的触发源。在Profiler里看SetVerticesDirty的调用栈,找到是谁触发了重建。常见的触发源包括:Update里的属性修改、LayoutGroup的重新计算、ContentSizeFitter的尺寸调整、动画系统的驱动。
然后,检查Canvas的拆分是否合理。如果一个Canvas下有大量元素,任何一个小改动都会导致整个Canvas重建。把频繁变化的元素拆到独立Canvas下,可以显著降低重建开销。
最后,考虑用Canvas.willRenderCanvases回调来监控重建频率:
void OnEnable() { Canvas.willRenderCanvases += OnWillRenderCanvases; } void OnDisable() { Canvas.willRenderCanvases -= OnWillRenderCanvases; } void OnWillRenderCanvases() { // 在这里记录重建相关的统计信息 // 比如通过反射获取CanvasUpdateRegistry的重建队列长度 }5.3 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| UI元素不显示 | CanvasRenderer被裁剪 | 检查Cull属性 | 调整裁剪区域或关闭Mask |
| UI元素不显示 | 顶点数为0 | 检查OnPopulateMesh | 确认顶点生成逻辑 |
| UI元素不显示 | 材质/Shader问题 | 检查material和shader | 使用默认UI材质测试 |
| UI刷新卡顿 | 重建频率过高 | Profiler看Rebuild调用 | 减少属性修改,拆分Canvas |
| UI刷新卡顿 | 顶点数过多 | 检查VertexHelper的顶点数 | 简化图形,减少分段 |
| UI闪烁 | 重建顺序问题 | 检查Layout和Graphic的更新顺序 | 调整脚本执行顺序 |
| 文字模糊 | 纹理采样问题 | 检查Canvas Scaler和字体大小 | 调整像素密度和字体渲染模式 |
| Mask失效 | MaskableGraphic设置问题 | 检查maskable属性 | 确保maskable为true |
5.4 几个容易忽略的细节问题
问题一:Graphic的raycastTarget属性。这个属性控制Graphic是否接收射线检测。很多开发者习惯性地保持默认的true,但实际上,不需要点击的UI元素(如背景图、装饰文字)应该把raycastTarget设为false。每个raycastTarget为true的Graphic都会参与射线检测的计算,在复杂界面下这个开销不容忽视。我做过测试,一个包含100个UI元素的界面,把所有不必要的raycastTarget关掉后,射线检测的耗时减少了约60%。
问题二:Canvas的overrideSorting和sortingOrder。嵌套Canvas如果设置了overrideSorting,会打断合批。除非确实需要调整渲染顺序,否则不要轻易开启。我见过一个项目为了调整弹窗层级,给每个弹窗都加了overrideSorting的Canvas,结果DrawCall暴涨。
问题三:Graphic的color和CanvasRenderer的color。这两个是不同的东西。Graphic.color是顶点颜色,会影响OnPopulateMesh生成的顶点数据;CanvasRenderer.SetColor是渲染器颜色,会与顶点颜色相乘。如果你在代码里直接调用canvasRenderer.SetColor,可能会和Graphic.color产生叠加效果,导致颜色不符合预期。
问题四:Text的supportRichText。富文本解析是有开销的。如果文字内容不需要富文本,把supportRichText设为false可以省去解析开销。对于大量静态文字,这个优化累积起来很可观。
问题五:Image的preserveAspect。这个属性会保持Sprite的宽高比,但需要额外的计算。如果不需要保持宽高比,关掉它可以省一点计算量。虽然单个Image的开销很小,但在大量Image的场景下,积少成多。
5.5 性能优化的经验法则
经过多个项目的实践,我总结了几条关于Graphic性能优化的经验法则,供参考:
- 能不动就不动:静态UI元素不要每帧修改属性,动画尽量用CanvasRenderer的Alpha而不是Graphic的Color。
- 能拆就拆:动态和静态元素分Canvas,不同更新频率的元素分Canvas。
- 能简就简:减少顶点数、减少材质数、减少纹理切换、关闭不必要的raycastTarget和maskable。
- 能缓存就缓存:自定义Graphic的计算结果缓存起来,避免重复计算;频繁使用的材质和纹理缓存引用。
- 能测就测:用Profiler实测,不要凭感觉优化。很多时候你以为的瓶颈并不是真正的瓶颈。
我在实际项目里遇到过这样一个案例:一个滚动列表,每个列表项包含一个Image和两个Text。滚动时帧率只有30左右。用Profiler分析后发现,主要开销在Text的重建上——每次滚动时,列表项的复用导致Text内容变化,触发重建。后来把Text换成了TextMeshPro,并且把列表项的Canvas单独拆出来,帧率稳定在了60。这个案例说明,理解Graphic的重建机制,能帮你快速定位性能瓶颈并找到有效的解决方案。
还有一个值得分享的小技巧:如果你需要频繁修改Text的内容,可以考虑用两个Text交替显示,或者用CanvasRenderer的Alpha做淡入淡出,而不是直接改文字。这样可以把重建的频率降到最低。当然,这取决于具体的交互需求,不是所有场景都适用。
最后再提一点关于自定义Graphic的注意事项。写自定义Graphic时,一定要处理好边界情况:RectTransform尺寸为0时不要生成顶点,cornerRadius超过尺寸限制时要clamp,segments为0时要给一个合理的默认值。这些边界情况在实际运行中很容易出现,如果处理不当,轻则显示异常,重则报错崩溃。我习惯在OnPopulateMesh的开头加一段参数校验和修正的逻辑,确保后续的顶点生成不会因为异常参数而出错。