Skip to content

UGUI

Canvas 的三种 Render Mode 是什么?

canvas-render-modes

一句话

Canvas 的三种 Render Mode 是:

c
Screen Space - Overlay:UI 直接贴在屏幕上
Screen Space - Camera:UI 挂在某个相机前面
World Space:UI 放在 3D 世界里

最短口诀:

Overlay 贴屏幕。
Camera 挂相机。
World 放世界。

Screen Space - Overlay

这是最简单、最常用的 UI 模式。

它的意思是:

UI 直接画在屏幕最上层
不需要指定 Camera
不受场景相机位置影响

比如这些 UI 通常用 Overlay:

血条
背包
技能按钮
暂停菜单
设置界面
主界面 HUD

零基础理解:

它就像手机 App 的界面,永远贴在屏幕上。
相机怎么转,UI 都固定在屏幕位置。

代码示例,带注释:

c
using UnityEngine;

public class CanvasOverlayExample : MonoBehaviour
{
    public Canvas canvas;

    private void Awake()
    {
        // 设置 Canvas 为屏幕空间 Overlay 模式
        // 这种模式不需要 Camera,UI 会直接覆盖在屏幕上
        canvas.renderMode = RenderMode.ScreenSpaceOverlay;
    }
}

优点:

简单
稳定
适合大多数 2D UI
不需要关心相机

缺点:

不适合做世界里的 UI
不容易和 3D 物体产生遮挡关系
不适合放到场景空间里当牌子

Screen Space - Camera

这个模式也是屏幕 UI,但它会依赖一个指定的 Camera。

意思是:

UI 仍然面向屏幕
但它被放在某个 Camera 前方的一个平面上

它需要设置:

c
Render Camera
Plane Distance

适合:

需要 UI 跟某个相机绑定
需要相机后处理影响 UI
需要控制 UI 和场景/特效的渲染关系
多相机 UI
过场镜头 UI

代码示例,带注释:

c
using UnityEngine;

public class CanvasCameraExample : MonoBehaviour
{
    public Canvas canvas;
    public Camera uiCamera;

    private void Awake()
    {
        // 设置 Canvas 为 Screen Space - Camera 模式
        canvas.renderMode = RenderMode.ScreenSpaceCamera;

        // 指定这个 Canvas 使用哪一个 Camera 渲染
        canvas.worldCamera = uiCamera;

        // 设置 UI 平面距离 Camera 多远
        // 数值太小或太大都可能影响显示和排序
        canvas.planeDistance = 10f;
    }
}

零基础理解:

Overlay 是直接贴屏幕。
Camera 是把 UI 放到摄像机前面的一张透明板上。

优点:

可以指定相机
可以和相机深度、排序、后处理产生关系
比 Overlay 更灵活

缺点:

c
要正确设置 Render Camera
相机没配好 UI 可能看不到
Plane Distance、Sorting、Camera Depth 容易让新手混

World Space

World Space 的意思是:

Canvas 变成 3D 世界里的一个物体
它有 Transform
有位置、旋转、缩放
可以放到场景中

适合:

NPC 头顶名字
怪物头顶血条
场景里的交互面板
3D 世界里的按钮
VR/AR UI
悬浮提示牌

代码示例,带注释:

c
using UnityEngine;

public class WorldSpaceCanvasExample : MonoBehaviour
{
    public Canvas canvas;
    public Camera mainCamera;

    private void Awake()
    {
        // 设置 Canvas 为 World Space 模式
        // 这时 Canvas 就像一个普通 3D 物体,可以放在世界坐标中
        canvas.renderMode = RenderMode.WorldSpace;

        // World Space 下通常也要设置 eventCamera
        // 这样 UI 点击、射线事件才能知道用哪个相机计算
        canvas.worldCamera = mainCamera;

        // 调整世界空间 UI 的大小
        // World Space Canvas 默认尺寸可能很大,需要手动缩放
        canvas.transform.localScale = Vector3.one * 0.01f;
    }

    private void LateUpdate()
    {
        // 让世界空间 UI 一直朝向摄像机
        // 常用于 NPC 头顶血条或名字
        canvas.transform.forward = mainCamera.transform.forward;
    }
}

零基础理解:

Overlay 是贴在你的屏幕上。
World Space 是放在游戏世界里的一块牌子。

比如 NPC 头顶名字:

NPC 走到哪里
名字 UI 跟到哪里
相机看过去才能看到

优点:

可以跟随 3D 物体
可以被场景遮挡
适合 VR/AR
空间感强

缺点:

需要处理缩放
需要处理朝向相机
需要注意距离、遮挡、排序
UI 太多时性能要小心

三者怎么选

普通游戏界面:

c
Screen Space - Overlay

比如:

主菜单
背包
技能栏
血条 HUD
设置界面

需要某个相机参与渲染:

c
Screen Space - Camera

比如:

多相机 UI
过场 UI
需要后处理影响 UI
特殊相机层级 UI

UI 要放到 3D 世界里:

c
World Space

比如:

NPC 头顶名字
怪物头顶血条
VR 面板
场景交互按钮
世界里的提示牌

常见坑

坑 1:World Space Canvas 太大。

解决:

c
调小 RectTransform 尺寸
调小 transform.localScale

坑 2:Screen Space - Camera 没设置 Render Camera。

结果:

UI 可能显示异常或不显示

坑 3:World Space UI 点击无效。

检查:

c
Canvas.worldCamera
GraphicRaycaster
EventSystem
相机是否正确

坑 4:头顶血条抖动。

通常要在:

c
LateUpdate()

里更新 UI 跟随和朝向,因为角色位置一般已经在 Update 或物理逻辑中更新完了。

坑 5:UI 排序混乱。

需要看:

c
Canvas Sorting Layer
Order in Layer
Camera Depth
Plane Distance

面试高分回答

TIP

Canvas 有三种 Render Mode。Screen Space - Overlay 是直接把 UI 渲染到屏幕上,不需要相机,适合普通 HUD 和菜单。Screen Space - Camera 会把 UI 放在指定相机前的平面上,需要设置 Render Camera 和 Plane Distance,适合需要相机参与渲染或多相机场景的 UI。World Space 则把 Canvas 当成 3D 世界中的物体,有位置、旋转和缩放,适合 NPC 头顶血条、场景交互面板、VR/AR UI。选择时看 UI 是固定在屏幕上、依附相机,还是存在于世界空间中。

最短记忆版

Overlay:屏幕 UI,最常用。
Camera:相机 UI,更灵活。
World:世界 UI,放进 3D 场景。

参考:Unity 官方 RenderMode / Canvas.worldCamera 文档。

RectTransform 和普通 Transform 区别是什么?

recttransform-vs-transform

一句话

Transform 是普通物体的空间变换组件,主要管:

c
位置 position
旋转 rotation
缩放 scale

RectTransform 是 UI 元素专用的 Transform,除了位置、旋转、缩放,还多了:

c
锚点 Anchor
轴心 Pivot
尺寸 Size
矩形 Rect
偏移 Offset

零基础比喻

Transform 像是在 3D 世界里问:

这个物体在哪里?
朝哪个方向?
放大缩小多少?

比如:

玩家
怪物
相机

子弹
特效

这些都主要靠普通 Transform

RectTransform 像是在一张 UI 画布里问:

这个按钮贴左上角还是右下角?
离父面板边缘多远?
宽高是多少?
屏幕变大后跟着哪边走?
以哪个点作为中心旋转和缩放?

比如:

按钮
血条
背包格子
技能图标
弹窗面板
小地图

这些都用 RectTransform

普通 Transform 管什么

普通 Transform 所有 GameObject 都有。

常用属性:

c
transform.position;      // 世界坐标位置
transform.localPosition; // 相对父物体的位置

transform.rotation;      // 世界旋转
transform.localRotation; // 相对父物体的旋转

transform.localScale;    // 相对父物体的缩放

例子,带注释:

c
using UnityEngine;

public class Move3DObject : MonoBehaviour
{
    public float speed = 3f;

    private void Update()
    {
        // 让普通 3D 物体每秒向前移动 speed 米
        transform.position += transform.forward * speed * Time.deltaTime;
    }
}

这个适合:

角色移动
子弹飞行
相机跟随
怪物转身
场景物体摆放

RectTransform 管什么

RectTransform 继承自 Transform,所以它也有:

c
position
localPosition
rotation
localScale

但 UI 真正常用的是:

c
rectTransform.anchoredPosition;
rectTransform.sizeDelta;
rectTransform.anchorMin;
rectTransform.anchorMax;
rectTransform.pivot;
rectTransform.rect;
rectTransform.offsetMin;
rectTransform.offsetMax;

也就是说:

c
RectTransform = Transform + UI 矩形布局能力

Anchor 锚点是什么

Anchor 决定 UI 元素参考父物体的哪里。

比如一个按钮:

锚到左上角:屏幕变大,它跟着左上角
锚到右下角:屏幕变大,它跟着右下角
锚到中心:屏幕变大,它保持在中心附近
四边拉伸:屏幕变大,它跟着父面板一起变宽变高

代码例子,带注释:

c
using UnityEngine;

public class AnchorExample : MonoBehaviour
{
    public RectTransform rect;

    private void Awake()
    {
        // anchorMin 和 anchorMax 都是 (0.5, 0.5)
        // 表示这个 UI 的锚点固定在父物体中心
        rect.anchorMin = new Vector2(0.5f, 0.5f);
        rect.anchorMax = new Vector2(0.5f, 0.5f);

        // pivot 是 UI 自己的中心点
        // (0.5, 0.5) 表示以自己中心作为定位点
        rect.pivot = new Vector2(0.5f, 0.5f);

        // anchoredPosition 表示相对锚点的位置
        // 这里表示放在父物体中心
        rect.anchoredPosition = Vector2.zero;

        // 当锚点没有拉伸时,sizeDelta 通常可以理解为宽高
        rect.sizeDelta = new Vector2(300f, 120f);
    }
}

Pivot 轴心是什么

Pivot 是 UI 自己矩形内部的参考点。

常见:

(0.5, 0.5):中心
(0, 0):左下角
(1, 1):右上角

它会影响:

定位
旋转
缩放
尺寸变化方向

比如血条从左往右增长,可以设置:

Pivot X = 0

这样宽度变化时会从左边开始展开。

anchoredPosition 和 localPosition 区别

这是面试常问点。

localPosition 是 Transform 的局部位置。

anchoredPosition 是 UI 里更常用的位置:

c
RectTransform 的 pivot 相对 anchor 的位置

简单说:

c
普通物体:多用 localPosition / position
UI 元素:多用 anchoredPosition

移动 UI 面板推荐:

c
using UnityEngine;

public class MoveUiPanel : MonoBehaviour
{
    public RectTransform panel;

    private void Start()
    {
        // 把 UI 面板移动到它锚点右边 100、上边 50 的位置
        panel.anchoredPosition = new Vector2(100f, 50f);
    }
}

不推荐新手随便写:

c
panel.transform.position = new Vector3(100f, 50f, 0f);

因为 UI 的世界坐标会受:

c
Canvas Render Mode
Canvas Scaler
父级 RectTransform
屏幕分辨率

影响,容易乱。

sizeDelta 是什么

如果锚点没有拉伸,比如:

c
anchorMin = anchorMax = 中心

