文章目录
  1. 1. 前言
  2. 2. UGUI适配
  3. 3. 多分辨率适配
  4. 4. 异形屏适配
  5. 5. UGUI适配方案
  6. 6. Reference

前言

本章节是深入UGUI多分辨率和异形屏的UI适配解决方案深入实战学习。

用到的东西:

Unity6000.3.19f1,CodeX,VSCode,Copilot,URP

UGUI适配

UGUI的UI适配核心目的是让我们的UI内容在不同的分辨率和特殊屏幕能在合适的位置显示给玩家,不至于出现核心内容看不到或者UI按钮被遮挡等显示问题。

适配结果有几个基础需求要满足:

  1. UI必须充满屏幕,不能有黑边
  2. UI必须在屏幕中,不能超出屏幕
  3. UI在多分辨率下的位置和尺寸保持一致
  4. UI不能变形
  5. 重要信息UI不能出现在特殊位置(比如刘海屏矩形刘海,水滴屏摄像头,挖孔屏特殊挖空等)

多分辨率适配

先不考虑特殊屏幕的前提下,来看看UGUI的多分辨率适配方案是怎样的。

设计分辨率这里我选择1920X1080(16:9),所以我把Canvas Scaler的Reference Resolution设置成1920X1080,Canvas Scales的Screen Match Mode设置成Match Width Or Height,Match设置成0(Width适配)

SetReferenceResolution

此时策划说我们要做一个窗口,包含一张全屏背景图(美术出了一张1920X1080的背景图),左上角显示玩家名字文本,左下,左中,右下,右中,右上和上中分别显示一个操作按钮。

所以我做了一个背景图片设置成固定宽高为1920X1080的Image和根据位置设置指定轴心(Pivot)和锚点(Anchors)去适配目标位置显示,如果设备分辨率就是1920X1080则内容完美显示:

1920X1080ResolutionDisplay

此时如果我将分辨率调整成2520X1080(21:9):

2520X1080ResolutionDisplay

此时设备分辨率宽高比例大于设计分辨率宽高比例,可以看到背景图因为前面的适配方式是**Match Width Or Height(Match值为0适配宽度)**的自适应后背景图变成宽度铺满显示但高度上下被裁剪显示,这样能符合背景图片不拉伸但铺满的需求,只是因为上下被裁剪不少所以设计全屏1920X1080背景图时要注意上下流出一部分可裁剪空间。

如果我将分辨率调整成2304X1440(16:10):

2304X1440ResolutionDisplay1

此时设备分辨率宽高比例小于设计分辨率宽高比例,可以看到背景图因为前面的适配方式是**Match Width Or Height(Match值为0适配宽度)**的自适应后背景图编程宽度铺满但高度显示不全镂空显示,这并不符合我们不希望背景图铺满的需求。

此时为了全屏背景不拉伸能铺满,我们应该采取Match Width Or Height(Match值为1适配高度)

2304X1440ResolutionDisplay2

可以看到修改成Match值为1适配高度后,高度铺满了符合背景铺满不拉伸的需求,只是背景图左右两边被裁剪了一部分,只是因为左右被裁剪不少所以设计全屏1920X1080背景图时要注意左右流出一部分可裁剪空间。

从上面可以看出关于屏幕适配,我们的Canvas Scaler的Match Width Or Height的Match值不是一层不变的。在屏幕宽高分辨率大于设计宽高分辨率时我们要适配宽度(Match值为0)从而实现上下裁剪全屏铺满效果。在屏幕宽高分辨率小于设计宽高分辨率时我们要适配高度(Match值为1)从而实现左右裁剪全屏铺满效果。

上述规则其实等价于将Canvas Scaler的Screen Match Mode设置成Shrink,至于为什么市面上大部分都选择代码动态计算Match Width Or Height的Match值,个人暂时还没想明白,目前我暂时直接使用Shrink的设置方式。

Note:

  1. 2026/09/20截止主流移动端设备分辨率为16:9,极端分辨率分别为21:9和16:10(含手机和平板)

异形屏适配

前面解决的多分辨率背景适配的问题,但后来移动设备除了很多各式各样的屏幕,比如刘海屏矩形刘海,水滴屏摄像头,挖孔屏特殊挖空等。

  • 刘海屏: 在屏幕上方中间位置,有一块矩形刘海

    LiuHaiPingExample

  • 水滴屏:在屏幕上方中间有一个水滴状摄像头

    ShuiDiPingExample

  • 挖孔屏:屏幕上方不规则的挖空,一般是摄像头位置

    WaKongPingExample

可以看出上述形状各异的屏幕,导致特殊区域如果显示UI会被遮挡看不到或无法操作。

Unity Editor提供了快速模拟移动设备显示的工具(Unity Editor->Window->General->Device Simulator),让我们来模拟看看效果:

