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 IndicatorUI 却仍然按照整块屏幕去布局。于是按钮就跟着底部锚点跑到了白条下面去。还有一层更隐蔽的影响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 243634iPhone XS Max / XR / 11414 x 8961242 x 268834iPhone 12 / 13 / 14390 x 8441170 x 253234iPhone 12 Pro Max / 14 Pro Max428 x 9261284 x 277834iPhone 14 Pro / 15 Pro393 x 8521179 x 255634iPhone 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 ScreeniOS 项目如果没有配置 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 - OverlayUI 直接覆盖在屏幕上方坐标天然对齐屏幕像素。这是最方便做安全区适配的模式直接用 safeArea 除以 Screen.width / Screen.height 就能得到锚点比例。Screen Space - CameraUI 由相机渲染Canvas 被映射到相机的一个平面上。这种模式下 Canvas 本身的 RectTransform 尺寸不直接等于屏幕像素需要额外做一步坐标换算把 safeArea 的屏幕坐标转换到 Canvas 本地坐标否则锚点会偏移。World SpaceUI 是三维世界里的物体一般不受安全区直接影响除非你要做类似 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 GetComponentRectTransform(); 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 栏的 AppTab 栏的图标和文字希望在视觉上“悬浮”在白条上方但整个 Tab 栏的高度是固定的直接缩进安全区会让它和白条之间出现一大段空隙。针对这种结构我一般会在 Tab 栏的 RectTransform 上单独加一个 Inset 脚本它只负责修改底部内边距而不是修改整个节点的锚点using UnityEngine; public class BottomInset : MonoBehaviour { private RectTransform rectTransform; private float lastBottomInset; private void Awake() { rectTransform GetComponentRectTransform(); } 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.safeAreaAndroid 根据设备 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 验收时才想起适配已经会牵扯到至少三四个界面的推倒重来。我自己在后期改过一个带排行榜、商城、设置等五六个页面的项目每一页都重新调锚点、调距离还因为部分界面用了固定的屏幕比例坐标导致全面屏上严重变形。所以如果你还没开始适配现在就动工如果你已经踩过这个坑应该能明白我说的不是夸张。