sizeDelta 基本就是 UI 的宽高:

c
rect.sizeDelta = new Vector2(200f, 80f);

如果锚点是拉伸的,比如:

c
anchorMin = (0, 0)
anchorMax = (1, 1)

sizeDelta 就不是简单宽高了,而是相对锚点拉伸区域的差值。

零基础先记:

不拉伸时,sizeDelta 像宽高。
拉伸时,sizeDelta 是相对父级拉伸后的偏移差。

做一个顶部血条 UI

带注释代码:

c
using UnityEngine;

public class TopHealthBarLayout : MonoBehaviour
{
    public RectTransform healthBar;

    private void Awake()
    {
        // 锚点都设为顶部中心
        // 父 UI 尺寸变化时,血条跟着顶部中心走
        healthBar.anchorMin = new Vector2(0.5f, 1f);
        healthBar.anchorMax = new Vector2(0.5f, 1f);

        // Pivot 设为顶部中心
        // 这样 anchoredPosition 的参考点更符合顶部 UI 的直觉
        healthBar.pivot = new Vector2(0.5f, 1f);

        // 距离顶部向下 30 像素
        healthBar.anchoredPosition = new Vector2(0f, -30f);

        // 设置血条宽高
        healthBar.sizeDelta = new Vector2(300f, 24f);
    }
}

做一个四边拉伸的背景面板

带注释代码:

c
using UnityEngine;

public class StretchPanelLayout : MonoBehaviour
{
    public RectTransform panel;

    private void Awake()
    {
        // anchorMin 左下角,anchorMax 右上角
        // 表示这个面板会跟随父物体四边拉伸
        panel.anchorMin = new Vector2(0f, 0f);
        panel.anchorMax = new Vector2(1f, 1f);

        // offsetMin 是左下偏移,offsetMax 是右上偏移
        // 这里四个方向都留 20 像素边距
        panel.offsetMin = new Vector2(20f, 20f);
        panel.offsetMax = new Vector2(-20f, -20f);
    }
}

注意:

offsetMax 用负数表示离右边、上边缩进。

屏幕坐标转 UI 坐标

比如鼠标点击屏幕,把 UI 移到鼠标位置。

带注释代码:

c
using UnityEngine;

public class ScreenToUiPosition : MonoBehaviour
{
    public RectTransform canvasRect;
    public RectTransform uiElement;
    public Camera uiCamera;

    private void Update()
    {
        if (Input.GetMouseButtonDown(0))
        {
            Vector2 localPoint;

            // 把屏幕坐标转换成 Canvas 下的局部 UI 坐标
            // Overlay 模式下 uiCamera 可以传 null
            // Camera / World Space 模式下通常要传对应 UI Camera
            bool success = RectTransformUtility.ScreenPointToLocalPointInRectangle(
                canvasRect,
                Input.mousePosition,
                uiCamera,
                out localPoint
            );

            if (success)
            {
                // UI 元素使用 anchoredPosition 放到对应位置
                uiElement.anchoredPosition = localPoint;
            }
        }
    }
}

常见坑

坑 1:UI 乱用 transform.position

能用,但新手容易因为 Canvas 模式、分辨率、缩放导致位置错乱。

UI 位置更推荐:

c
anchoredPosition
anchor
pivot
offset

坑 2:锚点没设对,换分辨率 UI 飘了。

比如你想按钮固定在右上角,但锚点还在中心。

结果:

编辑器里看着对
换手机分辨率就错位

坑 3:Pivot 不理解,缩放方向奇怪。

比如血条减少时从中间缩,而不是从右往左缩。

可以调:

c
Pivot X = 0

坑 4:拉伸模式下乱改 sizeDelta

四边拉伸时,很多布局应该改:

c
offsetMin / offsetMax

而不是只改 sizeDelta

面试高分回答

TIP

Transform 是所有 GameObject 的基础空间变换组件,主要描述物体在 3D 或层级空间中的 position、rotation、scale。RectTransform 是 UI 系统中使用的 Transform,它继承 Transform,但额外描述一个矩形在父 UI 矩形中的布局关系,包括 anchor、pivot、anchoredPosition、sizeDelta、offsetMin、offsetMax 等。普通 Transform 更关注世界空间和局部空间的位置旋转缩放;RectTransform 更关注 UI 在不同分辨率和父容器中的对齐、拉伸和尺寸适配。实际写 UI 时,我会优先使用 RectTransform 的锚点和 anchoredPosition,而不是直接改 transform.position。

最短记忆版

c
Transform:3D 物体在哪里。
RectTransform:UI 矩形在父 UI 里怎么摆。

Transform 看 position / rotation / scale。
RectTransform 看 anchor / pivot / anchoredPosition / sizeDelta。

锚点 Anchor 和 Pivot 怎么理解?

anchor-vs-pivot

一句话

Anchor 是:我跟父容器的哪里对齐

Pivot 是:我用自己身上的哪个点去对齐、旋转、缩放

最短口诀:

Anchor 看父物体。
Pivot 看自己。
anchoredPosition = 自己的 Pivot 离 Anchor 多远。

零基础比喻

假设父面板是一张白纸,按钮是一张小贴纸。

Anchor 问的是:

这张小贴纸要参考白纸的哪里?
左上角?
右下角?
中心?
还是跟白纸四边一起拉伸?

Pivot 问的是:

这张小贴纸用自己的哪个点去贴?
用中心点?
用左上角?
用右下角?

所以:

Anchor 是父级参考点。
Pivot 是自身参考点。

Anchor 怎么理解

Anchor 是在父 RectTransform 上的参考点。

它的数值通常是 0 到 1

(0, 0):父容器左下角
(0.5, 0.5):父容器中心
(1, 1):父容器右上角

比如一个按钮要固定在右上角:

Anchor = 右上角

这样屏幕变大时,它仍然跟着右上角。

Pivot 怎么理解

Pivot 是 UI 自己矩形内部的轴心点。

(0, 0):自己左下角
(0.5, 0.5):自己中心
(1, 1):自己右上角

Pivot 会影响:

anchoredPosition 的参考点
旋转围绕哪里转
缩放围绕哪里缩放
尺寸变化从哪里展开

比如血条从左往右减少,通常让血条填充部分:

Pivot = (0, 0.5)

这样宽度变小时,左边不动,右边往左缩。

anchoredPosition 是什么

anchoredPosition 可以理解为:

自己的 Pivot 相对 Anchor 的偏移

比如:

Anchor 在父物体中心
Pivot 在自己中心
anchoredPosition = (0, 0)

意思是:

这个 UI 的中心点对齐父物体中心

如果:

anchoredPosition = (100, 50)

意思是:

这个 UI 的 Pivot 在 Anchor 右边 100、上边 50

例子 1:居中面板

想让一个面板永远在屏幕中心:

c
using UnityEngine;

public class CenterPanelLayout : MonoBehaviour
{
    public RectTransform panel;

    private void Awake()
    {
        // 锚点放在父容器中心
        panel.anchorMin = new Vector2(0.5f, 0.5f);
        panel.anchorMax = new Vector2(0.5f, 0.5f);

        // Pivot 放在自己中心
        panel.pivot = new Vector2(0.5f, 0.5f);

        // Pivot 和 Anchor 重合,也就是居中
        panel.anchoredPosition = Vector2.zero;

        // 设置面板宽高
        panel.sizeDelta = new Vector2(400f, 260f);
    }
}

效果:

父容器变大变小
面板始终在中心

例子 2:右上角按钮

想让按钮固定在右上角,距离右边 20,距离上边 20:

c
using UnityEngine;

public class TopRightButtonLayout : MonoBehaviour
{
    public RectTransform button;

    private void Awake()
    {
        // 锚点放在父容器右上角
        button.anchorMin = new Vector2(1f, 1f);
        button.anchorMax = new Vector2(1f, 1f);

        // Pivot 也放在按钮自己的右上角
        // 这样按钮右上角对齐父容器右上角更直观
        button.pivot = new Vector2(1f, 1f);

        // 因为是从右上角往左下偏移,所以 x 是负数,y 也是负数
        button.anchoredPosition = new Vector2(-20f, -20f);

        // 设置按钮宽高
        button.sizeDelta = new Vector2(160f, 60f);
    }
}

为什么是 (-20, -20)

因为:

右上角 Anchor 在父容器右上角
往左是负 x
往下是负 y

例子 3:底部居中按钮

想让按钮固定在底部中心,离底部 30:

c
using UnityEngine;

public class BottomButtonLayout : MonoBehaviour
{
    public RectTransform button;

    private void Awake()
    {
        // 锚点放在父容器底部中心
        button.anchorMin = new Vector2(0.5f, 0f);
        button.anchorMax = new Vector2(0.5f, 0f);

        // Pivot 放在按钮底部中心
        button.pivot = new Vector2(0.5f, 0f);

        // x 为 0 表示水平居中
        // y 为 30 表示离底部向上 30
        button.anchoredPosition = new Vector2(0f, 30f);

        // 设置按钮宽高
        button.sizeDelta = new Vector2(220f, 70f);
    }
}

例子 4:四边拉伸面板

比如一个半透明背景面板,要填满父容器,但四边留 20 像素。

这时 Anchor 不是一个点,而是一块区域:

c
using UnityEngine;

public class StretchPanelLayout : MonoBehaviour
{
    public RectTransform panel;

    private void Awake()
    {
        // anchorMin 在父容器左下角
        panel.anchorMin = new Vector2(0f, 0f);

        // anchorMax 在父容器右上角
        panel.anchorMax = new Vector2(1f, 1f);

        // 这表示面板跟随父容器四边拉伸
        // offsetMin 是左下角偏移
        panel.offsetMin = new Vector2(20f, 20f);

        // offsetMax 是右上角偏移
        // 右边和上边缩进要用负数
        panel.offsetMax = new Vector2(-20f, -20f);
    }
}

这时 Inspector 里你看到的可能不是:

Pos X / Pos Y / Width / Height

而是:

Left / Right / Top / Bottom

这是因为锚点被拉伸了。

例子 5:血条从左向右缩放

假设血条背景不动,里面的填充条根据血量改变宽度。

c
using UnityEngine;

public class HealthBarFill : MonoBehaviour
{
    public RectTransform fill;
    public float maxWidth = 200f;

    public void SetHealth01(float health01)
    {
        // 保证血量比例在 0 到 1 之间
        health01 = Mathf.Clamp01(health01);

        // 锚点固定在左侧中间
        fill.anchorMin = new Vector2(0f, 0.5f);
        fill.anchorMax = new Vector2(0f, 0.5f);

        // Pivot 放在左侧中间
        // 这样改变宽度时,左边不动,右边缩放
        fill.pivot = new Vector2(0f, 0.5f);

        // 根据血量比例改变宽度
        fill.sizeDelta = new Vector2(maxWidth * health01, fill.sizeDelta.y);
    }
}

如果 Pivot 是中心:

血条会从中间往两边缩

如果 Pivot 是左边:

血条会从右边往左缩

这就是 Pivot 的作用。

Anchor 和 Pivot 的区别总结

对比AnchorPivot
看谁看父 RectTransform看自己 RectTransform
作用决定参考父容器哪里决定自己哪个点作为轴心
影响分辨率适配、父容器变化旋转、缩放、定位、尺寸变化方向
常见值左上、中心、右下、四边拉伸中心、左边、右上角
关键理解跟父亲哪里对齐拿自己哪里去对齐

