Skip to content

题海扩展:UGUI 与 UI 框架

Canvas 分层怎么设计?

unity-canvas-layering-design

标准答案

Canvas 分层我会按三件事设计:显示层级、更新频率、交互职责

常见结构是:

SceneHUD:怪物头顶血条、名字、世界提示。 HUD:血量、技能、摇杆、小地图。 Window:背包、商店、任务、设置。 Popup:确认框、奖励弹窗、二级弹窗。 Guide:新手引导、强制点击遮罩。 Toast / Loading:提示、转圈、全屏加载,通常最高层。

Unity 官方建议拆分 Canvas,因为单个 UI 元素变化会让整个 Canvas 重新分析和批处理;拆成多个 Canvas 后,每个 Canvas 像一个独立岛,能隔离 Rebuild 影响。

参考:Unity UI optimization tips

底层原理

Canvas 会把 UI 元素生成网格并批处理提交给 GPU。 如果一个大 Canvas 里有上千个元素,只改一个倒计时文本,也可能让整个 Canvas 重新计算批次,造成 Canvas.BuildBatch 或 Layout Rebuild 卡顿。

所以分层不是越多越好,而是:

经常变的和不常变的分开。 可交互和纯展示分开。 全屏遮挡和底层 HUD 分开。 列表、血条、倒计时这种高频变化内容单独拆子 Canvas。

显示顺序上,Canvas.sortingOrder 越高,越显示在上层。官方文档也说明:同一个 Sorting Layer 内,sorting order 更高的 Canvas 会显示在更低的上面。

参考:Canvas.sortingOrder

代码示例

c
using UnityEngine; // 引入 Unity 基础类型。
using UnityEngine.UI; // 引入 GraphicRaycaster 所在命名空间。
public enum UILayer // 定义 UI 层级枚举。
{ // 枚举开始。
    SceneHUD = 0, // 世界空间血条和头顶提示层。
    HUD = 100, // 主界面常驻 HUD 层。
    Window = 200, // 背包、商店、任务等窗口层。
    Popup = 300, // 确认框、奖励弹窗等弹窗层。
    Guide = 400, // 新手引导和强制遮罩层。
    Loading = 500 // 加载界面和最高优先级遮罩层。
} // 枚举结束。
public static class CanvasLayerUtility // 定义 Canvas 分层工具类。
{ // 类开始。
    public static void Setup(Canvas canvas, UILayer layer, bool interactive) // 配置 Canvas 层级和交互能力。
    { // 方法开始。
        canvas.overrideSorting = true; // 让这个 Canvas 使用自己的排序值。
        canvas.sortingOrder = (int)layer; // 设置 Canvas 显示层级,数值越大越靠上。
        GraphicRaycaster raycaster = canvas.GetComponent<GraphicRaycaster>(); // 获取当前 Canvas 上的 GraphicRaycaster。
        if (raycaster != null) // 如果这个 Canvas 挂了 GraphicRaycaster。
        { // 判断开始。
            raycaster.enabled = interactive; // 只有需要交互的层才开启射线检测。
        } // 判断结束。
    } // 方法结束。
} // 类结束。

项目实践

HUD 里我会继续拆:静态底框一个 Canvas,动态血量、CD、倒计时一个或多个子 Canvas。 ScrollView 单独拆,因为列表刷新和复用会频繁改层级。 全屏界面打开时,后面的 HUD 或 3D 场景如果完全被遮住,可以禁用对应 Canvas 或 Camera,减少绘制。Unity 官方也建议全屏 UI 覆盖时隐藏背后的 Canvas 或 Camera。

常见坑

不要所有 UI 都放一个 Canvas。 不要每个小控件都建一个 Canvas。 不要所有 Canvas 都挂 GraphicRaycaster。 纯装饰图片、文本要关闭 Raycast Target。 频繁变化的文本、LayoutGroup、Animator 不要和大静态界面混在一起。

静态 UI 和动态 UI 是否应该放一个 Canvas?

unity-static-dynamic-ui-separate-canvas

标准答案

一般不应该把静态 UI 和动态 UI 放在同一个大 Canvas 里。 原因是:动态 UI 一变化,比如血量、倒计时、技能 CD、列表滚动,就会让它所在的 Canvas 变脏,触发 Canvas 重新分析、重新生成网格、重新批处理。静态 UI 本来没变,但如果和动态 UI 混在一起,也可能被牵连。

Unity 官方优化建议也明确提到:要拆分 Canvas,把静态元素和动态元素分开,让每个 Canvas 像独立岛一样隔离 Rebuild 影响。

参考:Unity UI optimization tipsWorking with static and dynamic Canvases

底层原理

UGUI 的 Canvas 会把下面的 UI 元素生成渲染网格并做批处理。 如果一个 Canvas 里有很多元素,只改了一个动态文本,也可能导致整个 Canvas 重新计算。

所以:

静态 UI:背景框、固定图标、装饰边框,放 StaticCanvas

动态 UI:血量、CD、倒计时、列表内容,放 DynamicCanvas

这样动态部分变化时,只重建动态 Canvas,不影响静态 Canvas。

代码示例

c
using UnityEngine; // 引入 Unity 基础类型。
using UnityEngine.UI; // 引入 GraphicRaycaster 类型。
public sealed class CanvasSplitExample : MonoBehaviour // 定义 Canvas 拆分示例脚本。
{ // 类开始。
    [SerializeField] private Canvas staticCanvas; // 静态 Canvas,放背景框和固定装饰。
    [SerializeField] private Canvas dynamicCanvas; // 动态 Canvas,放血量、CD、倒计时等高频变化 UI。
    [SerializeField] private GraphicRaycaster staticRaycaster; // 静态层射线检测组件。
    [SerializeField] private GraphicRaycaster dynamicRaycaster; // 动态层射线检测组件。
    private void Awake() // 初始化 Canvas 设置。
    { // 方法开始。
        staticCanvas.overrideSorting = true; // 让静态 Canvas 使用自己的排序值。
        dynamicCanvas.overrideSorting = true; // 让动态 Canvas 使用自己的排序值。
        staticCanvas.sortingOrder = 100; // 静态层先绘制,作为底层 UI。
        dynamicCanvas.sortingOrder = 110; // 动态层后绘制,显示在静态层上面。
        staticRaycaster.enabled = false; // 静态装饰层不需要点击检测,关闭 Raycaster。
        dynamicRaycaster.enabled = true; // 动态交互层需要点击时才开启 Raycaster。
    } // 方法结束。
} // 类结束。

面试加分点

拆 Canvas 不是越多越好。Canvas 太多也会增加管理成本和额外批次。我的原则是:一起变化的放一起,变化频率不同的分开,纯装饰层不要参与 Raycast

如果界面很小,只打开时整体刷新一次,放一个 Canvas 也可以。真正需要拆的是 HUD、技能 CD、血条、ScrollView、大量列表 Item 这种高频变化区域。

GraphicRaycaster 会产生什么开销?

unity-graphicraycaster-cost

标准答案

GraphicRaycaster 的开销主要来自:它要在输入发生时,遍历当前 Canvas 上可被射线命中的 Graphic,比如 ImageTextRawImage,然后做矩形命中、过滤、排序,最后把事件发给最上层 UI。

Unity 官方优化建议里也提到:没有交互的 Canvas 不要挂 GraphicRaycaster,不需要点击的 Image / Text 要关闭 Raycast Target

参考:Unity UI optimization tipsGraphic Raycaster

底层原理

一次点击大概是:

Input -> EventSystem -> GraphicRaycaster -> 遍历候选 Graphic -> 命中测试 -> 排序 -> 分发事件

所以开销和这些因素强相关:

Canvas 上 Raycast Target = true 的 UI 数量。

当前有多少个启用的 GraphicRaycaster

是否开启了 Blocking Objects,如果开了,可能额外做 2D/3D 物理射线。

是否有大量透明图片、装饰文本也参与射线检测。

代码示例

c
using UnityEngine; // 引入 Unity 基础类型。
using UnityEngine.UI; // 引入 Graphic 和 GraphicRaycaster。
public sealed class UIRaycastOptimizer : MonoBehaviour // 定义 UI 射线检测优化脚本。
{ // 类开始。
    [SerializeField] private GraphicRaycaster decorativeCanvasRaycaster; // 纯装饰 Canvas 上的 GraphicRaycaster。
    [SerializeField] private Transform decorativeRoot; // 纯装饰 UI 的根节点。
    private void Awake() // 初始化时执行优化。
    { // 方法开始。
        if (decorativeCanvasRaycaster != null) // 如果纯装饰 Canvas 上存在 Raycaster。
        { // 判断开始。
            decorativeCanvasRaycaster.enabled = false; // 关闭它,避免这个 Canvas 参与 UI 射线检测。
        } // 判断结束。
        if (decorativeRoot == null) // 如果没有配置装饰根节点。
        { // 判断开始。
            return; // 直接结束。
        } // 判断结束。
        Graphic[] graphics = decorativeRoot.GetComponentsInChildren<Graphic>(true); // 获取装饰节点下所有 Graphic。
        for (int i = 0; i < graphics.Length; i++) // 遍历所有 Graphic。
        { // 循环开始。
            graphics[i].raycastTarget = false; // 关闭装饰图片和文本的 Raycast Target。
        } // 循环结束。
    } // 方法结束。
} // 类结束。

项目实践

按钮本身的点击区域要保留 Raycast Target,但按钮下面的文字、图标、装饰边框通常可以关掉。 HUD、背景、装饰层如果完全不需要点击,就直接移除或禁用 GraphicRaycaster。 如果是全屏弹窗遮罩,只让遮罩或按钮接收射线,不要让所有子节点都参与检测。

面试加分点

不要说“GraphicRaycaster 一定很慢”,更准确是:候选 UI 越多、Raycaster 越多、Blocking 检测越复杂,开销越明显。定位时看 Profiler 里的 EventSystem、UI Raycast、Canvas.BuildBatch,再检查 Raycast Target 数量。

为什么 Image 默认开启 Raycast Target 可能有问题?

unity-image-default-raycast-target-problem

标准答案

Image 默认开启 Raycast Target 可能有两个问题:性能开销变大误拦截点击

Raycast Target 的含义是:这个 Graphic 是否要被 UI 射线检测当作目标。Unity 官方 Graphic.raycastTarget 文档也说明,它决定这个 Graphic 是否被视为 raycast 目标。

参考:Graphic.raycastTarget

底层原理

点击 UI 时流程大概是:

c
Input -> EventSystem -> GraphicRaycaster -> 遍历 Raycast Target 开启的 Graphic -> 排序 -> 找最上层 UI

如果背景图、边框图、装饰图、按钮里的纯展示图标都开着 Raycast Target,它们都会进入候选列表。UI 越复杂,候选越多,检测越重。

更麻烦的是:有些透明 Image、大背景图、装饰图可能挡在按钮或场景点击前面,导致“按钮点不到”“场景点击被 UI 吃掉”“拖拽事件异常”。

Unity 官方 UI 优化建议也明确提到:不需要交互的 Text / Image 要关闭 Raycast Target。

参考:Unity UI optimization tips

什么时候应该关闭

背景图、边框、装饰线、纯展示图标、纯展示文字,一般都关。

按钮本体、拖拽区域、ScrollView 可交互区域、全屏遮罩、点击空白关闭弹窗的背景,可以保留。

代码示例:批量关闭装饰 UI 的 Raycast Target

c
using UnityEngine; // 引入 Unity 基础类型。
using UnityEngine.UI; // 引入 Graphic、Image、Text 等 UI 类型。
public sealed class DisableDecorativeRaycastTarget : MonoBehaviour // 定义关闭装饰 UI 射线检测的脚本。
{ // 类开始。
    [SerializeField] private Transform decorativeRoot; // 装饰 UI 的根节点。
    private void Awake() // 初始化时执行。
    { // 方法开始。
        Graphic[] graphics = decorativeRoot.GetComponentsInChildren<Graphic>(true); // 获取所有子节点上的 Graphic。
        for (int i = 0; i < graphics.Length; i++) // 遍历所有 Graphic。
        { // 循环开始。
            graphics[i].raycastTarget = false; // 关闭 Raycast Target,让它不参与 UI 点击检测。
        } // 循环结束。
    } // 方法结束。
} // 类结束。

面试加分点

不要说“所有 Image 都关掉”,正确说法是:只有真正需要接收点击、拖拽、遮挡点击的 Image 才开启。装饰图默认关闭,这样可以减少 GraphicRaycaster 的候选数量,也能避免透明图误挡点击。

LayoutGroup 为什么会引起频繁重建?

unity-layoutgroup-frequent-rebuild

标准答案

LayoutGroup 容易引起频繁重建,是因为它是“自动排版系统”。子节点数量、启用状态、文本内容、尺寸、层级顺序一变化,父级 LayoutGroup 就可能被标记为 dirty,然后在布局阶段重新计算子节点的位置和大小。

Unity 官方 Auto Layout 文档里说明,LayoutGroup 会根据子元素的 minimum / preferred / flexible 尺寸来分配空间,并且布局重建通常会在当前帧末尾、渲染前执行。

参考:Unity Auto Layout

底层原理

流程大概是:

子 UI 变化 -> 标记 Layout dirty -> 向父级查找 LayoutGroup -> 计算子元素尺寸 -> 设置 RectTransform -> Canvas Rebuild

比如背包里新增一个 Item,VerticalLayoutGroup 要重新算每个子节点的位置;如果 Item 里还有 ContentSizeFitter、嵌套 LayoutGroup、Text 自动尺寸,就会进一步触发更多布局计算。

Unity 官方 UI 优化建议也明确说:当子 UI 让布局 dirty 时,会向父级查找有效 LayoutGroup;每多一层 LayoutGroup,都可能增加 GetComponent 查找和重建成本,所以嵌套 LayoutGroup 对性能很不友好。

参考:Unity UI optimization tips

高频危险场景

背包格子频繁增删。 聊天列表持续刷消息。 排行榜不断刷新。 任务列表动态展开收起。 LayoutGroup + ContentSizeFitter + Text 多层嵌套。 ScrollView 里每个 Item 都挂 LayoutGroup。

优化思路

低频界面可以用 LayoutGroup,开发效率高。 高频列表尽量用对象池和虚拟列表。 固定格子用手写坐标或 Grid 计算。 批量改 UI 时,先批量改完,再统一 Rebuild 一次。 不要在每帧强制 ForceRebuildLayoutImmediate

代码示例:批量刷新后只重建一次

c
using System.Collections.Generic; // 引入 IReadOnlyList,用来传入要显示的 Item 列表。
using UnityEngine; // 引入 Unity 基础类型。
using UnityEngine.UI; // 引入 LayoutGroup、ContentSizeFitter、LayoutRebuilder。
public sealed class LayoutBatchUpdater : MonoBehaviour // 定义一个批量刷新 Layout 的组件。
{ // 类开始。
    [SerializeField] private RectTransform contentRoot; // ScrollView 或列表内容根节点。
    [SerializeField] private LayoutGroup layoutGroup; // 当前列表使用的 LayoutGroup。
    [SerializeField] private ContentSizeFitter contentSizeFitter; // 当前列表可能使用的 ContentSizeFitter。
    public void RefreshItems(IReadOnlyList<GameObject> items) // 批量刷新列表 Item。
    { // 方法开始。
        layoutGroup.enabled = false; // 临时关闭自动布局,避免每次改 Item 都触发布局计算。
        contentSizeFitter.enabled = false; // 临时关闭自适应尺寸,避免和 LayoutGroup 互相触发。
        for (int i = 0; i < items.Count; i++) // 遍历所有需要显示的 Item。
        { // 循环开始。
            items[i].transform.SetParent(contentRoot, false); // 把 Item 放到列表根节点下。
            items[i].SetActive(true); // 激活 Item,让它参与最终显示。
        } // 循环结束。
        layoutGroup.enabled = true; // 批量修改结束后重新开启自动布局。
        contentSizeFitter.enabled = true; // 重新开启尺寸适配。
        LayoutRebuilder.ForceRebuildLayoutImmediate(contentRoot); // 手动重建一次布局,不要每帧调用。
    } // 方法结束。
} // 类结束。

面试加分点

我不会简单说“LayoutGroup 不能用”。更准确是:它适合静态或低频变化 UI,不适合高频、大量、嵌套的动态列表。真正上线优化时,我会用 Profiler 看 LayoutRebuildCanvas.BuildBatch,再决定是拆 Canvas、去掉 LayoutGroup、做虚拟列表,还是手写布局。

ContentSizeFitterLayoutGroup 混用有什么坑?

unity-contentsizefitter-layoutgroup-pitfalls

标准答案

ContentSizeFitterLayoutGroup 混用最大的坑是:尺寸控制权冲突

LayoutGroup 是父级布局组件,它想控制子节点的位置和尺寸;ContentSizeFitter 是自适应尺寸组件,它想根据内容控制自己这个 RectTransform 的尺寸。 如果一个子节点既被父级 LayoutGroup 控制,又自己挂了 ContentSizeFitter 控制自身尺寸,就会出现“父级改子节点,子节点又改自己”的循环,结果可能是布局抖动、频繁 Rebuild、尺寸不稳定、性能变差。

Unity 官方文档也明确说:不要把 ContentSizeFitter 放到 LayoutGroup 的每个子物体上,因为 Fitter 想控制自己的 RectTransform,而父 LayoutGroup 也想控制这个子物体的 RectTransform,结果是 undefined behavior。

参考:Making UI elements fit content

底层原理

正确理解这两个组件:

LayoutGroup:负责“排别人”,比如父节点控制子节点位置和尺寸。 ContentSizeFitter:负责“改自己”,根据内容的 preferred size 改自己的宽高。

危险结构:

Parent: VerticalLayoutGroupChild: ContentSizeFitter

这时父级说:“Child 你应该这么大。” Child 又说:“不,我要根据 Text 内容自己变大。” 于是布局系统反复 dirty,尤其在聊天、背包、排行榜、任务列表这种动态 UI 里很容易卡。

可以混用的情况

不是所有混用都错。官方示例里提到,可以在同一个 UI 元素上同时放 HorizontalLayoutGroupContentSizeFitter,比如一个按钮根据子 Text 自动变宽:LayoutGroup 读取子 Text 的 preferred size,Fitter 再让按钮 Root 适配这个 preferred size。关键是:同一条轴向不要有两个组件抢同一个 RectTransform 的控制权

推荐做法

如果父节点已经有 VerticalLayoutGroup / HorizontalLayoutGroup,子节点不要再挂 ContentSizeFitter。 子节点需要指定尺寸时,用 LayoutElement 提供 preferredWidth / preferredHeight,让父 LayoutGroup 统一排版。 高频列表里,最好用对象池、虚拟列表、手写坐标,少用多层自动布局。

代码示例:用 LayoutElement 替代子节点 ContentSizeFitter

c
using UnityEngine; // 引入 Unity 基础类型。
using UnityEngine.UI; // 引入 LayoutElement 和 LayoutRebuilder。
public sealed class ChatItemLayout : MonoBehaviour // 定义聊天 Item 布局脚本。
{ // 类开始。
    [SerializeField] private Text messageText; // 聊天文本组件。
    [SerializeField] private LayoutElement layoutElement; // 用 LayoutElement 向父 LayoutGroup 提供尺寸信息。
    [SerializeField] private float minHeight = 48f; // 聊天气泡最小高度。
    [SerializeField] private float paddingHeight = 24f; // 文本上下留白高度。
    public void SetMessage(string message) // 设置聊天消息内容。
    { // 方法开始。
        messageText.text = message; // 更新聊天文本。
        float textHeight = messageText.preferredHeight; // 读取文本希望占用的高度。
        float finalHeight = Mathf.Max(minHeight, textHeight + paddingHeight); // 计算最终 Item 高度。
        layoutElement.preferredHeight = finalHeight; // 把高度交给父级 LayoutGroup 排版。
        LayoutRebuilder.MarkLayoutForRebuild((RectTransform)transform.parent); // 标记父布局需要刷新。
    } // 方法结束。
} // 类结束。

