UI 适配框架从 0 实现
这份文档不是项目源码说明书,而是一份“把适配方式变成自己经验”的实现手册。目标是:看完之后,你知道为什么要这样拆模块,也知道如果自己从 0 写一套 Unity UI 适配框架,应该先写什么、后写什么、每个类负责什么、Prefab 怎么接、最后怎么检查。
如果只记一句话:
UI 适配不是把整个界面等比缩放,而是先把内容分类,再给每类内容制定不同的变化规则。
背景可以铺满和裁切,主体要稳定,按钮要避开刘海和底条,列表要能滚动,弹窗要保持形状,世界浮层要防止跑出屏幕。
1. 先建立自己的适配观念
1.1 适配到底在解决什么
同一套 UI 要跑在这些屏幕上:
- 普通手机:接近设计稿比例。
- 超长手机:高度更多,底部可能有 Home Indicator。
- 刘海屏:顶部或底部有不可交互区域。
- 平板:屏幕更宽,主体如果拉满会很散。
- PC / 模拟器 / 折叠屏:宽高比例变化更大。
如果只做一个简单缩放,会出现这些问题:
- 按钮被刘海、圆角、底部条挡住。
- 宽屏上 UI 被横向拉得很散。
- 背景露黑边或露底。
- 弹窗在平板上显得太小。
- 列表在短屏显示不下。
- 世界浮层跟随目标后跑出屏幕。
所以适配系统要解决的不是一个问题,而是一组问题。
1.2 我会把 UI 内容分成 6 类
做适配前,不要先想代码。先看界面,把内容分成下面几类。
| 背景图、氛围图、底纹 | 露底、黑边、不满屏 | 铺满真实屏幕,必要时裁切或延展 |
| 主体内容 | 宽屏被拉散、信息距离过远 | 限制最大宽度,居中显示 |
| 按钮、页签、输入框 | 被刘海、底条、圆角挡住 | 贴安全区,不贴真实屏幕边缘 |
| 列表、滚动区 | 短屏显示不下,长屏空得难看 | 固定头尾,中间区域自适应,内容滚动 |
| 弹窗主体 | 宽屏太小,窄屏贴边,内部被拉伸 | 只缩放主体容器,不手动拉伸每个子节点 |
| 世界浮层、气泡 | 跟随目标后跑出屏幕 | 先算目标位置,再钳制回安全区域 |
这张表就是最重要的经验。只要分类对了,后面的实现就很自然。
1.3 一套好用的适配框架要有 4 层
从 0 写框架时,我建议拆成 4 层:
这样拆的好处是:
- 业务 UI 不用到处读 Screen.width。
- 所有界面共用同一套安全区和主内容区。
- 全屏和弹窗逻辑不会互相污染。
- 局部特殊情况有组件处理,不会写一堆散落的业务代码。
2. 从 0 设计框架目录
可以先按下面目录建文件:
UIAdapt/
├── Core/
│ ├── UIAdaptConfig.cs
│ ├── AdaptMargin.cs
│ ├── UIAdaptSnapshot.cs
│ ├── UIAdapt.cs
│ └── IUIAdaptElement.cs
├── Platform/
│ ├── IUIAdaptPlatform.cs
│ ├── DefaultUIAdaptPlatform.cs
│ └── UnitySafeAreaPlatform.cs
├── Runtime/
│ ├── UIAdaptRuntime.cs
│ └── UIAdaptEventBridge.cs
├── Components/
│ ├── RootCanvasAdapter.cs
│ ├── FullScreenWindowAdapter.cs
│ ├── PopupWindowAdapter.cs
│ ├── SafeEdgeAnchor.cs
│ ├── EdgeExtendAdapter.cs
│ └── RectTransformPresetAdapter.cs
└── Utility/
├── UIAdaptRectUtility.cs
└── UIOverlayClampUtility.cs
最小可用版本不用一次写完。推荐顺序:
先把全屏、弹窗、贴边按钮跑通,再补世界浮层和编辑器预览。
3. 第一步:确定设计基准
3.1 为什么必须先定设计分辨率
设计分辨率是整套 UI 的坐标基准。没有这个基准,Prefab 大小、弹窗尺寸、字体大小、背景尺寸都会变成各做各的。
本项目使用的是:
public static int DESIGN_WIDTH = 1080;
public static int DESIGN_HEIGHT = 2339;
从 0 做框架,可以写成配置类:
using UnityEngine;
public static class UIAdaptConfig
{
public static readonly Vector2 DesignResolution = new Vector2(1080f, 2339f);
public const float DesignAspect = 1080f / 2339f;
// 弹窗在宽屏上的最大安全放大比例。9:17.5 约等于 0.514。
public const float PopupSafeAspect = 9f / 17.5f;
// 主内容区最大比例。9:12 = 0.75,平板/PC 两侧留给背景。
public const float MainBodyAspect = 9f / 12f;
// 常见短屏比例,9:16 约等于 0.5625。
public const float ShortAspect = 9f / 16f;
}
3.2 怎么理解这些比例
设计比例:
1080 / 2339 = 0.462
屏幕越宽,这个值越大。
举例:
| 1080×2339 | 0.462 | 设计基准 |
| 1080×2520 | 0.428 | 更长的手机 |
| 1753×2339 | 0.749 | 平板附近 |
| 3366×2339 | 1.439 | 超宽屏 / PC |
经验:
- 比设计稿更窄或更长:优先保证宽度,垂直方向多出来。
- 比设计稿更宽:优先保证高度,横向多出来。
- 横向多出来的空间不要全部给主体,主体太宽会难看。
4. 第二步:定义安全区边距
安全区本质上就是四个边距。
using System;
[Serializable]
public class AdaptMargin
{
public float top;
public float bottom;
public float left;
public float right;
public AdaptMargin()
{
}
public AdaptMargin(float top, float bottom, float left, float right)
{
this.top = top;
this.bottom = bottom;
this.left = left;
this.right = right;
}
public AdaptMargin Clone()
{
return new AdaptMargin(top, bottom, left, right);
}
public bool Approximately(AdaptMargin other)
{
if (other == null)
{
return false;
}
return UnityEngine.Mathf.Approximately(top, other.top)
&& UnityEngine.Mathf.Approximately(bottom, other.bottom)
&& UnityEngine.Mathf.Approximately(left, other.left)
&& UnityEngine.Mathf.Approximately(right, other.right);
}
}
4.1 AdaptMargin 和 SafeArea 的关系
Unity 的 Screen.safeArea 是一个矩形:
safeArea.xMin
safeArea.yMin
safeArea.xMax
safeArea.yMax
而 UI 适配更常用的是四边边距:
left = safeArea.xMin
bottom = safeArea.yMin
right = screenWidth – safeArea.xMax
top = screenHeight – safeArea.yMax
转成边距后,后面做局部适配会更直接。
4.2 AdaptMargin 和 RealAdaptMargin 的区别
如果要做得更细,可以分两种边距:
- AdaptMargin:给普通 UI 用,保护按钮和主体内容。
- RealAdaptMargin:给背景延展用,更贴近真实设备边缘。
为什么要分?
普通按钮应该避开危险区域,但背景图可以延展进去。比如底部 Home Indicator 那块,按钮不能放过去,底部背景却应该铺过去,否则会露出空白。
最小版本可以先只做一个 AdaptMargin。后面发现背景延展不够,再加 RealAdaptMargin。
5. 第三步:定义适配快照
适配系统每次刷新,都应该产出一份“当前屏幕状态”。这份状态就是 Snapshot。
using UnityEngine;
public struct UIAdaptSnapshot
{
public int Width;
public int Height;
public float Aspect;
public Rect ScreenRect;
public Rect SafeRect;
public Rect MainBodyRect;
public AdaptMargin AdaptMargin;
public AdaptMargin RealAdaptMargin;
public bool IsLandscape;
public bool IsPortrait;
}
经验:
- 不要让业务到处重新算屏幕信息。
- 每次屏幕变化,统一生成一份快照。
- 所有组件都拿同一份快照做布局。
6. 第四步:写核心 UIAdapt 类
这个类相当于项目里的 ScreenAdapt。它要做三件事:
6.1 基础结构
using System;
using UnityEngine;
using UnityEngine.UI;
public static class UIAdapt
{
public static int Width
{
get { return Screen.width; }
}
public static int Height
{
get { return Screen.height; }
}
public static float Aspect
{
get { return CalcAspect(Width, Height); }
}
public static UIAdaptSnapshot Snapshot { get; private set; }
public static event Action<UIAdaptSnapshot> OnAdaptChanged;
public static event Action<UIAdaptSnapshot> OnScreenSizeChanged;
public static event Action<UIAdaptSnapshot> OnSafeAreaChanged;
private static IUIAdaptPlatform s_Platform;
private static int s_LastWidth;
private static int s_LastHeight;
private static AdaptMargin s_LastAdaptMargin;
public static void Init(IUIAdaptPlatform platform)
{
s_Platform = platform ?? new DefaultUIAdaptPlatform();
Refresh(true);
}
public static void Update()
{
if (s_Platform != null && s_Platform.NeedRefresh())
{
Refresh(false);
}
}
}
6.2 刷新逻辑
public static void Refresh(bool force)
{
int width = Mathf.Max(1, Width);
int height = Mathf.Max(1, Height);
AdaptMargin margin = s_Platform != null
? s_Platform.GetAdaptMargin()
: new AdaptMargin();
bool sizeChanged = force || width != s_LastWidth || height != s_LastHeight;
bool safeChanged = force || s_LastAdaptMargin == null || !s_LastAdaptMargin.Approximately(margin);
if (!sizeChanged && !safeChanged)
{
return;
}
Snapshot = BuildSnapshot(width, height, margin);
s_LastWidth = width;
s_LastHeight = height;
s_LastAdaptMargin = margin.Clone();
OnAdaptChanged?.Invoke(Snapshot);
if (sizeChanged)
{
OnScreenSizeChanged?.Invoke(Snapshot);
}
if (safeChanged)
{
OnSafeAreaChanged?.Invoke(Snapshot);
}
}
6.3 生成快照
private static UIAdaptSnapshot BuildSnapshot(int width, int height, AdaptMargin margin)
{
Rect screenRect = new Rect(0f, 0f, width, height);
Rect safeRect = ApplyMargin(screenRect, margin);
Rect mainBodyRect = ClampRectWidthToAspect(safeRect, UIAdaptConfig.MainBodyAspect);
return new UIAdaptSnapshot
{
Width = width,
Height = height,
Aspect = CalcAspect(width, height),
ScreenRect = screenRect,
SafeRect = safeRect,
MainBodyRect = mainBodyRect,
AdaptMargin = margin,
RealAdaptMargin = margin,
IsLandscape = width >= height,
IsPortrait = width < height,
};
}
6.4 核心算法
public static float CalcAspect(int width, int height)
{
return height <= 0 ? 0f : width / (float)height;
}
public static float CalcMatchWidthOrHeight(int width, int height)
{
float aspect = CalcAspect(width, height);
return aspect <= UIAdaptConfig.DesignAspect ? 0f : 1f;
}
public static Rect ApplyMargin(Rect rect, AdaptMargin margin)
{
if (margin == null)
{
return rect;
}
return Rect.MinMaxRect(
rect.xMin + margin.left,
rect.yMin + margin.bottom,
rect.xMax – margin.right,
rect.yMax – margin.top);
}
public static Rect ClampRectWidthToAspect(Rect rect, float maxAspect)
{
float maxWidth = rect.height * maxAspect;
if (rect.width <= maxWidth)
{
return rect;
}
float centerX = rect.center.x;
rect.xMin = centerX – maxWidth * 0.5f;
rect.xMax = centerX + maxWidth * 0.5f;
return rect;
}
6.5 这一步的经验
UIAdapt 不应该关心某个具体按钮怎么摆。它只负责回答这些问题:
- 当前屏幕多大?
- 当前安全区是多少?
- 主体内容应该放在哪个区域?
- 屏幕变化时谁需要重新适配?
越底层的类,职责越要稳定。不要把具体业务 UI 写进 UIAdapt。
7. 第五步:平台安全区提供者
平台层负责拿设备信息。最小版本可以先用 Unity 自带的 Screen.safeArea。
7.1 接口
public interface IUIAdaptPlatform
{
AdaptMargin GetAdaptMargin();
bool NeedRefresh();
}
7.2 默认平台
public class DefaultUIAdaptPlatform : IUIAdaptPlatform
{
public AdaptMargin GetAdaptMargin()
{
return new AdaptMargin();
}
public bool NeedRefresh()
{
return false;
}
}
7.3 Unity SafeArea 平台
using UnityEngine;
public class UnitySafeAreaPlatform : IUIAdaptPlatform
{
private int _lastWidth;
private int _lastHeight;
private Rect _lastSafeArea;
public AdaptMargin GetAdaptMargin()
{
int width = Mathf.Max(1, Screen.width);
int height = Mathf.Max(1, Screen.height);
Rect safeArea = Screen.safeArea;
return new AdaptMargin
{
left = safeArea.xMin,
bottom = safeArea.yMin,
right = width – safeArea.xMax,
top = height – safeArea.yMax,
};
}
public bool NeedRefresh()
{
bool changed = _lastWidth != Screen.width
|| _lastHeight != Screen.height
|| _lastSafeArea != Screen.safeArea;
_lastWidth = Screen.width;
_lastHeight = Screen.height;
_lastSafeArea = Screen.safeArea;
return changed;
}
}
真实项目里,微信小游戏、Android、iOS 可能会有平台 API,甚至需要修正 Unity 返回的安全区。这个时候不要改 UI 组件,只改平台层。
经验:
平台差异应该收敛在 Platform Provider,不能散落到每个 UI 脚本。
8. 第六步:根 Canvas 适配
Unity 的 CanvasScaler 是第一层适配。
8.1 推荐设置
根 Canvas:
- UI Scale Mode = Scale With Screen Size
- Reference Resolution = 1080 x 2339
- Screen Match Mode = Match Width Or Height
- Match 运行时动态设置
8.2 为什么要动态设置 Match
如果当前屏幕比设计稿更长:
currentAspect <= designAspect
使用 matchWidthOrHeight = 0,也就是按宽度适配。这样宽度不会丢,垂直方向多出来。
如果当前屏幕比设计稿更宽:
currentAspect > designAspect
使用 matchWidthOrHeight = 1,也就是按高度适配。这样高度稳定,横向多出来。
8.3 RootCanvasAdapter
using UnityEngine;
using UnityEngine.UI;
[RequireComponent(typeof(CanvasScaler))]
public class RootCanvasAdapter : MonoBehaviour
{
private CanvasScaler _scaler;
private void Awake()
{
_scaler = GetComponent<CanvasScaler>();
}
private void OnEnable()
{
UIAdapt.OnAdaptChanged += OnAdaptChanged;
Apply();
}
private void OnDisable()
{
UIAdapt.OnAdaptChanged -= OnAdaptChanged;
}
private void OnAdaptChanged(UIAdaptSnapshot snapshot)
{
Apply();
}
private void Apply()
{
if (_scaler == null)
{
return;
}
_scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
_scaler.referenceResolution = UIAdaptConfig.DesignResolution;
_scaler.screenMatchMode = CanvasScaler.ScreenMatchMode.MatchWidthOrHeight;
_scaler.matchWidthOrHeight = UIAdapt.CalcMatchWidthOrHeight(UIAdapt.Width, UIAdapt.Height);
}
}
经验:
根 Canvas 只解决“全局坐标缩放”问题,不解决每个界面内部怎么摆。
9. 第七步:统一窗口适配时机
不要让每个适配组件在 Start()、OnEnable()、Update() 里抢着改布局。更稳的做法是:
这样可以避免:
- 子节点还没创建就开始适配。
- LayoutGroup 还没刷新就开始读尺寸。
- 同一帧多个组件互相覆盖。
9.1 适配组件接口
public interface IUIAdaptElement
{
int AdaptOrder { get; }
void ApplyCurrentAdapt();
}
9.2 执行顺序建议
| 全屏窗口 / 弹窗根适配 | 0 | 先确定大区域 |
| 断点布局 | 100 | 再切换不同布局 |
| 安全区锚点 | 200 | 再处理局部按钮 |
| 边缘延展 | 300 | 最后补背景边缘 |
9.3 UIAdaptRuntime
using System.Collections.Generic;
using UnityEngine;
public static class UIAdaptRuntime
{
private static readonly List<IUIAdaptElement> s_Elements = new List<IUIAdaptElement>();
private static readonly List<GameObject> s_DirtyRoots = new List<GameObject>();
private static readonly List<IUIAdaptElement> s_TempElements = new List<IUIAdaptElement>();
private static bool s_Registered;
public static void Register(IUIAdaptElement element)
{
if (element == null || s_Elements.Contains(element))
{
return;
}
s_Elements.Add(element);
}
public static void Unregister(IUIAdaptElement element)
{
if (element == null)
{
return;
}
s_Elements.Remove(element);
}
public static void MarkDirty(GameObject windowRoot)
{
if (windowRoot == null)
{
return;
}
if (!s_DirtyRoots.Contains(windowRoot))
{
s_DirtyRoots.Add(windowRoot);
}
RegisterFlush();
}
private static void RegisterFlush()
{
if (s_Registered)
{
return;
}
Canvas.preWillRenderCanvases += Flush;
s_Registered = true;
}
private static void UnregisterFlush()
{
if (!s_Registered)
{
return;
}
Canvas.preWillRenderCanvases -= Flush;
s_Registered = false;
}
private static void Flush()
{
if (s_DirtyRoots.Count == 0)
{
UnregisterFlush();
return;
}
Canvas.ForceUpdateCanvases();
for (int i = 0; i < s_DirtyRoots.Count; i++)
{
RefreshWindow(s_DirtyRoots[i]);
}
s_DirtyRoots.Clear();
UnregisterFlush();
}
private static void RefreshWindow(GameObject root)
{
if (root == null)
{
return;
}
Transform rootTransform = root.transform;
s_TempElements.Clear();
for (int i = s_Elements.Count – 1; i >= 0; i—)
{
IUIAdaptElement element = s_Elements[i];
Behaviour behaviour = element as Behaviour;
if (behaviour == null || !behaviour.isActiveAndEnabled)
{
s_Elements.RemoveAt(i);
continue;
}
Transform elementTransform = behaviour.transform;
if (elementTransform == rootTransform || elementTransform.IsChildOf(rootTransform))
{
s_TempElements.Add(element);
}
}
s_TempElements.Sort(CompareElement);
for (int i = 0; i < s_TempElements.Count; i++)
{
s_TempElements[i].ApplyCurrentAdapt();
}
s_TempElements.Clear();
}
private static int CompareElement(IUIAdaptElement left, IUIAdaptElement right)
{
if (left == right)
{
return 0;
}
if (left == null)
{
return 1;
}
if (right == null)
{
return –1;
}
return left.AdaptOrder.CompareTo(right.AdaptOrder);
}
}
9.4 窗口打开时怎么接
你的 UI 框架里,窗口创建并刷新之后调用:
window.InternalCreate();
window.InternalRefresh();
UIAdaptRuntime.MarkDirty(window.gameObject);
经验:
适配应该发生在窗口内容刷新之后,渲染之前。
10. 第八步:全屏窗口适配器
全屏窗口适配器负责:
- 根节点落在安全区或主内容区。
- 背景可以补回真实屏幕。
- 主体内容不在超宽屏上无限拉宽。
10.1 推荐 Prefab 层级
FullScreenWindow
├── DFBackground
├── DFBackgroundCenter
├── vlay_content
└── BottomBar
通用命名可以换,但思想不变:
- Background:铺满真实屏幕。
- BackgroundCenter:居中的装饰背景。
- Content:主体内容。
- BottomBar:底部栏。
10.2 FullScreenWindowAdapter
using UnityEngine;
[ExecuteAlways]
[RequireComponent(typeof(RectTransform))]
public class FullScreenWindowAdapter : MonoBehaviour, IUIAdaptElement
{
[SerializeField] private bool useSafeArea = true;
[SerializeField] private bool useMainBodyArea = true;
public int AdaptOrder
{
get { return 0; }
}
private RectTransform _rectTransform;
private RectTransform RectTransform
{
get
{
if (_rectTransform == null)
{
_rectTransform = transform as RectTransform;
}
return _rectTransform;
}
}
private void OnEnable()
{
UIAdaptRuntime.Register(this);
UIAdapt.OnAdaptChanged += OnAdaptChanged;
}
private void OnDisable()
{
UIAdaptRuntime.Unregister(this);
UIAdapt.OnAdaptChanged -= OnAdaptChanged;
}
private void OnAdaptChanged(UIAdaptSnapshot snapshot)
{
ApplyCurrentAdapt();
}
public void ApplyCurrentAdapt()
{
RectTransform rect = RectTransform;
if (rect == null)
{
return;
}
RectTransform parent = rect.parent as RectTransform;
if (parent == null)
{
return;
}
ResetStretch(rect);
Rect parentRect = parent.rect;
Rect targetRect = parentRect;
if (useSafeArea)
{
targetRect = ConvertScreenRectToLocalRect(parent, UIAdapt.Snapshot.SafeRect);
}
if (useMainBodyArea)
{
targetRect = UIAdapt.ClampRectWidthToAspect(targetRect, UIAdaptConfig.MainBodyAspect);
}
ApplyRectAsInsets(rect, parentRect, targetRect);
ApplyBackgroundCompensation();
}
private static void ResetStretch(RectTransform rect)
{
rect.anchorMin = Vector2.zero;
rect.anchorMax = Vector2.one;
rect.pivot = new Vector2(0.5f, 0.5f);
rect.localScale = Vector3.one;
rect.localPosition = Vector3.zero;
rect.offsetMin = Vector2.zero;
rect.offsetMax = Vector2.zero;
}
private static void ApplyRectAsInsets(RectTransform rect, Rect parentRect, Rect targetRect)
{
float left = targetRect.xMin – parentRect.xMin;
float right = parentRect.xMax – targetRect.xMax;
float bottom = targetRect.yMin – parentRect.yMin;
float top = parentRect.yMax – targetRect.yMax;
rect.offsetMin = new Vector2(left, bottom);
rect.offsetMax = new Vector2(–right, –top);
}
private static Rect ConvertScreenRectToLocalRect(RectTransform parent, Rect screenRect)
{
Vector2 leftBottom;
Vector2 rightTop;
RectTransformUtility.ScreenPointToLocalPointInRectangle(
parent,
new Vector2(screenRect.xMin, screenRect.yMin),
null,
out leftBottom);
RectTransformUtility.ScreenPointToLocalPointInRectangle(
parent,
new Vector2(screenRect.xMax, screenRect.yMax),
null,
out rightTop);
return Rect.MinMaxRect(leftBottom.x, leftBottom.y, rightTop.x, rightTop.y);
}
private void ApplyBackgroundCompensation()
{
// 最小版本可以先不写。
// 后面需要背景补回真实屏幕时,再让 Background 节点反向抵消根节点 inset。
}
}
10.3 关键经验
全屏窗口不要把所有东西都塞在根节点拉伸里。要分成:
- 背景层:可以铺满真实屏幕。
- 内容层:放在安全区和主内容区。
- 边缘层:顶部/底部可能需要特殊补偿。
如果背景和内容不分层,适配就会很难做。
11. 第九步:弹窗适配器
弹窗适配和全屏适配不一样。
全屏窗口是“重新定义窗口可用区域”。
弹窗是“保持弹窗主体形状,然后按安全比例缩放”。
11.1 推荐 Prefab 层级
PopupWindow
├── m_btnBgClose
├── vlay_content
│ ├── title
│ ├── content
│ └── buttons
└── closeTips
要求:
- vlay_content 必须存在。
- 弹窗主体内容都放在 vlay_content 下。
- 根节点不手动拉伸每个子节点。
11.2 PopupWindowAdapter
using UnityEngine;
[ExecuteAlways]
public class PopupWindowAdapter : MonoBehaviour, IUIAdaptElement
{
[SerializeField] private string contentName = "vlay_content";
public int AdaptOrder
{
get { return 0; }
}
private void OnEnable()
{
UIAdaptRuntime.Register(this);
UIAdapt.OnAdaptChanged += OnAdaptChanged;
}
private void OnDisable()
{
UIAdaptRuntime.Unregister(this);
UIAdapt.OnAdaptChanged -= OnAdaptChanged;
}
private void OnAdaptChanged(UIAdaptSnapshot snapshot)
{
ApplyCurrentAdapt();
}
public void ApplyCurrentAdapt()
{
Transform content = transform.Find(contentName);
if (content == null)
{
Debug.LogError("[PopupWindowAdapter] Missing content: " + contentName + " in " + name);
return;
}
float scale = CalculateScale(UIAdapt.Width, UIAdapt.Height);
Vector3 localScale = content.localScale;
content.localScale = new Vector3(scale, scale, localScale.z);
}
private static float CalculateScale(int width, int height)
{
float match = UIAdapt.CalcMatchWidthOrHeight(width, height);
if (!Mathf.Approximately(match, 1f))
{
return 1f;
}
float currentAspect = UIAdapt.CalcAspect(width, height);
if (currentAspect <= UIAdaptConfig.DesignAspect)
{
return 1f;
}
float safeAspect = Mathf.Max(UIAdaptConfig.DesignAspect, UIAdaptConfig.PopupSafeAspect);
float targetAspect = Mathf.Min(currentAspect, safeAspect);
return Mathf.Max(1f, targetAspect / UIAdaptConfig.DesignAspect);
}
}
11.3 为什么弹窗只缩放主体
因为弹窗内部通常是一个完整设计:
- 标题和背景框要保持比例。
- 按钮和文案相对位置要稳定。
- 列表区域可能有固定尺寸。
如果每个节点都单独适配,很容易变形。弹窗更适合整体缩放。
12. 第十步:局部安全区锚点
有些控件不是整个窗口,而是窗口里的一个局部节点,比如:
- 顶部返回按钮。
- 底部操作按钮。
- 侧边工具栏。
- 靠右的悬浮入口。
这类控件应该有一个局部组件来处理。
12.1 SafeEdgeAnchor 的设计
它应该支持:
- 是否考虑安全区。
- 是否考虑主内容区。
- 应用左边、右边、顶部、底部。
- 保存一份设计稿里的基准布局。
12.2 简化版 SafeEdgeAnchor
using UnityEngine;
[ExecuteAlways]
[RequireComponent(typeof(RectTransform))]
public class SafeEdgeAnchor : MonoBehaviour, IUIAdaptElement
{
[SerializeField] private bool considerSafeArea = true;
[SerializeField] private bool considerMainBody = false;
[SerializeField] private bool applyLeft;
[SerializeField] private bool applyRight;
[SerializeField] private bool applyTop;
[SerializeField] private bool applyBottom;
[SerializeField] private Vector2 baseOffsetMin;
[SerializeField] private Vector2 baseOffsetMax;
[SerializeField] private bool hasBaseOffset;
public int AdaptOrder
{
get { return 200; }
}
private RectTransform _rectTransform;
private RectTransform RectTransform
{
get
{
if (_rectTransform == null)
{
_rectTransform = transform as RectTransform;
}
return _rectTransform;
}
}
private void OnEnable()
{
UIAdaptRuntime.Register(this);
UIAdapt.OnAdaptChanged += OnAdaptChanged;
EnsureBaseOffset();
ApplyCurrentAdapt();
}
private void OnDisable()
{
UIAdaptRuntime.Unregister(this);
UIAdapt.OnAdaptChanged -= OnAdaptChanged;
}
private void OnAdaptChanged(UIAdaptSnapshot snapshot)
{
ApplyCurrentAdapt();
}
public void CaptureBase()
{
RectTransform rect = RectTransform;
if (rect == null)
{
return;
}
baseOffsetMin = rect.offsetMin;
baseOffsetMax = rect.offsetMax;
hasBaseOffset = true;
}
public void ApplyCurrentAdapt()
{
EnsureBaseOffset();
RectTransform rect = RectTransform;
RectTransform parent = rect != null ? rect.parent as RectTransform : null;
if (rect == null || parent == null)
{
return;
}
Rect targetScreenRect = UIAdapt.Snapshot.ScreenRect;
if (considerSafeArea)
{
targetScreenRect = UIAdapt.Snapshot.SafeRect;
}
if (considerMainBody)
{
targetScreenRect = UIAdapt.Snapshot.MainBodyRect;
}
Rect localTarget = ScreenRectToLocalRect(parent, targetScreenRect);
Rect parentRect = parent.rect;
Vector2 nextMin = baseOffsetMin;
Vector2 nextMax = baseOffsetMax;
if (applyLeft)
{
nextMin.x += localTarget.xMin – parentRect.xMin;
}
if (applyRight)
{
nextMax.x -= parentRect.xMax – localTarget.xMax;
}
if (applyBottom)
{
nextMin.y += localTarget.yMin – parentRect.yMin;
}
if (applyTop)
{
nextMax.y -= parentRect.yMax – localTarget.yMax;
}
rect.offsetMin = nextMin;
rect.offsetMax = nextMax;
}
private void EnsureBaseOffset()
{
if (hasBaseOffset)
{
return;
}
CaptureBase();
}
private static Rect ScreenRectToLocalRect(RectTransform parent, Rect screenRect)
{
Vector2 min;
Vector2 max;
RectTransformUtility.ScreenPointToLocalPointInRectangle(
parent,
new Vector2(screenRect.xMin, screenRect.yMin),
null,
out min);
RectTransformUtility.ScreenPointToLocalPointInRectangle(
parent,
new Vector2(screenRect.xMax, screenRect.yMax),
null,
out max);
return Rect.MinMaxRect(min.x, min.y, max.x, max.y);
}
}
12.3 使用经验
顶部按钮:
considerSafeArea = true
applyTop = true
底部按钮:
considerSafeArea = true
applyBottom = true
贴主内容区右侧:
considerMainBody = true
applyRight = true
注意:
- 可点击控件通常贴安全区。
- 背景装饰通常不贴安全区。
- 不要同时让多个适配组件抢同一个 RectTransform 的同一条边。
13. 第十一步:边缘背景延展
有些节点不是要避开安全区,而是要覆盖安全区。比如:
- 底部导航背景。
- 顶部装饰背景。
- 全屏背景图边缘。
这时需要反向延展。
13.1 EdgeExtendAdapter
using UnityEngine;
[ExecuteAlways]
[RequireComponent(typeof(RectTransform))]
public class EdgeExtendAdapter : MonoBehaviour, IUIAdaptElement
{
public enum Direction
{
Down,
Up
}
[SerializeField] private Direction direction = Direction.Down;
[SerializeField] private Vector2 baseOffsetMin;
[SerializeField] private Vector2 baseOffsetMax;
[SerializeField] private bool hasBaseOffset;
public int AdaptOrder
{
get { return 300; }
}
private RectTransform _rectTransform;
private RectTransform RectTransform
{
get
{
if (_rectTransform == null)
{
_rectTransform = transform as RectTransform;
}
return _rectTransform;
}
}
private void OnEnable()
{
UIAdaptRuntime.Register(this);
UIAdapt.OnAdaptChanged += OnAdaptChanged;
EnsureBaseOffset();
ApplyCurrentAdapt();
}
private void OnDisable()
{
UIAdaptRuntime.Unregister(this);
UIAdapt.OnAdaptChanged -= OnAdaptChanged;
}
private void OnAdaptChanged(UIAdaptSnapshot snapshot)
{
ApplyCurrentAdapt();
}
public void ApplyCurrentAdapt()
{
EnsureBaseOffset();
RectTransform rect = RectTransform;
if (rect == null)
{
return;
}
AdaptMargin margin = UIAdapt.Snapshot.RealAdaptMargin;
Vector2 nextMin = baseOffsetMin;
Vector2 nextMax = baseOffsetMax;
if (direction == Direction.Down)
{
nextMin.y -= margin.bottom;
}
else
{
nextMax.y += margin.top;
}
rect.offsetMin = nextMin;
rect.offsetMax = nextMax;
}
public void CaptureBase()
{
RectTransform rect = RectTransform;
if (rect == null)
{
return;
}
baseOffsetMin = rect.offsetMin;
baseOffsetMax = rect.offsetMax;
hasBaseOffset = true;
}
private void EnsureBaseOffset()
{
if (!hasBaseOffset)
{
CaptureBase();
}
}
}
13.2 什么时候用延展,什么时候用避让
| 底部按钮 | 避让 |
| 底部按钮下面的背景条 | 延展 |
| 顶部返回按钮 | 避让 |
| 顶部背景装饰 | 延展 |
| 全屏背景图 | 延展 |
| 弹窗主体 | 不延展,整体缩放 |
14. 第十二步:断点布局
有些界面不是简单缩放能解决的。比如平板上左右结构更合适,手机上上下结构更合适。
这时要做断点布局。
14.1 定义屏幕类型
public enum UIAspectType
{
Normal,
Short,
MainBody,
Wide
}
public static UIAspectType GetAspectType(float aspect)
{
if (aspect <= UIAdaptConfig.DesignAspect)
{
return UIAspectType.Normal;
}
if (aspect <= UIAdaptConfig.ShortAspect)
{
return UIAspectType.Short;
}
if (aspect <= UIAdaptConfig.MainBodyAspect)
{
return UIAspectType.MainBody;
}
return UIAspectType.Wide;
}
14.2 断点布局的经验
断点布局适合:
- 商店页在宽屏上改成左右两栏。
- 背包页在平板上增加列数。
- 主界面按钮在超宽屏上靠主内容区而不是靠屏幕边。
- 对话界面在宽屏上让人物和文本位置重排。
断点布局不适合:
- 只是顶部按钮避开刘海。
- 只是底部背景延展。
- 只是弹窗放大。
也就是说,只有“布局结构变了”,才用断点布局。
15. 第十三步:世界浮层和气泡
世界浮层通常有目标点,比如地图建筑、怪物、资源点。它的逻辑是:
15.1 简化版钳制函数
using UnityEngine;
public static class UIOverlayClampUtility
{
public static Vector2 ClampLocalPosition(
RectTransform panel,
RectTransform parent,
Vector2 targetLocalPosition,
float marginX,
float marginY)
{
if (panel == null || parent == null)
{
return targetLocalPosition;
}
Rect safeRect = ScreenRectToLocalRect(parent, UIAdapt.Snapshot.SafeRect);
safeRect.xMin += marginX;
safeRect.xMax -= marginX;
safeRect.yMin += marginY;
safeRect.yMax -= marginY;
Vector2 size = panel.rect.size;
Vector2 pivot = panel.pivot;
float minX = safeRect.xMin + size.x * pivot.x;
float maxX = safeRect.xMax – size.x * (1f – pivot.x);
float minY = safeRect.yMin + size.y * pivot.y;
float maxY = safeRect.yMax – size.y * (1f – pivot.y);
return new Vector2(
Mathf.Clamp(targetLocalPosition.x, minX, maxX),
Mathf.Clamp(targetLocalPosition.y, minY, maxY));
}
private static Rect ScreenRectToLocalRect(RectTransform parent, Rect screenRect)
{
Vector2 min;
Vector2 max;
RectTransformUtility.ScreenPointToLocalPointInRectangle(
parent,
new Vector2(screenRect.xMin, screenRect.yMin),
null,
out min);
RectTransformUtility.ScreenPointToLocalPointInRectangle(
parent,
new Vector2(screenRect.xMax, screenRect.yMax),
null,
out max);
return Rect.MinMaxRect(min.x, min.y, max.x, max.y);
}
}
经验:
浮层不是“固定在安全区里”,而是“先跟随目标,再防止越界”。
16. Prefab 制作规范
框架再好,Prefab 不规范也会乱。
16.1 全屏界面规范
根节点:
- 挂 Canvas
- 挂 GraphicRaycaster
- 挂 FullScreenWindowAdapter
推荐层级:
XXXFullScreenUI
├── Background
│ └── bg_full
├── BackgroundCenter
│ └── bg_center
├── Content
│ ├── Title
│ ├── List
│ └── Buttons
└── BottomBar
├── bg_bar
└── buttons
制作经验:
- 背景放背景层,不要和内容混在一起。
- 重要按钮放 Content 或 BottomBar,不要放超宽屏两侧。
- 列表区域用锚点撑开,不要固定死高度。
- 底部栏背景可以用 EdgeExtendAdapter 向下补。
16.2 弹窗规范
根节点:
- 挂 Canvas
- 挂 GraphicRaycaster
- 挂 PopupWindowAdapter
推荐层级:
XXXPopupUI
├── m_btnBgClose
├── vlay_content
│ ├── bg
│ ├── title
│ ├── body
│ └── buttons
└── closeTips
制作经验:
- 弹窗主体必须统一放到 vlay_content。
- 不要在弹窗内部根据屏幕比例写一堆位置逻辑。
- 弹窗内部列表可以自己滚动,但弹窗整体由 PopupWindowAdapter 控制。
- 背景关闭按钮铺满根节点。
16.3 主界面规范
主界面通常最复杂,因为它同时有:
- 背景场景。
- 顶部资源栏。
- 左右按钮组。
- 底部功能栏。
- 世界入口。
- 活动入口。
推荐原则:
- 资源栏贴安全区顶部。
- 底部功能栏贴安全区底部。
- 底部背景延展到真实底边。
- 左右按钮组贴主内容区边缘,不贴超宽屏真实边缘。
- 场景背景或世界场景可以铺满真实屏幕。
17. 写业务 UI 时的判断流程
每次接新 UI,可以按这个流程问自己。
17.1 第一个问题:这是全屏还是弹窗
全屏:
- 有完整背景。
- 可能占满整个游戏视图。
- 有标题、返回、底栏、列表。
用:
FullScreenWindowAdapter
弹窗:
- 在当前界面上弹出。
- 中间有主体框。
- 点击空白可能关闭。
用:
PopupWindowAdapter
17.2 第二个问题:背景和内容有没有分层
如果没有分层,先拆层。
错误做法:
Root
└── 全部东西混在一起
推荐做法:
Root
├── Background
├── Content
└── EdgeBar
17.3 第三个问题:哪些东西可点击
可点击的东西要优先避开安全区:
- 返回按钮
- 关闭按钮
- 底部操作按钮
- 页签
- 输入框
不可点击的装饰可以延展:
- 背景底图
- 底部装饰条
- 顶部光效
17.4 第四个问题:宽屏时主体应该怎么办
常见选择:
不要默认拉满。
17.5 第五个问题:短屏时内容是否能滚
短屏最容易出问题的是:
- 奖励列表。
- 商店商品。
- 文本说明。
- 多按钮区域。
如果短屏显示不下,优先考虑:
- 中间内容滚动。
- 缩小上下留白。
- 次要信息折叠。
不要把按钮挤出屏幕。
18. 常见场景怎么写
18.1 底部按钮被 Home Indicator 挡住
判断:
- 按钮是可点击内容。
- 应该避开安全区。
做法:
按钮节点挂 SafeEdgeAnchor
considerSafeArea = true
applyBottom = true
不要做:
button.anchoredPosition += new Vector2(0, 30);
因为不同设备底部安全区不同,写死数值不可靠。
18.2 底部背景没有铺满
判断:
- 背景不是可点击内容。
- 应该延展到真实屏幕边缘。
做法:
底部背景节点挂 EdgeExtendAdapter
direction = Down
18.3 平板上主体太宽
判断:
- 主体内容不应该铺满真实屏幕。
- 应该限制主内容区。
做法:
FullScreenWindowAdapter
useMainBodyArea = true
如果只是内容区限制还不够,再做断点布局。
18.4 弹窗在宽屏上太小
判断:
- 弹窗主体应该保持形状并适度放大。
做法:
PopupWindowAdapter
确保 vlay_content 存在
不要把弹窗背景、按钮、标题分别写屏幕判断。
18.5 世界气泡跑到屏幕外
判断:
- 气泡是跟随目标的浮层。
- 位置来源是世界目标,不是固定锚点。
做法:
Vector2 localPosition = WorldToUILocalPosition(targetWorldPosition);
localPosition = UIOverlayClampUtility.ClampLocalPosition(
bubbleRect,
parentRect,
localPosition,
20f,
20f);
bubbleRect.anchoredPosition = localPosition;
19. 测试矩阵
适配框架必须靠测试矩阵兜底。
19.1 必测分辨率
| 1080×2339 | 设计基准 |
| 1080×2520 | 超长手机 |
| 1080×1920 | 短屏手机 |
| 1753×2339 | 平板比例 |
| 2339×1753 | 横屏/特殊场景 |
| 3366×2339 | 超宽屏/PC |
19.2 必测安全区
至少模拟:
- 顶部刘海。
- 底部 Home Indicator。
- 左右圆角。
- 上下同时有安全区。
19.3 每个界面检查项
全屏界面:
- 背景是否铺满。
- 主体是否太宽。
- 顶部按钮是否避开刘海。
- 底部按钮是否避开底条。
- 底部背景是否延展。
- 列表是否能滚。
弹窗:
- vlay_content 是否存在。
- 宽屏是否适度放大。
- 窄屏是否贴边。
- 背景关闭区域是否铺满。
- 弹窗内容是否变形。
主界面:
- 顶栏是否贴安全区。
- 底栏按钮是否可点。
- 侧边按钮是否跑到超宽屏边缘。
- 背景是否铺满。
- 弹窗打开后是否仍正常。
世界浮层:
- 目标在屏幕边缘时是否被钳制。
- 浮层是否挡住安全区。
- 浮层 pivot 不同时是否正确。
20. 排查问题的顺序
遇到适配问题,不要直接改数值。按这个顺序查。
20.1 先看内容分类
问:
- 这是背景还是内容?
- 这是可点击还是装饰?
- 这是固定 UI 还是跟随目标?
- 这是全屏还是弹窗?
分类错了,后面怎么调都别扭。
20.2 再看根节点
问:
- 根 Canvas 的 CanvasScaler 是否正确。
- 全屏窗口是否挂了 FullScreenWindowAdapter。
- 弹窗是否挂了 PopupWindowAdapter。
- 弹窗是否有 vlay_content。
20.3 再看局部组件
问:
- 贴边按钮是否挂了 SafeEdgeAnchor。
- 背景延展是否挂了 EdgeExtendAdapter。
- 是否有多个组件同时改同一个 RectTransform。
20.4 再看刷新时机
问:
- 窗口打开后有没有 UIAdaptRuntime.MarkDirty(root)。
- 动态创建节点后有没有重新标脏。
- 是否在适配之后业务脚本又改了 RectTransform。
20.5 最后才看算法
只有确认上面都没问题,再看:
- 安全区边距是否正确。
- 屏幕矩形转本地矩形是否正确。
- 主内容区宽度限制是否正确。
- 弹窗缩放比例是否正确。
经验:
适配 bug 大多数不是数学错,而是层级、分类、时机、组件职责错。
21. 一周实现计划
如果真的要从 0 写,可以按这个节奏。
第 1 天:跑通核心数据
完成:
- UIAdaptConfig
- AdaptMargin
- UIAdaptSnapshot
- UIAdapt
- UnitySafeAreaPlatform
验收:
- 能打印当前屏幕宽高。
- 能打印安全区边距。
- 能打印主内容区 Rect。
- 改分辨率能触发刷新。
第 2 天:跑通根 Canvas 和全屏窗口
完成:
- RootCanvasAdapter
- IUIAdaptElement
- UIAdaptRuntime
- FullScreenWindowAdapter
验收:
- 标准手机显示正常。
- 超宽屏主体居中且不拉满。
- 安全区生效。
- 背景和内容能分层。
第 3 天:跑通弹窗
完成:
- PopupWindowAdapter
- 弹窗模板
验收:
- 弹窗在标准屏比例正常。
- 弹窗在宽屏上适度放大。
- 弹窗在窄屏上不贴边。
- 缺少 vlay_content 会报错。
第 4 天:跑通局部按钮和背景延展
完成:
- SafeEdgeAnchor
- EdgeExtendAdapter
验收:
- 顶部按钮避开刘海。
- 底部按钮避开 Home Indicator。
- 底部背景延展到真实底边。
- 超宽屏侧边按钮贴主内容区,不贴真实屏幕边。
第 5 天:跑通断点布局和世界浮层
完成:
- RectTransformPresetAdapter
- UIOverlayClampUtility
验收:
- 平板比例能切另一套布局。
- 世界气泡在屏幕边缘不会出界。
第 6 天:补编辑器工具
完成:
- 分辨率预设。
- 安全区模拟。
- 一键刷新当前窗口。
验收:
- 不进真机也能在编辑器里测大部分问题。
第 7 天:整理规范和接入项目
完成:
- 全屏模板。
- 弹窗模板。
- 主界面示例。
- 检查清单。
验收:
- 新人能复制模板做一个界面。
- 不需要读框架源码也能知道组件怎么挂。
22. 最终你应该记住的框架
可以把整套适配框架记成 8 句话:
更短一点:
全局统一算,窗口统一套,局部组件补边界,业务尽量不写屏幕适配。
这就是能迁移到任何项目里的 UI 适配经验。