常见坑

坑 1:想固定右上角,但 Anchor 还在中心。

结果:

编辑器里看着在右上角
换分辨率后位置飘了

解决:

把 Anchor 设到右上角

坑 2:血条缩放方向不对。

结果:

血条从中间缩

解决:

Pivot X 设为 0

坑 3:拉伸布局还用 sizeDelta 当宽高。

当:

c
anchorMin != anchorMax

UI 是拉伸模式,sizeDelta 就不再是简单宽高。

这时常用:

c
offsetMin
offsetMax

坑 4:把 Anchor 和 Pivot 混为一谈。

记住:

Anchor 是父物体上的点。
Pivot 是自己身上的点。

面试高分回答

WARNING

Anchor 是 RectTransform 相对于父矩形的锚点,决定 UI 在父容器尺寸变化时参考哪里,比如左上、中心、右下或四边拉伸。Pivot 是 UI 自己矩形内部的轴心点,影响定位、旋转、缩放和尺寸变化方向。anchoredPosition 表示当前 RectTransform 的 Pivot 相对 Anchor 的偏移。实际做 UI 适配时,Anchor 决定跟随父容器哪边,Pivot 决定自己用哪个点对齐和变形。

最短记忆版

c
Anchor:父级参考点。
Pivot:自身轴心点。
anchoredPosition:自身 Pivot 离 Anchor 的距离。

固定右上角:Anchor 右上,Pivot 右上,Position 负偏移。
居中面板:Anchor 中心,Pivot 中心,Position 为 0
血条从左缩:Pivot 放左边。
四边拉伸:anchorMin 左下,anchorMax 右上。

Canvas Scaler 如何做多分辨率适配?

canvas-scaler-multi-resolution

一句话

Canvas Scaler 用来让 UI 在不同分辨率下整体按比例缩放

但要记住:

Canvas Scaler 管“整体大小缩放”
Anchor / Layout 管“元素摆在哪里”
Safe Area 管“避开刘海屏、圆角屏、底部横条”

所以完整适配不是只配 Canvas Scaler,而是:

c
Canvas Scaler + Anchor + LayoutGroup + SafeArea

最常用设置

大多数游戏 UI 推荐:

c
UI Scale Mode:Scale With Screen Size
Reference Resolution:1920 x 10801334 x 750
Screen Match Mode:Match Width Or Height
Match:0.5

意思是:

我按 1920x1080 设计 UI。
实际设备不是这个尺寸时,Unity 自动整体缩放 UI。
宽高比例不一样时,宽和高各参考一半。

Canvas Scaler 的三种 UI Scale Mode

Constant Pixel Size
UI 像素大小固定
屏幕越大,UI 看起来占比越小
屏幕越小,UI 可能显得太大

适合:

简单工具
编辑器面板
不太需要多分辨率适配的 UI

不太适合复杂手游 HUD。

Scale With Screen Size
按参考分辨率缩放 UI
最常用于游戏多分辨率适配

比如你设置:

c
Reference Resolution = 1920 x 1080

那你做 UI 时就假装屏幕是 1920x1080

实际设备是:

c
1280x720
2560x1440
2340x1080
2436x1125

Unity 会按规则缩放。

Constant Physical Size
按物理尺寸适配,比如厘米、毫米、点
依赖设备 DPI

问题是:

有些设备 DPI 不准
不同平台表现可能不稳定

所以游戏里没那么常用。

Reference Resolution 是什么

Reference Resolution 是你设计 UI 时的“标准画布尺寸”。

比如:

1920 x 1080

意思是:

美术和程序都按 1920x1080 来摆 UI
按钮宽 200
血条宽 300
字体 32
间距 20

到了不同设备,Canvas Scaler 再统一缩放。

常见选择:

横屏游戏:1920 x 1080
竖屏游戏:1080 x 1920
手游横屏:1334 x 750 或 1920 x 1080

Match Width Or Height 怎么理解

这是新手最容易懵的地方。

当实际屏幕和参考分辨率比例不同,比如:

参考:1920 x 1080,16:9
设备:2340 x 1080,更宽

Unity 要决定:

我是更参考宽度来缩放?
还是更参考高度来缩放?
Match = 0
完全按宽度匹配
Match = 1
完全按高度匹配
Match = 0.5
宽和高折中

常见经验:

横屏游戏:0.5 常用
竖屏游戏:0.5 常用
强依赖横向布局:偏向 Width
强依赖纵向高度:偏向 Height

但不能死背,最终要看 UI 在不同机型上的表现。

代码配置 Canvas Scaler

带注释代码:

c
using UnityEngine;
using UnityEngine.UI;

public class CanvasScalerSetup : MonoBehaviour
{
    public CanvasScaler canvasScaler;

    private void Awake()
    {
        // 使用“随屏幕尺寸缩放”的模式
        // 这是游戏 UI 多分辨率适配最常用的模式
        canvasScaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;

        // 设置设计参考分辨率
        // UI 制作时就按这个尺寸来摆放
        canvasScaler.referenceResolution = new Vector2(1920f, 1080f);

        // 使用宽高匹配模式
        // 当设备比例和参考比例不一致时,由 matchWidthOrHeight 决定参考宽还是高
        canvasScaler.screenMatchMode = CanvasScaler.ScreenMatchMode.MatchWidthOrHeight;

        // 0 表示完全匹配宽,1 表示完全匹配高
        // 0.5 表示宽高折中,横屏游戏里很常见
        canvasScaler.matchWidthOrHeight = 0.5f;
    }
}

只配 Canvas Scaler 还不够

比如一个关闭按钮应该在右上角。

如果你只设置 Canvas Scaler,但按钮 Anchor 还在中心,那么换分辨率后可能会飘。

正确做法:

Canvas Scaler 负责缩放
按钮 Anchor 设到右上角
按钮 Pivot 设到右上角
anchoredPosition 设置负偏移

右上角按钮代码:

c
using UnityEngine;

public class TopRightUiButton : MonoBehaviour
{
    public RectTransform button;

    private void Awake()
    {
        // 锚点固定在父容器右上角
        button.anchorMin = new Vector2(1f, 1f);
        button.anchorMax = new Vector2(1f, 1f);

        // Pivot 也放在按钮右上角,方便用右上角对齐
        button.pivot = new Vector2(1f, 1f);

        // 离右边 30,离上边 30
        // 因为是从右上角往左下偏移,所以是负数
        button.anchoredPosition = new Vector2(-30f, -30f);

        // 设置按钮大小
        button.sizeDelta = new Vector2(160f, 60f);
    }
}

Safe Area 适配

Canvas Scaler 不能自动解决:

刘海屏
圆角屏
iPhone 底部 Home Indicator
挖孔屏

这些要用 Screen.safeArea

带注释代码:

c
using UnityEngine;

public class SafeAreaFitter : MonoBehaviour
{
    public RectTransform safeAreaPanel;

    private Rect lastSafeArea;

    private void Start()
    {
        ApplySafeArea();
    }

    private void Update()
    {
        // 如果横竖屏切换或系统安全区变化,就重新适配
        if (lastSafeArea != Screen.safeArea)
        {
            ApplySafeArea();
        }
    }

    private void ApplySafeArea()
    {
        Rect safeArea = Screen.safeArea;
        lastSafeArea = safeArea;

        // 屏幕左下角安全区坐标,转换成 0 到 1
        Vector2 anchorMin = safeArea.position;
        anchorMin.x /= Screen.width;
        anchorMin.y /= Screen.height;

        // 屏幕右上角安全区坐标,转换成 0 到 1
        Vector2 anchorMax = safeArea.position + safeArea.size;
        anchorMax.x /= Screen.width;
        anchorMax.y /= Screen.height;

        // 把 UI 根面板锚定到安全区范围内
        safeAreaPanel.anchorMin = anchorMin;
        safeAreaPanel.anchorMax = anchorMax;

        // 清空偏移,让它完全贴合安全区
        safeAreaPanel.offsetMin = Vector2.zero;
        safeAreaPanel.offsetMax = Vector2.zero;
    }
}

常见结构:

c
Canvas
- SafeAreaPanel
  - TopBar
  - BottomBar
  - Buttons
  - HUD

这样所有重要 UI 都放到 SafeAreaPanel 下面。

LayoutGroup 也很重要

多分辨率下,很多 UI 不应该手动写死位置。

比如背包格子、排行榜列表、任务列表。

可以用:

c
Horizontal Layout Group
Vertical Layout Group
Grid Layout Group
Content Size Fitter
Layout Element

例如背包格子:

c
Grid Layout Group 控制每个格子大小和间距
Canvas Scaler 控制整体缩放
Anchor 控制背包面板位置

这样比手动摆几十个格子稳定很多。

常见坑

坑 1:以为 Canvas Scaler 能解决所有适配。

不行。

它主要解决:

整体缩放

它不负责:

按钮贴边
列表自动排版
刘海屏安全区

坑 2:参考分辨率乱选。

如果横屏游戏参考了竖屏分辨率,后面 UI 会很难调。

坑 3:Match 值不测试。

00.51 在超宽屏、平板、手机上效果不同,一定要多机型预览。

坑 4:UI 全部固定在中心 Anchor。

结果:

换分辨率后角落按钮、底部技能栏容易偏

坑 5:World Space UI 也以为 Canvas Scaler 全自动。

World Space Canvas 还要额外关注:

世界缩放
相机距离
像素密度
Canvas 尺寸

面试高分回答

IMPORTANT

Canvas Scaler 用来处理 UI 在不同分辨率下的整体缩放。项目里最常用的是 Scale With Screen Size,设置一个 Reference Resolution,比如 1920x1080,再用 Match Width Or Height 控制宽高比例不一致时更参考宽还是高。但 Canvas Scaler 只解决缩放问题,真正的位置适配还要靠 RectTransform 的 Anchor、Pivot、LayoutGroup,以及移动端的 SafeArea。我的做法通常是:Canvas Scaler 统一缩放,重要 UI 放在 SafeAreaPanel 下,角落按钮用对应角落 Anchor,列表和背包用 LayoutGroup,最后用多种分辨率和异形屏测试。

最短记忆版

c
Canvas Scaler:整体缩放。
Reference Resolution:设计基准尺寸。
Match 0:参考宽。
Match 1:参考高。
Match 0.5:宽高折中。
Anchor:决定元素跟父容器哪里走。
SafeArea:避开刘海和底部横条。

参考:Unity 官方 Canvas Scaler / Canvas 文档。

UGUI 重建是什么?为什么会卡?

ugui-rebuild-why-lag

一句话

UGUI 重建就是:UI 发生变化后,Unity 需要重新计算布局、重新生成 UI 顶点、重新构建 Canvas 渲染批次

它会卡,是因为:

一个小 UI 变化,可能牵连整个 Canvas 里的很多 UI 一起重新计算。

零基础理解

你可以把 Canvas 想成一张大海报。

海报上有:

头像
血条
技能按钮
倒计时文字
背包格子
任务文本

如果你每帧改一个倒计时文字:

10.0
9.9
9.8
9.7

Unity 不能只“改一个字符”那么简单。