面试加分点

我会这样总结:ContentSizeFitterLayoutGroup 不是不能一起出现,而是要保证布局控制方向清楚。Root 可以用 Fitter 适配整体内容;但被父 LayoutGroup 管的子节点不要再用 Fitter 管自己。高频列表里,我会优先用 LayoutElement + 父 LayoutGroup,或者直接手写布局和虚拟列表。

MaskRectMask2D 区别是什么?

unity-mask-vs-rectmask2d

标准答案

MaskRectMask2D 都是 UGUI 里用来裁剪子 UI 的组件,但它们的裁剪方式不同。

Mask 是按父物体 Graphic 的形状裁剪子节点,比如圆形头像、异形技能图标。Unity 官方文档说,Mask 会把子元素限制在父元素的形状范围内。

参考:Mask

RectMask2D 是按父节点 RectTransform 的矩形范围裁剪子节点,常用于 ScrollView、聊天列表、背包列表。官方文档也说明,RectMask2D 类似 Mask,但限制在父元素矩形内,并且不使用 stencil buffer、没有额外 DrawCall、没有材质变化,性能更快。

参考:RectMask2D

底层原理

Mask 常见实现依赖 stencil buffer。它先把遮罩区域写入模板缓冲,然后子 UI 渲染时根据 stencil 判断哪些像素能显示。优点是形状灵活,缺点是嵌套 Mask 时 stencil 层级复杂,可能带来额外材质和批次问题。

RectMask2D 不按图片形状裁剪,只计算一个矩形裁剪区域,把超出矩形的子 UI 裁掉。它限制更多,但对矩形窗口非常合适,尤其是 ScrollView。

怎么选

圆形头像、异形头像框、技能图标裁剪:用 Mask

ScrollView、排行榜、聊天列表、背包格子窗口:优先用 RectMask2D

如果只是矩形裁剪,不要用 Mask 硬做,移动端上尤其要少用深层嵌套 Mask。

代码示例:给 ScrollView Viewport 使用 RectMask2D

c
using UnityEngine; // 引入 Unity 基础类型。
using UnityEngine.UI; // 引入 Mask、RectMask2D、Image 等 UI 类型。
public sealed class ScrollViewMaskSetup : MonoBehaviour // 定义 ScrollView 裁剪设置脚本。
{ // 类开始。
    [SerializeField] private RectTransform viewport; // ScrollView 的 Viewport 节点。
    private void Awake() // 初始化时执行。
    { // 方法开始。
        Mask oldMask = viewport.GetComponent<Mask>(); // 检查 Viewport 上是否挂了普通 Mask。
        if (oldMask != null) // 如果存在普通 Mask。
        { // 判断开始。
            Destroy(oldMask); // 删除普通 Mask,避免矩形列表使用 stencil 裁剪。
        } // 判断结束。
        RectMask2D rectMask = viewport.GetComponent<RectMask2D>(); // 检查是否已经有 RectMask2D。
        if (rectMask == null) // 如果还没有 RectMask2D。
        { // 判断开始。
            rectMask = viewport.gameObject.AddComponent<RectMask2D>(); // 添加 RectMask2D,用矩形范围裁剪列表内容。
        } // 判断结束。
        Image viewportImage = viewport.GetComponent<Image>(); // 获取 Viewport 上可能存在的 Image。
        if (viewportImage != null) // 如果 Viewport 有 Image。
        { // 判断开始。
            viewportImage.raycastTarget = false; // Viewport 背景不需要点击时,关闭 Raycast Target。
        } // 判断结束。
    } // 方法结束。
} // 类结束。

面试加分点

一句话:Mask 是“形状裁剪”,RectMask2D 是“矩形裁剪”。 项目里大部分列表、滚动区域都优先用 RectMask2D;只有真的需要圆形、异形、按图片 alpha 裁剪时才用 Mask

ScrollRect 大量元素怎么优化?

unity-scrollrect-large-list-optimization

标准答案

ScrollRect 大量元素的核心优化是:虚拟列表 + 对象池。 数据可以有 5000 条,但真实 UI 节点只保留屏幕可见的十几个或几十个。滚动时不创建新 Item,而是把已有 Item 移到新位置,并重新绑定对应数据。

Unity 官方文档里 ScrollRect 本身只是提供小区域滚动大量内容的能力;而 Unity UI 优化建议明确提到,大列表和网格很贵,应该复用更小的 UI 对象池,而不是每条数据都创建一个 UI。

参考:ScrollRectUnity UI optimization tips

底层原理

普通做法:

c
5000 条数据 -> 5000 个 GameObject -> 5000 个 RectTransform / Image / Text

问题是:创建慢、GC 高、LayoutRebuild 高、Canvas.BuildBatch 高、Raycast 候选多、滚动掉帧。

虚拟列表做法:

5000 条数据 -> 只创建可见数量 + 缓冲数量

比如屏幕只能显示 10 个 Item,就创建 14 个 Item。滚动时根据 content.anchoredPosition.y 计算当前第一个可见下标,然后复用这些 Item 绑定新数据。

代码示例:固定高度竖向虚拟列表

c
using System.Collections.Generic; // 引入 IReadOnlyList,用来保存列表数据。
using UnityEngine; // 引入 MonoBehaviour、RectTransform、Mathf 等 Unity 类型。
using UnityEngine.UI; // 引入 ScrollRect 组件。
public interface IVirtualListItem // 定义虚拟列表 Item 需要实现的接口。
{ // 接口开始。
    void Bind(int index, object data); // 绑定当前下标和数据。
} // 接口结束。
public sealed class FixedHeightVirtualList : MonoBehaviour // 定义固定高度竖向虚拟列表。
{ // 类开始。
    [SerializeField] private ScrollRect scrollRect; // 引用 ScrollRect。
    [SerializeField] private RectTransform content; // 引用 ScrollRect 的 Content。
    [SerializeField] private RectTransform itemPrefab; // 引用 Item 预制体。
    [SerializeField] private float itemHeight = 80f; // 每个 Item 的固定高度。
    [SerializeField] private int extraBuffer = 2; // 屏幕外额外保留的缓冲 Item 数量。
    private readonly List<RectTransform> itemViews = new List<RectTransform>(); // 保存复用中的 Item 视图。
    private IReadOnlyList<object> dataList; // 保存完整数据列表。
    private int visibleCount; // 保存需要创建的可见 Item 数量。
    private void Awake() // 初始化阶段执行。
    { // 方法开始。
        scrollRect.onValueChanged.AddListener(_ => RefreshVisibleItems()); // 监听滚动变化并刷新可见 Item。
    } // 方法结束。
    public void SetData(IReadOnlyList<object> newDataList) // 设置完整数据源。
    { // 方法开始。
        dataList = newDataList; // 保存数据源。
        float viewportHeight = scrollRect.viewport.rect.height; // 获取可视区域高度。
        visibleCount = Mathf.CeilToInt(viewportHeight / itemHeight) + extraBuffer * 2; // 计算需要创建的 Item 数量。
        content.sizeDelta = new Vector2(content.sizeDelta.x, dataList.Count * itemHeight); // 设置 Content 总高度。
        CreatePoolIfNeeded(); // 确保对象池数量足够。
        RefreshVisibleItems(); // 立即刷新当前可见内容。
    } // 方法结束。
    private void CreatePoolIfNeeded() // 创建或补足 Item 对象池。
    { // 方法开始。
        while (itemViews.Count < visibleCount) // 如果池子数量不足。
        { // 循环开始。
            RectTransform item = Instantiate(itemPrefab, content); // 实例化一个 Item 到 Content 下。
            item.gameObject.SetActive(true); // 激活 Item。
            itemViews.Add(item); // 加入复用列表。
        } // 循环结束。
    } // 方法结束。
    private void RefreshVisibleItems() // 根据滚动位置刷新可见 Item。
    { // 方法开始。
        if (dataList == null || dataList.Count == 0) // 如果没有数据。
        { // 判断开始。
            return; // 直接返回。
        } // 判断结束。
        int firstIndex = Mathf.FloorToInt(content.anchoredPosition.y / itemHeight) - extraBuffer; // 计算第一个需要显示的数据下标。
        firstIndex = Mathf.Clamp(firstIndex, 0, Mathf.Max(0, dataList.Count - 1)); // 限制下标范围。
        for (int i = 0; i < itemViews.Count; i++) // 遍历所有复用 Item。
        { // 循环开始。
            int dataIndex = firstIndex + i; // 计算当前 Item 对应的数据下标。
            RectTransform item = itemViews[i]; // 取出当前 Item。
            bool valid = dataIndex >= 0 && dataIndex < dataList.Count; // 判断这个下标是否有效。
            item.gameObject.SetActive(valid); // 无效下标隐藏 Item。
            if (!valid) // 如果当前没有对应数据。
            { // 判断开始。
                continue; // 跳过绑定逻辑。
            } // 判断结束。
            item.anchoredPosition = new Vector2(0f, -dataIndex * itemHeight); // 设置 Item 在 Content 中的位置。
            IVirtualListItem view = item.GetComponent<IVirtualListItem>(); // 获取 Item 的绑定脚本。
            view.Bind(dataIndex, dataList[dataIndex]); // 绑定当前数据。
        } // 循环结束。
    } // 方法结束。
} // 类结束。

项目优化点

固定高度列表最好优化,直接用 index * itemHeight 算位置。 不固定高度的列表要缓存每个 Item 高度,用前缀和或二分查找算可见区间。

大量列表里尽量少用 LayoutGroup + ContentSizeFitter,它们会频繁触发布局重建。Item 里的装饰图、文本如果不需要点击,要关闭 Raycast Target。图标资源不要滚动时同步加载,应该提前缓存或异步加载,并处理 Item 复用后图标回调错绑的问题。

面试加分点

我会先用 Profiler 看 GC AllocLayoutRebuildCanvas.BuildBatchEventSystem。优化前后要能说数据,比如“背包 2000 个 Item,从一次性创建 2000 个降到常驻 18 个,打开耗时和 GC 明显下降”。这比只说“用了对象池”更能打动面试官。

虚拟列表如何计算可见范围?

unity-virtual-list-visible-range

标准答案

虚拟列表的可见范围,本质是把“滚动偏移”换算成“数据下标范围”。

固定高度 Item 最常用公式:

c
topIndex = floor(scrollY / itemHeight)
visibleCount = ceil(viewportHeight / itemHeight)
firstIndex = max(0, topIndex - buffer)
lastIndex = min(totalCount - 1, topIndex + visibleCount + buffer)

也就是说:先算当前滚到第几个 Item,再算屏幕能放几个 Item,最后前后多加几个缓冲 Item,避免快速滑动时露白。

底层原理

Unity 的 ScrollRect 里,真正移动的是 ContentViewport 只是一个可视窗口。官方文档里也强调了 Scroll View 的核心元素是 viewportcontent 和滚动条,所有滚动内容都在同一个 content 下;anchoredPosition 表示 RectTransform 相对锚点的位置。所以虚拟列表不是创建 10000 个 UI,而是只保留“当前可见范围附近”的十几个 UI,然后改位置、改数据。

C# 示例:固定高度可见范围计算

c
using UnityEngine; // 使用 Unity 的 Mathf 数学工具
public readonly struct VisibleRange // 定义一个只读结构体,用来保存可见范围
{ // VisibleRange 结构体开始
    public readonly int First; // 第一个需要显示的数据下标
    public readonly int Last; // 最后一个需要显示的数据下标
    public int Count => Last >= First ? Last - First + 1 : 0; // 计算这个范围内一共有多少个元素
    public VisibleRange(int first, int last) // 构造函数,接收起始和结束下标
    { // 构造函数开始
        First = first; // 保存第一个可见下标
        Last = last; // 保存最后一个可见下标
    } // 构造函数结束
} // VisibleRange 结构体结束
public static class VirtualListRangeCalculator // 定义虚拟列表范围计算工具类
{ // 工具类开始
    public static VisibleRange Calculate(float scrollY, float viewportHeight, float itemHeight, int totalCount, int buffer) // 计算固定高度列表的可见范围
    { // Calculate 方法开始
        if (totalCount <= 0 || itemHeight <= 0f) return new VisibleRange(0, -1); // 没有数据或 Item 高度非法时返回空范围
        scrollY = Mathf.Max(0f, scrollY); // 防止顶部回弹导致偏移为负数
        int topIndex = Mathf.FloorToInt(scrollY / itemHeight); // 用滚动距离除以 Item 高度,得到顶部 Item 下标
        int visibleCount = Mathf.CeilToInt(viewportHeight / itemHeight); // 用视口高度除以 Item 高度,得到屏幕能显示几个
        int maxIndex = totalCount - 1; // 最大合法下标
        int first = Mathf.Clamp(topIndex - buffer, 0, maxIndex); // 向上多保留 buffer 个,避免露白
        int last = Mathf.Clamp(topIndex + visibleCount + buffer, 0, maxIndex); // 向下多保留 buffer 个,避免快速滑动露白
        return new VisibleRange(first, last); // 返回最终需要复用和刷新的数据范围
    } // Calculate 方法结束
} // 工具类结束

项目里怎么说

如果 Item 高度固定,我会用 O(1) 公式直接算范围;如果 Item 高度不固定,就不能直接除以 itemHeight,而是维护一个 prefixHeight 累计高度数组,用二分查找找到第一个进入 Viewport 的 Item。网格列表也类似,只是先算可见行,再乘以列数得到数据下标范围。

常见坑点

content.anchoredPosition.y 的正负方向跟 Anchor、Pivot、布局方式有关,项目里要统一坐标规则。不要每次滚动一个像素就全量 Bind,应该只有 firstIndexlastIndex 变化时才刷新。虚拟列表最好少用 LayoutGroup + ContentSizeFitter 实时驱动,否则大量 Item 会反复触发布局重建。

UI 打开动画和逻辑初始化如何解耦?

unity-ui-open-animation-logic-decouple

标准答案

UI 打开动画和逻辑初始化要拆成两条线:逻辑初始化负责数据准备和界面绑定,打开动画只负责表现,状态机负责控制时序。面试里可以这样说:Open() 不直接写一堆业务,而是走 UIManager -> PanelLogic/Presenter -> View.Bind -> AnimationDriver.PlayOpen -> OnOpenFinished

底层原理

UI 打开其实有三个阶段:第一是创建或取出面板对象,第二是把数据刷到 UI 上,第三才是播放打开动画。动画本质只是改 alphascaleanchoredPosition 这类表现属性;业务逻辑比如背包数据、任务状态、按钮权限不应该写在动画事件里。Unity 里常用 CanvasGroup.alpha 控制淡入淡出,用 interactableblocksRaycasts 控制打开动画期间能不能点,Unity 官方文档也说明 CanvasGroup 可以影响子元素透明度、射线和交互状态。

Unity 工程做法

项目里我一般让 UI 有状态:ClosedOpeningOpenClosing。打开时先禁用交互,初始化数据并绑定 View,然后播放动画;动画完成后再把状态切到 Open,并恢复点击。这样即使玩家快速点开关、资源异步加载慢、动画中途被关闭,也能靠状态和版本号避免脏回调。

代码骨架

c
using System.Collections; // 引入 IEnumerator,用来写协程动画流程
using UnityEngine; // 引入 UnityEngine,使用 MonoBehaviour、CanvasGroup、Time、Mathf
public enum UIPanelState { Closed, Opening, Open, Closing } // 定义 UI 面板的生命周期状态
public sealed class DecoupledPanel : MonoBehaviour // 定义一个演示用 UI 面板类
{ // 类开始
    [SerializeField] private CanvasGroup group; // 用 CanvasGroup 控制透明度和点击
    [SerializeField] private float openDuration = 0.2f; // 配置打开动画时长
    private UIPanelState state = UIPanelState.Closed; // 保存当前 UI 状态
    private Coroutine openRoutine; // 保存当前打开动画协程
    private int version; // 用版本号防止旧异步回调污染新状态
    public void Open(object args) // 对外暴露打开入口
    { // Open 方法开始
        if (state == UIPanelState.Opening || state == UIPanelState.Open) return; // 已经在打开或已打开时直接返回,保证幂等
        version++; // 每次打开都增加版本号,让旧流程失效
        int currentVersion = version; // 记录本次打开流程的版本
        state = UIPanelState.Opening; // 切换到 Opening 状态
        group.interactable = false; // 打开动画期间禁止按钮交互
        group.blocksRaycasts = false; // 打开动画期间不拦截点击
        Prepare(args); // 初始化逻辑数据,例如读取背包列表或任务状态
        BindView(); // 把数据绑定到文本、图片、按钮等 View 上
        if (openRoutine != null) StopCoroutine(openRoutine); // 如果已有旧动画协程,就先停止它
        openRoutine = StartCoroutine(OpenAnimation(currentVersion)); // 启动打开动画协程
    } // Open 方法结束
    private IEnumerator OpenAnimation(int currentVersion) // 定义打开动画协程
    { // OpenAnimation 方法开始
        float time = 0f; // 记录动画已经播放的时间
        group.alpha = 0f; // 动画开始时先完全透明
        while (time < openDuration) // 当动画时间还没结束时持续更新
        { // while 循环开始
            if (currentVersion != version) yield break; // 如果版本不一致,说明面板已被关闭或重开,直接退出
            time += Time.unscaledDeltaTime; // 使用不受暂停影响的时间推进 UI 动画
            group.alpha = Mathf.Clamp01(time / openDuration); // 根据进度设置透明度
            yield return null; // 等到下一帧继续执行
        } // while 循环结束
        if (currentVersion != version) yield break; // 动画结束前再次确认流程没有过期
        group.alpha = 1f; // 确保最终完全显示
        group.interactable = true; // 动画完成后恢复按钮交互
        group.blocksRaycasts = true; // 动画完成后开始拦截点击
        state = UIPanelState.Open; // 状态切换为已打开
        openRoutine = null; // 清空协程引用
    } // OpenAnimation 方法结束
    public void Close() // 对外暴露关闭入口
    { // Close 方法开始
        version++; // 增加版本号,让未完成的打开流程失效
        if (openRoutine != null) StopCoroutine(openRoutine); // 停止打开动画协程
        group.interactable = false; // 关闭后禁止交互
        group.blocksRaycasts = false; // 关闭后不再拦截点击
        group.alpha = 0f; // 关闭后隐藏界面
        state = UIPanelState.Closed; // 状态切换为已关闭
    } // Close 方法结束
    private void Prepare(object args) { } // 准备数据,真实项目里放参数解析和数据读取
    private void BindView() { } // 绑定界面,真实项目里刷新文本、图片、按钮状态
} // 类结束

常见坑点

动画事件可以做轻量通知,但不要在动画事件里写强业务,比如发奖励、扣道具、请求网络。异步加载资源时要处理取消和过期回调。打开动画期间最好关闭交互,否则玩家可能在半透明状态点击按钮,造成重复请求。关闭 UI 时要取消监听、停止协程、失效旧 token,否则 UI 关了以后异步回调还会刷新已销毁对象。

UI 窗口返回栈怎么设计?

unity-ui-window-back-stack-design

标准答案

UI 窗口返回栈就是用一个栈记录“页面导航历史”。打开新页面时,把当前页面压栈;点击返回时,先关闭最上层弹窗,如果没有弹窗,再从页面栈里弹出上一个页面并恢复。

关键点是:全屏页面入栈,弹窗单独栈,Toast、飘字、Loading 这种临时 UI 不入栈。

底层设计

我会把 UI 分成三类:

  1. Page:全屏页面,比如大厅、背包、角色详情,需要返回历史。
  2. Popup:弹窗,比如确认框、奖励弹窗,返回键优先关闭它。
  3. Overlay:临时表现,比如 Toast、血条、Loading,不进入返回栈。

栈里不要直接存 GameObject,更推荐存 WindowRecord,里面有 windowId、打开参数、恢复状态。因为窗口可能被销毁、对象池回收、切场景清理,如果栈里直接拿旧对象,很容易出现 Missing Reference。