AppleIphoneXSMaxDeviceSimulator

可以看到在Apple Iphone XS Max设备上模拟最右侧被所谓的刘海屏挡住了部分按钮显示,黄色的区域是我勾上Safe Area会指出来的安全区域

要解决上述问题,这里要引出一个概念安全区域

  • 安全区域:指的是一个可视窗口范围,处于安全区域的内容不受圆角(corners)、齐刘海(sensor housing)、小黑条(Home Indicator)的影响。

下方蓝色区域即为安全区域:

SafeAreaExample

安全区域已经给出了安全显示内容的范围,那么我们处理异形屏的显示方案最简单的就是将窗口内容显示都放到这个安全区域即可。

ScreenSafeAreaService.cs(负责统一处理安全区域的计算处理,挂载到游戏一开始就能启动的GameObejct身上)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
using System;
using UnityEngine;
using Screen = UnityEngine.Device.Screen;

/// <summary>
/// ScreenSafeAreaService.cs
/// 全局安全区状态服务。挂在启动场景的专用根对象上,早于窗口初始化。
/// 仅数据变化时通知窗口;无效的临时屏幕状态不会覆盖最后一次有效状态。
/// </summary>
[DefaultExecutionOrder(-1000)]
[DisallowMultipleComponent]
public sealed class ScreenSafeAreaService : MonoBehaviour
{
/// <summary>
/// 屏幕安全区快照结构体。包含屏幕宽高和安全区矩形信息
/// </summary>
public readonly struct Snapshot
{
/// <summary>
/// 屏幕宽度
/// </summary>
public readonly int ScreenWidth;

/// <summary>
/// 屏幕高度
/// </summary>
public readonly int ScreenHeight;

/// <summary>
/// 安全区矩形
/// </summary>
public readonly Rect SafeArea;

/// <summary>
/// 安全区最小锚点(左下角)
/// </summary>
public Vector2 AnchorMin
{
get
{
return new Vector2(SafeArea.xMin / ScreenWidth, SafeArea.yMin / ScreenHeight);
}
}

/// <summary>
/// 安全区最大锚点(右上角)
/// </summary>
public Vector2 AnchorMax
{
get
{
return new Vector2(SafeArea.xMax / ScreenWidth, SafeArea.yMax / ScreenHeight);
}
}

/// <summary>
/// 构造函数
/// </summary>
/// <param name="screenWidth">屏幕宽度</param>
/// <param name="screenHeight">屏幕高度</param>
/// <param name="safeArea">安全区矩形</param>
public Snapshot(int screenWidth, int screenHeight, Rect safeArea)
{
ScreenWidth = screenWidth;
ScreenHeight = screenHeight;
SafeArea = safeArea;
}
}

/// <summary>
/// 获取全局唯一的屏幕安全区服务实例
/// </summary>
public static ScreenSafeAreaService Instance { get; private set; }

/// <summary>
/// 当屏幕安全区发生变化时触发,传递最新的快照信息
/// </summary>
public event Action<Snapshot> Changed;

/// <summary>
/// 是否已经有有效的屏幕安全区快照
/// </summary>
public bool HasSnapshot
{
get;
private set;
}

/// <summary>
/// 当前的屏幕安全区快照信息
/// </summary>
public Snapshot Current
{
get;
private set;
}

private void Awake()
{
if (Instance != null && Instance != this)
{
// 只移除重复组件,不删除对象上的其他业务组件。
enabled = false;
Destroy(this);
return;
}

Debug.Log($"ScreenSafeAreaService.Instance初始化完成!");
Instance = this;
DontDestroyOnLoad(gameObject);
CheckForChanges();
}

private void Update()
{
CheckForChanges();
}

private void OnDestroy()
{
if (Instance == this)
{
Instance = null;
}

Changed = null;
}

/// <summary>
/// 检查屏幕安全区是否发生变化,如果发生变化则更新快照并触发 Changed 事件
/// </summary>
private void CheckForChanges()
{
int width = Screen.width;
int height = Screen.height;

if (width <= 0 || height <= 0)
{
return;
}

Rect raw = Screen.safeArea;
float xMin = Mathf.Clamp(raw.xMin, 0f, width);
float yMin = Mathf.Clamp(raw.yMin, 0f, height);
float xMax = Mathf.Clamp(raw.xMax, xMin, width);
float yMax = Mathf.Clamp(raw.yMax, yMin, height);

// 取反判断也会拒绝 NaN。
if (!(xMax > xMin) || !(yMax > yMin))
{
return;
}

Rect safeArea = Rect.MinMaxRect(xMin, yMin, xMax, yMax);

if (HasSnapshot &&
Current.ScreenWidth == width &&
Current.ScreenHeight == height &&
Current.SafeArea.Equals(safeArea))
{
return;
}

Current = new Snapshot(width, height, safeArea);
HasSnapshot = true;
Changed?.Invoke(Current);
Debug.Log($"检测到屏幕分辨率变化,最新宽度: {width}, 最新高度: {height}, 安全区: {safeArea}");
}
}