它可能要做:

重新计算这个 Text 的网格
重新计算它所在 Canvas 的渲染顺序
重新合批
重新提交 UI 顶点

如果这个 Canvas 很大,里面有几百个 UI 元素,就可能卡。

UGUI 重建主要分几类

常见可以理解成三类:

c
Layout Rebuild:布局重建
Graphic Rebuild:图形重建
Canvas Batch Rebuild:批次重建

Layout Rebuild 是什么

Layout Rebuild 是重新算 UI 的布局。

比如:

这个按钮在哪里
这个文本宽高是多少
这个背包格子排第几个
这个列表内容总高度是多少

容易触发布局重建的组件:

c
Horizontal Layout Group
Vertical Layout Group
Grid Layout Group
Content Size Fitter
Layout Element
Aspect Ratio Fitter

比如你改了一个文本:

c
titleText.text = "新的任务标题";

如果这个 Text 在 ContentSizeFitterLayoutGroup 下面,Unity 可能要重新算:

Text 自己多宽
父物体多高
列表整体多高
其他兄弟节点位置
ScrollView 内容尺寸

所以布局重建容易向父级、子级扩散。

Graphic Rebuild 是什么

Graphic Rebuild 是重新生成 UI 图形数据。

比如:

c
Text 文字顶点
Image 图片顶点
颜色
材质
UV
Mask 裁剪
Shadow / Outline 效果

这些组件常参与 Graphic Rebuild:

c
Image
RawImage
Text
TextMeshProUGUI
Mask
RectMask2D
Shadow
Outline

例如你每帧改血条颜色:

c
hpImage.color = Color.red;

或者每帧改文字:

c
scoreText.text = score.ToString();

都会让对应 Graphic 变脏,之后需要重建。

Canvas Batch Rebuild 是什么

Canvas 为了减少 Draw Call,会把 UI 尽量合批。

当 UI 变了,Unity 可能要重新判断:

哪些 UI 可以合在一起画
哪些材质不同不能合
哪些层级顺序变了
哪些被 Mask 裁剪
哪些需要单独批次

这就是 Canvas 批次重建。

大 Canvas 里只改一个小文本,也可能导致整个 Canvas 的批处理重新构建。

这就是很多人说的:

UGUI 一个元素脏了,可能拖累整个 Canvas。

哪些操作容易触发重建

高危操作:

c
频繁修改 Text 内容
频繁修改 Image sprite / color / fillAmount
频繁 SetActive
频繁改变父子层级
频繁改变 RectTransform 尺寸
频繁改变 LayoutGroup 下的子节点
频繁启用/禁用 UI 组件
频繁添加/删除 UI 元素

比如倒计时:

c
timerText.text = timeLeft.ToString("F2");

如果每帧都改,Text 每帧都重建。

比如背包列表:

每次打开背包都 Destroy 所有格子,再 Instantiate 新格子

这也会造成大量重建和 GC。

为什么 LayoutGroup + ContentSizeFitter 容易卡

LayoutGroup 负责排子物体。

ContentSizeFitter 负责根据内容改变自己的尺寸。

如果嵌套复杂:

c
ScrollView
- ContentSizeFitter
  - VerticalLayoutGroup
    - Item
      - ContentSizeFitter
      - Text

一个 Text 改变后,Unity 可能要重新算:

c
Text 多高
Item 多高
Content 多高
ScrollView 滚动范围
所有兄弟节点位置

这就是级联重建。

所以:

静态列表可以用 LayoutGroup
高频变化列表要谨慎
大量列表最好对象池 + 固定尺寸

为什么一个 Canvas 太大会卡

如果你把所有 UI 都放在一个 Canvas:

主界面
血条
聊天
背包
商城
任务
技能栏
弹窗

那么一个经常变化的 UI,比如倒计时文本,可能让整个 Canvas 都参与批次重建。

更好的做法是拆 Canvas:

StaticCanvas:背景、固定装饰
HudCanvas:血条、技能、倒计时
PopupCanvas:弹窗
ListCanvas:背包、排行榜、ScrollView
FloatingCanvas:飘字、头顶血条

原则:

变化频率不同的 UI,尽量分到不同 Canvas。

这样一个动态文本变化时,不会拖累静态背景。

代码优化 1:值没变就不要刷新 UI

错误写法:

c
private void Update()
{
    // 每帧都设置文本,即使数值没变,也可能造成无意义重建
    coinText.text = coin.ToString();
}

更好:

c
using UnityEngine;
using UnityEngine.UI;

public class CoinTextView : MonoBehaviour
{
    public Text coinText;

    // 记录上一次显示的金币数量
    private int lastCoin = -1;

    public void SetCoin(int coin)
    {
        // 如果数值没变,就不要重新设置 Text
        // 避免无意义 Graphic Rebuild
        if (coin == lastCoin)
        {
            return;
        }

        lastCoin = coin;
        coinText.text = coin.ToString();
    }
}

核心思想:

不是每帧刷新 UI,而是数据变化时刷新 UI。

代码优化 2:倒计时不要每帧更新很多小数

错误:

c
private void Update()
{
    // 每帧显示两位小数,会导致 Text 每帧变化
    timerText.text = timeLeft.ToString("F2");
}

更好:

c
using UnityEngine;
using UnityEngine.UI;

public class TimerTextView : MonoBehaviour
{
    public Text timerText;

    private float timeLeft = 60f;

    // 记录上一次显示的整数秒
    private int lastDisplaySecond = -1;

    private void Update()
    {
        timeLeft -= Time.deltaTime;

        // UI 只显示整数秒
        int displaySecond = Mathf.CeilToInt(timeLeft);

        // 只有显示数字变化时才更新 Text
        if (displaySecond != lastDisplaySecond)
        {
            lastDisplaySecond = displaySecond;
            timerText.text = displaySecond.ToString();
        }
    }
}

如果游戏确实需要毫秒倒计时,比如竞速结算,那可以只在关键 UI 上做,并把它放到单独 Canvas。

代码优化 3:血条 fillAmount 变化才更新

c
using UnityEngine;
using UnityEngine.UI;

public class HealthBarView : MonoBehaviour
{
    public Image fillImage;

    // 记录上一次血量比例
    private float lastHp01 = -1f;

    public void SetHp(float currentHp, float maxHp)
    {
        // 计算 0 到 1 的血量比例
        float hp01 = Mathf.Clamp01(currentHp / maxHp);

        // 如果变化很小,就不更新 UI
        // 避免频繁触发 Graphic Rebuild
        if (Mathf.Abs(hp01 - lastHp01) < 0.001f)
        {
            return;
        }

        lastHp01 = hp01;
        fillImage.fillAmount = hp01;
    }
}

代码优化 4:拆分动态 Canvas

c
using UnityEngine;

public class UiCanvasSplitExample : MonoBehaviour
{
    public Canvas staticCanvas;
    public Canvas dynamicCanvas;
    public Canvas popupCanvas;

    private void Awake()
    {
        // 静态 Canvas 放不常变化的背景和装饰
        // 例如主界面背景、固定边框
        staticCanvas.sortingOrder = 0;

        // 动态 Canvas 放经常变化的血条、倒计时、技能冷却
        // 它重建时不会影响 staticCanvas
        dynamicCanvas.sortingOrder = 10;

        // 弹窗 Canvas 单独放,打开关闭时不影响 HUD
        popupCanvas.sortingOrder = 100;
    }
}

拆 Canvas 不是越多越好。

因为 Canvas 多了也会增加管理和 Draw Call。

原则是:

按变化频率拆。
按功能模块拆。
不要每个按钮一个 Canvas。

代码优化 5:对象池复用列表 Item

错误思路:

每次刷新排行榜:
Destroy 旧 Item
Instantiate 新 Item

更好:

提前创建一批 Item
刷新时复用
不用的隐藏

示例:

c
using System.Collections.Generic;
using UnityEngine;

public class SimpleItemPool : MonoBehaviour
{
    public GameObject itemPrefab;
    public Transform contentRoot;

    // 缓存已经创建出来的 Item
    private readonly List<GameObject> items = new List<GameObject>();

    public void Refresh(int count)
    {
        // 确保池子里有足够数量的 Item
        while (items.Count < count)
        {
            GameObject item = Instantiate(itemPrefab, contentRoot);
            items.Add(item);
        }

        for (int i = 0; i < items.Count; i++)
        {
            // 需要显示的 Item 打开
            // 超出数量的 Item 关闭
            items[i].SetActive(i < count);
        }
    }
}

进一步优化:

如果 SetActive 也很频繁,可以用 CanvasGroup 隐藏
或者把不用的 Item 移到池节点
或者分页/虚拟列表,只显示屏幕内的 Item

代码优化 6:关闭不需要点击的 Raycast Target

很多 Image 和 Text 默认可能参与射线检测。

如果只是装饰图,不需要点击,可以关闭:

c
using UnityEngine.UI;
using UnityEngine;

public class DisableRaycastTargets : MonoBehaviour
{
    public Graphic[] graphics;

    private void Awake()
    {
        foreach (Graphic graphic in graphics)
        {
            // 装饰性 UI 不需要接收点击
            // 关闭 raycastTarget 可以减少 EventSystem 射线检测压力
            graphic.raycastTarget = false;
        }
    }
}

注意:

这主要优化 UI 事件射线检测,不是直接解决 Canvas Rebuild。
但大量 UI 时也很重要。

如何在 Profiler 里看

重点看:

c
Canvas.SendWillRenderCanvases
Canvas.BuildBatch
LayoutRebuilder
Graphic.Rebuild
CanvasRenderer
UI.Batch

如果看到:

Canvas.BuildBatch 很高

通常说明 Canvas 批次重建重。

如果看到:

LayoutRebuilder 很高

通常说明 LayoutGroup / ContentSizeFitter / RectTransform 变化重。

如果看到:

Graphic.Rebuild 很高

通常说明 Text、Image、Mask、Outline 等图形重建重。

常见优化清单

1. 数据没变,不刷新 UI
2. 倒计时不要每帧改 Text
3. 动态 UI 和静态 UI 分 Canvas
4. 高频变化 UI 不要放巨大 Canvas
5. 少用复杂嵌套 LayoutGroup
6. 大列表用对象池或虚拟列表
7. 少用 Mask、Shadow、Outline
8. 装饰性 Graphic 关闭 raycastTarget
9. 不要频繁 SetActive 和改父子层级
10. 用 Profiler 找真正热点

常见误区

误区 1:

UGUI 卡一定是 Draw Call 多。

不一定。

很多时候是:

Canvas 重建 CPU 开销高

误区 2:

拆 Canvas 越多越好。

不对。

Canvas 太多也会增加渲染批次和管理成本。

正确是:

按变化频率和模块拆。

误区 3:

只要用对象池就不卡。

对象池能减少 Instantiate / Destroy 和 GC,但如果你每帧改 Text、Layout,还是会重建。

面试高分回答

IMPORTANT