C# 简化代码

c
using System.Collections.Generic; // 引入 Stack 集合,用来实现返回栈
public sealed class WindowRecord // 定义窗口历史记录
{ // WindowRecord 类开始
    public readonly string Id; // 保存窗口 ID,而不是直接保存 GameObject
    public readonly object Args; // 保存恢复窗口时需要的参数
    public WindowRecord(string id, object args) // 构造一条窗口记录
    { // 构造函数开始
        Id = id; // 记录窗口 ID
        Args = args; // 记录窗口参数
    } // 构造函数结束
} // WindowRecord 类结束
public sealed class UIBackStack // 定义 UI 返回栈管理器
{ // UIBackStack 类开始
    private readonly Stack<WindowRecord> pageStack = new Stack<WindowRecord>(); // 全屏页面返回栈
    private readonly Stack<string> popupStack = new Stack<string>(); // 弹窗栈
    private string currentPageId; // 当前正在显示的全屏页面 ID
    public void OpenPage(string pageId, object args = null) // 打开一个全屏页面
    { // OpenPage 方法开始
        if (currentPageId == pageId) return; // 重复打开同一个页面时直接忽略
        if (!string.IsNullOrEmpty(currentPageId)) pageStack.Push(new WindowRecord(currentPageId, null)); // 打开新页面前把当前页面压栈
        PausePage(currentPageId); // 暂停当前页面,例如隐藏或禁用交互
        currentPageId = pageId; // 更新当前页面 ID
        ShowPage(pageId, args); // 显示新页面并传入参数
    } // OpenPage 方法结束
    public void OpenPopup(string popupId) // 打开一个弹窗
    { // OpenPopup 方法开始
        popupStack.Push(popupId); // 弹窗进入弹窗栈
        ShowPopup(popupId); // 显示弹窗
    } // OpenPopup 方法结束
    public void Back() // 执行返回逻辑
    { // Back 方法开始
        if (popupStack.Count > 0) // 如果当前有弹窗
        { // 弹窗处理开始
            ClosePopup(popupStack.Pop()); // 优先关闭最上层弹窗
            return; // 弹窗关闭后本次返回结束
        } // 弹窗处理结束
        if (pageStack.Count > 0) // 如果页面栈里还有历史页面
        { // 页面返回处理开始
            ClosePage(currentPageId); // 关闭当前页面
            WindowRecord last = pageStack.Pop(); // 弹出上一个页面记录
            currentPageId = last.Id; // 恢复当前页面 ID
            ShowPage(last.Id, last.Args); // 重新显示上一个页面
            return; // 页面返回后结束
        } // 页面返回处理结束
        ShowExitConfirm(); // 没有可返回页面时弹出退出确认
    } // Back 方法结束
    private void ShowPage(string id, object args) { } // 显示页面,真实项目里会加载或从对象池取 UI
    private void ClosePage(string id) { } // 关闭页面,真实项目里会隐藏或释放 UI
    private void PausePage(string id) { } // 暂停页面,真实项目里会禁用交互或触发 OnPause
    private void ShowPopup(string id) { } // 显示弹窗
    private void ClosePopup(string id) { } // 关闭弹窗
    private void ShowExitConfirm() { } // 显示退出确认框
} // UIBackStack 类结束

项目里要注意

返回栈要做“幂等”:连续点返回、动画没播完、异步加载没完成时,不能重复关闭或重复打开。切场景、回登录、进入战斗时通常要清空栈。某些页面比如战斗结算、充值确认、强制新手引导,可以设置 CanBack = false 或自定义返回行为。

模态窗口和非模态窗口区别是什么?

unity-ui-modal-vs-nonmodal-window

标准答案

模态窗口 Modal:会抢占当前输入焦点,用户必须先处理它,背后的 UI 通常不能点。 非模态窗口 Non-modal:只是悬浮显示,不强制用户处理,背后的界面仍然可以继续操作。

简单说:模态会阻塞背后操作,非模态不会。

底层原理

在 UGUI 里,区别不只是“有没有黑色背景”,而是事件是否被拦截。 Unity 的点击会通过 EventSystem + GraphicRaycaster 找到可接收射线的 UI。模态窗口通常会加一个全屏遮罩 Mask,这个遮罩开启 Raycast Target,用来吃掉背后点击;非模态窗口一般只让窗口自身接收点击,窗口外区域不挡输入。

Unity 实现示例

c
using UnityEngine; // 使用 Unity 基础类型
using UnityEngine.UI; // 使用 Image 组件
public sealed class UIWindowMode : MonoBehaviour // 定义一个 UI 窗口模式控制脚本
{ // 类开始
    [SerializeField] private CanvasGroup windowGroup; // 控制窗口本身是否可交互
    [SerializeField] private Image modalMask; // 全屏遮罩,用来拦截背后点击
    public void SetModal(bool isModal) // 设置当前窗口是否为模态窗口
    { // 方法开始
        windowGroup.interactable = true; // 窗口自身始终允许交互
        windowGroup.blocksRaycasts = true; // 窗口自身始终接收 UI 射线
        modalMask.gameObject.SetActive(isModal); // 只有模态窗口才显示遮罩
        modalMask.raycastTarget = isModal; // 模态时遮罩拦截点击,非模态时不拦截
    } // 方法结束
} // 类结束

项目里怎么选

确认删除、支付确认、退出游戏、网络重连提示,一般用模态,因为玩家必须先做选择。聊天窗口、小地图、任务追踪、属性浮窗,一般用非模态,因为它们只是辅助信息,不应该打断玩家操作。

常见坑点

透明遮罩也可能挡点击,因为是否挡输入看的是 Raycast TargetblocksRaycasts,不是看透明度。黑色半透明背景也不一定是模态,如果它不接收射线,背后 UI 仍然可能被点到。返回键逻辑里,模态窗口通常优先关闭,非模态窗口要看它是否进入窗口栈。

Loading 界面如何防止重复点击?

unity-loading-prevent-repeat-click

标准答案

Loading 界面防重复点击,不能只靠“显示一个转圈图标”,要做三层保护:UI 层挡输入、按钮层置灰、逻辑层做幂等。最稳的回答是:点击后立刻加锁,显示全屏 Loading 遮罩,禁用按钮,异步请求结束后无论成功失败都在 finally 里解锁。

底层原理

UGUI 的点击是通过 EventSystem + GraphicRaycaster 找到可点击 UI。Loading 防重复点击通常放一个全屏透明遮罩,让它开启 Raycast TargetCanvasGroup.blocksRaycasts,这样背后的按钮就收不到点击。 但遮罩只是 UI 防线,真正关键是逻辑防线:如果 isLoading == true,第二次点击直接 return,避免重复发请求、重复扣道具、重复打开界面。

Unity 示例代码

c
using System.Collections; // 引入 IEnumerator,用来写协程
using UnityEngine; // 引入 Unity 基础类型
using UnityEngine.UI; // 引入 Button 和 CanvasGroup
public sealed class LoadingClickGuard : MonoBehaviour // 定义 Loading 防重复点击脚本
{ // 类开始
    [SerializeField] private Button submitButton; // 需要防连点的按钮
    [SerializeField] private CanvasGroup loadingGroup; // Loading 遮罩的 CanvasGroup
    private bool isLoading; // 是否正在请求中
    private Coroutine requestRoutine; // 当前请求协程引用
    public void OnClickSubmit() // 按钮点击入口
    { // 方法开始
        if (isLoading) return; // 如果已经在 Loading 中,直接忽略重复点击
        requestRoutine = StartCoroutine(RequestFlow()); // 启动请求流程协程
    } // 方法结束
    private IEnumerator RequestFlow() // 请求流程
    { // 协程开始
        isLoading = true; // 设置逻辑锁,防止重复请求
        submitButton.interactable = false; // 禁用按钮,防止同按钮连点
        loadingGroup.alpha = 1f; // 显示 Loading 遮罩
        loadingGroup.blocksRaycasts = true; // 让遮罩拦截背后 UI 点击
        loadingGroup.interactable = true; // 让遮罩可以参与交互拦截
        yield return new WaitForSecondsRealtime(1f); // 模拟异步请求或资源加载
        loadingGroup.alpha = 0f; // 隐藏 Loading 遮罩
        loadingGroup.blocksRaycasts = false; // 关闭遮罩射线拦截
        loadingGroup.interactable = false; // 关闭遮罩交互
        submitButton.interactable = true; // 恢复按钮点击
        isLoading = false; // 释放逻辑锁
        requestRoutine = null; // 清空协程引用
    } // 协程结束
} // 类结束

项目里怎么说

如果是网络请求,我会加 requestIdtoken,防止旧请求回来覆盖新状态。如果是全局 Loading,我会做引用计数:多个模块同时加载时,ShowLoading() 计数加一,HideLoading() 计数减一,只有计数归零才真正隐藏。这样不会出现 A 请求还没结束,B 请求先结束就把 Loading 关掉的问题。

常见坑点

只把按钮置灰不够,因为玩家可能点到其他按钮;只显示 Loading 图标也不够,因为图标不一定拦截射线。请求失败、超时、取消、切场景时一定要解锁,否则 UI 会永久卡死。涉及领奖、支付、购买这种操作时,客户端防重复点击只是体验保护,服务端也必须做幂等校验。

UI 资源如何分包?

unity-ui-resource-bundle-splitting

标准答案

UI 资源分包要按 加载时机、功能模块、共享依赖、更新频率 来拆,不是按文件夹随便切。常见做法是:基础 UI 放首包,背包/商城/任务等按模块分包,公共图集、字体、Shader 单独抽成公共包,活动 UI 和多语言图片单独做热更包。

底层原理

UI Prefab 通常会引用图片、图集、TMP 字体、材质、动画、Shader。 如果两个 UI 包都引用同一张公共图集,但公共图集没有单独打包,就可能被重复打进多个 Bundle,导致包体变大、内存重复加载。分包的核心就是管理依赖图:共享资源抽公共包,业务资源跟业务走,高频更新资源单独拆。

推荐拆法

首包 UI:登录、Loading、主界面基础组件、通用弹窗。 公共 UI 包:通用按钮、品质框、货币图标、公共字体、公共材质、公共 Shader。 模块 UI 包:背包、商城、任务、邮件、排行榜、角色详情。 活动 UI 包:限时活动、节日活动、运营弹窗,方便热更和下线。 语言/平台包:多语言图片、平台差异资源、不同清晰度资源。

简单配置示例

c
public enum UIResourceGroup // 定义 UI 资源分组类型
{ // 枚举开始
    BuiltIn, // 首包内置资源,例如 Loading 和登录界面
    Common, // 公共 UI 资源,例如字体、图集、通用材质
    Feature, // 功能模块资源,例如背包、商城、任务
    Activity, // 活动热更资源,例如限时活动界面
    Locale // 多语言资源,例如中文、英文、日文图片
} // 枚举结束
public sealed class UIResourceRule // 定义 UI 分包规则
{ // 类开始
    public string PanelName; // UI 面板名称
    public UIResourceGroup Group; // 这个面板所属资源组
    public string AddressableLabel; // Addressables 加载标签
    public bool Preload; // 是否需要预加载
} // 类结束

项目里怎么说

我会让 UI Manager 只关心 panelId,资源管理器根据配置决定从哪个 Addressables Group 加载。比如打开背包时加载 ui_bag,它依赖 ui_common;关闭背包后释放背包包引用,但公共包要看引用计数,不能马上卸掉。活动 UI 单独放远端组,版本更新时只改活动包和 catalog,避免玩家重新下载一大堆基础 UI。

常见坑点

包太粗:玩家只打开商城,却下载了整套 UI。 包太细:每个小图一个包,请求数多,加载变慢。 公共资源没抽出:多个 Bundle 重复携带同一张图集。 卸载只卸业务包:公共依赖引用计数没处理,可能泄漏或误卸。 热更只看文件名:应该用 hash、版本号、manifest 做校验和对比。

UI 图集如何规划?

unity-ui-atlas-planning

标准答案

UI 图集规划的核心是:同屏常用的放一起,跨界面复用的抽公共,模块专用的跟模块走,活动和多语言资源单独拆。不是图集越大越好,图集太大容易常驻内存高;也不是越碎越好,太碎会增加贴图切换、加载请求和管理成本。

底层原理

Sprite Atlas 会把多张小图合成一张大纹理。渲染 UI 时,如果多个 Image 使用同一张图集、同一种材质,就更容易减少贴图切换和批次。Unity 官方也建议把同一场景中同时活跃的 Sprite 尽量放在相同 Atlas 中,同时按共同使用场景拆成多个较小 Atlas。

但 UGUI 的批次不只看图集,还看材质、Mask、Canvas、层级穿插、Shader 等因素。所以面试里不要说“用了图集就一定一个 DrawCall”,更准确的说法是:图集减少纹理切换,是 UI 合批的重要前提。

项目规划方式

公共图集:按钮底、通用图标、货币、品质框、红点、通用边框。 模块图集:背包、商城、任务、邮件、排行榜,各自独立。 活动图集:节日活动、运营弹窗、限时礼包,方便热更和下线。 语言图集:带文字的图片,比如中文按钮图、英文活动标题。 大图资源:全屏背景、插画、Banner 不建议硬塞公共图集,可以单独管理。

简单规则代码

c
public enum UIAtlasGroup // 定义 UI 图集分组类型
{ // 枚举开始
    Common, // 公共图集,放多个界面都会用的小图
    Feature, // 功能图集,放某个模块专用图片
    Activity, // 活动图集,放限时活动和运营图片
    Locale, // 语言图集,放带文字的多语言图片
    Standalone // 独立纹理,放大背景、大 Banner、插画
} // 枚举结束
public sealed class UIAtlasRule // 定义一条图集规划规则
{ // 类开始
    public string AtlasName; // 图集名称,例如 atlas_ui_common
    public UIAtlasGroup Group; // 图集分组类型
    public bool LoadOnStartup; // 是否启动时加载
    public bool CanHotUpdate; // 是否允许热更新
    public bool IsResident; // 是否常驻内存
} // 类结束

常见坑点

公共图集不能什么都塞,否则它会越来越大,最后变成常驻内存包袱。模块图集之间不要互相引用,否则会造成依赖混乱和重复加载。九宫格、边缘透明图要注意 Padding、Extrude 和压缩格式,否则可能出现边缘串色。移动端 UI 图集要按平台选压缩格式,透明 UI 尤其要注意清晰度和 alpha 质量。

面试加分说法

我会先用 Frame Debugger 看 UI 是否因为图集不同导致批次被打断,再用 Memory Profiler 看图集是否常驻过大,最后用 Addressables Analyze 或 Bundle Layout 检查公共图集是否被重复打包。图集规划不是美术资源整理问题,本质是渲染批次、内存、下载体积和热更粒度之间的取舍。

多语言文本长度变化如何适配?

unity-localization-text-length-adaptation

标准答案

多语言文本长度变化,核心不是“翻译完再改 UI”,而是提前让 UI 具备弹性:容器可伸缩、文本可换行、字号可缩放、超长可省略、文案用表管理,并用伪本地化提前测试

中文通常短,英文会变长,德文、俄文可能更长;阿拉伯语还涉及 RTL 方向。固定宽高写死,很容易出现遮挡、裁切、按钮撑爆。

底层原理

UGUI/TMP 文本更新后,会产生新的 preferredWidthpreferredHeight。如果父节点用了 LayoutGroupContentSizeFitterLayoutElement,布局系统会根据这些尺寸重新排版。问题是:如果父容器宽度固定、按钮没有 padding、文本不允许换行,语言一变长就会挤压其他 UI。

Unity 实现示例

c
using TMPro; // 引入 TextMeshPro 文本组件
using UnityEngine; // 引入 Unity 基础类型
using UnityEngine.UI; // 引入 LayoutElement 和 LayoutRebuilder
public sealed class LocalizedTextAdapter : MonoBehaviour // 定义多语言文本适配组件
{ // 类开始
    [SerializeField] private TMP_Text text; // 需要适配的 TMP 文本
    [SerializeField] private LayoutElement layout; // 用来控制当前节点布局尺寸
    [SerializeField] private float maxWidth = 360f; // 文本允许的最大宽度
    [SerializeField] private float minFontSize = 18f; // 自动缩小时允许的最小字号
    [SerializeField] private float maxFontSize = 28f; // 自动缩放时允许的最大字号
    public void ApplyText(string value) // 应用本地化文本
    { // 方法开始
        text.text = value; // 设置翻译后的文本内容
        text.enableWordWrapping = true; // 开启自动换行,防止横向无限溢出
        text.overflowMode = TextOverflowModes.Ellipsis; // 超出极限时使用省略号兜底
        text.enableAutoSizing = true; // 开启自动字号,空间不足时缩小文字
        text.fontSizeMin = minFontSize; // 设置最小字号,避免小到看不清
        text.fontSizeMax = maxFontSize; // 设置最大字号,避免短文本过大
        Vector2 preferred = text.GetPreferredValues(value, maxWidth, 0f); // 计算文本在最大宽度下的理想尺寸
        layout.preferredWidth = Mathf.Min(preferred.x, maxWidth); // 宽度不超过最大限制
        layout.preferredHeight = preferred.y; // 高度跟随换行后的文本高度
        LayoutRebuilder.MarkLayoutForRebuild((RectTransform)transform); // 标记布局需要刷新
    } // 方法结束
} // 类结束

项目里怎么做

按钮文本:优先让按钮宽度随文字变大,设置左右 padding;空间不足时再缩小字号。 描述文本:允许换行,高度随内容变化。 标题文本:空间有限时可以省略,并用详情页或 Tooltip 补充。 带文字图片:尽量不要把文字烘在图片里,否则多语言要出很多套图。 语言切换:只刷新当前打开 UI,不要全局所有 UI 一起重建。

常见坑点

不要只在中文环境下验 UI。最好做伪本地化,比如把文本长度扩大 30% 到 50%,提前检查遮挡。ContentSizeFitter + LayoutGroup 混用要小心,频繁改文本会触发布局重建,ScrollView 大量 Item 里尤其要避免每帧刷新文本。字体也要准备 fallback,不然日文、韩文、阿拉伯文可能显示方块。

富文本会带来什么问题?

unity-rich-text-problems

标准答案

富文本的好处是方便在一段文本里控制颜色、大小、粗体、图片、链接,比如 TMP 的 <color><size><sprite><link>。 但它的问题也很明显:会增加解析成本,影响布局尺寸,带来标签注入风险,也会让多语言和资源依赖更难维护

底层原理

普通文本是直接生成字符网格;富文本会先解析字符串里的标签,再决定每段文字的颜色、字号、材质、Sprite、链接区域等。Unity TMP 官方文档也说明,Rich Text Tags 会补充或覆盖 TextMesh Pro 对象的显示属性。

所以文本一变,可能不只是改几个字符,而是重新解析、重新计算 preferred size、重新生成 mesh,甚至触发 UGUI Layout Rebuild。

主要问题

布局问题:<size=40><sprite> 可能把文本撑大,按钮、聊天气泡、任务描述容易被撑爆。 性能问题:高频刷新富文本,比如伤害飘字、聊天列表、倒计时,可能产生解析成本、字符串分配和 UI 重建。 安全问题:玩家昵称如果直接拼进富文本,可能输入 <color=red> 伪造系统消息。 多语言问题:翻译可能漏掉闭合标签,或者移动变量位置后导致标签嵌套错误。 资源问题:<sprite><font> 依赖 TMP SpriteAsset、FontAsset,资源没打包就会显示异常。

Unity 示例:用户输入要转义