WindowSafeAreaAdapter.cs(负责单个窗口的安全区域RectTransform设置,挂载到每一个需要安全区适配的窗口根节点上)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
using UnityEngine;

/// <summary>
/// WindowSafeAreaAdapter.cs
/// 窗口创建时订阅,销毁时取消;隐藏期间仍接收普通 C# 事件。
/// 仅支持主显示器的根 Overlay Canvas,其直接子节点作为内容根节点。
/// 不绘制黑边、不裁剪内容、不改变相机视口。
/// </summary>
[DisallowMultipleComponent]
[RequireComponent(typeof(Canvas))]
public sealed class WindowSafeAreaAdapter : MonoBehaviour
{
/// <summary>
/// 屏幕安全区服务实例
/// </summary>
private ScreenSafeAreaService service;

/// <summary>
/// 内容RectTransform
/// </summary>
private RectTransform mContentRoot;

private void Awake()
{
mContentRoot = GetComponent<RectTransform>();

service = ScreenSafeAreaService.Instance;
if (service == null)
{
Debug.LogError($"必须先初始化并启用,再创建窗口。", this);
service = null;
return;
}

service.Changed += Apply;

// 只读取服务缓存,不要求服务重新采样。
if (service.HasSnapshot)
{
Apply(service.Current);
}
}

/// <summary>
/// 响应销毁
/// </summary>
private void OnDestroy()
{
if (service != null)
{
service.Changed -= Apply;
}
service = null;
}

/// <summary>
/// 应用屏幕安全区快照
/// </summary>
/// <param name="snapshot">屏幕安全区快照</param>
private void Apply(ScreenSafeAreaService.Snapshot snapshot)
{
if (mContentRoot == null)
{
return;
}

Vector2 min = snapshot.AnchorMin;
Vector2 max = snapshot.AnchorMax;

if (mContentRoot.anchorMin == min &&
mContentRoot.anchorMax == max &&
mContentRoot.offsetMin == Vector2.zero &&
mContentRoot.offsetMax == Vector2.zero)
{
return;
}

mContentRoot.anchorMin = min;
mContentRoot.anchorMax = max;
mContentRoot.offsetMin = Vector2.zero;
mContentRoot.offsetMax = Vector2.zero;
Debug.Log($"应用屏幕安全区快照,AnchorMin: {min}, AnchorMax: {max},OffsetMin: {mContentRoot.offsetMin}, OffsetMax: {mContentRoot.offsetMax}");
}
}

上面放了详细代码,就不详细介绍了,核心就是根据屏幕宽高和安全区域大小计算出窗口应该显示的位置大小从而实现将窗口只显示到安全区域内的显示。

AppleIphoneXSMaxUIAdaptionLog

AppleIphoneXSMaxSimulatorDisplay

AppleIphoneXSMaxSceneDisplay

通过Unity的Device Simulator,我成功在Editor模拟了Apple Iphone XS Max的真机适配效果,可以看到因为我们通过得到的Screen.SafeArea计算出窗口的RectTransform显示范围,从而将所有有Pivot和Anchor适配的内容显示到了安全区域内,而背景图因为没设置适配所以只受Canvas Scaler的Screen Match Mode(Shrink)影响。

Note:

  1. 老版Untiy(Unity 6之前)屏幕宽高分辨率用UnityEngine.Screen.Width和UnityEngine.Screen.Height获取,新版Unity(含Unity 6和之后)屏幕宽高分辨率用UnityEngine.Device.Screen.Width和UnityEngine.Device.Screen.Height获取

UGUI适配方案

通过前面的学习我们得到UGUI适配方案如下:

  1. 将Canvas Scaler的UI Scale Mode设置成Scale With Screen Size,Screen Match Mode设置成Shrink(这个可以换成通过比较设备分辨率比例和设计分辨率比例的大小动态设置Screen Match Mode为Match Width Or Height的Match值来实现相同效果),Reference Resolution设置成参考分辨率,背景图Image不设置适配,直接设置成设计分辨率大小,从而实现背景图自动适配铺满不拉伸的显示效果。
  2. 利用Screen.SafeArea的安全区域信息,动态计算窗口应该显示的范围大小,从而避免UI内容显示到特殊区域影响操作或看不到的问题

Reference

Unity UI适配方案

【游戏交互】手游适配设计

文章目录
  1. 1. 前言
  2. 2. UGUI适配
  3. 3. 多分辨率适配
  4. 4. 异形屏适配
  5. 5. UGUI适配方案
  6. 6. Reference