ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Unity UGUI从组件源码到性能优化实战

Unity UGUI从组件源码到性能优化实战 UGUI 这套东西我从 4.6 时代一路用到现在的 Unity 6中间踩过的坑基本能编一本小册子。经常有刚入行的朋友问我为什么我的 UI 列表越滑越卡、为什么按钮明明在屏幕上却点不动、为什么换个分辨率整套界面就散了。这些问题九成以上的答案都藏在 UGUI 那几个组件的内部实现里。这篇就按我自己带人时的讲法把 Canvas、RectTransform、Image、Text、Selectable、EventSystem、ScrollRect 这一整套 UI 系统从表层用法一直拆到源码逻辑最后附上三个可以直接拖进项目用的自定义组件源码。适合谁看刚学 Unity 3D 做界面、只会拖控件不会调参数的新手也适合做了两三年、想搞清楚为什么这么写性能就上去了的中级开发。源码部分我会贴出关键片段并解释每一行的意图不会出现复制粘贴就完事的情况。1. UGUI 的骨架长什么样1.1 先建立一个认知UGUI 本质是网格生成器很多人把 UGUI 当成一套控件库这个认知会让你后面所有的性能问题都想不明白。正确的理解是UGUI 是一套把 UI 元素实时转成三角网格、再交给 CanvasRenderer 绘制的系统。一个 Image本质上就是两个三角形、四个顶点。一个 Text每个字符大概是四个顶点加两个三角形。一张 1920x1080 的纯色背景 Image不管它看起来多大它只有 4 个顶点。而一个由 200 个字符组成的文本会产生 800 个顶点。你在 Canvas 里加的所有东西最后都会被转换成UIVertex数组塞进CanvasRenderer。这条链路是Graphic组件调用OnPopulateMesh(VertexHelper vh)生成顶点数据然后canvasRenderer.SetMesh(vh)交给原生层原生层再和材质、纹理一起决定怎么合批、怎么画。所以一个组件的性能成本等于它生成多少顶点加上它触发了多少次重建。前者跟三角形数量有关后者跟SetVerticesDirty的调用频率有关。理解了这一层后面所有优化手段就都有落脚点了。比如为什么改一个 Image 的颜色会导致整个 Canvas 重建因为Graphic.color的 setter 里调用了SetVerticesDirty()而顶点脏了就会触发CanvasUpdateRegistry把整个 Canvas 标记为需要重新生成网格。这也是为什么把高频变化的 UI 拆到独立 Canvas是 UGUI 优化的第一课。1.2 Canvas 的三种渲染模式别乱选Screen Space - Overlay 是最常用的。它把 UI 直接画在屏幕上不经过任何摄像机Canvas的sortingOrder决定它和其他 Canvas 的前后关系。优点是省事、性能好缺点是没法在 UI 和 3D 物体之间插入其他效果比如你想让一个 3D 模型挡住 UI 的一部分做不到。Screen Space - Camera 需要指定一个 Camera。UI 会被当成放在这个摄像机前方某个距离上的平面物体。它的价值在于可以把 UI 放到 3D 场景的渲染队列中间实现 3D 物体与 UI 交错遮挡。代价是它依赖摄像机的Near/Far和Plane Distance如果摄像机的视野发生裁剪UI 可能会消失。这个坑我踩过——摄像机的 Near 调得太大Plane Distance 又设得很小结果 UI 直接被裁掉了。World Space 是把 Canvas 当成一个普通的 3D 物体扔进场景可以做血条、可以做贴在墙面上的交互面板。它的问题是没有自动的像素对齐文字容易糊。血条这种小元素用 World Space 完全没问题但整个游戏的主界面千万别用它。渲染模式适用场景主要坑点Screen Space - Overlay绝大多数 2D 界面无法与 3D 物体交错遮挡Screen Space - Camera需要与 3D 内容分层遮挡受摄像机裁剪影响Plane Distance 要调好World Space血条、场景内交互面板像素不对齐文字易模糊1.3 Canvas Scaler分辨率适配的全部秘密在这几行公式里Canvas Scaler 有三个模式Constant Pixel Size、Scale With Screen Size、Constant Physical Size。实际项目里 95% 的情况用Scale With Screen Size设一个参考分辨率比如 1920x1080再调 Match 值。Match 这个滑条看起来简单但它背后的计算很多人是不知道的。源码逻辑大致是这样的CanvasScaler.HandleScaleWithScreenSizefloat logWidth Mathf.Log(screenSize.x / m_ReferenceResolution.x, kLogBase); float logHeight Mathf.Log(screenSize.y / m_ReferenceResolution.y, kLogBase); float logWeightedAverage Mathf.Lerp(logWidth, logHeight, m_MatchWidthOrHeight); scaleFactor Mathf.Pow(kLogBase, logWeightedAverage);其中kLogBase是 2。注意它是在对数空间做插值而不是线性插值。这一点非常关键Match 0.5 并不是宽度和高度各占一半的线性混合而是几何平均意义下的折中。举个具体的数参考分辨率 1920x1080设备分辨率 2340x1080。宽度比例是 1.21875高度比例是 1.0。Match 0 时scaleFactor 1.21875UI 放大 1.22 倍屏幕上横向能看到的 UI 宽度变小画面会显得挤Match 1 时scaleFactor 1.0UI 保持原尺寸横屏上多出来的空间显示为额外内容也就是俗称的多看到一块地图。所以 Match 的选择本质上是在回答一个问题你的游戏是固定视野重要还是固定内容比例重要。竖版三消类游戏通常是 UI 元素位置敏感、视野可以变Match 取 0 到 0.5 之间横版 RPG 的大世界 HUD 通常希望视野稳定Match 取 1。我自己的习惯是主玩法界面 Match 1弹窗和背包这类纯 UI 界面单独挂一个 CanvasMatch 0.5。1.4 Canvas 的嵌套与 Sub Canvas 的价值Canvas 可以嵌套。子 Canvas 拥有自己的CanvasRenderer收集范围父 Canvas 重建时不会连坐子 Canvas反过来也一样。这个特性是做性能优化的关键手段。做法很简单把那些频繁变化的元素血条、倒计时、金币数字单独提出来挂一个子 Canvas不勾 Override Sorting只当个分组容器用。这样血条每帧掉血引起的重建不会波及到后面几百个静止不动的背包格子。代价是子 Canvas 会打断合批。UGUI 的合批条件是同一张图集、同一个材质、层级连续且中间没有插队。一个 Canvas 边界就等于一次潜在的批次切分。所以拆 Canvas 这件事要权衡变化的频率 vs. 被打断的批次数量。倒计时、血条这种每帧都在动的拆出去通常稳赚一个偶尔变一次的文字拆出去反而亏。2. RectTransform 与布局系统2.1 锚点、轴心和 offsetMin / offsetMax 的真实关系RectTransform 是 UGUI 里最容易被误解的组件没有之一。它的核心变量其实就四组anchorMin、anchorMax、pivot、sizeDelta和anchoredPosition。anchorMin和anchorMax是相对父物体矩形的归一化坐标。当两者相等时锚点是一个点当两者不等时锚点是一个矩形区域。这个区别决定了一切。当锚点是一个点时sizeDelta就等于元素的实际尺寸anchoredPosition等于轴心pivot相对这个锚点的像素偏移。当你希望一个按钮固定在大小的同时贴着屏幕右上角时这就是正确用法。当锚点是一个矩形区域时情况变了sizeDelta表示的是实际尺寸减去锚点区域尺寸。这就是为什么你把一个 Image 的锚点设成全屏拉伸anchorMin 0,0anchorMax 1,1之后sizeDelta会变成 0——因为实际尺寸和锚点区域尺寸此时是同一个东西差值为零。很多人第一次看到sizeDelta (0,0)却发现元素铺满全屏就是被这一点搞懵的。offsetMin和offsetMax是更直观的一组属性它们等价于元素左下角和右上角相对锚点区域边缘的距离public Vector2 offsetMin { get { return anchoredPosition - Vector2.Scale(sizeDelta, pivot); } set { Vector2 offset value - (anchoredPosition - Vector2.Scale(sizeDelta, pivot)); sizeDelta - offset; anchoredPosition Vector2.Scale(offset, Vector2.one - pivot); } }上面是官方源码里的实现。你如果要用代码批量控制 UI 边距比如适配刘海屏、适配异形屏的安全区直接操作offsetMin / offsetMax比手算sizeDelta靠谱得多。我处理刘海屏的通用做法就是拿Screen.safeArea换算成 Canvas 坐标然后给根节点的四个边距直接赋offsetMin / offsetMax。2.2 三种尺寸模式与它们在编辑器里的表现Unity 编辑器里 RectTransform 左上角的方形锚点工具给出了三种快捷模式第一种是固定尺寸anchorMin anchorMax元素大小不随父物体变化适合按钮、图标、头像框这类。第二种是拉伸anchorMin ! anchorMax且四条边都指定了偏移元素随父物体缩放适合背景板、面板、滚动视口。第三种是按比例通常配合 pivot 使用元素大小随父物体等比变化适合那些占屏幕宽度 80%的设计稿。这三种模式不是互斥的同一层级的 UI 可以混用。真正会出事的是父子之间尺寸设定的冲突。比如父物体用固定尺寸子物体用拉伸父物体一变大小子物体跟着变看起来没问题但如果父物体挂了 LayoutGroup子物体的锚点就会被 LayoutGroup 强制改写这时候你手动设的锚点就失效了表现为改了半天没反应。注意任何挂了 LayoutGroup 或 ContentSizeFitter 的节点其子物体的 Anchors、AnchoredPosition、SizeDelta 都会被视为被驱动属性在 Inspector 里显示为灰色不可编辑。想手动调先摘掉父节点的布局组件。2.3 LayoutGroup 与 ContentSizeFitter 的重建陷阱LayoutGroupHorizontal / Vertical / Grid和 ContentSizeFitter 是 UGUI 里最方便也最危险的两个东西。危险在哪布局计算是级联的。一个 LayoutGroup 在计算之前必须先知道所有子物体的尺寸而子物体如果自己也是 LayoutGroup那就得先算它的子物体……这个链条会一直递归下去。Unity 的LayoutRebuilder为此设计了一套自底向上计算尺寸、自顶向下设置位置的流程并且会做一些脏标记合并但代价依然存在。更麻烦的是ContentSizeFitter。它需要知道内容的尺寸而内容尺寸由 LayoutGroup 决定LayoutGroup 又会因为父物体尺寸变化重新计算……这套互相依赖的关系稍不注意就形成一帧内多轮布局重建。我实测过一个案例一个 200 项的背包用的是 VerticalLayoutGroup ContentSizeFitter ScrollRect。打开背包的瞬间耗时 40ms其中 32ms 花在了布局上。改成手动计算位置、只在数据变化时刷新耗时降到 6ms。实操建议是这样的条目数超过 50 的列表不要用 LayoutGroup。自己写一个简单的流式布局或者用对象池 手动设置anchoredPosition。Grid 布局也一样GridLayoutGroup在 100 项以上的表现非常糟糕因为它每次重建都要对所有子物体排序、测量、再逐个设置位置。如果非要用 LayoutGroup那就把 ContentSizeFitter 去掉改用 ScrollRect 的content手动撑高。滚动视口的 content 高度 条目高度 * 数量 间距 * (数量 - 1) 上下 padding这个公式比任何自动布局都可靠。3. 可视组件逐个拆Image、RawImage、Text、Mask3.1 Image 的四种 Type 与合批的关系Image 有四种 TypeSimple、Sliced、Tiled、Filled。Simple 就是直接铺一个矩形4 个顶点。Sliced 是九宫格按 Sprite 导入时设置的 Border 切片顶点数会涨到 16 个以上四角不拉伸、四边单向拉伸、中间双向拉伸。Tiled 是平铺顶点数取决于平铺的重复次数一个 50x50 的图铺满 500x500 的区域就是 100 个格子顶点数直接上千。Filled 用于做进度条、冷却圈顶点数是 Simple 的两倍左右因为它的 UV 是动态计算的。性能上Tiled 是最贵的而且它的重复次数由pixelsPerUnitMultiplier和 Sprite 的 Pixels Per Unit 共同决定。很多人做描边、做纹理背景时随手选了 Tiled结果一个界面多出几千个顶点。我的做法是能用 Sliced 就别用 Tiled真的需要平铺效果用一张可重复的 Shader 配合 Simple 更划算。合批方面Image 能不能和别人合到一起取决于三点用的是不是同一张图集、材质的 shader 和关键字是否一致、在层级里中间有没有被别的材质打断。Sprite Atlas 就是为第一点服务的把同一个界面的小图打进一张图集DrawCall 能砍掉一大半。3.2 Legacy Text 的性能账以及什么时候该换 TMPUnity 自带的Text组件Legacy Text有几个硬伤它是把字体图集整张渲染出来的字符数一多顶点数线性增长它不支持 SDF放大就会糊它的换行和富文本解析都在 C# 层做字符串一长就会产生大量 GC。TextMeshProTMP解决了这些问题SDF 渲染保证任意放大都清晰字符图集按需生成富文本解析更高效还支持字间距、行间距的精细控制。代价是它需要预生成字体资产中文字体尤其需要注意——一个完整的常用汉字字库资产动辄几十 MB如果不做字符子集Character Set 选 Custom Characters 或者用 Dynamic 模式包体会很难看。我现在的原则是新项目一律用 TMPLegacy Text 只在维护老项目时保留。字符串拼接用StringBuilder而不是因为 TMP 的SetText有重载版本可以接收StringBuilder能显著降低 GC。另外 TMP 的SetText系列方法比直接改.text属性更省因为它跳过了部分校验逻辑。3.3 Mask、RectMask2D、CanvasGroup 到底差在哪这三个名字看起来都像遮罩但实现完全不同。Mask是模板缓冲方案。它会给自身和所有子物体生成一个额外的绘制步骤把内容画进模板缓冲再裁剪。好处是能实现任意形状的遮罩——前提是你挂 Mask 的节点上有个 ImageImage 的 alpha 通道就是遮罩形状。坏处是它必然增加 DrawCall而且会打断合批因为它引入了额外的渲染状态切换。RectMask2D是纯矩形的裁剪方案。它不写模板缓冲而是通过材质属性_ClipRect把裁剪区域传给 shader在片元着色器里丢掉范围外的像素。所以它不产生额外绘制步骤性能比 Mask 好很多。缺点是只能裁矩形做不了圆形头像那种效果。CanvasGroup严格来说不是遮罩它做的是三件事整组透明度alpha、整组可交互性interactable、整组射线阻挡blocksRaycasts。它的实现是在渲染时把 alpha 乘到顶点色上所以不会打断合批这也是为什么做界面淡入淡出时用 CanvasGroup 比逐个改 Image 颜色高效得多。组件实现方式额外 DrawCall适用场景Mask模板缓冲有圆形头像、异形裁剪RectMask2DShader 参数裁剪无滚动视口、矩形裁切CanvasGroup顶点色乘算无整组淡入淡出、整组禁用交互注意Mask 和 RectMask2D 都会影响子物体的渲染但不会影响射线检测。也就是说一个被裁掉的部分鼠标点上去照样能触发点击。要做视觉和交互同步的裁切得配合ICanvasRaycastFilter自己实现。4. 交互组件的源码级解析4.1 SelectableButton、Toggle、Slider 的共同基类Button、Toggle、Slider、Dropdown、Scrollbar全部继承自Selectable。这个基类管着状态机、过渡效果、导航键盘/手柄方向键。Selectable 的状态有五个Normal、Highlighted、Pressed、Selected、Disabled。状态切换时调用DoStateTransition它会根据transition的类型执行不同动作ColorTint直接改 targetGraphic 的颜色。这会调用SetVerticesDirty触发顶点重建。SpriteSwap换 Sprite。也会触发重建还可能导致合批断开。Animation驱动一个 Animator开销最大但效果最灵活。Button的点击流程是这样的OnPointerClick收到事件后判断IsActive() IsInteractable()然后调用Press()Press()里执行m_OnClick.Invoke()即onClick事件再播放一次按下音效如果配了Selectable上的 Audio Source。同一帧内的多次点击有去重逻辑m_DelayedClick控制的是延迟触发用于模拟双击。一个常见误区是按钮点不动就是脚本没挂对。实际排查顺序应该是先看 Raycast Target 有没有被关掉再看是不是被更上层的透明 Image 挡住了再看 CanvasGroup 的blocksRaycasts是不是 false最后看 EventSystem 在不在场景里。这四步能解决 90% 的点不动问题。4.2 EventSystem 与 GraphicRaycaster 的完整派发链路EventSystem是 UGUI 的输入中枢StandaloneInputModule新输入系统下是InputSystemUIInputModule负责把鼠标、触摸、键盘输入翻译成 UGUI 事件。一次鼠标点击的完整流程大致是StandaloneInputModule.Process()每帧被 EventSystem 调用。它调用GetMousePointerEventData()构造一个PointerEventData里面记录了屏幕坐标、按下状态、拖拽距离等。调用eventSystem.RaycastAll(pointerEventData, results)遍历场景里所有BaseRaycaster包括GraphicRaycaster和PhysicsRaycaster。GraphicRaycaster.Raycast拿到当前 Canvas 上所有注册过的Graphic通过GraphicRegistry.GetGraphicsForCanvas逐个调用graphic.Raycast(sp, camera, out distance)内部是判断屏幕点是否落在 RectTransform 的矩形内并且是否被ICanvasRaycastFilter组件否决。所有命中的结果会按排序规则排一次序先看sortingLayer和sortingOrder然后看depth最后看距离。排在第一个的就是最终接收事件的对象。找到目标后依次派发PointerEnter、PointerDown、PointerUp、PointerClick等事件先在该对象上执行然后沿层级向上冒泡到实现了对应IEventSystemHandler接口的父节点。理解这条链路最大的价值在于射线检测是有成本的。GraphicRaycaster会遍历当前 Canvas 上所有raycastTarget true的 Graphic。你界面上有 800 个元素每次点击就要做 800 次矩形包含判断。所以那些纯装饰的 Image、TextRaycast Target一定要关掉这是最容易被忽略的性能点之一。4.3 ScrollRect 的惯性、吸附与嵌套滚动ScrollRect的核心逻辑集中在OnDrag、OnEndDrag和LateUpdate里。拖拽时它把指针位移换算成 content 的anchoredPosition变化同时记录m_Velocity。松手后进入惯性阶段LateUpdate里每帧做m_Velocity * Mathf.Pow(m_DecelerationRate, Time.unscaledDeltaTime)然后把位移叠加到 content 上直到速度小于阈值才停下。Movement Type有三档Unrestricted完全不限制可以让 content 拖到屏幕外面去Elastic允许拖出去但会弹回来回弹速度由Elasticity控制Clamped硬性夹住不允许越界。移动端列表一般用Elastic手感自然需要精确对齐的场景用Clamped。嵌套滚动是另一个高频问题。一个横向 ScrollRect 套一个纵向 ScrollRect拖动时事件会被内层吃掉。解决办法是实现IBeginDragHandler转发或者在父级用ScrollRect的OnInitializePotentialDrag做条件判断。我一般用后者——判断当前拖拽方向的主轴如果是横向就切给父级的横向列表纵向就留给子级。这个判断逻辑不复杂但比单纯禁用内层滚动的体验好太多。还有一个坑ScrollRect 的content必须正确设置锚点和 pivot。如果 content 的锚点是个点anchorMin anchorMax它的尺寸不会随内容增长滚动范围计算就会出错表现为能拖但是拖不到底或者内容还没到底就停住了。正确的做法是把 content 的锚点设成横向拉伸、纵向顶部对齐anchorMin (0,1)anchorMax (1,1)pivot (0.5,1)然后靠 ContentSizeFitter 或者手动设置高度。5. 手写三个自定义组件附完整源码5.1 圆角矩形 RoundedRectGraphic原生的 Image 做不出圆角只能靠切图。但切图有个问题同一个圆角按钮换成不同颜色时要重新出图。自己继承MaskableGraphic生成圆角网格就能一个组件搞定所有颜色和尺寸。using System.Collections.Generic; using UnityEngine; using UnityEngine.UI; [AddComponentMenu(UI/Rounded Rect Graphic)] [RequireComponent(typeof(CanvasRenderer))] public class RoundedRectGraphic : MaskableGraphic { [SerializeField, Range(0f, 200f)] private float m_Radius 16f; [SerializeField, Range(2, 16)] private int m_CornerSegments 6; public float Radius { get { return m_Radius; } set { m_Radius Mathf.Max(0f, value); SetVerticesDirty(); } } public int CornerSegments { get { return m_CornerSegments; } set { m_CornerSegments Mathf.Clamp(value, 2, 16); SetVerticesDirty(); } } protected override void OnPopulateMesh(VertexHelper vh) { vh.Clear(); // GetPixelAdjustedRect 会把 Canvas 的缩放换算进来 // 保证在缩放分辨率下圆角的像素半径依然正确 Rect rect GetPixelAdjustedRect(); float r Mathf.Min(m_Radius, Mathf.Min(rect.width, rect.height) * 0.5f); if (r 0.01f) { // 半径为 0 时退化成普通四边形省掉无谓的顶点 AddQuad(vh, rect); return; } // 四个圆角圆心顺序右下 - 右上 - 左上 - 左下 Vector2[] centers { new Vector2(rect.xMax - r, rect.yMin r), new Vector2(rect.xMax - r, rect.yMax - r), new Vector2(rect.xMin r, rect.yMax - r), new Vector2(rect.xMin r, rect.yMin r) }; float[] startAngles { -90f, 0f, 90f, 180f }; ListVector2 outline new ListVector2((m_CornerSegments 1) * 4); for (int c 0; c 4; c) { for (int s 0; s m_CornerSegments; s) { float t (float)s / m_CornerSegments; float ang (startAngles[c] 90f * t) * Mathf.Deg2Rad; outline.Add(centers[c] new Vector2(Mathf.Cos(ang), Mathf.Sin(ang)) * r); } } UIVertex vert UIVertex.simpleVert; vert.color color; // 0 号顶点是扇形的中心放在矩形正中 vert.position rect.center; vert.uv0 new Vector2(0.5f, 0.5f); vh.AddVert(vert); for (int i 0; i outline.Count; i) { Vector2 p outline[i]; vert.position p; vert.uv0 new Vector2( Mathf.InverseLerp(rect.xMin, rect.xMax, p.x), Mathf.InverseLerp(rect.yMin, rect.yMax, p.y)); vh.AddVert(vert); } int n outline.Count; for (int i 0; i n; i) { int cur i 1; int next (i 1) % n 1; vh.AddTriangle(0, next, cur); } } private void AddQuad(VertexHelper vh, Rect rect) { vh.AddVert(new Vector3(rect.xMin, rect.yMin), color, new Vector2(0f, 0f)); vh.AddVert(new Vector3(rect.xMin, rect.yMax), color, new Vector2(0f, 1f)); vh.AddVert(new Vector3(rect.xMax, rect.yMax), color, new Vector2(1f, 1f)); vh.AddVert(new Vector3(rect.xMax, rect.yMin), color, new Vector2(1f, 0f)); vh.AddTriangle(0, 1, 2); vh.AddTriangle(2, 3, 0); } }几个关键点说一下。GetPixelAdjustedRect()是必须用的直接用rectTransform.rect在缩放分辨率下圆角半径会算错。VUIVertex.simpleVert提供了一个默认结构避免每次都 new。顶点数方面m_CornerSegments 6时整块圆角矩形是 29 个顶点、28 个三角形。如果它和普通 Image 用同一张图集是可以合批的——因为它继承自MaskableGraphic走的还是 UI/Default 材质。要让它在编辑器里实时预览重写OnValidate调用SetVerticesDirty()就行。还有一点这个组件默认raycastTarget true如果只是装饰用记得在 Inspector 里关掉。5.2 环形布局 CircularLayoutGroup做环形菜单、转盘、环形技能栏的时候用代码算位置很麻烦。写一个 LayoutGroup 子类把它当成普通布局组件用就行。using UnityEngine; using UnityEngine.UI; [AddComponentMenu(Layout/Circular Layout Group)] public class CircularLayoutGroup : LayoutGroup { [SerializeField] private float m_Radius 120f; [SerializeField] private float m_StartAngle 90f; [SerializeField] private bool m_Clockwise true; [SerializeField] private bool m_FaceCenter false; [SerializeField] private float m_RotationOffset 0f; public float Radius { get { return m_Radius; } set { m_Radius Mathf.Max(0f, value); SetDirty(); } } protected override void OnEnable() { base.OnEnable(); SetDirty(); } protected override void OnValidate() { base.OnValidate(); m_Radius Mathf.Max(0f, m_Radius); SetDirty(); } public override void CalculateLayoutInputHorizontal() { base.CalculateLayoutInputHorizontal(); float side m_Radius * 2f padding.horizontal; SetLayoutInputForAxis(side, side, -1f, 0); } public override void CalculateLayoutInputVertical() { float side m_Radius * 2f padding.vertical; SetLayoutInputForAxis(side, side, -1f, 1); } public override void SetLayoutHorizontal() { LayoutChildren(); } public override void SetLayoutVertical() { LayoutChildren(); } private void LayoutChildren() { m_Tracker.Clear(); int count rectChildren.Count; if (count 0) return; float step 360f / count; // Unity 的 Z 轴正方向是逆时针所以「顺时针」对应负角度增量 float dir m_Clockwise ? -1f : 1f; float centerX padding.left m_Radius; float centerY padding.bottom m_Radius; for (int i 0; i count; i) { RectTransform child rectChildren[i]; // 交给 Tracker 托管编辑器里这些属性会变灰 // 避免手动改完被下一次布局覆盖掉 m_Tracker.Add(this, child, DrivenTransformProperties.Anchors | DrivenTransformProperties.AnchoredPosition | DrivenTransformProperties.Pivot | DrivenTransformProperties.Rotation); child.anchorMin child.anchorMax Vector2.zero; child.pivot new Vector2(0.5f, 0.5f); float deg m_StartAngle dir * step * i; float rad deg * Mathf.Deg2Rad; child.anchoredPosition new Vector2( centerX Mathf.Cos(rad) * m_Radius, centerY Mathf.Sin(rad) * m_Radius); child.localRotation m_FaceCenter ? Quaternion.Euler(0f, 0f, -deg m_RotationOffset) : Quaternion.Euler(0f, 0f, m_RotationOffset); } } }有一个细节值得展开m_Tracker.Add这一步。LayoutGroup 通过DrivenRectTransformTracker声明这些属性归我管编辑器会把它们变成灰色不可编辑状态同时在组件被移除时自动恢复。如果你偷懒不写这套代码在运行时是能跑的但美术在编辑器里一改子物体的位置就会被下一次布局冲掉调试起来很抓狂。我早期写自定义布局时就吃过这个亏后来养成习惯只要布局组件动了子物体的属性就必须登记到 Tracker 里。CalculateLayoutInputHorizontal/Vertical这两个方法决定了容器自身的首选尺寸。如果父级还有别的布局组件比如外层的 VerticalLayoutGroup这两个值会直接影响父级的排布结果。半径 120 时容器首选尺寸就是 240 加 padding简单直接。5.3 顶点灰化效果 GrayScaleEffect置灰按钮是 UI 开发里的高频需求。常见的做法是给每种状态准备一张灰图或者用 Shader 变体。用BaseMeshEffect在顶点层面做好处是不增加材质、不打断合批缺点是颜色精度受顶点色限制每通道 8 位。using UnityEngine; using UnityEngine.UI; [AddComponentMenu(UI/Effects/Gray Scale Effect)] public class GrayScaleEffect : BaseMeshEffect { [SerializeField, Range(0f, 1f)] private float m_Amount 1f; public float Amount { get { return m_Amount; } set { m_Amount Mathf.Clamp01(value); graphic.SetVerticesDirty(); } } public override void ModifyMesh(VertexHelper vh) { if (!IsActive() || vh.currentVertCount 0) return; UIVertex vert UIVertex.simpleVert; for (int i 0; i vh.currentVertCount; i) { vh.PopulateUIVertex(ref vert, i); Color32 c vert.color; // 人眼亮度权重比简单的 (rgb)/3 更接近真实观感 byte gray (byte)(c.r * 0.299f c.g * 0.587f c.b * 0.114f); float t m_Amount; c.r (byte)Mathf.Lerp(c.r, gray, t); c.g (byte)Mathf.Lerp(c.g, gray, t); c.b (byte)Mathf.Lerp(c.b, gray, t); vert.color c; vh.SetUIVertex(vert, i); } } }BaseMeshEffect的执行时机是在Graphic.OnPopulateMesh之后、提交给 CanvasRenderer 之前。ModifyMesh收到的VertexHelper已经是完整的顶点流你只需要遍历修改。IsActive()会检查组件是否启用、graphic是否存在省掉自己写判空。这里有个实测出来的注意点ModifyMesh里的循环不要做任何分配UIVertex用UIVertex.simpleVert复用别在循环里 new 数组。一个 200 字符的文本有 800 个顶点每次置灰切换都新分配一次数组GC 压力会上来。另外PopulateUIVertex和SetUIVertex成对使用直接改vh内部的数组是不安全的因为顶点流可能已经被压缩或重排过。6. 性能优化与问题排查实录6.1 重建与合批把 Canvas 拆对位置UGUI 的性能问题基本可以归成两类重建太多和批次太碎。这两个问题的解法往往是互相冲突的。重建的源头是SetVerticesDirty和SetLayoutDirty。前者由颜色、尺寸、文本内容变化触发后者由 LayoutGroup 相关的变化触发。一个 Canvas 上的任意一个元素脏了整个 Canvas 都会走一遍重建流程——这就是为什么拆 Canvas 有用。拆的原则我总结成一句话按变化频率分家按图集归属合户。高频变化的血条、倒计时、连击数字、技能冷却单独一个 Canvas静态的背景、面板底图、标题文字放一起共享一张图集同一个 ScrollRect 里的条目放一个 Canvas因为它们本来就是同一张图集、同一批滚动。补充一条很多人不知道的CanvasRenderer.cull可以用来关闭某个渲染器的绘制性能比SetActive(false)略好一点因为它不触发整个层级结构的变化但SetActive会真正跳过OnPopulateMesh在元素数量大的时候反而更划算。我一般是对单个小元素用cull对整块面板用SetActive。6.2 常见问题速查表现象常见原因排查方法按钮点不动Raycast Target 被关 / 被透明 Image 挡 / CanvasGroup.blocksRaycasts 为 false逐层检查 Graphic 的 raycastTarget用 Frame Debugger 看层级UI 在有的分辨率下错位锚点设置与父级尺寸模式冲突检查锚点是否被父级 LayoutGroup 驱动文字发虚Canvas Scaler 缩放产生半像素 / TMP 未用 SDF检查 Canvas 的缩放因子与像素对齐设置图片边缘拉伸变形Sprite 的 Border 未设置或九宫格 border 太小在 Sprite 导入设置里补 Border用 Sliced 模式滚动列表越滑越卡LayoutGroup 每帧重建 / 条目未做对象池Profiler 看Canvas.SendWillRenderCanvases耗时Mask 下的元素不显示子物体材质不支持模板缓冲检查自定义 Shader 是否有 Stencil 相关设置圆形头像边缘有锯齿Mask 的模板缓冲精度 / 未开抗锯齿换用 Alpha 裁剪的 Shader 方案界面首次打开卡顿大量 Graphic 首次生成顶点提前 SetActive 预热或用对象池预创建6.3 几条踩坑之后才明白的经验第一条别在 Update 里改 UI 的属性。血条每帧改fillAmount倒计时每帧改text这些都是重建源。正确的做法是把改变节流到变化真的发生时或者用自定义的 Shader 参数比如用MaterialPropertyBlock传一个 0 到 1 的值给进度条 Shader这样才能绕开顶点重建。第二条Inspector 里的raycastTarget默认是勾上的。Text 和 Image 都是。一个界面几十个文本每个都在参与射线检测点击一下就是几十次RectangleContainsScreenPoint。养成习惯新建一个 Image 或 Text 之后如果它不需要响应点击立刻把勾去掉。我在项目里加过一个编辑器脚本在Awake时自动把没有Selectable父级的 Text 的raycastTarget关掉效果立竿见影。第三条Sprite Atlas 里的 Packing 模式要选对。默认的Tight模式虽然省空间但旋转和对齐可能出问题尤其是九宫格图。九宫格图建议强制Rectangle模式并且勾上Allow Rotation的关闭选项否则边缘会出现像素错位。第四条TMP 的字体资产在运行时扩容会触发重建。如果你的 TMP 字体用的是 Dynamic 模式当出现字库中没有的字符时它会在运行时往图集里塞新字形这一步会触发纹理上传和可能的图集重排。中文字库尤其明显。我的做法是先统计项目里所有可能出现的中文用 Character Set 的 Custom 模式打包成静态字库如果确实无法穷举比如玩家可以输入昵称那就把动态字体单独放在一个 Canvas 上别让它污染主界面。最后一条也是我最想强调的先用 Profiler 看到底卡在哪再动手优化。UGUI 的性能问题Profiler 里的Canvas.SendWillRenderCanvases对应的是重建总耗时Canvas.BuildBatch对应的是合批耗时Canvas.RenderOverlays对应的是实际绘制。这三个数看清楚了问题基本就定位了。我曾经花了半天时间重构一个列表的布局逻辑结果 Profiler 一开真正的大头是那个每帧都在改颜色的背景图。数据不会骗人直觉经常会。
返回列表