c
using TMPro; // 引入 TextMeshPro 文本组件
using UnityEngine; // 引入 Unity 基础类型
public sealed class SafeRichTextLabel : MonoBehaviour // 定义安全富文本显示组件
{ // 类开始
    [SerializeField] private TMP_Text label; // 引用需要显示内容的 TMP 文本
    public void SetChatLine(string playerName, string message) // 设置聊天行文本
    { // 方法开始
        label.richText = true; // 系统模板允许使用富文本
        string safeName = EscapeRichText(playerName); // 转义玩家昵称,防止标签注入
        string safeMessage = EscapeRichText(message); // 转义聊天内容,防止伪造样式
        label.text = "<color=#22C55E>" + safeName + "</color>: " + safeMessage; // 只让可信模板控制颜色
    } // 方法结束
    private static string EscapeRichText(string value) // 定义富文本转义函数
    { // 函数开始
        if (string.IsNullOrEmpty(value)) return string.Empty; // 空字符串直接返回
        return value.Replace("&", "&amp;").Replace("<", "&lt;").Replace(">", "&gt;"); // 转义关键 XML 字符
    } // 函数结束
} // 类结束

项目里怎么控制

可信系统文案可以用富文本,比如“获得 <color=yellow>金币</color>”。玩家输入、昵称、聊天内容要么关闭 richText,要么先转义。高频文本不要每帧拼富文本,尽量缓存模板,减少字符串拼接。多语言表里最好不要让翻译随便改标签结构,可以用占位符和统一 RichTextBuilder 生成样式。

TMP 和 Unity Text 区别是什么?

unity-tmp-vs-unity-text

标准答案

TMP 是 TextMesh Pro,Unity Text 通常指 UGUI 里的传统 UnityEngine.UI.Text。 一句话区别:Unity Text 简单够用,TMP 显示质量、排版能力、富文本、字体回退、描边阴影都更强;但 TMP 需要额外管理 FontAsset、材质和字符集。

底层原理

Unity Text 更像传统文本渲染:根据字体生成字符纹理,再把字符画到 UI 上。字号变化、缩放、描边阴影效果复杂时,容易发糊或表现受限。

TMP 的核心是 SDF,也就是 Signed Distance Field。它的 FontAsset 图集里不只是存“像素颜色”,而是存“这个像素距离字形边缘有多远”。Shader 渲染时根据距离值还原边缘,所以放大缩小时更清晰,也更容易做描边、阴影、发光。

主要区别

Unity Text:简单、旧项目常见、上手快,但排版和效果能力较弱。 TMP:文字清晰度更好,支持更丰富的 Rich Text、Sprite、Fallback Font、字距行距控制、链接、描边阴影等。 性能上不能简单说 TMP 一定更快:频繁改 TMP 文本也会重新生成 Mesh,触发布局或 Canvas 重建。只是 TMP 在复杂文本效果和显示质量上更适合正式项目。

Unity 示例代码

c
using TMPro; // 引入 TextMeshPro 的 TMP_Text
using UnityEngine; // 引入 Unity 基础类型
using UnityEngine.UI; // 引入 Unity 传统 UI.Text
public sealed class TextCompareDemo : MonoBehaviour // 定义一个文本对比示例组件
{ // 类开始
    [SerializeField] private Text unityText; // 引用传统 Unity Text
    [SerializeField] private TMP_Text tmpText; // 引用 TMP 文本
    public void SetText(string value) // 设置文本内容
    { // 方法开始
        if (unityText != null) unityText.text = value; // 如果存在 Unity Text,就设置它的文本
        if (tmpText != null) tmpText.text = value; // 如果存在 TMP Text,就设置它的文本
    } // 方法结束
    public void SetWarning(string value) // 设置带颜色的警告文本
    { // 方法开始
        if (unityText != null) unityText.text = value; // Unity Text 也能显示普通文本
        if (tmpText != null) tmpText.text = "<color=#FF4444>" + value + "</color>"; // TMP 更常用于富文本样式
    } // 方法结束
} // 类结束

项目里怎么选

正式项目的新 UI,我一般优先用 TMP。比如伤害数字、按钮文本、聊天、任务描述、多语言 UI,都更适合 TMP。Unity Text 适合老项目维护、临时 Debug、小工具,或者不想引入 TMP 资源管理成本的极简单场景。

常见坑点

TMP 要准备 TMP Essential Resources。多语言项目要准备 FontAsset 和 Fallback,否则日文、韩文、特殊符号可能缺字。动态生成字符可能导致运行时卡顿。不同 TMP 材质、不同字体图集也可能打断合批。迁移 Unity Text 到 TMP 时,要重新检查字号、行高、对齐、富文本标签和布局。

字体缺字怎么处理?

unity-font-missing-glyph-handling

标准答案

字体缺字就是:当前 TMP FontAsset 里没有某个字符的 glyph,所以显示成 ?、空白,或者某些语言直接显示不出来。

处理思路是:先定位缺哪个字,再补 FontAsset 字符集,配置 Fallback Font,最后做自动缺字扫描和资源分包校验。

底层原理

TMP 显示字符时,会先查当前文本组件的主 FontAsset。如果找不到,就沿着 fallback 链继续查:主字体的 fallback、SpriteAsset、全局 fallback、默认字体,最后才显示 missing glyph。Unity TMP 官方文档也说明,fallback 适合 CJK 这类字符量很大的语言、移动端图集尺寸受限、特殊字符补充等场景。

项目处理流程

第一步:收集缺字。比如从本地化表、公告、剧情文本、Prefab 文本、玩家昵称日志里收集字符。 第二步:生成或更新 TMP FontAsset,常用 UI 字放主字体。 第三步:按语言配置 fallback,比如中文主字体、日文 fallback、韩文 fallback、Emoji 用 TMP SpriteAsset。 第四步:上线前跑工具扫描所有文本,发现缺字就输出报告。 第五步:字体资源要跟语言包或公共 UI 包一起打包,否则 Editor 正常,真机可能缺字。

检测代码示例

c
using System.Collections.Generic; // 引入集合类型
using TMPro; // 引入 TMP_FontAsset
public static class TMPGlyphChecker // 定义 TMP 缺字检测工具
{ // 工具类开始
    public static bool HasGlyph(TMP_FontAsset font, char c, HashSet<TMP_FontAsset> visited = null) // 检查字体和 fallback 是否包含字符
    { // 方法开始
        if (font == null) return false; // 字体为空时直接认为找不到
        visited ??= new HashSet<TMP_FontAsset>(); // 如果没有传入访问集合,就创建一个
        if (!visited.Add(font)) return false; // 防止 fallback 循环引用导致递归死循环
        if (font.HasCharacter(c)) return true; // 主 FontAsset 有这个字符就返回 true
        foreach (TMP_FontAsset fallback in font.fallbackFontAssetTable) // 遍历当前字体的 fallback 列表
        { // 循环开始
            if (HasGlyph(fallback, c, visited)) return true; // fallback 中找到了就返回 true
        } // 循环结束
        return false; // 所有字体都没有这个字符就返回 false
    } // 方法结束
} // 工具类结束

常见坑点

不要把所有 Unicode 都塞进一个字体图集,包体和内存会爆。动态 FontAsset 很方便,但运行时补字可能带来卡顿和内存增长,更适合玩家输入或开发期。多语言项目要注意字体授权,不能随便把系统字体打进包。Emoji 不一定走普通字体,很多项目会用 TMP SpriteAsset 单独管理。

动态字体和静态字体有什么区别?

unity-dynamic-vs-static-font

标准答案

动态字体和静态字体的核心区别是:静态字体提前把字符烘进字体图集,运行时只查表;动态字体运行时遇到新字符,可以从源字体文件生成 glyph 并写入图集。

静态字体更稳定,适合 UI、剧情、配置表这种已知文本。动态字体更灵活,适合聊天、昵称、玩家输入、多语言兜底,但可能带来首次生成字符时的卡顿、内存增长和源字体打包成本。

底层原理

TMP 的 FontAsset 里有字符表、glyph 表、字体图集和材质。 静态模式下,字符在编辑器或构建前已经生成好,运行时遇到缺字不会自动补,只能走 fallback 或显示缺字符号。 动态模式下,TMP 可以在运行时根据源字体文件生成缺失 glyph,然后把它加入 atlas。Unity TMP 文档里也提到 Atlas Population ModeDynamicStatic,动态模式可以改变 atlas 尺寸,也会涉及运行时填充。

项目里怎么选

已知文本:用静态 FontAsset。比如按钮、菜单、任务、剧情、本地化表。 未知文本:用动态 fallback。比如玩家昵称、聊天、公告、输入框。 推荐方案:主 UI 字体静态化,未知字符走动态 fallback,并在 Loading 阶段预热常见字符。

代码示例:动态字体预热

c
using TMPro; // 引入 TextMeshPro 相关类型
using UnityEngine; // 引入 Unity 基础类型
public sealed class TMPFontWarmup : MonoBehaviour // 定义 TMP 动态字体预热组件
{ // 类开始
    [SerializeField] private TMP_FontAsset dynamicFont; // 需要预热的动态 FontAsset
    [TextArea] [SerializeField] private string preloadCharacters; // 预热字符集合,例如常用昵称字符
    public void Warmup() // 执行字体预热
    { // 方法开始
        if (dynamicFont == null) return; // 字体资源为空时直接返回
        if (dynamicFont.atlasPopulationMode != AtlasPopulationMode.Dynamic) return; // 不是动态字体时不做运行时补字
        bool success = dynamicFont.TryAddCharacters(preloadCharacters, out string missingCharacters); // 尝试把字符加入字体图集
        if (!success) Debug.LogWarning("Missing glyphs: " + missingCharacters); // 如果仍有缺字,就输出日志方便排查
    } // 方法结束
} // 类结束

常见坑点

不要把所有文字都交给动态字体,否则第一次打开聊天、排行榜、公告时可能突然卡一下。动态字体需要源字体文件进入包体,要注意授权和包体大小。静态字体要上线前扫描缺字,否则真机上可能显示方块。CJK 字符很多,不建议塞进一个超大 FontAsset,通常按语言拆 fallback。

UI 特效如何避免破坏合批?

unity-ui-effects-batching-safe

标准答案

UI 特效避免破坏合批,核心是:同一批 UI 尽量保持相同图集、相同材质、相同 Shader、相同裁剪状态,并且让动态特效和静态 UI 分层隔离

UGUI 合批最怕这些东西:每个控件一个材质实例、不同贴图、不同 Shader、不同 Stencil/Mask 状态、层级中插入特殊材质对象、动画每帧修改大量 UI。

底层原理

UGUI 会在 Canvas 内收集 UI 元素,按材质、纹理、裁剪、层级顺序等条件生成批次。如果中间某个 Image 用了特殊材质,或者一个按钮闪光特效单独实例化了 Material,就可能把原本连续的一批拆开。

Unity 官方 UI 优化建议里也提到:动态 UI 和静态 UI 要拆 Canvas,同一个 Canvas 里的元素尽量保持相同 Z、材质和纹理。

项目做法

普通 UI:用统一图集、统一 UI Shader、统一共享材质。 按钮闪光:优先用序列帧图集、顶点色、共享材质参数,而不是每个按钮 new 一个材质。 复杂特效:放到独立 EffectCanvas,即使它破坏合批,也只影响特效层。 Mask 裁剪:少用嵌套 Mask,因为 Stencil 状态会改变渲染状态。 Outline/Shadow:不一定破坏材质合批,但会增加顶点数量,列表里大量使用也会贵。

代码示例:使用共享材质

c
using UnityEngine; // 引入 Unity 基础类型
using UnityEngine.UI; // 引入 UGUI Graphic 类型
[RequireComponent(typeof(Graphic))] // 要求当前物体必须有 Graphic 组件
public sealed class SharedUIEffectMaterial : MonoBehaviour // 定义共享 UI 特效材质组件
{ // 类开始
    [SerializeField] private Material sharedEffectMaterial; // 所有同类 UI 共用同一个特效材质
    private Graphic graphic; // 缓存当前 UI 的 Graphic 组件
    private void Awake() // Unity 初始化回调
    { // Awake 开始
        graphic = GetComponent<Graphic>(); // 获取 Image、Text、RawImage 等 Graphic 组件
    } // Awake 结束
    public void SetEffectEnabled(bool enabled) // 设置是否启用 UI 特效
    { // 方法开始
        graphic.material = enabled ? sharedEffectMaterial : null; // 启用时用共享材质,关闭时恢复默认材质
    } // 方法结束
    public void SetColor(Color color) // 设置颜色变化
    { // 方法开始
        graphic.color = color; // 使用顶点色变化,通常不会为每个 UI 创建独立材质
    } // 方法结束
} // 类结束

常见坑点

不要在运行时对每个 UI 执行 new Material()。不要让 ScrollView 每个 Item 都带独立特效材质。不要把动态闪光、粒子、遮罩特效混在静态主 Canvas 里。验证时不要只看 DrawCall 数量,还要用 Frame Debugger 看具体是哪个材质、贴图或 Mask 把批次拆开。

UI 粒子如何渲染在正确层级?

unity-ui-particle-correct-layer

标准答案

UI 粒子要渲染在正确层级,第一步不是调粒子的 sortingOrder,而是先看 Canvas Render Mode

如果是 Screen Space - Overlay,普通 ParticleSystem 属于场景渲染,Overlay Canvas 会画在场景之上,所以普通粒子很难插到 Overlay UI 中间。 如果要让粒子在两个 UI 层之间,推荐用 Screen Space - CameraWorld Space Canvas,让 UI 和粒子进入同一套相机排序体系,再通过 Canvas.sortingOrderParticleSystemRenderer.sortingOrder 控制层级。

常见方案

方案一:Screen Space - Camera 把 UI Canvas 改成 Camera 模式,指定 UI Camera,然后:

c
BackCanvas      sortingOrder = 0
UIParticle      sortingOrder = 50
FrontCanvas     sortingOrder = 100

这样粒子就能夹在背景 UI 和前景 UI 中间。

方案二:独立 UI 特效层 例如:

c
UI_Background_Canvas
UI_Particle_Canvas
UI_Foreground_Canvas
UI_Top_Canvas

每层 Canvas 负责一类内容,统一管理 sortingOrder,不要让每个功能自己乱填层级。

方案三:使用 UI 粒子组件 把粒子转成 CanvasRenderer 或 UI Mesh,让它像普通 UI 一样参与 Canvas 层级和 Hierarchy 排序。适合需要被 MaskRectMask2D 裁剪的 UI 粒子。

代码示例:绑定粒子排序层

c
using UnityEngine; // 引入 Unity 基础类型
[RequireComponent(typeof(ParticleSystemRenderer))] // 要求当前物体必须有 ParticleSystemRenderer
public sealed class UIParticleSortingBinder : MonoBehaviour // 定义 UI 粒子排序绑定组件
{ // 类开始
    [SerializeField] private Canvas referenceCanvas; // 参考 Canvas,用它的 sortingLayer 和 sortingOrder
    [SerializeField] private int orderOffset = 1; // 粒子相对 Canvas 的排序偏移
    private ParticleSystemRenderer particleRenderer; // 缓存粒子渲染器
    private void Awake() // Unity 初始化回调
    { // Awake 开始
        particleRenderer = GetComponent<ParticleSystemRenderer>(); // 获取粒子渲染器组件
    } // Awake 结束
    private void OnEnable() // 对象启用时调用
    { // OnEnable 开始
        ApplySorting(); // 应用排序设置
    } // OnEnable 结束
    public void ApplySorting() // 应用粒子层级
    { // 方法开始
        if (referenceCanvas == null) return; // 没有参考 Canvas 时直接返回
        if (particleRenderer == null) return; // 没有粒子渲染器时直接返回
        particleRenderer.sortingLayerID = referenceCanvas.sortingLayerID; // 使用和 Canvas 相同的 Sorting Layer
        particleRenderer.sortingOrder = referenceCanvas.sortingOrder + orderOffset; // 让粒子排在参考 Canvas 之后
    } // 方法结束
} // 类结束

注意:这段适合 Screen Space - CameraWorld Space 场景。Screen Space - Overlay 下,普通场景粒子通常不能靠这个插到 Overlay UI 中间。

面试加分点

我会说:UI 粒子层级问题本质是渲染队列和排序体系问题。Overlay Canvas 是屏幕空间最终叠加;普通 ParticleSystem 走 Renderer 排序;二者不是天然一个排序体系。项目里我会统一 UI 层级表,比如 Back=0Normal=100Effect=200Popup=300Top=1000,避免各模块乱写 order。

常见坑

只调 ParticleSystemRenderer.sortingOrder,但 Canvas 是 Overlay,结果粒子永远在 UI 后面。 粒子放在 UI 上面了,但不受 Mask 裁剪。 粒子材质用特殊 Shader,导致 UI 合批被破坏。 粒子范围太大、透明 Overdraw 高,移动端容易掉帧。 多 Camera 时忘记设置 Culling Mask、Camera Depth 或 Sorting Layer,导致真机层级和编辑器不一致。

UI 摄像机模式有什么优缺点?

unity-ui-camera-mode-pros-cons

标准答案

UI 摄像机模式通常指 Canvas 的 Screen Space - Camera。它不是像 Overlay 那样直接盖到屏幕最上层,而是把 Canvas 放在指定 Camera 前方的一个平面上,由这个 Camera 来渲染 UI。

一句话:Overlay 简单稳定;Camera 模式更适合 UI 粒子、3D 模型、相机后处理和复杂层级,但配置更复杂。

优点

可以和粒子、3D 角色、模型展示一起排序,比如抽卡界面、角色详情界面、UI 粒子特效。 可以通过 Canvas.sortingOrder、Camera Depth、Sorting Layer 控制多个 UI 层级。 UI 会受 Camera 设置影响,某些项目可以让 UI 进入特定相机流程,比如特殊后处理或相机栈。 比 Overlay 更容易实现“UI 背后有 3D 模型,前面还有按钮和粒子”的混合界面。

缺点

依赖 Camera,如果 worldCamera 没配或被销毁,UI 可能不显示。 受 Camera 的 Near/Far Clip、FOV、Plane Distance 影响,距离配错可能被裁掉或透视变形。 多相机时排序更容易乱,比如 Camera Depth、Canvas Order、Sorting Layer 混在一起。 输入事件也要配对正确的事件相机,否则点击坐标可能异常。 比 Overlay 多一层管理成本,小项目普通 HUD 没必要强行用。

底层原理

Unity 官方文档里说,Screen Space - Camera 和 Overlay 类似,但 Canvas 会被放在指定 Camera 前方一定距离,UI 由这个 Camera 渲染,因此 Camera 的设置会影响 UI 外观。也就是说,它本质上让 UI 进入相机渲染体系,而不是最后无脑盖屏。

Unity 示例代码

c
using UnityEngine; // 引入 Unity 基础类型
public sealed class UICameraCanvasSetup : MonoBehaviour // 定义 UI 摄像机模式配置脚本
{ // 类开始
    [SerializeField] private Canvas canvas; // 引用需要设置的 Canvas
    [SerializeField] private Camera uiCamera; // 引用负责渲染 UI 的摄像机
    [SerializeField] private float planeDistance = 10f; // Canvas 距离摄像机的平面距离
    [SerializeField] private int sortingOrder = 100; // Canvas 的排序顺序
    private void Awake() // Unity 初始化回调
    { // Awake 开始
        canvas.renderMode = RenderMode.ScreenSpaceCamera; // 设置 Canvas 为摄像机模式
        canvas.worldCamera = uiCamera; // 指定渲染这个 Canvas 的 UI Camera
        canvas.planeDistance = planeDistance; // 设置 UI 平面距离摄像机多远
        canvas.overrideSorting = true; // 开启独立排序,方便控制 UI 层级
        canvas.sortingOrder = sortingOrder; // 设置 Canvas 的排序值
    } // Awake 结束
} // 类结束

项目里怎么选

普通 HUD、血条、菜单:优先 Overlay,简单稳定。 角色展示、抽卡、UI 粒子、3D 道具预览:适合 Camera 模式。 世界空间血条、NPC 头顶名字:适合 World Space Canvas。 如果团队 UI 层级复杂,我会统一定义层级表,比如 Back=0Normal=100Effect=200Popup=500Top=1000,避免各模块乱写。