UGUI 重建是 Canvas 在 UI 变脏后重新计算布局、图形顶点和渲染批次的过程。比如修改 Text、Image、RectTransform、LayoutGroup 子节点、SetActive 或层级变化,都可能触发 Layout Rebuild、Graphic Rebuild 或 Canvas BuildBatch。卡顿的原因通常是一个频繁变化的小 UI 放在很大的 Canvas 里,导致整个 Canvas 重新合批,或者复杂 LayoutGroup / ContentSizeFitter 产生级联布局重算。优化时我会先用 Profiler 看 Canvas.BuildBatch、LayoutRebuilder、Graphic.Rebuild,再把静态 UI 和动态 UI 分 Canvas,减少每帧 Text 更新,避免复杂嵌套 Layout,列表用对象池/虚拟列表,并关闭不必要的 RaycastTarget、Mask、Shadow、Outline。

最短记忆版

c
UGUI 重建 = 布局重算 + 图形重建 + Canvas 重新合批。

为什么卡:
一个小 UI 变了,可能拖着整个 Canvas 重建。

怎么优化:
动静分离 Canvas;
数据变了才刷新;
少用复杂 Layout;
列表用对象池;
少改 Text;
Profiler 定位。

如何优化 ScrollView 大量 Item?

scrollview-many-items-optimization

一句话

ScrollView 大量 Item 的核心优化是:虚拟列表 + 对象池

也就是:

数据可以有 10000 条
但场景里不要真的创建 10000 个 UI Item
只创建屏幕能看到的十几个或几十个
滚动时复用这些 Item,换数据、换位置

零基础理解

普通错误做法:

c
排行榜有 10000 条数据
就 Instantiate 10000 个 Item
全部放到 Content 下面

这会导致:

c
GameObject 数量巨大
RectTransform 巨多
Text/Image 巨多
LayoutGroup 计算很重
Canvas Rebuild 很重
ScrollView 滚动卡
打开界面也卡
内存也高

正确思路:

屏幕上最多只能看到 10 条
那就创建 12 到 20 个 Item
多出来几个作为缓冲
滚动时把旧 Item 挪到新位置并刷新数据

这就叫:

c
虚拟列表 Virtual List
循环列表 Recycled List
复用列表 Reuse List

对象池和虚拟列表的区别

对象池 解决的是:

c
不要频繁 Instantiate / Destroy

虚拟列表 解决的是:

不要让所有数据都对应一个真实 UI 节点

所以只用对象池还不够。

如果你对象池里还是创建了 10000 个 Item,只是不 Destroy,那还是很重。

真正要做的是:

只创建可见数量的 Item。

大 ScrollView 为什么卡

主要卡在这些地方:

c
大量 Item GameObject
大量 Text / Image
大量 RectTransform
LayoutGroup 重算
ContentSizeFitter 重算
Canvas Rebuild
Graphic Rebuild
Mask 裁剪
Raycast 检测
Instantiate / Destroy
GC Alloc

尤其是这种结构很危险:

c
ScrollView
- Viewport
  - Content
    - VerticalLayoutGroup
    - ContentSizeFitter
    - Item 1
    - Item 2
    - Item 3
    - ...
    - Item 10000

每次改一个 Item、刷新列表、改变 Content 高度,都可能触发大量布局重建。

优化方向 1:固定 Item 高度

虚拟列表最好先做固定高度。

比如:

c
每个 Item 高度 = 100
数据数量 = 10000
Content 高度 = 10000 * 100 = 1000000

但场景里真实 Item 只有:

可见数量 + 缓冲数量

比如屏幕高度 600:

c
600 / 100 = 6 个可见
2 个缓冲
创建 810 个 Item 就够

固定高度虚拟列表示例

下面是一个简化版,代码里加了注释。

先定义 Item 脚本:

c
using UnityEngine;
using UnityEngine.UI;

public class RankItemView : MonoBehaviour
{
    public Text rankText;
    public Text nameText;
    public Text scoreText;

    // 绑定数据到这个 Item
    public void Bind(int dataIndex, RankData data)
    {
        // 显示排名,从 1 开始
        rankText.text = (dataIndex + 1).ToString();

        // 显示玩家名字
        nameText.text = data.name;

        // 显示分数
        scoreText.text = data.score.ToString();
    }
}

public struct RankData
{
    public string name;
    public int score;
}

虚拟列表核心代码:

c
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;

public class VirtualRankList : MonoBehaviour
{
    public ScrollRect scrollRect;

    // Content 是 ScrollView 里真正滚动的容器
    public RectTransform content;

    // Viewport 是可见区域
    public RectTransform viewport;

    // Item 预制体
    public RankItemView itemPrefab;

    // 每个 Item 的固定高度
    public float itemHeight = 100f;

    // 额外缓冲数量,避免滚动边缘露空
    public int bufferCount = 2;

    // 所有排行榜数据
    private List<RankData> dataList = new List<RankData>();

    // 真实创建出来并复用的 Item
    private readonly List<RankItemView> itemViews = new List<RankItemView>();

    // 上一次的起始索引,用来避免重复刷新
    private int lastFirstIndex = -1;

    public void SetData(List<RankData> data)
    {
        // 保存数据
        dataList = data;

        // 设置 Content 总高度
        // 注意:真实 Item 很少,但 Content 要表现得像有完整高度
        content.sizeDelta = new Vector2(
            content.sizeDelta.x,
            dataList.Count * itemHeight
        );

        // 创建可见 Item
        CreateVisibleItems();

        // 立即刷新一次
        RefreshVisibleItems(force: true);
    }

    private void Awake()
    {
        // 监听 ScrollRect 滚动
        scrollRect.onValueChanged.AddListener(_ =>
        {
            RefreshVisibleItems(force: false);
        });
    }

    private void CreateVisibleItems()
    {
        // 根据 Viewport 高度计算最多能看见几个 Item
        int visibleCount = Mathf.CeilToInt(viewport.rect.height / itemHeight);

        // 加上缓冲数量
        int needCount = visibleCount + bufferCount;

        // 数据少时,不需要创建超过数据数量的 Item
        needCount = Mathf.Min(needCount, dataList.Count);

        // 创建足够数量的 Item
        while (itemViews.Count < needCount)
        {
            RankItemView item = Instantiate(itemPrefab, content);
            itemViews.Add(item);
        }

        // 多余的 Item 关闭
        for (int i = 0; i < itemViews.Count; i++)
        {
            itemViews[i].gameObject.SetActive(i < needCount);
        }
    }

    private void RefreshVisibleItems(bool force)
    {
        if (dataList == null || dataList.Count == 0)
        {
            return;
        }

        // content.anchoredPosition.y 表示向上滚了多少
        float scrollY = content.anchoredPosition.y;

        // 根据滚动距离计算第一个可见数据索引
        int firstIndex = Mathf.FloorToInt(scrollY / itemHeight);

        // 限制范围,防止越界
        firstIndex = Mathf.Clamp(firstIndex, 0, dataList.Count - 1);

        // 如果起始索引没变,就不需要重新绑定数据
        if (!force && firstIndex == lastFirstIndex)
        {
            return;
        }

        lastFirstIndex = firstIndex;

        for (int i = 0; i < itemViews.Count; i++)
        {
            int dataIndex = firstIndex + i;

            RankItemView item = itemViews[i];

            if (dataIndex >= dataList.Count)
            {
                item.gameObject.SetActive(false);
                continue;
            }

            item.gameObject.SetActive(true);

            // 设置 Item 的位置
            // 注意 UI 里 y 往下通常是负方向
            RectTransform itemRect = (RectTransform)item.transform;
            itemRect.anchorMin = new Vector2(0f, 1f);
            itemRect.anchorMax = new Vector2(1f, 1f);
            itemRect.pivot = new Vector2(0.5f, 1f);
            itemRect.sizeDelta = new Vector2(0f, itemHeight);
            itemRect.anchoredPosition = new Vector2(0f, -dataIndex * itemHeight);

            // 绑定当前数据
            item.Bind(dataIndex, dataList[dataIndex]);
        }
    }
}

这个代码的核心是:

Content 很高,让 ScrollView 以为有很多内容
真实 Item 很少,只是根据滚动位置换数据和位置

优化方向 2:大列表少用 LayoutGroup

如果是几百上千个 Item,不建议依赖:

c
VerticalLayoutGroup
ContentSizeFitter

来实时排布所有 Item。

因为它们会带来 Layout Rebuild。

虚拟列表里更推荐:

c
手动设置 Content 高度
手动设置 Item anchoredPosition
固定 Item 高度

也就是:

c
itemRect.anchoredPosition = new Vector2(0f, -dataIndex * itemHeight);

这样不需要 LayoutGroup 每次帮你重新排。

优化方向 3:Item 数据没变不要重复刷新

错误写法:

c
private void Update()
{
    item.nameText.text = data.name;
    item.scoreText.text = data.score.ToString();
}

这会每帧改 Text,导致 Graphic Rebuild。

更好:

c
public class RankItemView : MonoBehaviour
{
    public Text nameText;
    public Text scoreText;

    private string lastName;
    private int lastScore;

    public void Bind(RankData data)
    {
        // 名字没变就不刷新 Text
        if (data.name != lastName)
        {
            lastName = data.name;
            nameText.text = data.name;
        }

        // 分数没变就不刷新 Text
        if (data.score != lastScore)
        {
            lastScore = data.score;
            scoreText.text = data.score.ToString();
        }
    }
}

核心思想:

数据变化时刷新 UI
不要每帧刷新 UI

优化方向 4:关闭不需要的 Raycast Target

Item 里可能有很多:

Text
Image
Icon
背景图
装饰线

默认它们可能参与 UI 射线检测。

如果不需要点击,就关掉:

c
using UnityEngine;
using UnityEngine.UI;

public class ItemRaycastOptimizer : MonoBehaviour
{
    public Graphic[] graphics;

    private void Awake()
    {
        foreach (Graphic graphic in graphics)
        {
            // 装饰性图片和文字不需要响应点击
            // 关闭 raycastTarget 可以减少 EventSystem 检测压力
            graphic.raycastTarget = false;
        }
    }
}

只保留真正需要点击的按钮或 Item 根节点参与射线。

优化方向 5:减少 Mask、Shadow、Outline

ScrollView 常有:

c
Mask
RectMask2D
Shadow
Outline

建议:

c
矩形裁剪优先考虑 RectMask2D
少用复杂 Mask
少给大量 Text 加 Shadow / Outline
少叠很多透明 Image

因为这些会增加:

顶点数量
材质变化
裁剪成本
Overdraw
Batch 复杂度

尤其是大量 Item 中每个文字都有 Outline,会非常贵。

优化方向 6:拆 Canvas

如果 ScrollView 很大、变化频繁,最好单独放一个 Canvas。

比如:

c
MainCanvas
- StaticBackground
- HudCanvas
- PopupCanvas
- ScrollListCanvas

原因:

ScrollView 滚动时会频繁变化
如果和静态背景在同一个巨大 Canvas 里
可能拖着静态 UI 一起重建

可以把大列表隔离:

ScrollView 单独 Canvas
静态 UI 单独 Canvas

但 Canvas 不是越多越好。

原则:

按变化频率拆
不要每个 Item 一个 Canvas

优化方向 7:分页和搜索

如果数据特别多,不一定非要让玩家滚 10000 条。

可以设计上优化:

分页
搜索
筛选
分类
只显示前 100
滚动加载更多

很多时候 UI 性能问题不是技术不够,而是交互设计不合理。

比如排行榜:

显示前 100
显示自己附近 20
搜索玩家
分页加载

