ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Unity全面屏适配:安全区与Home Indicator避让实战指南

Unity全面屏适配:安全区与Home Indicator避让实战指南

iPhone X 刚出来那阵子,我接过一个休闲游戏项目的适配。开发机已经到位,游戏也能跑,可一装上真机,底部的“继续游戏”按钮直接被一条白色横条顶到了屏幕边缘,手指点上去完全没有反应,只有尴尬的悬停反馈。当时第一反应是“分辨率不对”,结果查了半天,发现问题根本不在分辨率,而是那条不起眼的白色横条——也就是 Home Indicator。

这个白条是全面屏机型的系统级 UI,普通游戏默认会把渲染区域铺满整个屏幕,但系统并不把你的 UI 当作“合法内容”来看待。于是你的按钮要么被它盖住,要么就挤到最底部,点击区域的判定还会错乱。这篇文章就把 Unity 适配 iPhone X 及后续全面屏机型时,底部白条这个问题的完整处理思路捋一遍,从安全区概念、工程设置、适配脚本到真机踩坑,一条线讲清楚。

1. 先弄明白:底部白条是怎么让 UI“翻车”的

1.1 Home Indicator 和 Safe Area 到底是什么关系

全面屏手机正面几乎没有边框,系统底部会常驻显示一根白色(深色界面下是黑色)的横条,用来提示用户“向上滑回到主屏幕”。这个组件就是 Home Indicator,也就是大家俗称的底部白条。

关键点是:这块区域对系统来说是“功能操作区域”,不是给 App 的内容用的。iOS 系统把屏幕分成了两部分,一部分是内容可以安全展示的区域,叫 Safe Area(安全区),另一部分就是刘海、圆角、底部指示条这些不能放交互内容的地方。你可以把 Safe Area 理解成一块“绝对不能被任何 UI 遮挡的净空区”,而底部白条所在的那一块,正是安全区之外的“不可用空间”。

对 Unity 开发者来说,这个不可用空间的存在意味着两件事:

  • 安全区内的 UI 位置要重新计算,不能在屏幕边缘直接锚定。
  • 底部白条虽然看起来只是条线,但它的高度加上底部圆角,实际占用的空间并不小,在 iPhone X 竖屏时底部大约有 34pt,换算成 Unity 里的屏幕像素,会占到 68px 甚至更高,足以吃掉一个标准按钮的高度。

1.2 为什么 Unity 默认会把 UI 画到白条下面

很多 Unity 项目的 UI 本身锚点设置得没问题,底部按钮就摆在屏幕最下面,满心以为换到全面屏上也能正常显示。但现实是,Unity 的 UI 渲染默认是“铺满整个渲染目标”的,也就是说它会从屏幕最左上角画到最右下角,它不知道也不关心系统那个白条的存在。

举个例子就明白了。你的 Canvas 是 Screen Space - Overlay 模式,锚点拉到屏幕底部边缘,在 iPhone 8 这种带实体 Home 键的机型上完全没有问题,因为屏幕底部没有系统遮挡。但在 iPhone X 上,系统会在屏幕底部叠加一层 Home Indicator,UI 却仍然按照整块屏幕去布局。于是按钮就跟着底部锚点,跑到了白条下面去。

还有一层更隐蔽的影响:Unity 的触摸输入和点击判定,本质上是对应屏幕坐标的。当 Home Indicator 区域被系统手势接管后,你在白条范围内的触摸事件根本不会正常进入游戏,所以不只是“看不见”,而是“点了没反应”。这就解释了为什么很多开发者第一反应是修改按钮样式,搞了半天却发现事件根本触发不了。

1.3 哪些设备有这玩意,数据上有什么差异

从 iPhone X 开始,后续的 XS、11 Pro、12、13、14、15 等全面屏机型,都有 Home Indicator。不同机型的分辨率和逻辑尺寸差异比较大,我整理了一个常用对照表,方便大家写适配代码时参考。

机型系列逻辑分辨率(pt)屏幕像素分辨率(px)底部安全区高度(pt 左右)
iPhone X / XS375 x 8121125 x 243634
iPhone XS Max / XR / 11414 x 8961242 x 268834
iPhone 12 / 13 / 14390 x 8441170 x 253234
iPhone 12 Pro Max / 14 Pro Max428 x 9261284 x 277834
iPhone 14 Pro / 15 Pro393 x 8521179 x 255634
iPhone 15 Pro Max430 x 9321290 x 279634