Safe Area 如何处理?

unity-safe-area-handling

标准答案

Safe Area 就是设备上“不会被刘海、圆角、Home Indicator、系统手势条遮挡”的安全矩形。Unity 里用 Screen.safeArea 读取,它返回的是像素 Rect,然后把它转换成 RectTransform.anchorMin / anchorMax,应用到一个 SafeRoot 节点上。

重点:不要把整个 Canvas 缩进 Safe Area,背景仍然铺满全屏;只把按钮、文字、返回键、货币栏这些关键 UI 放进 SafeRoot。

底层原理

Screen.safeArea 返回屏幕安全区的像素范围。比如屏幕是 2400 x 1080,安全区可能是从 x=80x=2320,说明左右有危险区域。UGUI 的 Anchor 用的是 0~1 的归一化坐标,所以要用安全区像素除以屏幕宽高。

Unity 示例代码

c
using UnityEngine; // 引入 Unity 基础类型
[ExecuteAlways] // 允许在编辑器模式下也执行,方便预览
[RequireComponent(typeof(RectTransform))] // 要求当前对象必须有 RectTransform
public sealed class SafeAreaFitter : MonoBehaviour // 定义 Safe Area 适配组件
{ // 类开始
    private RectTransform rectTransform; // 缓存当前 RectTransform
    private Rect lastSafeArea; // 记录上一次安全区
    private Vector2Int lastScreenSize; // 记录上一次屏幕尺寸
    private void Awake() // 初始化回调
    { // Awake 开始
        rectTransform = GetComponent<RectTransform>(); // 获取当前 RectTransform
        ApplyIfChanged(true); // 初始化时强制应用一次安全区
    } // Awake 结束
    private void OnEnable() // 对象启用时调用
    { // OnEnable 开始
        ApplyIfChanged(true); // 启用时重新应用一次安全区
    } // OnEnable 结束
    private void Update() // 每帧检查变化
    { // Update 开始
        ApplyIfChanged(false); // 屏幕旋转或分辨率变化时更新
    } // Update 结束
    private void ApplyIfChanged(bool force) // 只有变化时才重新计算
    { // 方法开始
        Rect safeArea = Screen.safeArea; // 读取 Unity 返回的安全区像素 Rect
        Vector2Int screenSize = new Vector2Int(Screen.width, Screen.height); // 读取当前屏幕宽高
        if (!force && safeArea == lastSafeArea && screenSize == lastScreenSize) return; // 没变化就不重复刷新
        lastSafeArea = safeArea; // 保存当前安全区
        lastScreenSize = screenSize; // 保存当前屏幕尺寸
        Vector2 anchorMin = safeArea.position; // 安全区左下角像素坐标
        Vector2 anchorMax = safeArea.position + safeArea.size; // 安全区右上角像素坐标
        anchorMin.x /= Screen.width; // 把左边界转换成 0 到 1 的锚点
        anchorMin.y /= Screen.height; // 把下边界转换成 0 到 1 的锚点
        anchorMax.x /= Screen.width; // 把右边界转换成 0 到 1 的锚点
        anchorMax.y /= Screen.height; // 把上边界转换成 0 到 1 的锚点
        rectTransform.anchorMin = anchorMin; // 应用安全区最小锚点
        rectTransform.anchorMax = anchorMax; // 应用安全区最大锚点
        rectTransform.offsetMin = Vector2.zero; // 清空左下偏移
        rectTransform.offsetMax = Vector2.zero; // 清空右上偏移
    } // 方法结束
} // 类结束

项目里怎么做

层级一般这样设计:

c
Canvas
 ├─ FullScreenRoot   背景、边框、氛围特效,全屏铺满
 ├─ SafeRoot         返回键、货币栏、按钮、文本、关键 HUD
 └─ TopRoot          Loading、Toast、全屏遮罩

横竖屏切换、分辨率变化、折叠屏、分屏模式下,safeArea 可能变化,所以不能只在 Awake 算一次。上线前要用 Device Simulator 和真机测刘海屏、圆角屏、iPhone Home Indicator、Android 手势导航。

常见坑点

把整个 Canvas 缩进安全区,会导致背景露边。 只适配 iPhone 刘海,忽略 Android 挖孔和手势条。 按钮贴边太近,虽然在 Safe Area 内,但操作手感仍然差。 全屏弹窗的关闭按钮忘记放进 SafeRoot。 横竖屏切换后没有重新计算 Safe Area。

刘海屏适配怎么做?

unity-notch-screen-adaptation

标准答案

刘海屏适配的核心是:背景全屏铺满,关键 UI 放进 Safe Area。 Unity 里主要用 Screen.safeArea 获取安全区域,然后把这个像素矩形转换成 RectTransformanchorMin / anchorMax,应用到一个 SafeRoot 节点上。

推荐层级

c
Canvas
 ├─ FullScreenRoot   背景、边框、氛围特效,允许铺到刘海区域
 ├─ SafeRoot         返回键、关闭按钮、货币栏、技能键、聊天入口
 └─ TopRoot          Loading、Toast、系统弹窗

底层原理

Screen.safeArea 返回的是屏幕像素坐标,比如左下角位置和安全区宽高。 UGUI 的 Anchor 是 0~1 归一化坐标,所以要这样转换:

c
anchorMin = safeArea.position / screenSize
anchorMax = (safeArea.position + safeArea.size) / screenSize

竖屏要注意顶部刘海和底部手势条,横屏要注意左右挖孔、圆角、Home Indicator 位置变化。

Unity 代码

c
using UnityEngine; // 引入 Unity 基础类型
[ExecuteAlways] // 允许编辑器模式下也执行,方便预览 Safe Area
[RequireComponent(typeof(RectTransform))] // 要求当前对象必须挂 RectTransform
public sealed class NotchSafeAreaAdapter : MonoBehaviour // 定义刘海屏安全区适配组件
{ // 类开始
    private RectTransform rectTransform; // 缓存当前节点的 RectTransform
    private Rect lastSafeArea; // 记录上一次的安全区
    private Vector2Int lastScreenSize; // 记录上一次的屏幕尺寸
    private void Awake() // 初始化回调
    { // Awake 开始
        rectTransform = GetComponent<RectTransform>(); // 获取 RectTransform 组件
        ApplySafeArea(true); // 启动时强制适配一次
    } // Awake 结束
    private void OnEnable() // 对象启用时调用
    { // OnEnable 开始
        ApplySafeArea(true); // 重新启用时也适配一次
    } // OnEnable 结束
    private void Update() // 每帧检查变化
    { // Update 开始
        ApplySafeArea(false); // 横竖屏或分辨率变化时刷新
    } // Update 结束
    private void ApplySafeArea(bool force) // 应用安全区
    { // 方法开始
        Rect safeArea = Screen.safeArea; // 获取 Unity 返回的安全区像素矩形
        Vector2Int screenSize = new Vector2Int(Screen.width, Screen.height); // 获取当前屏幕宽高
        if (!force && safeArea == lastSafeArea && screenSize == lastScreenSize) return; // 没变化就不重复计算
        lastSafeArea = safeArea; // 保存当前安全区
        lastScreenSize = screenSize; // 保存当前屏幕尺寸
        Vector2 anchorMin = safeArea.position; // 获取安全区左下角像素坐标
        Vector2 anchorMax = safeArea.position + safeArea.size; // 获取安全区右上角像素坐标
        anchorMin.x /= Screen.width; // 左边界转换成归一化锚点
        anchorMin.y /= Screen.height; // 下边界转换成归一化锚点
        anchorMax.x /= Screen.width; // 右边界转换成归一化锚点
        anchorMax.y /= Screen.height; // 上边界转换成归一化锚点
        rectTransform.anchorMin = anchorMin; // 设置 SafeRoot 最小锚点
        rectTransform.anchorMax = anchorMax; // 设置 SafeRoot 最大锚点
        rectTransform.offsetMin = Vector2.zero; // 清空左下偏移
        rectTransform.offsetMax = Vector2.zero; // 清空右上偏移
    } // 方法结束
} // 类结束

项目注意点

不要把整个 Canvas 缩进安全区,否则背景会露边。 不要只适配竖屏顶部刘海,横屏左右挖孔更容易挡住返回键、技能键。 横竖屏切换、折叠屏、分屏模式下要重新计算。 重要按钮最好额外留一点 padding,不要刚好贴着 Safe Area 边界。 可以用 Screen.cutouts 查看不可显示区域列表,但大多数 UI 适配优先用 Screen.safeArea

参考:Unity Screen.safeAreaScreen.cutouts

UI 点击音效怎么统一管理?

unity-ui-click-sound-management

标准答案

UI 点击音效要统一放到 UIAudioManager 管理,按钮本身只挂一个轻量组件,点击时告诉管理器“我要播哪种 UI 音效”。不要每个界面自己写 AudioSource.PlayOneShot,否则会出现音量不统一、重复播放、静音不生效、资源重复引用的问题。

核心设计

普通按钮挂 UIButtonClickSound。 音效播放走 UIAudioManager.PlayClick(type)。 音量和静音走统一设置。 短 UI 音效提前加载,常驻或跟 UI 公共包走。 特殊按钮用枚举区分,比如普通、确认、关闭、错误。

代码示例

c
using UnityEngine; // 引入 Unity 基础类型
using UnityEngine.UI; // 引入 UGUI Button 类型
public enum UIClickSoundType { Default, Confirm, Close, Error } // 定义 UI 点击音效类型
public sealed class UIAudioManager : MonoBehaviour // 定义 UI 音效管理器
{ // 类开始
    public static UIAudioManager Instance { get; private set; } // 全局访问入口
    [SerializeField] private AudioSource source; // 用一个公共 AudioSource 播放短 UI 音效
    [SerializeField] private AudioClip defaultClick; // 默认点击音效
    [SerializeField] private AudioClip confirmClick; // 确认按钮音效
    [SerializeField] private AudioClip closeClick; // 关闭按钮音效
    [SerializeField] private AudioClip errorClick; // 错误提示音效
    [SerializeField] private float volume = 1f; // UI 音效音量
    public bool Muted { get; set; } // 是否静音
    private void Awake() // 初始化单例
    { // Awake 开始
        if (Instance != null && Instance != this) // 如果已经存在另一个管理器
        { // 重复实例处理开始
            Destroy(gameObject); // 销毁重复实例
            return; // 直接退出
        } // 重复实例处理结束
        Instance = this; // 保存当前实例
        DontDestroyOnLoad(gameObject); // 切场景不销毁音效管理器
    } // Awake 结束
    public void PlayClick(UIClickSoundType type) // 播放指定类型的点击音效
    { // 方法开始
        if (Muted) return; // 静音时不播放
        if (source == null) return; // AudioSource 缺失时不播放
        AudioClip clip = GetClip(type); // 根据类型获取音效资源
        if (clip == null) return; // 没有配置音效时不播放
        source.PlayOneShot(clip, volume); // 使用 PlayOneShot 播放短音效
    } // 方法结束
    private AudioClip GetClip(UIClickSoundType type) // 根据音效类型取 AudioClip
    { // 方法开始
        return type switch // 使用 switch 表达式选择音效
        { // switch 开始
            UIClickSoundType.Confirm => confirmClick, // 确认按钮使用确认音效
            UIClickSoundType.Close => closeClick, // 关闭按钮使用关闭音效
            UIClickSoundType.Error => errorClick, // 错误按钮使用错误音效
            _ => defaultClick // 其他情况使用默认点击音效
        }; // switch 结束
    } // 方法结束
} // 类结束
[RequireComponent(typeof(Button))] // 要求当前对象必须有 Button
public sealed class UIButtonClickSound : MonoBehaviour // 定义按钮点击音效绑定组件
{ // 类开始
    [SerializeField] private UIClickSoundType type = UIClickSoundType.Default; // 当前按钮的音效类型
    private Button button; // 缓存 Button 组件
    private void Awake() // 初始化回调
    { // Awake 开始
        button = GetComponent<Button>(); // 获取当前物体上的 Button
    } // Awake 结束
    private void OnEnable() // 启用时添加监听
    { // OnEnable 开始
        button.onClick.AddListener(PlaySound); // 监听按钮点击事件
    } // OnEnable 结束
    private void OnDisable() // 禁用时移除监听
    { // OnDisable 开始
        button.onClick.RemoveListener(PlaySound); // 移除监听,避免重复播放和泄漏
    } // OnDisable 结束
    private void PlaySound() // 实际播放点击音效
    { // 方法开始
        if (!button.IsInteractable()) return; // 按钮不可交互时不播放
        UIAudioManager.Instance?.PlayClick(type); // 通知统一管理器播放音效
    } // 方法结束
} // 类结束

项目里怎么落地

最好把 UIButtonClickSound 挂在基础按钮 Prefab 上,新按钮自动带点击音。旧项目可以在 UI 打开时扫描子节点 Button,没有组件就补一个,但不要每帧扫描。普通点击音效在 onClick 时播放;业务成功音效、领奖音效、购买成功音效要由业务结果回调单独播放,不要混进普通点击音里。

常见坑点

AddListener 重复添加会导致点一次响多次,所以要成对 AddListener / RemoveListener。禁用按钮、拖拽控件、Slider、ScrollView 不一定要播放普通点击音。音效资源不要每次点击时加载,短 UI 音效应该预加载。静音和音量设置必须走统一入口,否则设置面板关了声音,某些界面还在响。

参考:Unity ButtonButton.onClickAudioSource.PlayOneShot

UI 红点如何避免全量刷新?

unity-ui-red-dot-incremental-refresh

标准答案

UI 红点不要每次都 RefreshAll()。更好的做法是把红点做成一棵“红点树”,业务数据变化时只更新对应叶子节点,然后把变化量向父节点传播,最后只通知订阅了这些节点的 UI。

比如邮件来了 1 封,只更新:

c
Mail.Unread -> Mail -> MainUI

而不是重新扫描背包、任务、商城、活动、成就所有系统。

底层原理

全量刷新是:

所有红点规则 × 所有 UI 节点

增量刷新是:

变化节点 × 父链深度 × 当前可见 UI

所以复杂度会从接近 O(N) 变成 O(depth)。红点本质不是 UI 状态,而是业务数据状态的可视化结果。

Unity 项目里我会这样做

红点数据放在独立 RedDotSystem,不要让每个 UI 自己算。 业务模块只负责通知变化,比如任务完成、邮件到达、背包获得新物品。 UI 只在 OnEnable 订阅,OnDisable 取消订阅,打开界面时先同步一次当前值。

c
using System; // 引入 Action 回调类型
using System.Collections.Generic; // 引入 Dictionary 和 List

public sealed class RedDotSystem // 定义红点系统
{ // 红点系统开始
    private sealed class Node // 定义红点节点
    { // 节点开始
        public string ParentKey; // 保存父节点 key
        public int Count; // 保存当前红点数量
    } // 节点结束

    private readonly Dictionary<string, Node> nodes = new(); // 保存所有红点节点
    private readonly Dictionary<string, Action<int>> listeners = new(); // 保存 UI 订阅回调

    public void Bind(string childKey, string parentKey) // 建立子节点到父节点的关系
    { // Bind 开始
        GetNode(childKey).ParentKey = parentKey; // 给子节点记录父节点
        GetNode(parentKey); // 确保父节点存在
    } // Bind 结束

    public void SetCount(string key, int newCount) // 设置某个叶子节点红点数量
    { // SetCount 开始
        Node node = GetNode(key); // 拿到当前节点
        int delta = newCount - node.Count; // 计算变化量
        if (delta == 0) return; // 数值没变就不刷新
        while (key != null) // 沿父链向上传播
        { // 循环开始
            node = GetNode(key); // 拿到当前传播节点
            node.Count += delta; // 当前节点累加变化量
            Notify(key, node.Count); // 只通知这个节点的订阅者
            key = node.ParentKey; // 继续传播到父节点
        } // 循环结束
    } // SetCount 结束

    public void Subscribe(string key, Action<int> callback) // UI 订阅某个红点节点
    { // Subscribe 开始
        listeners[key] = listeners.GetValueOrDefault(key) + callback; // 添加回调
        callback(GetNode(key).Count); // 订阅后立刻同步一次当前状态
    } // Subscribe 结束

    public void Unsubscribe(string key, Action<int> callback) // UI 取消订阅
    { // Unsubscribe 开始
        if (!listeners.ContainsKey(key)) return; // 没订阅过就直接返回
        listeners[key] -= callback; // 移除回调
    } // Unsubscribe 结束

    private Node GetNode(string key) // 获取或创建节点
    { // GetNode 开始
        if (!nodes.TryGetValue(key, out Node node)) // 如果节点不存在
            nodes[key] = node = new Node(); // 创建新节点并加入字典
        return node; // 返回节点
    } // GetNode 结束

    private void Notify(string key, int count) // 通知 UI
    { // Notify 开始
        if (listeners.TryGetValue(key, out Action<int> callback)) // 如果有人监听
            callback?.Invoke(count); // 通知红点数量变化
    } // Notify 结束
} // 红点系统结束

常见坑点

不要在 Update 里轮询红点。 不要 UI 面板各自扫描业务数据。 不要关闭 UI 后还保留订阅,否则可能造成无效刷新或引用泄漏。 频繁变化的红点可以用 dirtySet 合并到一帧末尾统一 Flush。 Unity UI 变化可能触发 Canvas 重建,所以高频红点最好减少文字变化,必要时拆 Canvas。

参考 Unity 官方:UI optimization tipsUGUI Canvas

红点依赖树怎么设计?

unity-ui-red-dot-dependency-tree-design

标准答案

红点依赖树的核心是:把红点从 UI 逻辑里抽出来,做成一棵配置驱动的节点树。叶子节点由业务数据驱动,比如邮件未读、背包新物品、任务可领取;父节点不直接查业务,只负责聚合子节点结果。

比如:

Root -> Bag -> Bag.EquipRoot -> Mail -> Mail.UnreadRoot -> Quest -> Quest.Daily

Bag.Equip 从 2 变成 3 时,只沿着:

c
Bag.Equip -> Bag -> Root

向上传播,不刷新整棵树。

底层原理

每个红点节点至少保存:

key:节点唯一 ID parent:父节点 children:子节点列表 count:当前红点数量 listeners:哪些 UI 订阅了这个节点

叶子节点变化时,计算差值 delta = newCount - oldCount,然后父节点一路 count += delta。这样复杂度是 O(树深度),而不是 O(所有红点规则)

复杂项目里,如果一个叶子要影响多个入口,可以从“树”扩展成 DAG 有向无环图,但一定要做循环依赖检查。

代码示例

c
using System; // 引入 Action 回调
using System.Collections.Generic; // 引入集合类型

public sealed class RedDotTree // 定义红点依赖树
{ // 类开始
    private sealed class Node // 定义红点节点
    { // 节点开始
        public string Parent; // 父节点 key
        public int Count; // 当前红点数量
    } // 节点结束

    private readonly Dictionary<string, Node> nodes = new Dictionary<string, Node>(); // 保存所有节点
    private readonly Dictionary<string, Action<int>> listeners = new Dictionary<string, Action<int>>(); // 保存 UI 监听

    public void AddNode(string key, string parent) // 添加节点关系
    { // 方法开始
        GetNode(key).Parent = parent; // 设置父节点
        if (parent != null) GetNode(parent); // 确保父节点存在
    } // 方法结束

    public void SetLeafCount(string key, int newCount) // 设置叶子节点数量
    { // 方法开始
        Node node = GetNode(key); // 获取叶子节点
        int delta = newCount - node.Count; // 计算变化量
        if (delta == 0) return; // 没变化就不刷新
        while (key != null) // 沿父链向上传播
        { // 循环开始
            node = GetNode(key); // 获取当前节点
            node.Count += delta; // 当前节点累加变化量
            Notify(key, node.Count); // 通知订阅这个 key 的 UI
            key = node.Parent; // 移动到父节点
        } // 循环结束
    } // 方法结束