比真的滚 10000 条更合理。

优化方向 8:变高 Item 怎么办

如果每个 Item 高度不同,虚拟列表会复杂很多。

比如聊天列表:

短消息 50 高
长消息 180 高
图片消息 300 高

需要维护:

c
每个 Item 高度
前缀高度数组
根据 scrollY 找 firstIndex
动态更新 Content 总高度

零基础面试可以先说:

固定高度虚拟列表最简单。
不等高列表需要缓存每条高度,并用累计高度查找可见区间。

如果想更高级,可以说:

可以用二分查找根据 scrollY 找第一个可见 Item。

但普通 Unity 客户端面试,能讲清固定高度虚拟列表已经很不错。

常见坑

坑 1:只做对象池,不做虚拟化。

创建了 10000 个 Item 放池里
还是很重

坑 2:ScrollView 里套复杂 LayoutGroup。

滚动或刷新时 Layout Rebuild 很高

坑 3:每帧更新所有可见 Item 的 Text。

只在数据变化或索引变化时刷新

坑 4:每个 Item 都有很多 Shadow / Outline / Mask。

顶点数和重建成本会很高

坑 5:滚动时频繁 SetActive。

更好的虚拟列表通常保持固定数量 Item 激活,只换位置和数据。

只有超出数据范围时才隐藏少量 Item。

坑 6:没有缓冲 Item。

如果只创建刚好可见数量,快速滚动边缘可能露空。

所以要加:

c
bufferCount = 25

面试高分回答

TIP

ScrollView 大量 Item 的核心优化是虚拟列表。不要根据数据量创建全部 UI 节点,而是只创建可见范围加少量缓冲的 Item,滚动时根据 content.anchoredPosition 计算 firstIndex,然后复用这些 Item,重新设置 anchoredPosition 和绑定数据。对象池解决 Instantiate/Destroy 的开销,虚拟化解决场景中 UI 节点数量过多的问题。大列表里我会尽量固定 Item 高度,手动设置 Content 高度和 Item 位置,避免使用复杂 LayoutGroup 和 ContentSizeFitter;同时减少 Text 更新、Mask、Shadow、Outline,关闭无用 RaycastTarget,并把高频滚动列表和静态 UI 拆到不同 Canvas。数据特别多时还可以配合分页、搜索或按需加载。

最短记忆版

c
大量 ScrollView 优化 = 虚拟列表 + 对象池 + 少重建。

对象池:不频繁创建销毁。
虚拟列表:不创建所有数据的 UI。
固定高度:方便根据滚动距离算索引。
手动布局:减少 LayoutGroup 重建。
数据变化才刷新 Text。
关闭无用 RaycastTarget。
大列表单独 Canvas。

图集有什么用?

sprite-atlas-purpose

一句话

图集,也就是 Sprite Atlas,作用是:把很多小图合成一张大图,减少纹理切换,提高 UI / 2D 渲染合批效率,也方便资源管理。

你可以先这样记:

不用图集:很多小图片,各用各的纹理。
使用图集:很多小图片,放进同一张大纹理。

零基础比喻

假设你有很多小图标:

金币图标
钻石图标
背包图标
技能图标
按钮边框
装备品质框

如果它们都是单独图片,GPU 渲染 UI 时可能要这样:

换金币纹理 → 画金币
换钻石纹理 → 画钻石
换技能纹理 → 画技能
换按钮纹理 → 画按钮

每次换纹理都有成本。

图集就是把它们放到一张大图里:

UIAtlas.png
- 金币
- 钻石
- 技能
- 按钮
- 边框

GPU 就可以更像这样:

使用同一张图集纹理
连续画多个 UI
减少纹理切换
更容易合批

图集最核心的作用 1:减少纹理切换

渲染时,GPU 不喜欢频繁换资源。

如果每个 UI 图片都来自不同 Texture,就可能频繁切换:

c
Texture A
Texture B
Texture C
Texture D

使用图集后,很多 Sprite 实际来自同一张 Texture:

UIAtlas

这样渲染更顺。

面试里可以说:

图集可以减少 GPU Texture Bind 的次数。

图集最核心的作用 2:提高合批机会

Unity UI 想合批,需要很多条件接近,比如:

相同材质
相同纹理
相同 Shader
渲染顺序没有被打断
没有复杂 Mask 干扰

图集能解决其中一个很关键的问题:

让多个 UI Sprite 使用同一张纹理。

所以图集可以提高合批机会,减少 Draw Call。

但注意:

不是用了图集就一定 1 个 Draw Call。

因为还可能被这些东西打断:

不同材质
不同 Canvas
Mask
层级顺序穿插
不同 Shader
不同 Sorting

图集最核心的作用 3:方便资源管理

比如你可以按模块打图集:

CommonUIAtlas:通用按钮、边框、底板
ItemIconAtlas:道具图标
SkillIconAtlas:技能图标
HeroIconAtlas:角色头像
ActivityAtlas:活动界面专用图

这样好处是:

资源更好分类
加载更可控
可以按模块卸载
可以做平台压缩设置
可以做图集变体

Unity 里图集通常是什么

在 Unity 里常见的是:

Sprite Atlas

你可以在 Project 面板:

Create → 2D → Sprite Atlas

然后把 Sprite 或文件夹拖进去。

Unity 打包时会把它们打到图集里。

UI 代码里你看到的还是:

Sprite

但底层可能来自同一张 Atlas Texture。

代码例子:从 SpriteAtlas 里取图标

代码里加注释:

c
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.U2D;

public class IconFromAtlas : MonoBehaviour
{
    public SpriteAtlas itemAtlas;
    public Image iconImage;

    public void SetIcon(string spriteName)
    {
        // 从图集中按名字取出 Sprite
        // 注意:spriteName 要和图集里的 Sprite 名字一致
        Sprite sprite = itemAtlas.GetSprite(spriteName);

        // 如果名字写错,GetSprite 可能返回 null
        if (sprite == null)
        {
            Debug.LogWarning("图集中找不到图标:" + spriteName);
            return;
        }

        // 把取到的 Sprite 设置给 UI Image
        iconImage.sprite = sprite;
    }
}

使用:

c
SetIcon("icon_sword");
SetIcon("icon_coin");
SetIcon("icon_potion");

图集适合放什么

适合放在同一个图集里的资源:

同一个界面经常一起出现的图
同一个模块的图标
尺寸较小的 UI Sprite
通用按钮、边框、底板
技能图标
道具图标
表情小图
2D 角色的小部件

比如背包界面:

物品格子背景
品质框
金币图标
道具类型图标
常用小按钮

这些可以放一张或几张背包相关图集。

不适合乱放进同一张图集的内容

不要把所有东西都塞进一张巨大图集。

比如:

主界面 UI
背包 UI
商城 UI
活动 UI
战斗 UI
角色立绘
大背景图

全塞一张会有问题:

只打开背包,也可能加载整张巨大图集
内存占用变大
不常用资源被迫常驻
更新热更颗粒度变差
图集太大可能超过平台限制

图集不是越大越好

图集太小:

图集数量多
纹理切换仍然多

图集太大:

加载一个小图也带进整张大图
内存浪费
低端机压力大
活动资源不好卸载

所以更合理的是按用途拆:

常驻通用 UI 一张
背包道具图标一张或多张
技能图标一张或多张
活动资源单独图集
大背景图单独资源

图集和内存的关系

假设你只显示一个小图标:

c
icon_coin

但它在一张 4096 x 4096 的图集里。

那么加载这个 Sprite 时,可能整张大图集都要进内存。

这就是为什么:

不常用资源不要混进常驻图集
大图不要随便塞进小图标图集

图集和 UI 性能的关系

图集主要优化的是:

纹理切换
合批机会
Draw Call
资源加载管理

它不直接解决:

c
UGUI 重建
Layout Rebuild
Text 每帧变化
ScrollView 太多 Item
Canvas 太大

比如你用了图集,但每帧改 100 个 Text:

还是会卡

因为那是 Canvas Rebuild 问题,不是图集能完全解决的。

常见坑

坑 1:以为图集一定减少 Draw Call。

不一定。

如果 UI 中间插了不同材质、Mask、不同 Canvas,还是可能打断合批。

坑 2:把所有图片塞进一张超大图集。

结果:

内存变大
加载慢
不常用资源也常驻

坑 3:图集 Padding 太小。

可能出现:

边缘串色
图标边缘有脏线
缩放或压缩后漏色

尤其开启压缩、MipMap 或缩放时更明显。

坑 4:不同压缩需求的图混在一起。

比如:

透明 UI 图标
大背景
高精度角色头像
低精度小图标

它们可能需要不同压缩格式和质量。

混在一起会让设置变尴尬。

坑 5:动态图集和热更资源拆分不合理。

如果某个活动图标经常热更,却放在通用大图集里,每次更新可能要更新整张图集。

项目建议

可以这样分图集:

c
UI_Common:通用按钮、边框、底板
UI_Battle:战斗 HUD
UI_Bag:背包界面
Icon_Item_01:道具图标第一组
Icon_Skill_01:技能图标第一组
UI_Activity_XXX:活动专用,活动结束可卸载

原则:

一起出现的放一起
生命周期相同的放一起
压缩格式相同的放一起
常驻和临时分开
大图和小图分开

面试高分回答

TIP

图集的作用是把多个小 Sprite 打包到同一张大纹理中。这样 UI 或 2D 渲染时,多个元素可以共享同一个 Texture 和材质,减少 GPU 纹理切换,提高合批机会,从而降低 Draw Call 和渲染状态切换成本。同时图集也方便按模块管理资源、做平台压缩和按需加载。但图集不是越大越好,因为加载其中一个 Sprite 可能会把整张 Atlas 带入内存,所以项目里要按界面、模块、生命周期和压缩需求合理拆分。图集能优化渲染和资源管理,但不能替代 Canvas 拆分、虚拟列表、减少 UI 重建这些 UGUI 优化手段。

最短记忆版

c
图集 = 很多小图合成一张大图。

好处:
减少纹理切换;
提高合批机会;
减少 Draw Call;
方便资源管理。

注意:
不要所有图塞一张;
常用和不常用分开;
大图和小图分开;
图集优化渲染,不直接解决 UI 重建。

Raycast Target 为什么要关闭不必要的?

raycast-target-why-disable

一句话

Raycast Target 决定这个 UI 图形是否参与 UGUI 的点击/触摸射线检测。

不需要点击的 UI,应该关闭它:

减少 EventSystem 检测成本
避免透明图、装饰图挡住下面的按钮

最短口诀:

能点的保留。
不能点的关闭。

零基础理解

你点一下屏幕,Unity 不是直接知道你点了哪个按钮。

它要通过:

EventSystem
GraphicRaycaster

去检查当前 Canvas 下有哪些 UI 图形被点到了。

比如一个按钮上可能有:

按钮背景 Image
按钮图标 Image
按钮文字 Text
装饰边框 Image
红点 Image

如果它们的 Raycast Target 都开着,Unity 点击时就可能把这些都当成候选对象检查。

但实际上真正需要接收点击的可能只有:

按钮背景 Image

其他:

文字
图标
装饰边框
红点

只是显示用,不需要参与点击检测。

Raycast Target 在哪里

在 UGUI 组件上常见:

c
Image
Text
RawImage
TextMeshProUGUI