可以看到,逻辑分辨率的差异并没有想象中那么大,但像素分辨率却五花八门。Unity 的 Screen.safeArea 返回的单位是“渲染像素”,不是 pt,所以适配代码里不能写死某个具体值,而应该用安全区数据动态计算。

2. 动手适配前,先把工程设置和概念理顺

2.1 Unity 版本与 iOS Build 的几项关键设置

要处理底部白条,首先得确保 Unity 工程具备读取安全区信息的基础能力。Screen.safeArea 这个 API 是从 Unity 2017.2 开始引入的,如果你的项目还停在 2017 之前,建议先把编辑器升级到 2018 LTS 以上,这样后面省事很多。

进入 Player Settings 的 iOS 栏目,这里有几个关键项要确认:

  • Render outside safe area:这个选项控制 Unity 是否把渲染扩展到屏幕边缘。建议保持勾选,让 Unity 把画面铺满屏幕,之后我们用脚本自行规避安全区。如果取消勾选,渲染区域会被限制在安全区内,屏幕边缘会出现黑边,视觉上反而更难看。
  • Target Device Family:检查是否勾选了 iPhone 对应的选项。
  • Orientation:建议按产品形态先锁定方向,特别是竖屏游戏,最好直接锁定 Portrait。横屏游戏则要同时处理两个方向的旋转,稍后细说。
  • 启动界面(Launch Screen):iOS 项目如果没有配置 Launch Screen,系统会按兼容模式运行,导致真机上 Unity 拿不到真实的屏幕分辨率。这个坑很多老项目都踩过,解决方式是让 Unity 自动生成 Launch Screen 或手动配置一个 iOS Launch Screen 文件。

2.2 Screen.safeArea 能查到什么信息

Screen.safeArea 是 Unity 提供的一个属性,返回一个 Rect 类型的数据,里面记录了安全区域的坐标和尺寸。这个 Rect 的坐标系统是屏幕左下角为原点,向右为 X 轴正方向,向上为 Y 轴正方向。

竖屏状态下的 iPhone X,返回的数据大致是这样:

Rect safeArea = Screen.safeArea; // 输出示例:x:0, y:132, width:1125, height:2172

其中 y 值 132 就是底部不安全区域的高度,2122 则是安全区的高度。注意,这个高度会随着机型、方向、甚至有来电状态栏出现而动态变化,所以不能图省事只在 Awake 里取一次。

由于 safeArea 返回的是像素值,而 UI 布局通常用的是比例或相对坐标,推荐的做法是把安全区的像素坐标换算成比例(anchor 值),这样无论屏幕分辨率怎么变都不用怕。

2.3 Canvas 渲染模式对适配方案的影响

适配代码和 Canvas 渲染模式是强关联的。Unity 的 Canvas 有三种渲染模式:

  • Screen Space - Overlay:UI 直接覆盖在屏幕上方,坐标天然对齐屏幕像素。这是最方便做安全区适配的模式,直接用 safeArea 除以 Screen.width / Screen.height 就能得到锚点比例。
  • Screen Space - Camera:UI 由相机渲染,Canvas 被映射到相机的一个平面上。这种模式下 Canvas 本身的 RectTransform 尺寸不直接等于屏幕像素,需要额外做一步坐标换算,把 safeArea 的屏幕坐标转换到 Canvas 本地坐标,否则锚点会偏移。
  • World Space:UI 是三维世界里的物体,一般不受安全区直接影响,除非你要做类似 AR 的界面,这种情况较少见。

有条件的话,建议新项目尽量统一使用 Screen Space - Overlay 模式,后续的适配脚本就一套方案通吃,不用为不同模式维护不同代码。

3. 最实用的整体缩进方案:SafeAreaFitter 脚本

3.1 完整脚本与逐行逻辑

核心方案很简单:把整个 Canvas 下面的 UI 容器用代码动态设置到安全区内。我项目里一直在用的适配脚本,思路就是把 Canvas 的 RectTransform 的锚点改成安全区对应的锚点,代码不算复杂但非常稳定。