    public void Subscribe(string key, Action<int> callback) // UI 订阅红点节点
    { // 方法开始
        if (!listeners.ContainsKey(key)) listeners[key] = null; // 如果没有监听列表就初始化
        listeners[key] += callback; // 添加监听回调
        callback(GetNode(key).Count); // 订阅时立刻同步当前状态
    } // 方法结束

    public void Unsubscribe(string key, Action<int> callback) // UI 取消订阅
    { // 方法开始
        if (!listeners.ContainsKey(key)) return; // 没有订阅就直接返回
        listeners[key] -= callback; // 移除监听回调
    } // 方法结束

    private Node GetNode(string key) // 获取或创建节点
    { // 方法开始
        if (!nodes.ContainsKey(key)) nodes[key] = new Node(); // 节点不存在就创建
        return nodes[key]; // 返回节点
    } // 方法结束

    private void Notify(string key, int count) // 通知 UI
    { // 方法开始
        if (!listeners.ContainsKey(key)) return; // 没人监听就不处理
        listeners[key]?.Invoke(count); // 调用监听回调
    } // 方法结束
} // 类结束

Unity 项目里怎么落地

红点配置可以放配置表或 ScriptableObject;业务模块只调用 SetLeafCount;UI 在 OnEnableSubscribe,在 OnDisableUnsubscribe。Unity 官方 UI 优化也强调,UI 元素变化可能导致 Canvas 重新分析和重建,所以红点刷新要尽量局部化,避免频繁改动大 Canvas:Unity UI optimization tips

常见坑点

不要在 UI 打开时全量扫描所有模块。 不要在 Update 里轮询红点。 不要忘记取消订阅,否则 UI 关闭后还可能收到回调。 不要让父节点直接查业务,父节点只聚合子节点。 配置加载时要检查重复 key、孤儿节点、循环依赖。

红点循环依赖怎么处理?

unity-ui-red-dot-cycle-dependency-handling

标准答案

红点循环依赖要在配置加载阶段就检测并拒绝,不要等运行时才发现。红点依赖关系应该是:

DAG 有向无环图

不能出现:

c
Bag -> Quest -> Bag

因为红点刷新通常是“叶子变化后向父节点传播”,如果依赖成环,就可能出现无限递归、重复累加、重复通知 UI,甚至卡死。

底层原理

红点依赖可以看成一张有向图:

父节点 -> 子节点

检测循环依赖,本质就是检测有向图有没有环。常用做法是 DFS 三色标记

White:没访问过 Gray:正在递归访问中 Black:已经访问完成

如果 DFS 过程中访问到了 Gray 节点,说明当前路径绕回来了,就是环。

代码示例

c
using System.Collections.Generic; // 引入 Dictionary、List、HashSet 等集合

public static class RedDotCycleChecker // 定义红点循环检测工具类
{ // 类开始
    private enum VisitState // 定义节点访问状态
    { // 枚举开始
        Visiting, // 表示节点正在当前递归栈中
        Done // 表示节点已经检查完成
    } // 枚举结束

    public static bool HasCycle(Dictionary<string, List<string>> graph, out List<string> cyclePath) // 检测红点图是否有环
    { // 方法开始
        Dictionary<string, VisitState> states = new Dictionary<string, VisitState>(); // 保存每个节点的访问状态
        List<string> stack = new List<string>(); // 保存当前 DFS 路径
        cyclePath = new List<string>(); // 初始化输出的环路径

        foreach (string node in graph.Keys) // 遍历所有红点节点
        { // 循环开始
            if (Dfs(node, graph, states, stack, cyclePath)) // 从当前节点开始做 DFS
            { // if 开始
                return true; // 找到环就返回 true
            } // if 结束
        } // 循环结束

        return false; // 所有节点检查完都没发现环
    } // 方法结束

    private static bool Dfs(string node, Dictionary<string, List<string>> graph, Dictionary<string, VisitState> states, List<string> stack, List<string> cyclePath) // 深度优先搜索
    { // 方法开始
        if (states.TryGetValue(node, out VisitState state)) // 如果节点已经有访问状态
        { // if 开始
            if (state == VisitState.Visiting) // 如果访问到正在递归中的节点
            { // if 开始
                int startIndex = stack.IndexOf(node); // 找到环在路径中的起点
                cyclePath.AddRange(stack.GetRange(startIndex, stack.Count - startIndex)); // 复制环路径
                cyclePath.Add(node); // 把回到的节点也加进去
                return true; // 返回发现环
            } // if 结束

            return false; // 已完成节点不用重复检查
        } // if 结束

        states[node] = VisitState.Visiting; // 标记当前节点正在访问
        stack.Add(node); // 把当前节点压入递归路径

        if (graph.TryGetValue(node, out List<string> children)) // 如果当前节点有子节点
        { // if 开始
            foreach (string child in children) // 遍历所有子节点
            { // 循环开始
                if (Dfs(child, graph, states, stack, cyclePath)) // 递归检查子节点
                { // if 开始
                    return true; // 子节点发现环就直接返回
                } // if 结束
            } // 循环结束
        } // if 结束

        stack.RemoveAt(stack.Count - 1); // 当前节点检查完成后弹出递归路径
        states[node] = VisitState.Done; // 标记当前节点已经完成
        return false; // 当前分支没有环
    } // 方法结束
} // 类结束

Unity 项目里怎么处理

我一般会做三层保护:

  1. 编辑器导表时检测 配置表里如果出现环,直接导表失败,并输出完整路径: Root -> Bag -> Quest -> Bag
  2. 客户端启动时再校验一次 防止热更配置、远程配置、增量包里带进错误数据。
  3. 运行时传播加保护 红点向父节点传播时限制最大深度,发现异常就打日志并禁用异常节点,避免玩家机器卡死。

面试加分说法

红点系统不要靠 UI 自己算,也不要每次打开界面全量扫。正确做法是:业务改叶子,红点树做聚合,UI 只订阅结果。Unity UI 本身频繁变化可能带来 Canvas 重建成本,所以红点刷新越局部越好。

参考 Unity 官方 UI 优化建议:Unity UI optimization tips

新手引导遮罩如何实现挖洞?

unity-guide-mask-hole-implementation

标准答案

新手引导遮罩“挖洞”常见做法有两种: 一种是 自定义 UGUI Graphic,洞外生成 Mesh,洞内不生成顶点;另一种是 Shader 根据坐标把洞区域 alpha 变成 0

面试里我更推荐先说 Mesh 挖洞,因为矩形按钮引导最常见,逻辑清楚,点击穿透也好处理。

核心思路是:

全屏遮罩 = 上方矩形 + 下方矩形 + 左侧矩形 + 右侧矩形

中间目标区域不画,所以看起来就是“挖了一个洞”。

底层原理

UGUI 的 Graphic 可以通过 OnPopulateMesh(VertexHelper vh) 自己生成 UI 顶点。Unity 官方文档也说明,OnPopulateMesh(VertexHelper) 是 UI 元素需要生成顶点时的回调;VertexHelper.AddUIVertexQuad 可以向顶点流里添加一个四边形;而 Graphic.Raycast 可以决定某个屏幕点是否算命中 UI。

所以挖洞要分两件事:

视觉上:洞内不生成遮罩顶点。 交互上:点击洞内时 Raycast 返回 false,让事件穿透到下面的按钮。

代码示例:矩形挖洞遮罩

c
using UnityEngine; // 引入 Unity 基础类型
using UnityEngine.UI; // 引入 UGUI 的 MaskableGraphic 和 VertexHelper

public sealed class GuideHoleMask : MaskableGraphic // 定义一个可被 Canvas 渲染的自定义遮罩
{ // 类开始
    [SerializeField] private RectTransform target; // 被引导的目标 UI
    [SerializeField] private float padding = 12f; // 洞比目标 UI 多出来的边距
    private Rect holeRect; // 洞在遮罩本地坐标系下的矩形
    private readonly Vector3[] corners = new Vector3[4]; // 缓存目标 UI 的四个世界角点
    private readonly UIVertex[] quad = new UIVertex[4]; // 缓存四边形顶点数组,减少临时分配

    protected override void Awake() // 组件初始化
    { // 方法开始
        base.Awake(); // 调用父类初始化
        raycastTarget = true; // 让遮罩参与 UI 射线检测
    } // 方法结束

    public void SetTarget(RectTransform newTarget, float newPadding) // 外部设置当前引导目标
    { // 方法开始
        target = newTarget; // 保存目标 UI
        padding = newPadding; // 保存洞的额外边距
        RefreshHole(); // 重新计算洞的位置
    } // 方法结束

    public void RefreshHole() // 根据目标 UI 重新计算洞区域
    { // 方法开始
        if (target == null) // 如果没有目标
        { // if 开始
            SetVerticesDirty(); // 通知 Unity 重建遮罩顶点
            return; // 直接返回
        } // if 结束

        target.GetWorldCorners(corners); // 获取目标 UI 的四个世界坐标角点
        Vector2 p0 = rectTransform.InverseTransformPoint(corners[0]); // 转成遮罩本地坐标
        Vector2 p1 = rectTransform.InverseTransformPoint(corners[1]); // 转成遮罩本地坐标
        Vector2 p2 = rectTransform.InverseTransformPoint(corners[2]); // 转成遮罩本地坐标
        Vector2 p3 = rectTransform.InverseTransformPoint(corners[3]); // 转成遮罩本地坐标

        float minX = Mathf.Min(p0.x, p1.x, p2.x, p3.x) - padding; // 计算洞左边界
        float maxX = Mathf.Max(p0.x, p1.x, p2.x, p3.x) + padding; // 计算洞右边界
        float minY = Mathf.Min(p0.y, p1.y, p2.y, p3.y) - padding; // 计算洞下边界
        float maxY = Mathf.Max(p0.y, p1.y, p2.y, p3.y) + padding; // 计算洞上边界

        holeRect = Rect.MinMaxRect(minX, minY, maxX, maxY); // 保存洞矩形
        SetVerticesDirty(); // 标记顶点脏,让 Canvas 重建这个 Graphic
    } // 方法结束

    protected override void OnPopulateMesh(VertexHelper vh) // Unity 重建 UI 顶点时调用
    { // 方法开始
        vh.Clear(); // 清空旧顶点
        Rect full = GetPixelAdjustedRect(); // 获取遮罩自身完整矩形

        if (target == null) // 如果没有洞目标
        { // if 开始
            AddQuad(vh, full.min, full.max); // 画一整块遮罩
            return; // 结束绘制
        } // if 结束

        AddQuad(vh, new Vector2(full.xMin, full.yMin), new Vector2(full.xMax, holeRect.yMin)); // 绘制下方遮罩
        AddQuad(vh, new Vector2(full.xMin, holeRect.yMax), new Vector2(full.xMax, full.yMax)); // 绘制上方遮罩
        AddQuad(vh, new Vector2(full.xMin, holeRect.yMin), new Vector2(holeRect.xMin, holeRect.yMax)); // 绘制左侧遮罩
        AddQuad(vh, new Vector2(holeRect.xMax, holeRect.yMin), new Vector2(full.xMax, holeRect.yMax)); // 绘制右侧遮罩
    } // 方法结束

    public override bool Raycast(Vector2 sp, Camera eventCamera) // 判断点击是否命中遮罩
    { // 方法开始
        if (target == null) return true; // 没有洞时遮罩拦截所有点击
        RectTransformUtility.ScreenPointToLocalPointInRectangle(rectTransform, sp, eventCamera, out Vector2 localPoint); // 屏幕点转本地坐标
        return !holeRect.Contains(localPoint); // 点在洞里返回 false,洞外返回 true
    } // 方法结束

    private void AddQuad(VertexHelper vh, Vector2 min, Vector2 max) // 添加一个矩形遮罩块
    { // 方法开始
        if (max.x <= min.x || max.y <= min.y) return; // 无效矩形不绘制
        UIVertex vertex = UIVertex.simpleVert; // 创建默认 UI 顶点
        vertex.color = color; // 使用组件上的遮罩颜色
        quad[0] = vertex; // 复制第一个顶点
        quad[1] = vertex; // 复制第二个顶点
        quad[2] = vertex; // 复制第三个顶点
        quad[3] = vertex; // 复制第四个顶点
        quad[0].position = new Vector3(min.x, min.y); // 左下角
        quad[1].position = new Vector3(min.x, max.y); // 左上角
        quad[2].position = new Vector3(max.x, max.y); // 右上角
        quad[3].position = new Vector3(max.x, min.y); // 右下角
        vh.AddUIVertexQuad(quad); // 把四边形加入 UI 顶点流
    } // 方法结束
} // 类结束

Unity 项目里怎么用

把这个组件挂在全屏 RectTransform 上,放到新手引导 Canvas 的最上层。颜色设成半透明黑或半透明蓝灰。每一步引导切换时调用:

c
mask.SetTarget(buttonRect, 16f);

如果目标 UI 会移动,比如动画打开、布局变化、滚动列表定位,需要在动画过程中适当调用 RefreshHole()。但不要每帧无脑刷新,因为 SetVerticesDirty() 会让这个 UI 图形重新生成顶点,频繁做也有成本。

常见坑点

透明不代表能点击穿透,必须重写 Raycast。 目标和遮罩不在同一个 Canvas 时,坐标转换要特别小心。 圆形洞、柔边洞适合 Shader,但点击区域仍然要自己判断。 多个洞可以生成多组洞外 Mesh,或者用 Shader 做多洞参数。 引导遮罩最好单独放一个 Canvas,避免影响主 UI 的 Canvas 重建。

官方依据:Unity Graphic.OnPopulateMeshGraphic.RaycastSetVerticesDirtyVertexHelper.AddUIVertexQuad 都是这类实现的基础,可参考 Graphic APIVertexHelper API

新手引导步骤如何配置化?

unity-guide-step-configuration-design

标准答案

新手引导步骤配置化,就是把每一步从代码里拆成数据:

第几步 -> 触发条件 -> 指向哪个 UI -> 显示什么文案 -> 等待什么完成 -> 下一步是谁

代码只做“解释配置”和“驱动表现”,不要在代码里写一堆:

if step == 1if step == 2

这样策划改新手引导流程时,只改配置,不用改 C#。

底层原理

新手引导可以看成一个小型状态机:

未开始 -> 检查触发条件 -> 显示当前步骤 -> 等待完成条件 -> 保存进度 -> 跳到下一步

每一步配置里通常有这些字段:

stepId:步骤 ID nextStepId:下一步 ID targetKey:要指向的 UI 控件 textKey:多语言文案 key triggerType:什么时候触发 completeType:怎么完成 maskType:矩形洞、圆形洞、无遮罩 allowSkip:是否允许跳过

代码示例

c
using System; // 引入 Serializable 特性
using System.Collections.Generic; // 引入 List 和 Dictionary
using UnityEngine; // 引入 Unity 基础类型

public enum GuideTriggerType { Always, LevelReached, QuestAccepted } // 定义引导触发条件类型
public enum GuideCompleteType { ClickTarget, WaitEvent, Auto } // 定义引导完成条件类型

[Serializable] // 让 Unity 可以序列化这个配置类
public sealed class GuideStepConfig // 定义单步引导配置
{ // 类开始
    public int stepId; // 当前步骤 ID
    public int nextStepId; // 下一步 ID,0 表示结束
    public string targetKey; // 目标 UI 的注册 key
    public string textKey; // 多语言文案 key
    public GuideTriggerType triggerType; // 触发条件类型
    public GuideCompleteType completeType; // 完成条件类型
    public int intParam; // 条件参数,例如等级或任务 ID
    public bool allowSkip; // 是否允许玩家跳过
} // 类结束

[CreateAssetMenu(fileName = "GuideFlowConfig", menuName = "Guide/Flow Config")] // 允许在 Unity 菜单里创建配置资源
public sealed class GuideFlowConfig : ScriptableObject // 用 ScriptableObject 保存一组步骤
{ // 类开始
    public List<GuideStepConfig> steps = new List<GuideStepConfig>(); // 保存所有步骤配置
} // 类结束

public sealed class GuideManager : MonoBehaviour // 定义引导管理器
{ // 类开始
    [SerializeField] private GuideFlowConfig config; // 引用引导配置资源
    private readonly Dictionary<int, GuideStepConfig> stepMap = new Dictionary<int, GuideStepConfig>(); // 用字典加速按 stepId 查找
    private readonly HashSet<int> completedSteps = new HashSet<int>(); // 记录已经完成的步骤
    private GuideStepConfig currentStep; // 保存当前正在执行的步骤

    private void Awake() // Unity 初始化回调
    { // 方法开始
        foreach (GuideStepConfig step in config.steps) // 遍历所有配置步骤
        { // 循环开始
            stepMap[step.stepId] = step; // 把步骤放进字典
        } // 循环结束
    } // 方法结束

    public void TryStartStep(int stepId) // 尝试开始某一步
    { // 方法开始
        if (!stepMap.TryGetValue(stepId, out GuideStepConfig step)) return; // 找不到配置就直接返回
        if (completedSteps.Contains(stepId)) return; // 已完成的步骤不重复执行
        if (!CheckTrigger(step)) return; // 触发条件不满足就不开始
        currentStep = step; // 记录当前步骤
        ShowStep(step); // 显示引导表现
    } // 方法结束

    private bool CheckTrigger(GuideStepConfig step) // 检查触发条件
    { // 方法开始
        if (step.triggerType == GuideTriggerType.Always) return true; // Always 表示无条件触发
        if (step.triggerType == GuideTriggerType.LevelReached) return GetPlayerLevel() >= step.intParam; // 等级达到才触发
        if (step.triggerType == GuideTriggerType.QuestAccepted) return HasQuest(step.intParam); // 接到指定任务才触发
        return false; // 未识别条件默认不触发
    } // 方法结束

    private void ShowStep(GuideStepConfig step) // 显示当前步骤
    { // 方法开始
        RectTransform target = GuideTargetRegistry.Find(step.targetKey); // 根据 targetKey 查找目标 UI
        if (target == null) return; // 目标不存在时不显示,实际项目可等待 UI 打开
        GuideView.Instance.Show(target, step.textKey, step.allowSkip); // 显示遮罩、文案、箭头和手指
    } // 方法结束

    public void CompleteCurrentStep() // 完成当前步骤
    { // 方法开始
        if (currentStep == null) return; // 没有当前步骤就返回
        completedSteps.Add(currentStep.stepId); // 记录当前步骤已完成
        int nextId = currentStep.nextStepId; // 取出下一步 ID
        currentStep = null; // 清空当前步骤
        if (nextId != 0) TryStartStep(nextId); // 如果还有下一步就继续
    } // 方法结束

    private int GetPlayerLevel() // 获取玩家等级
    { // 方法开始
        return 1; // 示例代码返回固定等级
    } // 方法结束

    private bool HasQuest(int questId) // 判断是否接取任务
    { // 方法开始
        return questId > 0; // 示例代码返回简单判断
    } // 方法结束
} // 类结束

Unity 工程实践

开发期我会用 ScriptableObject 配引导流程,因为它独立于 GameObject,适合集中保存项目数据;Unity 官方也说明 ScriptableObject 适合集中化数据。热更新项目里,可以把同样结构导成 JSON/CSV,由配置系统加载。

目标 UI 不建议直接在配置里拖引用,因为界面可能是动态加载的。更稳的方式是:UI 打开时注册 targetKey,引导系统通过 targetKey 找目标控件。

比如:

MainUI.StartButtonBag.EquipSlot_1Quest.AcceptButton

常见坑点

目标 UI 还没打开时,引导不要直接失败,可以等待一小段时间。 配置要做校验:重复 stepId、找不到 nextId、循环步骤、空 targetKey。 文案要用 textKey,不要直接写死中文,方便多语言。 引导进度要存档,否则玩家重登后会重复播放。 强引导和弱引导要区分,强引导会限制点击,弱引导只提示。

官方参考:ScriptableObjectCreateAssetMenuAttributeJsonUtility

新手引导中断后如何恢复?