Inspector 里会有一个勾选:

Raycast Target

如果勾上:

这个 Graphic 会参与 UI 射线检测

如果关掉:

这个 Graphic 不会作为点击命中目标

为什么能优化性能

UI 点击检测时,GraphicRaycaster 会扫描 Canvas 里的可射线检测图形。

它大概会做这些事:

找出 Canvas 下的 Graphic
过滤 raycastTarget=false 的对象
判断鼠标/手指是否在 RectTransform 内
判断是否被 CanvasGroup、Mask 等影响
按深度排序
把结果交给 Button、Toggle、Drag 等事件系统

如果一个界面有很多 UI:

几百个 Image
几百个 Text
很多按钮、图标、装饰

而它们都开着 Raycast Target,点击和拖动时检测成本会增加。

尤其这些界面:

背包
商城
排行榜
技能树
ScrollView 大列表
复杂 HUD

关闭无用的 Raycast Target 可以减少候选数量。

为什么能避免误挡点击

这是比性能更常见的坑。

比如:

一个透明 Image 盖在按钮上
它 Raycast Target 开着

结果你点按钮,点击被透明 Image 挡住了。

表现就是:

按钮看得见
鼠标也点在按钮上
但 Button 没反应

原因可能是上面有个不需要点击的 UI 图挡住了它。

解决:

把那张装饰图或透明图的 Raycast Target 关掉

哪些应该关闭

一般应该关闭:

纯装饰 Image
背景图
边框图
分割线
纯展示 Text
不需要点击的 Icon
红点提示
不可交互的头像框
只是显示的技能图标

比如:

按钮上的文字通常可以关
按钮上的装饰图通常可以关
界面背景通常可以关

哪些不能乱关

这些要保留:

c
Button 用来接收点击的目标 Graphic
Toggle
Slider
Scrollbar
Dropdown
InputField
需要拖拽的 Item
需要点击的背包格子
需要点击的技能按钮

特别注意:

Button 通常依赖它所在物体上的 Image 作为点击区域。

如果你把 Button 背景 Image 的 Raycast Target 关掉,按钮可能就点不到了。

但 Button 子物体里的 Text 可以关。

代码:关闭所有装饰性 Graphic

带注释:

c
using UnityEngine;
using UnityEngine.UI;

public class DisableDecorativeRaycastTarget : MonoBehaviour
{
    public Graphic[] decorativeGraphics;

    private void Awake()
    {
        foreach (Graphic graphic in decorativeGraphics)
        {
            if (graphic == null)
            {
                continue;
            }

            // 这些 Graphic 只是显示用,不需要接收点击
            // 关闭后 GraphicRaycaster 会跳过它们
            graphic.raycastTarget = false;
        }
    }
}

代码:批量关闭某个节点下的非交互 Graphic

这个适合工具化,但要小心不要把 Button 的目标图关掉。

c
using UnityEngine;
using UnityEngine.UI;

public class RaycastTargetCleaner : MonoBehaviour
{
    public Transform root;

    private void Awake()
    {
        DisableUnneededRaycastTargets(root);
    }

    private void DisableUnneededRaycastTargets(Transform targetRoot)
    {
        Graphic[] graphics = targetRoot.GetComponentsInChildren<Graphic>(true);

        foreach (Graphic graphic in graphics)
        {
            // 如果这个 Graphic 所在物体有可交互组件,就保留
            // 比如 Button、Toggle、Slider、InputField
            bool hasSelectable = graphic.GetComponent<Selectable>() != null;

            if (hasSelectable)
            {
                continue;
            }

            // 没有交互组件的 Graphic,多数只是显示用
            // 可以关闭 Raycast Target
            graphic.raycastTarget = false;
        }
    }
}

注意:

这个脚本是运行时示例。
真实项目最好做成 Editor 工具,避免运行时遍历太多 UI。

代码:按钮结构推荐

按钮结构可以这样:

c
Button
- Image:Raycast Target 开,作为点击区域
- Icon:Raycast Target 关
- Text:Raycast Target 关
- RedDot:Raycast Target 关

示例:

c
using UnityEngine;
using UnityEngine.UI;

public class ButtonRaycastSetup : MonoBehaviour
{
    public Image buttonBackground;
    public Image icon;
    public Text label;
    public Image redDot;

    private void Awake()
    {
        // 按钮背景需要接收点击,所以保留 Raycast Target
        buttonBackground.raycastTarget = true;

        // 图标只是显示,不需要挡点击
        icon.raycastTarget = false;

        // 文字只是显示,不需要挡点击
        label.raycastTarget = false;

        // 红点只是提示,不需要挡点击
        redDot.raycastTarget = false;
    }
}

它和 Canvas Rebuild 是一回事吗

不是。

Raycast Target 主要影响:

UI 点击/触摸射线检测
EventSystem 检测成本
是否挡住点击

它不是主要解决:

c
Canvas.BuildBatch
LayoutRebuild
GraphicRebuild

所以不要说:

关闭 Raycast Target 可以解决所有 UGUI 重建卡顿

更准确是:

它能减少 UI 事件射线检测成本,也能避免无用 UI 阻挡点击。

和 CanvasGroup 的关系

CanvasGroup 也能影响点击。

常见属性:

c
interactable
blocksRaycasts
ignoreParentGroups

比如弹窗关闭后,你可以:

c
canvasGroup.blocksRaycasts = false;

意思是:

这个组不再挡住点击

区别:

c
Graphic.raycastTarget:单个 Image/Text 是否参与射线
CanvasGroup.blocksRaycasts:一组 UI 是否整体阻挡射线

两者可以配合用。

常见坑

坑 1:透明 Image 挡住按钮。

解决:

c
关闭透明 Image 的 Raycast Target

坑 2:按钮点不到。

检查:

c
Button 自己有没有可射线命中的 Graphic
Canvas 有没有 GraphicRaycaster
场景有没有 EventSystem
上面有没有别的 Raycast Target 挡住
CanvasGroup.blocksRaycasts 是否为 true

坑 3:把 Button 背景 Image 关了。

结果:

按钮失去点击区域

如果按钮没有别的 Graphic 接收射线,就点不到。

坑 4:ScrollView Item 子节点太多都开着。

结果:

c
拖动列表时 EventSystem 检测很多无用 Graphic

可以关掉 Item 里装饰性 Image/Text 的 Raycast Target,只保留 Item 根部或 Button 区域。

面试高分回答

IMPORTANT

Raycast Target 决定一个 UGUI Graphic 是否参与 EventSystem 的射线检测。点击或触摸时,GraphicRaycaster 会扫描 Canvas 下可检测的 Graphic,过滤并排序后再把结果交给 Button、Toggle 等组件。如果大量装饰 Image、Text、Icon 都开启 Raycast Target,会增加 UI 事件检测成本;更常见的问题是透明图或装饰图挡住下面的按钮,导致按钮点不到。所以项目里我会只保留真正需要点击、拖拽、交互的 Graphic 的 Raycast Target,关闭背景、装饰、纯文本、红点等展示元素。它主要优化事件射线检测和误挡点击,不是直接解决 Canvas Rebuild 的手段。

最短记忆版

c
Raycast Target = 这个 UI 能不能被 UI 射线点到。

需要点击:开。
只是显示:关。

好处:
减少 GraphicRaycaster 检测数量;
避免透明图、装饰图挡住按钮。

注意:
别把 Button 的点击目标关掉。

UI 事件穿透怎么处理?

一句话回答: UI 事件穿透就是“我点的是 UI,但游戏世界或后面的 UI 也响应了”。处理思路是:先判断指针是否在 UI 上,再决定要不要执行世界点击逻辑;弹窗/遮罩要负责拦截点击;无用的装饰 UI 关闭 Raycast Target。

ui-event-penetration-handling

零基础理解

你可以把点击想象成一束射线:

用户点下去以后,Unity 可能会做两件事:

  1. GraphicRaycaster 检测 UI,比如按钮、图片、文本。
  2. 你的代码又手动做了 Physics.Raycast,检测场景里的怪物、地面、物体。

如果你没有先问一句:

我现在是不是点在 UI 上?

那就会出现:

点了背包按钮
结果背包打开了
同时角色还往地面移动了

这就是 UI 事件穿透。

最常见的处理方式:点 UI 时,不执行世界点击逻辑

c
using UnityEngine;
using UnityEngine.EventSystems;

public class PlayerClickController : MonoBehaviour
{
    private Camera mainCamera;

    private void Awake()
    {
        // 缓存主相机,避免每次点击都 Camera.main 查找
        mainCamera = Camera.main;
    }

    private void Update()
    {
        // 鼠标左键按下时,才处理点击
        if (Input.GetMouseButtonDown(0))
        {
            // 如果当前鼠标点在 UI 上,直接返回
            // 这样后面的 Physics.Raycast 就不会执行
            if (EventSystem.current != null &&
                EventSystem.current.IsPointerOverGameObject())
            {
                return;
            }

            // 只有没有点到 UI,才允许点击场景
            Ray ray = mainCamera.ScreenPointToRay(Input.mousePosition);

            if (Physics.Raycast(ray, out RaycastHit hitInfo))
            {
                Debug.Log("点到了场景物体:" + hitInfo.collider.name);
            }
        }
    }
}

移动端要特别注意

移动端有多个手指,不能只用无参数版本。无参数的 IsPointerOverGameObject() 常用于鼠标,移动端应该传入 fingerId

c
using UnityEngine;
using UnityEngine.EventSystems;

public class MobileClickController : MonoBehaviour
{
    private Camera mainCamera;

    private void Awake()
    {
        // 缓存主相机
        mainCamera = Camera.main;
    }

    private void Update()
    {
        // 没有触摸就不处理
        if (Input.touchCount <= 0)
            return;

        Touch touch = Input.GetTouch(0);

        // 只在手指刚按下时处理一次点击
        if (touch.phase != TouchPhase.Began)
            return;

        // 移动端要传 fingerId
        // 否则可能判断不准,导致点 UI 仍然穿透到场景
        if (EventSystem.current != null &&
            EventSystem.current.IsPointerOverGameObject(touch.fingerId))
        {
            return;
        }

        // 没点到 UI,才处理世界射线
        Ray ray = mainCamera.ScreenPointToRay(touch.position);

        if (Physics.Raycast(ray, out RaycastHit hitInfo))
        {
            Debug.Log("触摸到了场景物体:" + hitInfo.collider.name);
        }
    }
}

弹窗后面的按钮还能点到,怎么处理?

这种不是“UI 穿透到 3D 世界”,而是“上层 UI 没有挡住下层 UI”。

比如你打开一个设置面板,结果还能点到背后的背包按钮。通常原因是:

c
弹窗背景没有开启 Raycast Target
或者 CanvasGroup.blocksRaycasts = false

做法是给弹窗加一个全屏遮罩。

c
using UnityEngine;
using UnityEngine.UI;

public class PopupBlocker : MonoBehaviour
{
    [SerializeField] private Image maskImage;
    [SerializeField] private CanvasGroup canvasGroup;

    private void Awake()
    {
        // 遮罩图必须能接收射线
        // 这样点击会被遮罩挡住,不会点到后面的按钮
        maskImage.raycastTarget = true;

        // CanvasGroup 控制整组 UI 是否阻挡射线
        canvasGroup.blocksRaycasts = true;

        // 如果希望弹窗里的按钮可以交互,interactable 要为 true
        canvasGroup.interactable = true;
    }
}

