Appearance
UGUI
Canvas 的三种 Render Mode 是什么?
一句话
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
特殊相机层级 UIUI 要放到 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 区别是什么?
一句话
Transform 是普通物体的空间变换组件,主要管:
c
位置 position
旋转 rotation
缩放 scaleRectTransform 是 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 是:我跟父容器的哪里对齐。
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 的区别总结
| 对比 | Anchor | Pivot |
|---|---|---|
| 看谁 | 看父 RectTransform | 看自己 RectTransform |
| 作用 | 决定参考父容器哪里 | 决定自己哪个点作为轴心 |
| 影响 | 分辨率适配、父容器变化 | 旋转、缩放、定位、尺寸变化方向 |
| 常见值 | 左上、中心、右下、四边拉伸 | 中心、左边、右上角 |
| 关键理解 | 跟父亲哪里对齐 | 拿自己哪里去对齐 |
常见坑
坑 1:想固定右上角,但 Anchor 还在中心。
结果:
编辑器里看着在右上角
换分辨率后位置飘了解决:
把 Anchor 设到右上角坑 2:血条缩放方向不对。
结果:
血条从中间缩解决:
Pivot X 设为 0坑 3:拉伸布局还用 sizeDelta 当宽高。
当:
c
anchorMin != anchorMaxUI 是拉伸模式,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 用来让 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 1080 或 1334 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
2436x1125Unity 会按规则缩放。
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 1080Match 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 值不测试。
0、0.5、1 在超宽屏、平板、手机上效果不同,一定要多机型预览。
坑 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 重建就是:UI 发生变化后,Unity 需要重新计算布局、重新生成 UI 顶点、重新构建 Canvas 渲染批次。
它会卡,是因为:
一个小 UI 变化,可能牵连整个 Canvas 里的很多 UI 一起重新计算。零基础理解
你可以把 Canvas 想成一张大海报。
海报上有:
头像
血条
技能按钮
倒计时文字
背包格子
任务文本如果你每帧改一个倒计时文字:
10.0
9.9
9.8
9.7Unity 不能只“改一个字符”那么简单。
它可能要做:
重新计算这个 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 在 ContentSizeFitter 或 LayoutGroup 下面,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 大量 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 个缓冲
创建 8 到 10 个 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 = 2 到 5面试高分回答
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,作用是:把很多小图合成一张大图,减少纹理切换,提高 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 决定这个 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
TextMeshProUGUIInspector 里会有一个勾选:
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。
零基础理解
你可以把点击想象成一束射线:
用户点下去以后,Unity 可能会做两件事:
GraphicRaycaster检测 UI,比如按钮、图片、文本。- 你的代码又手动做了
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 里面 Image、Text、RawImage 这些都继承自 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 自己的脚本里。
零基础理解
你可以把 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,实现自己的 OnOpen、OnClose、事件绑定和数据刷新。Canvas 下会分 Background、Normal、Popup、Toast 等层级,保证显示顺序稳定。频繁打开的 UI 会缓存,避免反复 Instantiate 和 Destroy;大型项目里加载部分可以从 Resources 替换成 Addressables 异步加载。”
最短记忆版
c
UIManager 管流程,不管细节。
它负责:
打开、关闭、层级、缓存、返回栈、遮罩。
UIBase 负责:
OnOpen、OnClose、刷新数据、解绑事件。
具体界面负责:
自己的按钮、文本、列表、动画、业务表现。这题你面试时一定要强调一句:好的 UIManager 是协调者,不是所有 UI 代码的大垃圾桶。