using UnityEngine; [RequireComponent(typeof(RectTransform))] public class SafeAreaFitter : MonoBehaviour { private RectTransform rectTransform; private Rect lastSafeArea; private ScreenOrientation lastOrientation; private Vector2 lastScreenSize; private void Awake() { rectTransform = GetComponent<RectTransform>(); ApplySafeArea(); } private void Update() { Rect safeArea = Screen.safeArea; if (safeArea != lastSafeArea || Screen.orientation != lastOrientation || Screen.width != lastScreenSize.x || Screen.height != lastScreenSize.y) { ApplySafeArea(); } } private void ApplySafeArea() { lastSafeArea = Screen.safeArea; lastOrientation = Screen.orientation; lastScreenSize = new Vector2(Screen.width, Screen.height); Rect safeArea = Screen.safeArea; // 把安全区的像素坐标换算成锚点比例 Vector2 anchorMin = new Vector2( safeArea.xMin / Screen.width, safeArea.yMin / Screen.height ); Vector2 anchorMax = new Vector2( safeArea.xMax / Screen.width, safeArea.yMax / Screen.height ); rectTransform.anchorMin = anchorMin; rectTransform.anchorMax = anchorMax; rectTransform.offsetMin = Vector2.zero; rectTransform.offsetMax = Vector2.zero; } }

这段代码做的事情拆开讲就是:

  • 把屏幕安全区左下角坐标和右上角坐标分别除以屏幕宽高,得到 0 到 1 之间的比例。
  • 把这个比例赋给 UI 容器的 anchorMin 和 anchorMax,让容器自动缩放到只占据安全区。
  • 将 offsetMin 和 offsetMax 归零,保证容器不会在锚点基础上额外偏移。

之所以放在 Update 里反复检查,是因为安全区数据并不是一成不变的。来电话时状态栏出现会压缩安全区,分屏模式也会变,甚至某些机型在旋转动画期间,安全区数值会连续变化。

3.2 挂载 Canvas 的正确姿势:背景层与内容层分离

把 SafeAreaFitter 直接挂在 Canvas 上是最粗糙的做法,因为这样会让整个 Canvas 缩进到安全区,背景图也跟着缩,四周会出现难看的黑边或透明区域。

这是我实际项目中总结出来的推荐结构:

  • Canvas 根节点:不挂任何适配脚本,保持全屏。
  • 背景节点:单独放一张拉伸模式为“Stretch”的背景图,铺满整个 Canvas,并让背景图内容适当超出安全区外沿,确保在刘海和底部圆角区域也能有背景覆盖。
  • 内容容器节点:挂 SafeAreaFitter,所有的按钮、文本、面板都放在这个节点下。
  • 底部操作栏:如果需要贴底按钮,优先放在内容容器内部,通过 BottomInset 脚本动态调整,不直接锚定屏幕底部。

这种结构的妙处在于,背景层和内容层各司其职,背景层覆盖全屏用于视觉,内容层缩进安全区用于交互。游戏元素永远不会出现在白条下方,而画面又不会因为缩进出现割裂感。

3.3 横竖屏切换和刘海方向变化的响应

横屏游戏比竖屏麻烦的地方在于,横屏分为 LandscapeLeft 和 LandscapeRight 两种方向。iPhone 的刘海对应的屏幕区域会跟着旋转,Home Indicator 会从底部跑到屏幕的左侧或者右侧。

换句话说,安全区的缩进方向不是固定的。横屏 LandscapeRight 时,底部白条实际在屏幕右侧,safeArea 返回的 rect 右侧会有一个大的缩进。如果适配脚本只在 Awake 里执行一次,一旦玩家把手机转到另一个方向,底部按钮就会瞬间出现在屏幕不可点击的区域。

上面那段脚本已经考虑了这个问题:通过监听 Screen.orientation 和 Screen.width / Screen.height 的变化,每次旋转都会重新计算锚点。旋转动画期间,系统会连续回调多次,脚本也会跟着连续刷新,表现上基本是平滑过渡。

如果想要更平滑的视觉效果,可以在 ApplySafeArea 里加一个插值动画,比如用 DOTween 对 anchorMin 和 anchorMax 做 tween,但要注意在快速旋转时可能造成位置延迟,反而让玩家困惑,我一般不建议开,真机上直接硬切最干净。

4. Tab 栏、吸底按钮、顶部状态栏的局部适配

4.1 底部操作栏单独适配的思路

有些界面的设计并不希望所有内容都缩进安全区,比如一个有底部 Tab 栏的 App,Tab 栏的图标和文字希望在视觉上“悬浮”在白条上方,但整个 Tab 栏的高度是固定的,直接缩进安全区会让它和白条之间出现一大段空隙。

针对这种结构,我一般会在 Tab 栏的 RectTransform 上单独加一个 Inset 脚本,它只负责修改底部内边距,而不是修改整个节点的锚点:

using UnityEngine; public class BottomInset : MonoBehaviour { private RectTransform rectTransform; private float lastBottomInset; private void Awake() { rectTransform = GetComponent<RectTransform>(); } private void Update() { float bottom = Screen.safeArea.yMin; if (Mathf.Abs(bottom - lastBottomInset) < 0.5f) return; lastBottomInset = bottom; // 如果 Canvas 有缩放,需要根据 scaleFactor 做换算 // 这里按 Overlay 且无缩放的情况处理 rectTransform.offsetMin = new Vector2(rectTransform.offsetMin.x, bottom); } }

这个脚本的效果就是,Tab 栏的 bottom offset 一直跟着安全区的底部高度走,图标保持原位,整体栏高不变,只是底部距离屏幕边缘多了安全区的间距。你还可以在这个基础上加一个安全区底部的毛玻璃背景或渐变条,让视觉过渡更自然。

4.2 顶部刘海区域的使用与避让策略

底部白条处理完之后,顶部刘海也是一个绕不开的点。竖屏游戏顶部的状态栏时间、信号图标会叠在刘海区域,如果 UI 里有返回按钮或标题,默认锚定在屏幕顶部就会直接撞进刘海。

处理方式很简单:把顶部 UI 的锚点 y 轴设置到安全区上边沿。但有些游戏想利用刘海旁边的空间显示背景装饰,这时可以采用“顶栏避让队列”的方式,让真正的按钮和文字处于安全区内,装饰图案允许延伸到安全区外。

实际操作时,我会给顶部区域做一个专门的容器节点,容器的 bottom 锚定在安全区上边沿,height 固定,内部再按需求自由布局。这样面对不同机型,顶栏内容始终处于安全区内的最上方,视觉位置稳定。

4.3 在不规则屏幕上做动画时注意 Cache 问题

适配完成后,UI 动画也要留意。很多项目会把 UI 的 RectTransform 的位置或偏移量缓存起来,在动画中反复使用。换了全面屏之后,如果缓存的初始值来自旧的屏幕尺寸,动画会把它动画到一个错误的位置。

我踩过这样一个坑:一个按钮的“滑入”动画,起点设置的是anchorMin = new Vector2(0, -0.2f),终点是(0, 0.5f),在 iPhone 8 上正好从屏幕底部滑入。适配全面屏后,该按钮放在了安全区内,而起点位置仍然是相对屏幕底部的百分比,结果按钮每次都从白条区域上方跳出来,像抽风一样。

解决方案是:所有和位置有关的动画起点、终点,都建立在安全区容器内部坐标上,不要拿屏幕底部、屏幕顶部这些绝对概念去算。也就是说,让动画参数相对容器的锚点来设定,这样即使在真机上安全区域不断调整,动画也不会错位。

5. 真机测试与踩坑记录

5.1 模拟器不可信,safeArea 必须真机验证

iOS 模拟器上的安全区数据有时是错的。尤其是旧版 Xcode 的模拟器,在“无刘海屏幕”的模拟设备上,Screen.safeArea 返回的可能是全屏 Rect,但真机上是有安全区缩进的。这就导致你在模拟器上看起来“完美适配”,一上真机就翻车。

我的习惯是:模拟器只用来验证 UI 布局和逻辑,安全区相关的所有数值,一律以真机为准。早期适配阶段,至少要准备一台带刘海的竖屏机型和一台全面屏横屏机型,两边都跑过才敢打包。

5.2 横屏启动、旋转初始化时安全区读取不到

横屏游戏在启动时,UI 可能是在竖屏状态下初始化的,然后快速旋转到横屏。如果适配代码在 Awake 里读了 safeArea,此时拿到的可能是竖屏的数据,等到横屏时又没触发变化事件,整个界面就会按竖屏安全区来布局,一侧被裁掉,另一侧却空出一大块。

前面脚本里通过 Update 中每帧比对解决,就是为此准备的。只要检测到宽度或高度变化,就重新应用安全区。如果你想让代码更优雅一点,也可以监听OnRectTransformDimensionsChange回调,但实测这个函数触发时机并不稳定,还是 Update 对比最可靠。

5.3 Android 刘海机型的平台判断与兼容写法

写适配代码时,很容易顺手把 iOS 的安全区逻辑直接用在 Android 上。Android 的刘海机制和 iOS 完全不同,Screen.safeArea 在某些 Android 机型上返回的是全屏值,等于没有适配。

Android 9 以上可以通过 DisplayCutout API 获取刘海区域,但不同厂商的适配方式差异很大,华为、小米、三星都有自己的刘海管理策略。我一般在代码里做平台判断,iOS 走 Screen.safeArea,Android 根据设备 API 版本走 DisplayCutout,并在 Player Settings 里关闭 Android 的刘海模拟(模拟器才需要那个)。

#if UNITY_IOS Rect safeArea = Screen.safeArea; // iOS 用 Screen.safeArea 处理 #elif UNITY_ANDROID // Android 需要根据具体情况单独处理 #endif

对大多数手游来说,Android 的刘海区域如果不避开,顶部的摄像头也会遮挡按钮。只是处理逻辑要按 Android 的 API 来,不要直接照搬 iOS 的代码。

5.4 老版本 Unity 或特殊项目没有 safeArea 怎么办

如果项目因为历史原因还停在 Unity 2017.2 之前,拿不到 Screen.safeArea,只能退而求其次:

  • 通过 iOS 原生代码获取安全区,用 UnitySendMessage 传给 C#。
  • 或者按机型硬编码底部白条的高度。硬编码时要注意 iOS 版本的兼容性,因为从 iPhone 12 到 15,底部安全高度基本一致(34pt),而 iPhone X 和 XS 的刘海高度则有细微差异。

硬编码方案其实没想象中那么脆弱,因为全面屏机型的底部安全高度多年来一直保持 34pt,只要做的是竖屏游戏,直接写死 68px(按 2x 渲染)也不会差太多。但横屏游戏就不建议硬编码了,因为 Home Indicator 在左右两侧时,安全距离取决于屏幕宽度而非固定值。

6. 适配之外的视觉设计思路

6.1 底部白条不一定要“避开”,可以“融合”

适配做到最后,我发现纯粹把 UI 全部缩进安全区,虽然功能没有问题,但视觉上底部会显得空旷。后来在做一个音乐游戏时,我尝试把底部白条区域设计成渐变毛玻璃效果,让背景色和 UI 主色延伸下去,白条和背景融为一体,实际效果比“避让”更好。

具体实现不复杂:在 Canvas 根节点下放一层覆盖屏幕底部的渐变图,高度略大于安全区下边距,颜色和主界面背景一致。当 safeArea 变化时,这层渐变图跟着调整高度,保持在白条区域可见。这样既能让画面延伸到底部,用户体验又不会因为触感区域不可点击而感到奇怪。

6.2 测试清单要覆盖的场景比想象中多

最后给个我常用的真机自测清单,每次适配完都会过一遍:

  • 竖屏启动,检查底部 Tab/按钮是否被白条遮挡。
  • 横屏左右两方向各启动一次,检查按钮是否被刘海或圆角裁切。
  • 在游戏中来电(测试时用飞行模式加电话),检查 UI 是否被状态栏压缩。
  • 使用系统手势退出 App,再重新进入,检查安全区数值是否重置。
  • 切换无障碍大字体,检查大量文本 UI 是否被顶出安全区。

这些场景看着琐碎,但每一个都可能让安全区数据发生变化。适配工作本身不难,难的是把变化都处理干净。

6.3 如果你是新项目,强烈建议开局就做适配

有一个经验:新项目在出 UI 初稿时,就留出安全区保护的开关。美术出图时,背景图要按“扩边”处理,预留刘海和圆角区域;UI 设计稿里明确“内容安全边界”,让程序不用在后期到处救火。等到 UI 验收时才想起适配,已经会牵扯到至少三四个界面的推倒重来。

我自己在后期改过一个带排行榜、商城、设置等五六个页面的项目,每一页都重新调锚点、调距离,还因为部分界面用了固定的屏幕比例坐标,导致全面屏上严重变形。所以如果你还没开始适配,现在就动工;如果你已经踩过这个坑,应该能明白我说的不是夸张。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进