CanvasGroup 这几个属性要分清楚

c
canvasGroup.alpha = 1f;

控制透明度。

c
canvasGroup.interactable = true;

控制这个 UI 组里的按钮、Toggle、Slider 是否能交互。

c
canvasGroup.blocksRaycasts = true;

控制这个 UI 组是否阻挡鼠标/触摸射线。

面试里重点说 blocksRaycasts,它和“事件穿透”关系最大。

Raycast Target 应该怎么开关?

UGUI 里面 ImageTextRawImage 这些都继承自 Graphic,它们有一个属性:

c
raycastTarget

它决定这个 UI 元素要不要参与点击检测。

装饰用的图片、标题文字、背景花纹,一般关掉:

c
using UnityEngine;
using UnityEngine.UI;

public class DisableDecorRaycastTarget : MonoBehaviour
{
    [SerializeField] private Image decorImage;
    [SerializeField] private Text titleText;

    private void Awake()
    {
        // 装饰图片不需要响应点击,关闭 Raycast Target
        decorImage.raycastTarget = false;

        // 标题文字也不需要挡点击,关闭 Raycast Target
        titleText.raycastTarget = false;
    }
}

但是注意:遮罩背景不要关。

c
装饰图:raycastTarget = false
弹窗遮罩:raycastTarget = true
按钮点击区域:raycastTarget = true

很多新手会把所有 Image 的 Raycast Target 都关掉,结果弹窗挡不住后面的按钮,这也是一个常见坑。

如果是新输入系统,也要做 UI 判断

如果你用的是 Unity New Input System,UI 通常走 InputSystemUIInputModule,但思路不变:

世界点击逻辑执行前,先判断当前指针是否在 UI 上。

老输入系统常见写法是:

c
EventSystem.current.IsPointerOverGameObject()

新输入系统项目里,如果你仍然通过鼠标位置做世界射线,也一样要加 UI 拦截判断。

面试高分回答

CAUTION

“UI 事件穿透一般分两种。第一种是点到 UI 的同时,场景里的点击逻辑也执行了,本质是业务代码里手动做了 Physics.Raycast,但没有先判断指针是否在 UI 上。解决方式是在世界点击、选怪、点击地面移动之前,先用 EventSystem.current.IsPointerOverGameObject() 判断,如果点在 UI 上就直接 return;移动端要传 touch.fingerId。第二种是弹窗没有挡住后面的 UI,一般是遮罩 Image.raycastTarget 没开,或者 CanvasGroup.blocksRaycasts 是 false。实际项目里我会把装饰性 UI 的 Raycast Target 关掉来减少检测,但弹窗遮罩、按钮点击区域必须保留。”

最短记忆版

c
点 UI 还点到世界:
IsPointerOverGameObject(),是 UI 就 return

弹窗挡不住后面按钮:
遮罩 Image.raycastTarget = true
CanvasGroup.blocksRaycasts = true

移动端:
IsPointerOverGameObject(touch.fingerId)。

优化:
无用 Image/Text 关闭 Raycast Target。

这题面试里不要只说“加个遮罩”,更好的说法是:UI 穿透的本质是输入分层没处理好,UI 检测和世界射线检测都响应了同一次输入。

UI 管理器怎么设计?

一句话回答: UI 管理器的核心不是“把所有 UI 逻辑都写进一个类”,而是统一管理:打开、关闭、层级、缓存、返回栈、遮罩、生命周期。具体按钮逻辑、数据显示、事件绑定,应该放在每个 UI 自己的脚本里。

ui-manager-design

零基础理解

你可以把 UIManager 想成“前台调度员”。

玩家说:

我要打开背包
我要关闭设置
我要弹出确认框
我要按返回键关闭最上层窗口

UIManager 负责调度:

这个 UI 放哪一层?
有没有已经创建过?
要不要从缓存里拿?
打开时要不要加遮罩?
关闭时要不要从栈里弹出?

但是 UIManager 不应该负责:

背包格子怎么刷新
按钮点了卖出还是使用
红点怎么计算
商城价格怎么显示

这些应该由具体 UI 自己处理。

常见层级设计

一般会把 Canvas 下面分几层:

Background  背景层,比如主界面背景
Normal      普通界面,比如背包、角色、任务
Popup       弹窗层,比如确认框、设置面板
Toast       提示层,比如飘字、加载中、弱提示

层级越靠后,显示越靠上。这样不会出现“提示被背包挡住”“弹窗被主界面盖住”的问题。

基础代码:UIBase

每个界面都继承一个统一基类。

c
using UnityEngine;

public enum UILayer
{
    Background,
    Normal,
    Popup,
    Toast
}

public abstract class UIBase : MonoBehaviour
{
    // 当前 UI 属于哪一层,默认是普通层
    public virtual UILayer Layer => UILayer.Normal;

    // 是否进入返回栈,比如背包、设置可以入栈,Toast 一般不入栈
    public virtual bool PushToStack => true;

    // UI 打开时调用,data 可以传入外部数据
    public virtual void OnOpen(object data)
    {
        gameObject.SetActive(true);
    }

    // UI 关闭时调用,适合解绑事件、停止协程、隐藏界面
    public virtual void OnClose()
    {
        gameObject.SetActive(false);
    }
}

基础代码:UIManager

这个版本用 Resources.Load,方便你面试讲。实际项目里可以替换成 Addressables

c
using System;
using System.Collections.Generic;
using UnityEngine;

public class UIManager : MonoBehaviour
{
    public static UIManager Instance { get; private set; }

    [SerializeField] private Transform backgroundRoot;
    [SerializeField] private Transform normalRoot;
    [SerializeField] private Transform popupRoot;
    [SerializeField] private Transform toastRoot;

    // 缓存已经创建过的 UI,避免重复 Instantiate
    private readonly Dictionary<Type, UIBase> uiCache = new();

    // 返回栈,用来处理 Android 返回键或 ESC
    private readonly Stack<UIBase> uiStack = new();

    private void Awake()
    {
        Instance = this;
    }

    public T Open<T>(object data = null) where T : UIBase
    {
        Type type = typeof(T);

        // 如果缓存里没有,就加载并创建
        if (!uiCache.TryGetValue(type, out UIBase ui))
        {
            // 约定:Prefab 放在 Resources/UI/类名.prefab
            T prefab = Resources.Load<T>("UI/" + type.Name);

            if (prefab == null)
            {
                Debug.LogError("找不到 UI Prefab:" + type.Name);
                return null;
            }

            // 根据 UI 自己声明的 Layer,挂到对应父节点
            Transform root = GetLayerRoot(prefab.Layer);
            ui = Instantiate(prefab, root);

            // 放进缓存,下次打开直接复用
            uiCache[type] = ui;
        }

        // 保证当前打开的 UI 显示在同层最上面
        ui.transform.SetAsLastSibling();

        // 调用界面自己的打开逻辑
        ui.OnOpen(data);

        // 需要返回键关闭的 UI,压入栈
        if (ui.PushToStack)
        {
            uiStack.Push(ui);
        }

        return ui as T;
    }

    public void Close<T>() where T : UIBase
    {
        Type type = typeof(T);

        if (uiCache.TryGetValue(type, out UIBase ui))
        {
            ui.OnClose();
        }
    }

    public void CloseTop()
    {
        // 关闭最上层 UI,常用于返回键
        while (uiStack.Count > 0)
        {
            UIBase top = uiStack.Pop();

            if (top != null && top.gameObject.activeSelf)
            {
                top.OnClose();
                break;
            }
        }
    }

    private Transform GetLayerRoot(UILayer layer)
    {
        return layer switch
        {
            UILayer.Background => backgroundRoot,
            UILayer.Normal => normalRoot,
            UILayer.Popup => popupRoot,
            UILayer.Toast => toastRoot,
            _ => normalRoot
        };
    }
}

具体界面怎么写

比如背包界面:

c
using UnityEngine;
using UnityEngine.UI;

public class BagPanel : UIBase
{
    [SerializeField] private Button closeButton;

    public override UILayer Layer => UILayer.Normal;

    private void Awake()
    {
        // 按钮事件由 BagPanel 自己管理,不塞进 UIManager
        closeButton.onClick.AddListener(OnClickClose);
    }

    public override void OnOpen(object data)
    {
        base.OnOpen(data);

        // 打开背包时刷新数据
        Debug.Log("刷新背包列表");
    }

    public override void OnClose()
    {
        // 关闭时隐藏界面
        base.OnClose();
    }

    private void OnClickClose()
    {
        UIManager.Instance.Close<BagPanel>();
    }
}

打开背包:

c
UIManager.Instance.Open<BagPanel>();

打开确认弹窗:

c
UIManager.Instance.Open<ConfirmPopup>("是否删除这个道具?");

按返回键关闭最上层 UI:

c
private void Update()
{
    if (Input.GetKeyDown(KeyCode.Escape))
    {
        UIManager.Instance.CloseTop();
    }
}

为什么要缓存 UI?

如果每次打开背包都:

c
Instantiate
Destroy
Instantiate
Destroy

会造成:

频繁分配内存
产生 GC
打开界面卡顿
复杂 UI 初始化成本高

所以常见做法是:

c
第一次打开:加载 + 创建
以后打开:从缓存取出来 SetActive(true)
关闭界面:SetActive(false),不一定 Destroy

但是也不是所有 UI 都永久缓存。比如很少用的大型界面,可以关闭时销毁;频繁打开的背包、设置、主界面,更适合缓存。

UIManager 不应该做什么

不要这样设计:

c
public void OnBagButtonClick() { }
public void OnShopBuyButtonClick() { }
public void OnEquipButtonClick() { }
public void RefreshBagItem() { }
public void RefreshShopPrice() { }

这样 UIManager 会变成“上帝类”,后面越来越难维护。

更好的边界是:

UIManager:管 UI 的生死和位置
BagPanel:管背包自己的显示和按钮
ShopPanel:管商城自己的显示和按钮
数据系统:管背包数据、金币数据、商品数据

面试高分回答

可以这样说:

“我会把 UI 管理器设计成一个统一调度层,而不是把所有 UI 业务都塞进去。它主要负责 UI 的打开关闭、层级管理、缓存复用、返回栈、遮罩和生命周期调用。每个界面继承一个 UIBase,实现自己的 OnOpenOnClose、事件绑定和数据刷新。Canvas 下会分 Background、Normal、Popup、Toast 等层级,保证显示顺序稳定。频繁打开的 UI 会缓存,避免反复 Instantiate 和 Destroy;大型项目里加载部分可以从 Resources 替换成 Addressables 异步加载。”

最短记忆版

c
UIManager 管流程,不管细节。

它负责:
打开、关闭、层级、缓存、返回栈、遮罩。

UIBase 负责:
OnOpen、OnClose、刷新数据、解绑事件。

具体界面负责:
自己的按钮、文本、列表、动画、业务表现。

这题你面试时一定要强调一句:好的 UIManager 是协调者,不是所有 UI 代码的大垃圾桶。

文章评价

读完这篇,留下你的看法

暂无审核通过的评价。

登录账号后才能评价。

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