unity-guide-interruption-recovery

标准答案

新手引导中断后恢复,核心是:把引导当成可恢复的状态机,而不是一次性播放流程。每进入一步、完成一步、切场景、后台暂停时,都保存当前进度;玩家回来后读取进度,校验配置、场景、目标 UI 是否有效,再恢复到当前步骤或回到最近检查点。

底层原理

不要存 GameObjectRectTransform 这种运行时引用,因为切场景或重启后会失效。应该存稳定数据:

guideId:哪条引导 currentStepId:当前执行到哪一步 checkpointStepId:最近安全检查点 configVersion:配置版本 sceneName:目标步骤所属场景 completedSteps:已完成步骤集合

恢复流程是:

读取存档 -> 校验配置版本 -> 校验 stepId -> 等待场景和 UI -> 找 targetKey -> 恢复表现

如果恢复不了,比如目标 UI 已经不存在,就不要卡死玩家,要么回到检查点,要么跳过废弃步骤。

代码示例

c
using System; // 引入 Serializable
using UnityEngine; // 引入 Unity API
using UnityEngine.SceneManagement; // 引入场景 API

[Serializable] // 允许 JsonUtility 序列化
public sealed class GuideProgress // 定义引导进度数据
{ // 类开始
    public string guideId; // 当前引导 ID
    public int currentStepId; // 当前步骤 ID
    public int checkpointStepId; // 最近安全检查点步骤 ID
    public int configVersion; // 当前引导配置版本
    public string sceneName; // 当前步骤所在场景名
    public bool running; // 是否有正在恢复的引导
} // 类结束

public sealed class GuideRecoveryStore // 定义引导恢复存档工具
{ // 类开始
    private const string Key = "guide_progress"; // PlayerPrefs 保存 key

    public static void Save(GuideProgress progress) // 保存引导进度
    { // 方法开始
        string json = JsonUtility.ToJson(progress); // 把进度转成 JSON
        PlayerPrefs.SetString(Key, json); // 保存到本地小型键值存储
        PlayerPrefs.Save(); // 主动落盘,降低后台中断丢失概率
    } // 方法结束

    public static bool TryLoad(out GuideProgress progress) // 尝试读取引导进度
    { // 方法开始
        string json = PlayerPrefs.GetString(Key, string.Empty); // 从本地读取 JSON
        if (string.IsNullOrEmpty(json)) // 判断是否没有进度
        { // if 开始
            progress = null; // 输出空进度
            return false; // 返回读取失败
        } // if 结束
        progress = JsonUtility.FromJson<GuideProgress>(json); // JSON 反序列化成对象
        return progress != null && progress.running; // 只有正在运行的引导才恢复
    } // 方法结束

    public static void Clear() // 清理引导进度
    { // 方法开始
        PlayerPrefs.DeleteKey(Key); // 删除本地进度
        PlayerPrefs.Save(); // 立刻保存删除结果
    } // 方法结束
} // 类结束

public sealed class GuideRecoveryExample : MonoBehaviour // 定义一个恢复示例组件
{ // 类开始
    private GuideProgress progress; // 保存当前运行中的进度

    public void EnterStep(string guideId, int stepId, int checkpointId, int version) // 进入某一步
    { // 方法开始
        progress = new GuideProgress(); // 创建新的进度对象
        progress.guideId = guideId; // 保存引导 ID
        progress.currentStepId = stepId; // 保存当前步骤
        progress.checkpointStepId = checkpointId; // 保存检查点
        progress.configVersion = version; // 保存配置版本
        progress.sceneName = SceneManager.GetActiveScene().name; // 保存当前场景
        progress.running = true; // 标记引导正在进行
        GuideRecoveryStore.Save(progress); // 进入步骤时立即保存
    } // 方法结束

    public void CompleteStep() // 完成当前步骤
    { // 方法开始
        if (progress == null) return; // 没有进度就直接返回
        progress.running = false; // 标记当前步骤不再需要恢复
        GuideRecoveryStore.Save(progress); // 保存完成状态
    } // 方法结束

    private void OnApplicationPause(bool pause) // 应用进入后台或恢复时调用
    { // 方法开始
        if (pause && progress != null) GuideRecoveryStore.Save(progress); // 进入后台时保存断点
    } // 方法结束

    public void TryRecover(int currentConfigVersion) // 尝试恢复引导
    { // 方法开始
        if (!GuideRecoveryStore.TryLoad(out GuideProgress saved)) return; // 没有断点就不恢复
        if (saved.configVersion != currentConfigVersion) // 如果配置版本不一致
        { // if 开始
            RecoverToCheckpoint(saved); // 回到检查点或走迁移逻辑
            return; // 停止当前恢复流程
        } // if 结束
        if (saved.sceneName != SceneManager.GetActiveScene().name) return; // 场景不一致时等待切到目标场景
        ResumeStep(saved.currentStepId); // 恢复当前步骤
    } // 方法结束

    private void ResumeStep(int stepId) // 恢复某个步骤
    { // 方法开始
        Debug.Log("Resume guide step: " + stepId); // 示例:实际项目里显示遮罩和目标 UI
    } // 方法结束

    private void RecoverToCheckpoint(GuideProgress saved) // 回到最近安全检查点
    { // 方法开始
        Debug.Log("Recover guide checkpoint: " + saved.checkpointStepId); // 示例:实际项目里恢复检查点步骤
    } // 方法结束
} // 类结束

Unity 工程实践

PlayerPrefs 适合保存少量引导进度;Unity 官方 PlayerPrefs.SetString 文档也提示字符串值建议保持很小,较大数据应写到 Application.persistentDataPath。移动端进入后台时,Unity 会调用 OnApplicationPause(true),所以这是保存断点的关键时机。

参考:PlayerPrefs.SetStringOnApplicationPause

常见坑点

CAUTION

完成奖励、解锁功能、上报埋点要做幂等,恢复时不能重复发。 目标 UI 未加载时不要立刻失败,可以等待注册,但要有超时。 热更新后步骤变化,要做版本迁移或跳过废弃步骤。 强制引导恢复失败时,必须能安全跳过,不能让玩家卡死。 重要引导进度最好和服务端同步,避免换设备或重装丢失。

UI 与业务如何通信?

unity-ui-business-communication-design

标准答案

UI 与业务通信的核心是:UI 发命令,业务改数据,事件通知 UI 刷新。UI 不应该直接改背包、货币、任务数据;业务层也不应该直接操作 ButtonTextImage

比较推荐的结构是:

View/UI -> Presenter/Controller -> Service/Model -> EventBus -> View/UI

底层原理

UI 层负责“玩家输入”和“界面显示”;业务层负责“规则、数据、状态变化”。中间通过接口、命令、事件解耦。Unity 的 Button.onClick 本质就是 UI 输入事件,适合把点击转成业务命令;业务处理完成后,再用 C# event 或事件总线通知 UI 局部刷新。

代码示例

c
using System; // 引入 Action 委托
using UnityEngine; // 引入 MonoBehaviour 和 Debug
using UnityEngine.UI; // 引入 Button 和 Text

public readonly struct InventoryChangedEvent // 定义背包变化事件
{ // 结构体开始
    public readonly int ItemId; // 变化的物品 ID
    public readonly int NewCount; // 变化后的物品数量

    public InventoryChangedEvent(int itemId, int newCount) // 构造事件数据
    { // 构造函数开始
        ItemId = itemId; // 保存物品 ID
        NewCount = newCount; // 保存最新数量
    } // 构造函数结束
} // 结构体结束

public static class GameEventBus // 定义简单事件总线
{ // 类开始
    public static event Action<InventoryChangedEvent> InventoryChanged; // 背包变化事件

    public static void Publish(InventoryChangedEvent evt) // 发布背包变化事件
    { // 方法开始
        InventoryChanged?.Invoke(evt); // 通知所有订阅者
    } // 方法结束
} // 类结束

public sealed class InventoryService // 定义业务层服务
{ // 类开始
    private int itemCount; // 保存物品数量

    public void BuyItem(int itemId) // 购买物品的业务接口
    { // 方法开始
        itemCount += 1; // 修改业务数据
        GameEventBus.Publish(new InventoryChangedEvent(itemId, itemCount)); // 发布数据变化事件
    } // 方法结束
} // 类结束

public sealed class ShopPanel : MonoBehaviour // 定义商店 UI 面板
{ // 类开始
    [SerializeField] private Button buyButton; // 购买按钮
    [SerializeField] private Text countText; // 数量文本
    private readonly InventoryService inventoryService = new InventoryService(); // 持有业务服务
    private const int ItemId = 1001; // 示例物品 ID

    private void OnEnable() // UI 打开时调用
    { // 方法开始
        buyButton.onClick.AddListener(OnBuyClicked); // 订阅按钮点击
        GameEventBus.InventoryChanged += OnInventoryChanged; // 订阅业务变化事件
    } // 方法结束

    private void OnDisable() // UI 关闭时调用
    { // 方法开始
        buyButton.onClick.RemoveListener(OnBuyClicked); // 取消按钮点击订阅
        GameEventBus.InventoryChanged -= OnInventoryChanged; // 取消业务事件订阅
    } // 方法结束

    private void OnBuyClicked() // 玩家点击购买按钮
    { // 方法开始
        inventoryService.BuyItem(ItemId); // UI 只发业务命令
    } // 方法结束

    private void OnInventoryChanged(InventoryChangedEvent evt) // 收到背包变化事件
    { // 方法开始
        if (evt.ItemId != ItemId) return; // 不是当前物品就忽略
        countText.text = evt.NewCount.ToString(); // 根据业务结果刷新显示
    } // 方法结束
} // 类结束

Unity 工程实践

IMPORTANT

简单界面可以 View 直接调用 Service。复杂界面建议加 PresenterViewModel,负责把业务数据转换成 UI 展示数据。跨模块通知,比如背包变化影响红点、任务、成就,就适合用事件总线。

常见坑点是:忘记在 OnDisable 取消订阅,导致 UI 关闭后还收到事件;或者 UI 直接改业务数据,后期网络同步、存档、埋点都会变乱。

官方参考:Button APIButton.onClick

UI 是否应该直接读配置表?

unity-ui-read-config-table-boundary

标准答案

UI 一般不应该直接读原始配置表。更推荐:

c
配置表 -> ConfigService -> Presenter/ViewModel -> UI

UI 只拿最终的展示数据,比如:

名字、图标、价格文本、按钮是否可点

不要让 UI 自己去查:

价格表、奖励表、解锁条件表、等级表

否则 UI 会变成半个业务层,后期配置字段一改,很多界面都要跟着改。

底层原理

配置表是全项目共享的静态数据,应该由配置系统统一负责:

加载、校验、缓存、索引、版本兼容、缺失兜底。

UI 层只应该关心“怎么显示”,不应该关心“规则怎么算”。比如商店界面显示一个商品,UI 不应该自己判断玩家金币够不够,而应该由业务层根据商品配置和玩家货币状态算出 canBuy,再交给 UI。

代码示例

c
using UnityEngine; // 引入 Unity 基础类型
using UnityEngine.UI; // 引入 UGUI 组件

public sealed class ItemConfig // 定义物品配置数据
{ // 类开始
    public int id; // 物品 ID
    public string name; // 物品名字
    public Sprite icon; // 物品图标
    public int price; // 物品价格
} // 类结束

public interface IConfigService // 定义配置服务接口
{ // 接口开始
    ItemConfig GetItemConfig(int itemId); // 根据物品 ID 查询配置
} // 接口结束

public readonly struct ShopItemViewData // 定义 UI 展示数据
{ // 结构体开始
    public readonly string Name; // UI 要显示的名字
    public readonly Sprite Icon; // UI 要显示的图标
    public readonly string PriceText; // UI 要显示的价格文本
    public readonly bool CanBuy; // UI 要显示的按钮状态

    public ShopItemViewData(string name, Sprite icon, string priceText, bool canBuy) // 构造展示数据
    { // 构造函数开始
        Name = name; // 保存名字
        Icon = icon; // 保存图标
        PriceText = priceText; // 保存价格文本
        CanBuy = canBuy; // 保存是否可购买
    } // 构造函数结束
} // 结构体结束

public sealed class ShopPresenter // 定义商店中间层
{ // 类开始
    private readonly IConfigService configService; // 保存配置服务
    private readonly int playerCoins; // 保存玩家当前金币

    public ShopPresenter(IConfigService configService, int playerCoins) // 构造商店中间层
    { // 构造函数开始
        this.configService = configService; // 注入配置服务
        this.playerCoins = playerCoins; // 注入玩家金币
    } // 构造函数结束

    public ShopItemViewData BuildItemViewData(int itemId) // 组装 UI 展示数据
    { // 方法开始
        ItemConfig config = configService.GetItemConfig(itemId); // 从配置服务读取配置
        bool canBuy = playerCoins >= config.price; // 根据业务状态计算是否能买
        string priceText = config.price.ToString(); // 把价格转换成显示文本
        return new ShopItemViewData(config.name, config.icon, priceText, canBuy); // 返回 UI 可直接使用的数据
    } // 方法结束
} // 类结束

public sealed class ShopItemView : MonoBehaviour // 定义商店物品 UI
{ // 类开始
    [SerializeField] private Text nameText; // 名字文本
    [SerializeField] private Image iconImage; // 图标图片
    [SerializeField] private Text priceText; // 价格文本
    [SerializeField] private Button buyButton; // 购买按钮

    public void SetData(ShopItemViewData data) // 绑定展示数据
    { // 方法开始
        nameText.text = data.Name; // 显示名字
        iconImage.sprite = data.Icon; // 显示图标
        priceText.text = data.PriceText; // 显示价格
        buyButton.interactable = data.CanBuy; // 设置按钮是否可点
    } // 方法结束
} // 类结束

Unity 项目里怎么说

如果只是 UI 专用的样式配置,比如颜色、字体大小、按钮布局,可以让 UI 通过只读配置服务拿。 但如果是价格、奖励、解锁条件、战斗数值、任务条件,就应该由业务层读取和计算,UI 只显示结果。

Unity 里配置可以用 ScriptableObject 做开发期数据容器,也可以用 JSON/表格做热更配置;关键不是格式,而是配置读取入口要统一

参考 Unity 官方:ScriptableObjectButton API

常见坑点

UI 打开时不要解析配置文件。 UI 不要重复写解锁、价格、奖励判断。 配置缺失要由 ConfigService 统一兜底和打日志。 热更配置版本变化时,不要让每个 UI 自己兼容。 UI 层最好接收 ViewData,这样界面逻辑最干净。

UI 刷新如何避免每帧轮询?

unity-ui-refresh-avoid-polling

标准答案

UI 刷新不要在 Update() 里每帧轮询业务数据,而应该用:

事件通知 + 脏标记 + 差异刷新

也就是:数据变化时主动通知 UI,UI 只刷新变化的部分

比如金币变化,不要这样:

Update -> 每帧检查 coin 是否变化

而是这样:

金币模型变化 -> 触发 CoinsChanged -> 金币 UI 刷新文本

底层原理

每帧轮询的问题是:即使数据没变,UI 也在跑判断逻辑。面板越多,Update 越多,CPU 成本越高。更糟的是,如果每帧都 text.text = xxx,可能让 UI 元素变脏,引发 Canvas 分析和重建。

Unity 官方 UI 优化建议也强调,UI 改动会带来 Canvas 重建成本,所以要减少不必要的 UI 修改:Unity UI optimization tips

代码示例

c
using System; // 引入 Action 委托
using UnityEngine; // 引入 MonoBehaviour
using UnityEngine.UI; // 引入 Text 组件
public sealed class CurrencyModel // 定义金币数据模型
{ // 类开始
    public event Action<int> CoinsChanged; // 金币变化事件
    private int coins; // 保存当前金币数量
    public int Coins => coins; // 对外只读暴露金币
    public void SetCoins(int value) // 设置金币数量
    { // 方法开始
        if (coins == value) return; // 数值没变就不通知 UI
        coins = value; // 更新金币数据
        CoinsChanged?.Invoke(coins); // 数据变化时主动通知 UI
    } // 方法结束
} // 类结束
public sealed class CoinPanel : MonoBehaviour // 定义金币 UI 面板
{ // 类开始
    [SerializeField] private Text coinText; // 金币文本组件
    private CurrencyModel model; // 保存业务数据模型
    private int lastShownCoins = -1; // 缓存上一次显示的金币
    public void Init(CurrencyModel newModel) // 初始化 UI 面板
    { // 方法开始
        model = newModel; // 注入业务模型
    } // 方法结束
    private void OnEnable() // UI 打开时调用
    { // 方法开始
        if (model == null) return; // 没有模型就直接返回
        model.CoinsChanged += OnCoinsChanged; // 订阅金币变化事件
        RefreshCoins(model.Coins); // 打开时同步一次当前数据
    } // 方法结束
    private void OnDisable() // UI 关闭时调用
    { // 方法开始
        if (model == null) return; // 没有模型就直接返回
        model.CoinsChanged -= OnCoinsChanged; // 取消订阅,避免关闭后还刷新
    } // 方法结束
    private void OnCoinsChanged(int coins) // 收到金币变化事件
    { // 方法开始
        RefreshCoins(coins); // 刷新金币显示
    } // 方法结束
    private void RefreshCoins(int coins) // 刷新金币文本
    { // 方法开始
        if (coins == lastShownCoins) return; // 显示值没变就不重新赋值
        lastShownCoins = coins; // 更新缓存值
        coinText.text = coins.ToString(); // 只在变化时修改 UI 文本
    } // 方法结束
} // 类结束

Unity 项目里怎么落地

普通 UI:事件来了就刷新。 高频 UI:比如血条、倒计时、滚动列表,可以先 dirty = true,一帧末尾统一刷新一次。 大界面:按模块拆刷新函数,比如 RefreshMoney()RefreshItems()RefreshRedDot(),不要 RefreshAll()。 关闭 UI:必须取消订阅,否则会出现空引用、无效刷新、对象无法释放。

面试关键词

事件驱动,不每帧轮询。 脏标记合并高频刷新。 差异刷新,值没变不赋值。 OnEnable 订阅,OnDisable 解绑。 减少 Text、Layout、Canvas 的无效重建。

UI 关闭时如何取消事件监听?

unity-ui-unsubscribe-events-on-close

标准答案

UI 关闭时取消事件监听,最稳的写法是:

OnEnable 订阅,OnDisable 取消订阅,OnDestroy 再兜底

也就是 UI 可见时才监听事件,UI 关闭、隐藏、切场景、销毁时把自己从事件列表里移除。

底层原理

C# 事件或委托内部会保存订阅者的回调引用。 如果事件发布者是长生命周期对象,比如 GameManagerEventBus、单例服务,而 UI 面板关闭后没有取消订阅,那么事件列表里还持有这个 UI 的方法引用。

结果可能是:

UI 关闭后还被回调。 重复打开 UI 后重复订阅,导致一次事件刷新多次。 UI 对象无法正常释放,造成内存泄漏。 对象已销毁后被回调,出现空引用或 MissingReference。

代码示例

c
using System; // 引入 Action 委托
using UnityEngine; // 引入 MonoBehaviour
using UnityEngine.UI; // 引入 Button 和 Text

public static class GameEvents // 定义全局事件中心
{ // 类开始
    public static event Action<int> CoinsChanged; // 金币变化事件
    public static void PublishCoinsChanged(int coins) // 发布金币变化事件
    { // 方法开始
        CoinsChanged?.Invoke(coins); // 通知所有监听者
    } // 方法结束
} // 类结束

public sealed class CoinPanel : MonoBehaviour // 定义金币 UI 面板
{ // 类开始
    [SerializeField] private Button closeButton; // 关闭按钮
    [SerializeField] private Text coinText; // 金币文本
    private bool subscribed; // 防止重复订阅

    private void OnEnable() // UI 打开或启用时调用
    { // 方法开始
        SubscribeEvents(); // 订阅事件
    } // 方法结束

    private void OnDisable() // UI 关闭或禁用时调用
    { // 方法开始
        UnsubscribeEvents(); // 取消事件监听
    } // 方法结束

    private void OnDestroy() // UI 被销毁时调用
    { // 方法开始
        UnsubscribeEvents(); // 再兜底清理一次
    } // 方法结束

    private void SubscribeEvents() // 统一订阅入口
    { // 方法开始
        if (subscribed) return; // 已经订阅过就不重复订阅
        closeButton.onClick.AddListener(OnCloseClicked); // 监听按钮点击
        GameEvents.CoinsChanged += OnCoinsChanged; // 监听业务事件
        subscribed = true; // 标记已订阅
    } // 方法结束

    private void UnsubscribeEvents() // 统一取消订阅入口
    { // 方法开始
        if (!subscribed) return; // 没订阅过就不处理
        closeButton.onClick.RemoveListener(OnCloseClicked); // 取消按钮点击监听
        GameEvents.CoinsChanged -= OnCoinsChanged; // 取消业务事件监听
        subscribed = false; // 标记已取消
    } // 方法结束

    private void OnCoinsChanged(int coins) // 金币变化回调
    { // 方法开始
        coinText.text = coins.ToString(); // 刷新 UI 文本
    } // 方法结束

    private void OnCloseClicked() // 关闭按钮回调
    { // 方法开始
        gameObject.SetActive(false); // 关闭面板并触发 OnDisable
    } // 方法结束
} // 类结束

关键坑点

不要这样写:

c
button.onClick.AddListener(() => Buy(id));

然后关闭时又写:

c
button.onClick.RemoveListener(() => Buy(id));

这两个 lambda 不是同一个委托对象,通常移除不掉。更稳的是用命名方法,或者把 lambda 缓存成字段。

如果 UI 只是 CanvasGroup.alpha = 0 隐藏,没有禁用 GameObject,那么 OnDisable 不会触发,需要在自己的 Close() 方法里手动解绑。

如果某个对象即使不可见也要继续收事件,就可以 Awake 订阅、OnDestroy 取消;普通 UI 面板更推荐 OnEnable / OnDisable 成对处理。

官方参考:MonoBehaviour.OnDisableButton.onClick

UI 异步打开时资源还没加载完怎么办?

unity-ui-async-open-resource-not-ready

标准答案

UI 异步打开时资源没加载完,不能让界面处于“半开半可点”的状态。正确做法是:

Open 请求 -> Loading 状态 -> 等资源完成 -> 校验请求是否还有效 -> 实例化 UI -> 绑定数据 -> 播放打开动画

如果玩家在加载中关闭了 UI,或者又打开了另一个界面,那么旧加载回调回来时不能再操作 UI,只能释放资源。

底层原理

异步加载最大的问题不是“慢”,而是时序不确定

资源可能晚回来。 玩家可能已经关闭界面。 同一个 UI 可能被重复打开。 旧请求可能覆盖新请求。 加载失败可能让界面卡在 Loading。

所以一般要做:

requestId:防止旧回调污染新状态。 LoadingView:显示加载中并禁用点击。 失败兜底:显示错误、重试或关闭。 资源句柄管理:UI 关闭时释放 Addressables handle。

Unity Addressables 官方文档也强调,LoadAssetAsync 返回 AsyncOperationHandle,结果通过 handle 获取;加载出的资源不再使用时要 Release,否则引用计数不降,Bundle 可能常驻内存。

参考:Addressables LoadAssetAsyncAsyncOperationHandleAddressables memory management

代码示例

c
using System.Collections; // 引入 IEnumerator 协程
using UnityEngine; // 引入 Unity 基础类型
using UnityEngine.AddressableAssets; // 引入 Addressables
using UnityEngine.ResourceManagement.AsyncOperations; // 引入 AsyncOperationHandle

public sealed class AsyncPanelLoader : MonoBehaviour // 定义异步 UI 加载器
{ // 类开始
    [SerializeField] private Transform uiRoot; // UI 实例挂载父节点
    [SerializeField] private GameObject loadingView; // 加载中界面
    private int requestId; // 当前打开请求 ID
    private GameObject panelInstance; // 当前 UI 实例
    private AsyncOperationHandle<GameObject> prefabHandle; // 当前资源加载句柄
    private bool hasHandle; // 是否持有有效加载句柄

    public void Open(string panelKey) // 打开 UI
    { // 方法开始
        requestId++; // 生成新的请求 ID,让旧请求过期
        int currentRequestId = requestId; // 缓存本次请求 ID
        CloseCurrentPanel(); // 清理旧 UI 和旧资源
        loadingView.SetActive(true); // 显示 Loading
        StartCoroutine(OpenRoutine(panelKey, currentRequestId)); // 启动异步加载流程
    } // 方法结束

    private IEnumerator OpenRoutine(string panelKey, int currentRequestId) // 异步打开流程
    { // 方法开始
        prefabHandle = Addressables.LoadAssetAsync<GameObject>(panelKey); // 异步加载 UI Prefab
        hasHandle = true; // 标记当前持有资源句柄
        yield return prefabHandle; // 等待资源加载完成

        if (currentRequestId != requestId) // 判断请求是否已经过期
        { // if 开始
            ReleaseHandle(); // 旧请求完成后只释放资源
            yield break; // 不再操作 UI
        } // if 结束

        if (prefabHandle.Status != AsyncOperationStatus.Succeeded) // 判断加载是否失败
        { // if 开始
            loadingView.SetActive(false); // 关闭 Loading
            Debug.LogError("UI load failed: " + panelKey); // 打印失败资源 key
            ReleaseHandle(); // 释放失败句柄
            yield break; // 结束流程
        } // if 结束

        panelInstance = Instantiate(prefabHandle.Result, uiRoot); // 实例化 UI
        loadingView.SetActive(false); // 关闭 Loading
        panelInstance.SetActive(true); // 显示 UI
    } // 方法结束

    public void Close() // 关闭 UI
    { // 方法开始
        requestId++; // 让还没完成的异步请求全部过期
        loadingView.SetActive(false); // 关闭 Loading
        CloseCurrentPanel(); // 清理当前 UI 和资源
    } // 方法结束

    private void CloseCurrentPanel() // 清理当前面板
    { // 方法开始
        if (panelInstance != null) // 如果当前 UI 已经实例化
        { // if 开始
            Destroy(panelInstance); // 销毁 UI 实例
            panelInstance = null; // 清空实例引用
        } // if 结束
        ReleaseHandle(); // 释放 Addressables 句柄
    } // 方法结束

    private void ReleaseHandle() // 释放资源句柄
    { // 方法开始
        if (!hasHandle) return; // 没有句柄就不处理
        Addressables.Release(prefabHandle); // 释放加载句柄并降低引用计数
        hasHandle = false; // 标记不再持有句柄
    } // 方法结束

    private void OnDestroy() // 对象销毁时调用
    { // 方法开始
        Close(); // 兜底清理 UI 和资源
    } // 方法结束
} // 类结束

Unity 项目里怎么处理

小界面:可以显示一个全局 Loading,资源成功后再打开。 大界面:先打开壳子,局部模块用骨架屏,资源分批加载。 重复点击:打开中禁用按钮,或者同一个 panelKey 只保留一个请求。 关闭中加载完成:用 requestId 判断过期,旧结果只释放,不绑定 UI。 加载失败:显示重试、关闭界面、上报资源 key,不能让玩家卡死。

面试关键词

异步 UI 要有状态机:Idle / Loading / Ready / Failed / Closed。 资源未完成时 UI 不应可交互。 关闭 UI 时要让请求过期,防止旧回调操作销毁对象。 Addressables handle 要按 UI 生命周期释放。 依赖资源要一起管理,比如 Prefab、图集、字体、音效。

重复打开同一个窗口怎么处理?

unity-ui-duplicate-window-open-handling

标准答案

重复打开同一个窗口,不能每次都 Instantiate。我一般会把 OpenWindow 设计成幂等接口

已打开:直接置顶,并刷新参数。 正在打开:复用当前异步打开请求,不重复加载。 未打开:加载资源、实例化、注册到窗口表。 允许多实例:必须带 instanceKey,比如 ItemTips:1001Chat:playerId

底层原理

UI 管理器通常会维护一个窗口表:

c
Dictionary<string, UIWindow> openedWindows

key 可以是:

windowKey:普通单例窗口,比如背包、商店。 windowKey + instanceKey:多实例窗口,比如道具详情、私聊窗口。

重复打开时先查表,而不是直接创建。这样可以避免重复窗口、重复事件订阅、重复资源加载、窗口栈重复入栈。

代码示例

c
using System.Collections; // 引入协程 IEnumerator
using System.Collections.Generic; // 引入 Dictionary
using UnityEngine; // 引入 Unity 基础类型

public enum UIWindowState // 定义窗口状态
{ // 枚举开始
    Opening, // 正在异步打开
    Opened, // 已经打开
    Closing // 正在关闭
} // 枚举结束

public class UIWindowBase : MonoBehaviour // 定义窗口基类
{ // 类开始
    public string Key { get; private set; } // 当前窗口唯一 key
    public UIWindowState State { get; private set; } // 当前窗口状态

    public void Init(string key) // 初始化窗口
    { // 方法开始
        Key = key; // 保存窗口 key
        State = UIWindowState.Opened; // 标记窗口已打开
    } // 方法结束

    public virtual void Refresh(object args) // 刷新窗口参数
    { // 方法开始
        Debug.Log("Refresh window: " + Key); // 示例:实际项目里刷新数据
    } // 方法结束

    public void BringToFront() // 把窗口置顶
    { // 方法开始
        transform.SetAsLastSibling(); // 设置为父节点最后一个子节点
    } // 方法结束

    public void MarkClosing() // 标记窗口正在关闭
    { // 方法开始
        State = UIWindowState.Closing; // 更新窗口状态
    } // 方法结束
} // 类结束

public sealed class UIManager : MonoBehaviour // 定义 UI 管理器
{ // 类开始
    [SerializeField] private Transform uiRoot; // 所有窗口的父节点
    private readonly Dictionary<string, UIWindowBase> openedWindows = new Dictionary<string, UIWindowBase>(); // 已打开窗口表
    private readonly HashSet<string> openingWindows = new HashSet<string>(); // 正在打开的窗口 key 集合

    public void Open(string windowKey, GameObject prefab, object args = null, string instanceKey = null) // 打开窗口
    { // 方法开始
        string realKey = BuildKey(windowKey, instanceKey); // 生成真实窗口 key

        if (openedWindows.TryGetValue(realKey, out UIWindowBase openedWindow)) // 如果窗口已经打开
        { // if 开始
            openedWindow.gameObject.SetActive(true); // 确保窗口处于显示状态
            openedWindow.BringToFront(); // 把窗口置顶
            openedWindow.Refresh(args); // 用新参数刷新已有窗口
            return; // 不重复创建窗口
        } // if 结束

        if (openingWindows.Contains(realKey)) // 如果窗口正在异步打开
        { // if 开始
            return; // 复用当前打开请求,避免重复加载
        } // if 结束

        openingWindows.Add(realKey); // 标记窗口正在打开
        StartCoroutine(OpenRoutine(realKey, prefab, args)); // 启动异步打开流程
    } // 方法结束

    private IEnumerator OpenRoutine(string realKey, GameObject prefab, object args) // 异步打开窗口流程
    { // 方法开始
        yield return null; // 示例:这里模拟异步加载一帧

        if (openedWindows.ContainsKey(realKey)) // 如果等待期间窗口已经被创建
        { // if 开始
            openingWindows.Remove(realKey); // 移除正在打开标记
            yield break; // 结束协程
        } // if 结束

        GameObject go = Instantiate(prefab, uiRoot); // 实例化窗口对象
        UIWindowBase window = go.GetComponent<UIWindowBase>(); // 获取窗口脚本
        window.Init(realKey); // 初始化窗口 key 和状态
        openedWindows.Add(realKey, window); // 注册到已打开窗口表
        openingWindows.Remove(realKey); // 移除正在打开标记
        window.Refresh(args); // 绑定打开参数
        window.BringToFront(); // 打开后置顶
    } // 方法结束

    public void Close(string windowKey, string instanceKey = null) // 关闭窗口
    { // 方法开始
        string realKey = BuildKey(windowKey, instanceKey); // 生成真实窗口 key

        if (!openedWindows.TryGetValue(realKey, out UIWindowBase window)) return; // 找不到窗口就直接返回

        openedWindows.Remove(realKey); // 从已打开窗口表移除
        window.MarkClosing(); // 标记窗口正在关闭
        Destroy(window.gameObject); // 销毁窗口对象
    } // 方法结束

    private string BuildKey(string windowKey, string instanceKey) // 生成窗口唯一 key
    { // 方法开始
        if (string.IsNullOrEmpty(instanceKey)) return windowKey; // 没有实例 key 就用窗口 key
        return windowKey + ":" + instanceKey; // 有实例 key 就拼成多实例 key
    } // 方法结束
} // 类结束

Unity 工程实践

背包、商店、任务这种窗口通常是单例,重复打开只需要 BringToFront + Refresh。 道具详情、聊天私聊、玩家资料这种窗口可以多实例,但必须用 instanceKey 区分。 异步加载中的重复打开要合并请求,否则会出现两个同样窗口同时实例化。 关闭时要从窗口表、窗口栈里移除,并取消事件、协程、资源引用。

Unity 里真正创建和销毁对象通常会用 Object.InstantiateObject.Destroy,隐藏复用可以用 GameObject.SetActive

参考官方文档:Object.InstantiateGameObject.SetActiveObject.Destroy

UI 层级混乱怎么排查?

unity-ui-layer-confusion-debug

标准答案

UI 层级混乱先别急着改数值,先判断是哪一种:

显示层级错:窗口被盖住、弹窗在底下。 点击层级错:看得到按钮,但点不到,通常是透明 UI 挡住了。

排查顺序我会这样走:

  1. Canvas.sortingLayersortingOrder
  2. 查子 Canvas 是否开了 overrideSorting
  3. 查同一个 Canvas 下的 SiblingIndex
  4. 查 UIManager 的窗口栈是否重复入栈或没置顶。
  5. 查透明 ImageRaycastTargetCanvasGroup.blocksRaycasts 是否挡点击。

底层原理

Unity UI 的显示顺序大概看这几层:

不同 Canvas -> sortingLayer / sortingOrder同一个 Canvas -> Transform 子节点顺序子 Canvas -> overrideSorting 可能打破父级顺序

Unity 官方说明:同一 sorting layer 里,sortingOrder 更高的 Canvas 会显示在更上面;overrideSorting 会让子 Canvas 覆盖父 Canvas 的排序;SetAsLastSibling 会把对象移动到同级列表最后,常用于窗口置顶;CanvasGroup.blocksRaycasts 会影响是否阻挡点击。

排查脚本

c
using UnityEngine; // 引入 Unity 基础类型

public sealed class UILayerDumper : MonoBehaviour // 定义 UI 层级打印工具
{ // 类开始
    [ContextMenu("Dump UI Layers")] // 在 Inspector 右键菜单里添加调试入口
    private void Dump() // 打印当前场景所有 Canvas 信息
    { // 方法开始
        Canvas[] canvases = FindObjectsOfType<Canvas>(true); // 查找场景中包含隐藏对象的 Canvas
        foreach (Canvas canvas in canvases) // 遍历每一个 Canvas
        { // 循环开始
            Debug.Log(BuildLine(canvas)); // 打印 Canvas 的关键层级信息
        } // 循环结束
    } // 方法结束

    private static string BuildLine(Canvas canvas) // 构造一行调试文本
    { // 方法开始
        Transform t = canvas.transform; // 缓存 Canvas 的 Transform
        return GetPath(t) + " | root=" + canvas.isRootCanvas + " | mode=" + canvas.renderMode + " | layer=" + canvas.sortingLayerName + " | order=" + canvas.sortingOrder + " | override=" + canvas.overrideSorting + " | sibling=" + t.GetSiblingIndex(); // 返回完整层级信息
    } // 方法结束

    private static string GetPath(Transform t) // 获取对象在 Hierarchy 里的完整路径
    { // 方法开始
        string path = t.name; // 先记录当前节点名字
        while (t.parent != null) // 只要还有父节点就继续向上找
        { // 循环开始
            t = t.parent; // 移动到父节点
            path = t.name + "/" + path; // 把父节点拼到路径前面
        } // 循环结束
        return path; // 返回完整路径
    } // 方法结束
} // 类结束

Unity 项目里怎么落地

我会规定固定层级,比如:

Background = 0HUD = 100Window = 200Popup = 300Guide = 400Loading = 500

所有窗口通过 UIManager 打开和置顶,不允许各个面板自己随便改 sortingOrder。如果是点击错层,就用 EventSystem 或调试日志看最先命中的 UI,重点查透明遮罩、关闭但没禁用的 Image、CanvasGroup.blocksRaycasts

官方参考:Canvas.sortingOrderCanvas.overrideSortingTransform.SetAsLastSiblingCanvasGroup.blocksRaycasts

UI 性能报告应该包含哪些指标?

unity-ui-performance-report-metrics

标准答案

UI 性能报告不能只写“FPS 提升了”,应该包含五类指标:测试条件、结果指标、瓶颈指标、结构指标、优化前后对比。它要回答三个问题:

是否真的卡? 卡在 CPU、GPU、GC、内存还是资源加载? 优化后提升了多少,代价是什么?

必须包含的指标

测试条件:机型、系统版本、Unity 版本、包版本、画质档、分辨率、测试场景、操作路径。

帧率和帧耗时:平均 FPS、最低 FPS、平均帧时间、P95/P99 帧时间、超过 33ms 或 50ms 的卡顿帧数量。

UI CPU 指标:Canvas.BuildBatchCanvas.SendWillRenderCanvasesLayoutRebuilderGraphicRaycaster、UI 脚本耗时。

渲染指标:DrawCall、SetPass、Batches、Overdraw、透明 UI 面积、UI 特效数量、是否全屏半透明叠加。

内存和 GC:每帧 GC Alloc、打开 UI 时 GC、Managed Heap、Texture 内存、SpriteAtlas 内存、字体/TMP 字体图集内存。

UI 结构指标:Canvas 数量、Graphic 数量、RaycastTarget 数量、LayoutGroup 数量、ContentSizeFitter 数量、ScrollView 实例数量、可见 Item 数量。

交互指标:首次打开耗时、二次打开耗时、关闭耗时、滚动列表帧率、点击响应延迟、资源加载耗时。

报告模板可以这样写

模块优化前优化后结论
背包打开耗时420ms135ms异步加载 + 预加载图集
GC Alloc180KB/次4KB/次去掉 LINQ 和临时字符串
Canvas.BuildBatch6.5ms1.8ms拆分动态 Canvas
ScrollView Item300 个18 个虚拟列表复用
DrawCall8532图集和合批优化

面试加分说法

TIP

我不会只凭感觉说 UI 卡,而是先用 Profiler 抓一次完整操作链路,比如“打开背包、滚动 5 秒、关闭背包”。然后把 CPU、Rendering、Memory、GC、UI rebuild 分开看。Unity 官方 Profiler 的 CPU 模块本身就会按 Rendering、Scripts、GarbageCollector、UI 等分类统计耗时;Unity UI 优化建议也明确提到 Canvas 脏化、GraphicRaycaster、LayoutGroup、大列表、Overdraw 都是高频问题点。

官方参考:CPU Usage ProfilerRendering ProfilerMemory ProfilerUnity UI optimization tips

常见坑点

NOTE

只写平均 FPS,不写 P95/P99,掩盖偶发卡顿。 只说 DrawCall 多,不区分 CPU 提交成本和 GPU Overdraw。 只说 GC 高,不写具体是哪个操作产生分配。 只给优化后数据,不给优化前基线。 不写测试机型,导致数据没有可比性。

文章评价

读完这篇,留下你的看法

暂无审核通过的评价。

登录账号后才能评价。

本站访客数0总站访问量0本页访问量0