客户端 UI 适配 2026:分辨率、界面栈与列表虚拟化
客户端 UI 难交付不是美术问题,而是缩放、安全区、界面层级、长列表规模与刷新时机在运行时互相耦合。本文以 Unity uGUI、UI Toolkit 与 Godot 4 的源码和官方文档为事实来源,实算七种屏幕的缩放系数,拆解 Screen.safeArea 在 Android 14 与 15 的行为差异与两套 UI 的原点冲突,并给出界面栈、焦点栈、列表虚拟化与刷新门禁的落地方案。
约 63 分钟阅读18,805 字2 次阅读博主

客户端 UI 难交付不是美术问题,而是缩放、安全区、界面层级、长列表规模与刷新时机在运行时互相耦合。本文以 Unity uGUI、UI Toolkit 与 Godot 4 的源码和官方文档为事实来源,实算七种屏幕的缩放系数,拆解 Screen.safeArea 在 Android 14 与 15 的行为差异与两套 UI 的原点冲突,并给出界面栈、焦点栈、列表虚拟化与刷新门禁的落地方案。

一句话摘要:客户端 UI 难交付,不是因为美术做得不好,而是因为「缩放策略、安全区语义、界面层级、长列表规模、刷新时机」这五件事在运行时互相耦合,而绝大多数团队只做了美术侧的静态布局——本文给出一套纯客户端侧的 UI 框架工程化方案,全部数值由实测公式与源码推导。
先说一个常见的错位:很多团队把 UI 当成「美术资源 + 少量逻辑」,于是它的排期永远排在玩法系统之后、崩溃治理之前,出问题时第一反应是「美术再调一下」。这个反应在静态布局上偶尔有效,但在真正的客户端里无效——因为运行时至少有五层东西在同时动:
第 5 层 刷新层 数据变了,哪些控件要重算?重算多少?
第 4 层 规模层 500 行的背包列表,是实例化 500 行还是 24 行?
第 3 层 层级层 弹窗压在弹窗上,输入该给谁?焦点在哪?
第 2 层 适配层 这块屏幕是 20:9 还是 4:3?刘海/圆角挖掉多少?
第 1 层 坐标系 设计稿的 1920×1080 到底对应多少物理像素?
这五层里,第 1 层是契约,第 2~5 层是在契约上的运行时行为。本文要建立的核心判断是:UI 适配工程的第一性问题是「坐标系契约」,不是「控件长什么样」。契约定错了,上面四层怎么写都是补丁。
因此本文的技术范围被刻意收窄:只谈客户端侧的运行时 UI 框架(缩放、安全区、层级栈、列表虚拟化、刷新时机),不碰渲染管线内部(那是另一条线的题目),不碰服务端(本文没有任何一行是关于服务端的),也不碰 AI/NPC。
这里要提前划清一条边界,避免与相邻话题混淆:屏幕朗读语义树(把 UI 的可导航性变成数据结构)属于「输出侧语义」层,本文不涉及;本文处理的是「输入侧几何与规模」层。两者都会读同一棵控件树,但关心的问题完全不同。
Unity uGUI 的 CanvasScaler(源码 com.unity.ugui/Runtime/UGUI/UI/Core/Layout/CanvasScaler.cs,本次实测 HTTP 200 / 21809 字节)把缩放策略抽象成一个枚举加一个匹配模式:
ScaleMode:
ConstantPixelSize 元素按像素固定,不随屏幕变
ScaleWithScreenSize 元素按参考分辨率定义,随屏幕缩放
ConstantPhysicalSize 元素按物理单位(毫米/英寸/磅)定义
ScreenMatchMode:
MatchWidthOrHeight = 0 按宽、按高、或两者之间
Expand = 1 画布面积不小于参考
Shrink = 2 画布面积不大于参考
关键在 MatchWidthOrHeight 的实现。源码没有用普通的算术平均,而是在对数空间插值:
float logWidth = Mathf.Log(screenSize.x / m_ReferenceResolution.x, kLogBase); // kLogBase = 2
float logHeight = Mathf.Log(screenSize.y / m_ReferenceResolution.y, kLogBase);
float logWeightedAverage = Mathf.Lerp(logWidth, logHeight, m_MatchWidthOrHeight);
scaleFactor = Mathf.Pow(kLogBase, logWeightedAverage);
源码注释直接给出了理由:如果一个轴是 2 倍分辨率、另一个轴是 1/2 倍,普通空间的平均是 (0.5 + 2) / 2 = 1.25,而对数空间的平均是 (-1 + 1) / 2 = 0。在普通空间做平均会引入系统性偏置——宽高比越极端,偏置越大。这条注释是本次全文最有价值的源码级事实之一,因为它解释了为什么「参考分辨率 + 匹配宽高」这套看似土的方案在工程上是对的。
同源源码里还有几个容易被忽略的默认值,它们决定了「开箱即用」时的行为:m_ReferenceResolution 默认 (1920, 1080);m_MatchWidthOrHeight 默认 0(纯按宽);m_FallbackScreenDPI 默认 96;m_ReferencePixelsPerUnit 默认 100;k_MinimumResolution = 0.00001f(参考分辨率的合法下界);SetScaleFactor 里有一道 0.000005f 的 epsilon 阈值——缩放系数变化小于这个量级就不写 Canvas,这是防止浮点抖动引发无谓重建的一道闸。
另外两条源码事实值得记下来:HandleScaleWithScreenSize 取的是 m_Canvas.renderingDisplaySize 而不是 Screen.width;多显示器场景下,当 targetDisplay > 0 时它改用 Display.displays[displayIndex].renderingWidth/renderingHeight。这意味着「用 Screen.width 自己算缩放」的写法在多屏和某些渲染路径下会和引擎算出的系数不一致——这是自研缩放方案最常见的偏差来源。
按上面的源码公式实算(参考分辨率 1920×1080,s = 一个设计单位占多少物理像素):
| 设备 | s 按宽 | s 按高 | s 对数中点 | s Expand | s Shrink |
|---|---|---|---|---|---|
| 16:9 桌面 1920×1080 | 1.0000 | 1.0000 | 1.0000 | 1.0000 | 1.0000 |
| 16:9 手机 2340×1080 | 1.2188 | 1.0000 | 1.1040 | 1.0000 | 1.2188 |
| 19.5:9 手机 2532×1170 | 1.3188 | 1.0833 | 1.1953 | 1.0833 | 1.3188 |
| 20:9 手机 2400×1080 | 1.2500 | 1.0000 | 1.1180 | 1.0000 | 1.2500 |
| 4:3 平板 2048×1536 | 1.0667 | 1.4222 | 1.2317 | 1.0667 | 1.4222 |
| 折叠屏展开 1812×2176 | 0.9437 | 2.0148 | 1.3789 | 0.9437 | 2.0148 |
| 超宽 2560×1080 | 1.3333 | 1.0000 | 1.1547 | 1.0000 | 1.3333 |
这张表如果只读一行会看漏,必须和「Canvas 实际可见多少设计单位」一起读——那才是美术关心的量:
| 设备 | 按宽 | 按高 | 对数中点 | Expand | Shrink |
|---|---|---|---|---|---|
| 16:9 手机 2340×1080 | 1920×886 | 2340×1080 | 2120×978 | 2340×1080 | 1920×886 |
| 20:9 手机 2400×1080 | 1920×864 | 2400×1080 | 2147×966 | 2400×1080 | 1920×864 |
| 4:3 平板 2048×1536 | 1920×1440 | 1440×1080 | 1663×1247 | 1920×1440 | 1440×1080 |
| 折叠屏展开 1812×2176 | 1920×2306 | 899×1080 | 1314×1578 | 1920×2306 | 899×1080 |
| 超宽 2560×1080 | 1920×810 | 2560×1080 | 2217×935 | 2560×1080 | 1920×810 |
三个可直接用的结论:
结论一:按宽匹配在极端宽屏上会切掉 17.9%~25% 的设计高度。 2340×1080 的手机上,按宽匹配的可见高度只有 886 设计单位,比设计稿的 1080 少 193.8 单位(17.9%);超宽 2560×1080 更极端,只剩 810 单位,少了 25%。这意味着任何「贴在底部的 HUD」「底部按钮栏」在按宽匹配下会直接被裁掉四分之一。按高匹配则反过来:横向会多出 21.9%~33.3% 的空白宽度(超宽屏多出 640 单位,左右各 320)。两种匹配方式没有一种是无损的,这就是为什么 matchWidthOrHeight 必须是可配项而不是写死。
结论二:折叠屏展开态是两个轴的极端背离。 1812×2176 上,按宽的 s 是 0.9437、按高的 s 是 2.0148,两者相差 2.13 倍。落到具体数值上,同一个 32 设计单位的字号,按宽匹配是 30.2 物理像素、按高匹配是 64.5 物理像素,极差 34.3 像素——同一张 UI 在同一个设备上,字号可以差一倍多。如果团队里存在「策划按 1920×1080 出稿、程序按设备原生分辨率摆控件」这种混合做法,折叠屏就是那个必然翻车的设备。
结论三:Expand 与 Shrink 不是「更安全的版本」,而是两种不同的失败模式。 Expand 取 min(W/refW, H/refH),保证画布面积不小于参考,代价是多出来的部分会被裁掉(4:3 平板上按宽是 1920×1440,比设计稿高出 33.3%,上下各溢出 180 单位);Shrink 取 max(...),保证不大于参考,代价是多出来的部分变成留白。前者裁内容,后者留空白——美术对这两者的容忍度完全不同,所以选 Expand 还是 Shrink 应该是美术与程序的共同决策,而不是程序替美术默认。
ConstantPhysicalSize 在源码里的实现是:
float currentDpi = Screen.dpi;
float dpi = (currentDpi == 0 ? m_FallbackScreenDPI : currentDpi);
float targetDPI = 1;
switch (m_PhysicalUnit) {
case Unit.Centimeters: targetDPI = 2.54f; break;
case Unit.Millimeters: targetDPI = 25.4f; break;
case Unit.Inches: targetDPI = 1; break;
case Unit.Points: targetDPI = 72; break;
case Unit.Picas: targetDPI = 6; break;
}
SetScaleFactor(dpi / targetDPI);
它依赖 Screen.dpi。而 Unity 的 Screen.dpi 文档写得很直接:在 Android 上,这个属性用的是 densityDpi,那是一个包含一段 DPI 范围的逻辑分桶,不是精确值;设备无法确定 DPI 时该属性返回 0(此时回落到 fallbackScreenDPI,默认 96)。
于是这个模式在移动端有一个无法回避的漏洞:它把 UI 尺寸绑在了一个本身就不精确的系统量上。同一个 UI 元素,在 densityDpi=160 和 densityDpi=320 的两台设备上会差出一倍,而在大量 Android 定制系统上 Screen.dpi 干脆返回 0,全部退化成 96 的兜底值。这条模式适合做「以实物尺寸标注」的少数场景(少见),不适合做整套客户端 UI 的默认策略。默认策略应当是 ScaleWithScreenSize,物理尺寸模式只作为特例开关存在。
Godot 侧没有「参考分辨率 + 匹配宽高」这个二元结构,它把缩放拆成正交的三个维度(class_window.rst.txt,本次实测 HTTP 200):
content_scale_size 设计基准尺寸(Vector2i)
content_scale_mode DISABLED=0 / CANVAS_ITEMS=1 / VIEWPORT=2
content_scale_aspect IGNORE=0 / KEEP=1 / KEEP_WIDTH=2 / KEEP_HEIGHT=3 / EXPAND=4
content_scale_factor 基准缩放(float,默认 1.0)
两个模式的官方描述值得原样记住,因为它们点出了移动端 UI 的一个本质矛盾:
CANVAS_ITEMS:「内容按目标尺寸渲染。这比 CONTENT_SCALE_MODE_VIEWPORT 更耗性能,但提供更好的效果。」VIEWPORT:「内容按基准尺寸渲染然后缩放到目标尺寸。比 CONTENT_SCALE_MODE_CANVAS_ITEMS 更 performant,但结果会有像素化。」UI 清晰度和缩放开销在移动端是一对直接对立的取舍,两个引擎只是把这个取舍暴露在不同参数上。Godot 的 aspect 枚举则对应 Unity 的 ScreenMatchMode:KEEP_WIDTH/KEEP_HEIGHT 对应按宽/按高,EXPAND 对应 Expand,KEEP 则对应 Unity 的 MatchWidthOrHeight(对数插值)——注意 Unity 的 Shrink 在 Godot 这套枚举里没有直接对应项,需要用 aspect=IGNORE 配合手写约束近似,这是跨引擎移植时的一个真实缺口。
Unity 6(6000.0)Screen.safeArea 的官方描述里,藏着两个必须知道的细节。
陷阱一:safeArea 是相对「应用窗口」而不是「物理屏幕」的。 原文:「Note: Screen.safeArea is relative to the application window, not the physical device screen.」这个区别在分屏、悬浮窗、自由窗口(桌面端)场景下会直接体现出来——窗口化时按屏幕算出来的安全区是错的。
陷阱二:Android 14 与 Android 15 的行为不同,而且与 PlayerSettings 开关耦合。 原文:「On Android 14 (API level 34) and earlier, if PlayerSettings.Android.renderOutsideSafeArea is disabled, Unity resizes the application window to fit the device safe area. The returned Rect typically equals Rect(0, 0, Screen.width, Screen.height), because the window no longer includes unsafe regions.」以及「On Android 15 (API level 35) and later, PlayerSettings.Android.renderOutsideSafeArea has no effect. The application always renders into the display cutout (notch) area, so Screen.safeArea excludes it.」
把这两句连起来读,可以得到一个非常反直觉但极其重要的结论:同一个 Screen.safeArea API,在 Android 14 及更早版本上返回「窗口矩形减去被裁掉的部分」(通常等于整屏),在 Android 15+ 上返回「整屏减去刘海区域」。前者已经由引擎帮你把窗口缩小了,后者没有。PlayerSettings.Android.renderOutsideSafeArea 的官方描述是「Extends rendering layout into display cutout area, utilizing all of the available screen space.」——也就是说这个开关在 API 35+ 变成无效开关。
工程后果很直接:如果 UI 里有一层「根据 safeArea 计算顶部偏移」的自适应逻辑,它在 Android 14 上几乎总是算出 0 偏移(因为窗口已经被裁好了),在 Android 15+ 上才真正开始生效。团队里「为什么老设备好好的,新设备顶部被刘海盖住」的典型答案就在这里。正确做法是:不要用 safeArea 是否等于整屏来推断「有没有刘海」,直接读 Screen.cutouts(官方描述:「Returns a list of screen areas that do not display content... represented as Rect structures in pixels」),用「cutouts 非空」作为真实判据。
Screen.safeArea 的文档最后一段是本次调研中最实用的一段,原文给出:
Note: The origin of the safe area is located at the bottom-left corner of the screen. Therefore, if you intend to use
Screen.safeAreaorScreen.cutoutswith a UI system like UI Toolkit, where origin is at the top-left corner, you must invert the coordinates.
官方给出的翻转代码是:
var safeAreaForUIToolkit = new Rect(
Screen.safeArea.x,
Screen.height - Screen.safeArea.y,
Screen.safeArea.width,
Screen.safeArea.height);
这是 uGUI(左下原点)与 UI Toolkit(左上原点)之间的真实语义断层:同一个 Screen.safeArea 值,在两套 UI 系统里必须做不同的变换。文档还配了一张 Google Pixel 7 Pro(API 35)竖屏与横屏的安全区与 cutout 示意图,说明横竖屏下 cutout 的位置会换边——任何把安全区结果缓存下来的做法都会在转屏时失效。
Android 侧提供的是分方向的 inset,而不是一个矩形(androidx.core.view.WindowInsetsCompat,实测 HTTP 200):
getInsets(int typeMask):官方描述「Returns the insets of a specific set of windows causing insets, denoted by the typeMask bit mask of the WindowInsetsCompat.Type」——返回值是 Insets(左/上/右/下四个方向的量),不是一个 Rect。getInsetsIgnoringVisibility(int typeMask):忽略可见性版本,用于「即使某个栏当前不可见也要预留」的场景。Type 是位掩码组合,常见组合包括 systemBars()、displayCutout() 等;另有 getDisplayCutout()、getRoundedCorner(int)、getPrivacyIndicatorBounds()、getMandatorySystemGestureInsets() 等专门接口。android.view.DisplayCutout 描述:getSafeInsetLeft() 的官方描述是「Returns the inset from the left which avoids the display cutout in pixels」(API 28 起),另有 Top/Right/Bottom 三个方向;getBoundingRects() 返回「a list of Rects, each of which is the bounding rectangle for a non-functional area on the display」。WindowCompat.setDecorFitsSystemWindows(window, boolean):官方描述「Sets whether the decor view should fit root-level content views for WindowInsetsCompat」——这正是决定「内容是否自动避开系统栏」的那个总开关。Apple 侧的语义又不一样(UIView.safeAreaInsets,实测 HTTP 200):
The safe area of a view reflects the area not covered by navigation bars, tab bars, toolbars, and other ancestors that obscure a view controller's view. (In tvOS, the safe area reflects the area not covered by the screen's bezel.) You obtain the safe area for a view by applying the insets in this property to the view's bounds rectangle. If the view is not currently installed in a view hierarchy, or is not yet visible onscreen, the edge insets in this property are zero.
三个要点:第一,Apple 的安全区是视图层级相关的——导航栏、标签栏、工具栏以及任何遮挡的祖先视图都会计入,而 Android 的 systemBars() 是系统级的,两者不等价。第二,视图尚未装入层级或尚未上屏时,边距为零——这意味着「在 viewDidLoad 里读 safeArea」会拿到全零值,必须在 safeAreaInsetsDidChange() 或 viewDidLayoutSubviews 之后读。第三,tvOS 上安全区指的是屏幕边框(bezel),而不是刘海——这是主机端与移动端的又一条语义分叉。
Apple 还提供了 safeAreaLayoutGuide(自动布局用的 guide 版本)与 UIViewController.additionalSafeAreaInsets(供业务自定义额外边距,例如避让自绘工具条),以及 UIScreen.overscanCompensationInsets(「The edge inset values needed to avoid clipping the rectangle」)——后者是电视端过扫描的对应物,在主机移植时会被用到。
Godot DisplayServer 的两个接口,本次实测的官方注释非常关键:
get_display_safe_area():「Returns the unobscured area of the display where interactive controls should be rendered.」但接着是:「Currently only implemented on Android and iOS. On other platforms, screen_get_usable_rect(SCREEN_OF_MAIN_WINDOW) will be returned as a fallback.」get_display_cutouts():「Returns an Array of Rect2... Currently only implemented on Android. Other platforms will return an empty array even if they do have display cutouts or notches.」结论:Godot 的安全区在桌面端等于「无信息」(退化成整块可用矩形),挖孔信息只在 Android 上存在,iOS 上也没有。 对比之下,Unity 的 Screen.safeArea 在所有平台都有定义、Screen.cutouts 也全平台可用。
这构成一条跨引擎的不对称事实,和 Unity 侧「AccessibilitySettings 只支持 Android 与 iPhonePlayer」是同一类问题:引擎并没有替你把「安全区」抽象好,跨引擎跨平台的项目必须自维护一层安全区适配。这一层的最小实现是:定义一个引擎无关的 SafeArea { left, top, right, bottom }(以设计单位表达),每个平台各写一个提供者(Unity 读 Screen.safeArea + Screen.cutouts 并按 UI 系统做原点翻转;Android 读 WindowInsetsCompat.getInsets;Apple 读 UIView.safeAreaInsets;Godot 读 get_display_safe_area 并对非移动平台显式返回「未知」而不是「零」)。
「未知」与「零」必须区分,这是本文安全区部分最重要的一条工程纪律:把未知当零会导致桌面端莫名其妙地多出一圈空白边距。
安全区拿到的是物理像素,而 UI 布局用的是设计单位,两者之间必须过一次缩放系数。用前面那张表实算,假设一个 88 物理像素的缺口(顶部刘海):
| 设备 | 按宽折算(设计单位) | 按高折算(设计单位) |
|---|---|---|
| 16:9 手机 2340×1080 | 72.21 | 88.00 |
| 19.5:9 手机 2532×1170 | 66.73 | 81.23 |
| 4:3 平板 2048×1536 | 82.50 | 61.88 |
| 折叠屏展开 1812×2176 | 93.25 | 43.68 |
| 超宽 2560×1080 | 66.00 | 88.00 |
同一个物理缺口,在不同设备与不同匹配策略下,落到设计稿上是 43.68 到 93.25 单位不等,差了一倍多。这意味着「顶部留 80 单位的固定安全边距」这种做法在任何一种策略下都不精确。正确做法是:安全区边距永远由运行时物理值折算而来,且折算用的缩放系数必须与渲染层用的是同一个系数——这也是不能用 Screen.width 自己另算一套缩放的又一个理由。
很多项目的界面管理是「一堆 SetActive 加一个 Dictionary<string, GameObject>」,问题在于它没有表达顺序与排他性。而这两件事恰恰是运行时必需的信息:
Unity uGUI 的 EventSystem(源码实测 HTTP 200)已经把这件事下沉到引擎层了,值得逐条看:
m_sendNavigationEvents 默认 true,官方描述:「Should the EventSystem allow navigation events (move / submit / cancel)」——手柄/键盘的方向导航、确认、取消事件的开关。currentSelectedGameObject:「The GameObject currently considered active by the EventSystem」;firstSelectedGameObject 的注释很直白:「Only one object can be selected at a time. Think: controller-selected button.」——这就是焦点栈的原语。SetSelectedGameObject(GameObject selected) 的注释:「Will send an OnDeselect the the old selected object and OnSelect to the new selected object.」——切换焦点会成对派发事件,所以焦点管理必须成对处理,否则会出现「控件还显示着选中态但其实已经失焦」这类幽灵状态。IsPointerOverGameObject(int pointerId):源码里 IsPointerOverGameObject() 无参版本等价于用 PointerInputModule.kMouseLeftId 调用带参版本。拦截点击由 GraphicRaycaster(源码实测 HTTP 200)承担,它的三个属性构成完整的「谁挡谁」模型:
BlockingObjects: None=0 / TwoD=1 / ThreeD=2 / All=3
blockingMask LayerMask —— 哪些层的对象会被检查为「阻挡射线」
ignoreReversedGraphics —— 是否检查背向射线的 Graphic
renderOrderPriority —— 多个 Raycaster 之间的命中优先级
blockingMask 是模态实现的关键:模态弹窗打开时,把它的背景遮罩放到一个专用 Layer,并让下层 Canvas 的 Raycaster 的 blockingMask 排除这个 Layer(或反过来,让遮罩的 Raycaster 变成全屏拦截器),就能得到「下层完全不可点」的效果,而不需要给下层每个按钮都写禁用逻辑。注意 blockingObjects 默认值是 None(不阻挡)——很多「模态没挡住点击」的 bug,根源就是这里没配。
把上面这些引擎原语组织起来,一个可用的界面栈至少要维护四份状态:
界面栈(按 z 序从底到顶)
├─ 每个条目:界面 id、实例、生命周期状态、是否模态、遮挡策略、进入/退出动画状态
├─ 焦点栈:每个界面各自记住「我进来时的选中控件」和「我走的时候要还给谁」
├─ 输入路由:栈顶优先;模态条目声明「以下全部拦截」
└─ 资源引用:界面关闭后实例是销毁还是缓存(决定下次进入的耗时)
其中「焦点栈」是最容易漏的一层。Unity 的 SetSelectedGameObject 会把 OnDeselect 发给旧对象、OnSelect 发给新对象,但它不记录「谁该拿回焦点」。所以正确做法是:界面入栈时保存 EventSystem.current.currentSelectedGameObject,出栈时恢复;同时在模态栈顶存在期间,sendNavigationEvents 的处理要保证导航事件不会穿透到下层。
「资源引用:销毁还是缓存」是运行时成本的第一个分叉点。界面关闭后缓存实例,是拿内存换打开速度;销毁重建,是拿打开时间换内存。 没有普适答案,但必须显式决策——因为默认行为(SetActive(false))实际上是第三种选择:既不释放内存也不省重建时间,只是隐藏。一个界面栈如果允许同时存在「隐藏但不释放」「销毁重建」「池化复用」三种策略中的任意组合而不加约束,内存曲线就会随玩家操作路径漂移。
界面栈最容易暴露的问题不是「打不开」,而是「关闭之后状态丢了」:滚动位置、输入焦点、分页页码、已展开的折叠项。工程上的标准做法是把状态从实例里挪出来——实例是壳,状态是一份可序列化的数据对象,界面是「数据 → 视图」的单向渲染。这样界面栈只需要管理壳的生命周期,状态的存续由数据层负责。这一条同时也解决了「同一个界面被两个入口打开」的场景。
Unity uGUI 的 ScrollRect(源码实测 HTTP 200 / 58455 字节)提供了滚动容器,但它对「行数」没有任何概念——它只管 content 的位移与回弹:
MovementType: Unrestricted(可无限拖)/ Elastic(可暂时越界后弹回)/ Clamped(不可越界)
默认 m_MovementType = Elastic
默认 m_Elasticity = 0.1f
默认 m_Inertia = true
默认 m_DecelerationRate = 0.135f(仅 inertia 启用时使用)
content / viewport: 都是 RectTransform;viewport 的 setter 会调 SetDirtyCaching()
UpdateBounds() 计算 ScrollRect 应使用的边界
UpdatePrevData() —— 官方注释:Call this before you change data in your ScrollRect
StopMovement() 把两个轴的速度都置零
MovementType 三档的官方注释分别是:Unrestricted「The content can move forever」;Elastic「The content is allowed to temporarily move beyond the container, but is pulled back elastically」;Clamped「The content can not be moved beyond its container」。默认是 Elastic,意味着不做配置时列表会带橡皮筋效果——而「背包列表能不能拖过头」通常是产品决策,不是默认值决策。
UpdatePrevData() 那条注释是很多 UI bug 的源头:在改动 ScrollRect 数据之前必须先调用它,否则内部的「上一帧位置」缓存与实际数据不一致,会导致回弹位置错乱或首次滚动跳一下。
内容的尺寸则由另外两个组件决定:
ContentSizeFitter(源码实测 HTTP 200)的 FitMode 三档是 Unconstrained / MinSize / PreferredSize,横向纵向默认都是 Unconstrained。它的 HandleFit 会用 LayoutUtility.GetMinSize 或 GetPreferredSize 去设置自身尺寸。关键推论:内容高度是「算出来」的,不是「摆出来」的——要让一个 500 行的列表撑开高度,就必须有一条「内容高度 = 所有行高度之和」的链路(子项高度 + LayoutElement 的 preferredHeight + VerticalLayoutGroup 的 spacing)。LayoutGroup 的 SetLayoutHorizontal() / SetLayoutVertical() 是「由自动布局系统回调,处理对应轴的布局」;padding 是「The padding to add around the child layout elements」;childAlignment 用于对齐子项。设一个典型背包列表:500 行,每行 6 个 Graphic 组件。
全量实例化:500 × 6 = 3000 个 Graphic + 500 个行根节点 = 3500 个对象
虚拟化 :(12 可见 + 6 缓冲) × 6 = 144 个 Graphic + 24 个行根节点 = 168 个对象
倍数 :20.8 倍
20.8 倍不是重点,重点是它出现在布局重建的遍历成本上。Unity 的布局系统每轮重建都要遍历受影响的子节点集合;当列表有 500 行且每行参与布局时,遍历规模是 500 而虚拟化后是 24。同时,3500 个 Graphic 组件意味着 3500 次图元收集、3500 个 UI 批次的候选参与——即使最终合批数不高,「参与合批的图元数」本身也是 CPU 侧成本。这里的因果链是:节点数 → 布局遍历规模 → 每帧 CPU 时间,而不是「节点数 → GPU 时间」,这正是 UI 性能问题常被误诊的地方。
要让虚拟化真正成立,有三条约束必须同时满足:
约束一:行高必须可预测。 如果每行高度不一,虚拟化就需要维护「前缀和高度表」来把像素偏移映射到行索引,这在行高动态变化时需要重算。工程上的常见折中是「行高固定 + 特殊行单独处理」,或「行高只有少数几档 + 分桶」。
约束二:滚动条必须常驻占位,否则内容宽度会跳。 这一点在 Godot 侧有现成的答案,ScrollContainer 的 SCROLL_MODE_RESERVE 的官方描述是:「Combines SCROLL_MODE_AUTO and SCROLL_MODE_SHOW_ALWAYS. The scrollbar is only visible if necessary, but the content size is adjusted as if it was always visible. It's useful for ensuring that content size stays the same regardless if the scrollbar is visible.」——滚动条按需显示,但内容区永远按「有滚动条」的宽度来排。这是典型的「几何契约」思路:不解决「滚动条丑不丑」,而解决「内容会不会跳」。
约束三:虚拟化与布局系统会互相打架。 ContentSizeFitter 需要知道内容总高(所以它必须遍历全部行),而虚拟化只保留可见行。这两个诉求在架构上是冲突的——虚拟化要的是「按需」,fitter 要的是「全量」。正确解法是把内容总高作为独立数据维护(由数据层算出来并交给 UI),而不是让 UI 靠遍历子节点反推。这是虚拟化落地时最需要提前设计的一点,也是本文认为最容易被低估的一条。
UI 层的刷新问题与结算层的数值求值是不同层级的问题——结算层关心的是属性依赖图与求值顺序(那属于另一条线的题目),UI 层关心的是「一个数值变了,多少个控件需要重绘」。
Unity UI Toolkit 的 PanelSettings(实测 HTTP 200)里有一个直接指向这个问题的字段:vertexBudget——官方描述「The expected number of vertices that will be used by this panel」。它把「这个面板大概会有多少顶点」变成了一个可配置的预算而不是隐式事实。同一个类里还有 themeStyleSheet(全局样式表)、textSettings(文本设置,被每个 UIDocument 共享)、dynamicAtlasSettings(动态图集)、renderMode(默认 PanelRenderMode.ScreenSpaceOverlay)、sortingOrder(多面板时的排序)、targetDisplay、targetTexture、clearColor。
其中 themeStyleSheet 与 textSettings 的「共享」属性值得注意:官方对 textSettings 的描述是「Specifies a PanelTextSettings that will be used by every UI Document attached to the panel」,对 PanelSettings 整体的描述是「Multiple UIDocument components can point to the same PanelSettings object, which optimizes performance when using multiple documents」——共享 PanelSettings 是一种明确的性能手段,因为字体图集、样式解析、面板设置都是复用的。
落到刷新策略上,可以给出一条可执行的规则:把「值变了」和「视图要重建」解耦,用一个显式的脏标记 + 局部刷新替代「整棵控件树重新算布局」。具体做法是:
Canvas 的 scale 变化检测(前面提到的 SetScaleFactor 里那道 0.000005f epsilon 就是同类思路)避免抖动触发。对比一下规模:一次「背包里某个物品数量 +1」如果走全量重建,代价是 3500 个对象的遍历;如果走脏标记,代价是 1 个控件的文本刷新加它所在行的布局重算。这就是「少刷新比快刷新重要」的含义。
把三套系统的对应关系摆在一起,能直接看出工程上的复用边界:
| 能力 | Unity uGUI | Unity UI Toolkit | Godot 4 |
|---|---|---|---|
| 缩放模式 | CanvasScaler.ScaleMode 三档 | PanelSettings.scaleMode 三档 | Window.content_scale_mode 三档 |
| 缩放基准 | referenceResolution + matchWidthOrHeight | referenceResolution + match | content_scale_size + content_scale_aspect + content_scale_factor |
| 宽高比策略 | ScreenMatchMode 三档(Match/Expand/Shrink) | 同 uGUI | aspect 五档(多 IGNORE 与 EXPAND,少 Shrink) |
| 安全区 | Screen.safeArea(全平台)+ Screen.cutouts(全平台) | 同 API,但需翻转坐标 | get_display_safe_area()(仅 Android/iOS)+ get_display_cutouts()(仅 Android) |
| 锚点模型 | RectTransform.anchorMin/Max + pivot + sizeDelta | Style 体系(百分比/长度) | Control.anchor_top/left/right/bottom + offset |
| 容器布局 | LayoutGroup + ContentSizeFitter(SetLayoutHorizontal/Vertical) | USS 样式 + flex 布局 | Container + sort_children / pre_sort_children 信号 + fit_child_in_rect |
| 滚动容器 | ScrollRect(MovementType/elasticity/inertia) | Scroller | ScrollContainer(SCROLL_MODE_* 五档) |
| 输入路由 | EventSystem + GraphicRaycaster | UI Toolkit 自带事件系统 | Godot _gui_input / _unhandled_input |
| 焦点 | currentSelectedGameObject / firstSelectedGameObject / SetSelectedGameObject | 面板内 focus 环 | Control.grab_focus() / focus_neighbor |
Godot Control 的官方描述值得单独摘出来,因为它把锚点语义讲得比任何教程都清楚:「Control features a bounding rectangle that defines its extents, an anchor position relative to its parent control or the current viewport, and offsets relative to the anchor. The offsets update automatically when the node, any of its parents, or the screen size change.」——「屏幕尺寸变化时偏移自动更新」正是锚点模型存在的理由。而 set_anchor(side, anchor, keep_offset, push_opposite_anchor) 这套 API 把「改锚点时是否保持偏移、是否推动对侧锚点」显式暴露出来,与 Unity RectTransform 的「anchorMin/anchorMax/pivot/sizeDelta/offsetMin/anchoredPosition」是同一套语义的不同命名。
Container 侧 Godot 的做法值得注意:pre_sort_children 信号在「子节点即将被排序」时发出,sort_children 在「需要排序子节点」时发出,容器还有 fit_child_in_rect(child, rect) 用来把一个子节点摆进给定矩形。这是「容器主动调度子节点」的模型,比 uGUI 的「子节点上报 preferredSize,父节点分配」更显式,也更容易在运行时插入自定义调度逻辑。
复用边界的结论:
UI 问题的特点是**「能跑,但不对」**,所以传统单测几乎覆盖不到。可行的门禁有五条:
门禁一:缩放契约快照测试。 对一小组固定设备分辨率(本文的表就是候选集),断言算出的 scaleFactor 与 Canvas 可见设计单位落在预期区间内。这条测试的价值在于:它把「美术觉得字号变小了吗」这种主观争论变成一个具体的数值断言。当有人改动参考分辨率或匹配策略时,测试立刻失败并指出影响面。
门禁二:安全区一致性测试。 断言「有 cutouts 时安全区边距非零」「无 cutouts 时安全区边距为零」「safeArea 与 cutouts 的一致性」。第三项是最容易出问题的:safeArea 给的是「可用矩形」,cutouts 给的是「不可用区域」,两者在 Android 14 与 Android 15 上语义不同,必须写一条断言把这两种语义钉死,否则引擎升级或 PlayerSettings 变更时无人察觉。
门禁三:布局不溢出断言。 关键 UI 元素(主按钮、底部栏、返回键、货币显示位)的 RectTransform 必须落在安全区内,且关键文本在最小字号下不溢出。这条断言要覆盖「按宽匹配时可见高度只有 886 单位」这类极端情形——用设计稿的 1080 高度去断言,在 2340×1080 的设备上必然误报。
门禁四:长列表规模断言。 断言虚拟化列表的实例化行数不超过「可见行数 + 缓冲」,以及布局重建的单帧耗时不超过预算。这条把「虚拟化有没有被某个新功能悄悄退化掉」变成可检测的。
门禁五:帧级回归截图。 对一组固定设备尺寸与安全区组合截图比对。这一条能抓到前四条都抓不到的问题——比如某个控件被错误地 anchor 到了父节点边缘。它成本最高,所以放在最后。
对应的运行时度量(埋点侧)建议至少有四个:界面打开耗时(区分「首次打开」与「从缓存恢复」两种,这两条的优化路径完全不同)、长列表首屏渲染耗时、布局重建次数与耗时、以及**「缩放系数发生变化的次数」**——最后这个指标非常有用:它在正常运行中应该是 0,异常高频就说明有人在频繁触发分辨率变化(转屏、窗口缩放、动态分辨率),或者缩放计算里存在抖动。
UI 适配工程的全部难点可以归结为一句话:你必须先确定一份坐标系契约,然后让缩放、安全区、层级、规模、刷新这五层都服从同一份契约。
最实用的三条结论:
缩放不是「美术出 1920×1080,程序按设备摆」,而是一份写死的策略参数。 Unity 的 CanvasScaler 用对数空间插值而非算术平均,是有源码注释背书的工程决策;而 ScreenMatchMode 的三档对应三种不同的失败模式,折叠屏展开态下按宽与按高的 s 相差 2.13 倍,没有一个默认值是安全的。
安全区必须区分「未知」与「零」,并且必须自己折算回设计单位。 同一个 88 物理像素的缺口,在不同设备与匹配策略下折算出来是 43.68 到 93.25 设计单位;而 Godot 的 get_display_safe_area() 在非 Android/iOS 平台根本没有实现,get_display_cutouts() 只有 Android 有——跨引擎的安全区层只能自维护。另外,Screen.safeArea 的原点在下、UI Toolkit 的原点在上的那行翻转代码,是最容易被漏掉又最难被发现的坐标陷阱。
长列表的瓶颈是「遍历规模」而不是「GPU 时间」,而虚拟化与 ContentSizeFitter 在架构上直接冲突。 所以内容总高必须由数据层维护并交给 UI,而不是让 UI 靠遍历子节点反推;滚动条要常驻占位(SCROLL_MODE_RESERVE 的思路);最终把「少刷新」当作比「快刷新」更重要的优化目标。
这套方案不需要任何引擎私有特性,也不需要任何渲染管线知识,它是一条纯客户端侧的工程路径。真正需要团队投入的不是某个 API,而是把坐标系契约、界面栈契约、列表规模契约写下来,并且让它们可以被测试。
com.unity.ugui/Runtime/UGUI/UI/Core/Layout/CanvasScaler.cs(含 ScaleMode/ScreenMatchMode 枚举、对数空间插值实现、SetScaleFactor 的 0.000005f 阈值、ConstantPhysicalSize 的 DPI 换算表),本次实测 HTTP 200 / 21809 字节,https://raw.githubusercontent.com/Unity-Technologies/uGUI/HEAD/com.unity.ugui/Runtime/UGUI/UI/Core/Layout/CanvasScaler.cscom.unity.ugui/Runtime/UGUI/UI/Core/ScrollRect.cs(含 MovementType 三档、elasticity/inertia/decelerationRate 默认值、UpdatePrevData 与 UpdateBounds 注释),本次实测 HTTP 200 / 58455 字节,https://raw.githubusercontent.com/Unity-Technologies/uGUI/HEAD/com.unity.ugui/Runtime/UGUI/UI/Core/ScrollRect.cscom.unity.ugui/Runtime/UGUI/UI/Core/Layout/ContentSizeFitter.cs(FitMode 三档与 HandleFit 的 GetMinSize/GetPreferredSize 调用),本次实测 HTTP 200 / 6454 字节,https://raw.githubusercontent.com/Unity-Technologies/uGUI/HEAD/com.unity.ugui/Runtime/UGUI/UI/Core/Layout/ContentSizeFitter.cscom.unity.ugui/Runtime/UGUI/UI/Core/GraphicRaycaster.cs(BlockingObjects 四档、blockingMask、ignoreReversedGraphics、renderOrderPriority),本次实测 HTTP 200 / 13981 字节,https://raw.githubusercontent.com/Unity-Technologies/uGUI/HEAD/com.unity.ugui/Runtime/UGUI/UI/Core/GraphicRaycaster.cscom.unity.ugui/Runtime/UGUI/EventSystem/EventSystem.cs(sendNavigationEvents 默认值、currentSelectedGameObject/firstSelectedGameObject、SetSelectedGameObject 的 OnDeselect/OnSelect 注释、IsPointerOverGameObject 与 kMouseLeftId),本次实测 HTTP 200 / 20750 字节,https://raw.githubusercontent.com/Unity-Technologies/uGUI/HEAD/com.unity.ugui/Runtime/UGUI/EventSystem/EventSystem.csScreen.safeArea(Unity 6.0 / 6000.0),含「相对应用窗口而非物理屏幕」「Android 14 与 Android 15 行为差异」「原点在左下、UI Toolkit 需翻转」的官方说明与代码片段及 Pixel 7 Pro 示意图,本次实测 HTTP 200,https://docs.unity3d.com/6000.0/Documentation/ScriptReference/Screen-safeArea.htmlScreen.cutouts(返回不显示内容的区域列表,像素 Rect),本次实测 HTTP 200,https://docs.unity3d.com/6000.0/Documentation/ScriptReference/Screen-cutouts.htmlPlayerSettings.Android.renderOutsideSafeArea(官方描述:Extends rendering layout into display cutout area),本次实测 HTTP 200,https://docs.unity3d.com/6000.0/Documentation/ScriptReference/PlayerSettings.Android-renderOutsideSafeArea.htmlScreen.dpi(Android 上使用 densityDpi 逻辑分桶、无法确定时返回 0),本次实测 HTTP 200,https://docs.unity3d.com/6000.0/Documentation/ScriptReference/Screen-dpi.htmlScreen.width(Editor 事件中返回 Game 视图分辨率),本次实测 HTTP 200,https://docs.unity3d.com/6000.0/Documentation/ScriptReference/Screen-width.htmlRectTransform(锚点、pivot、sizeDelta 与 offsetMin/anchoredPosition 的官方语义),本次实测 HTTP 200,https://docs.unity3d.com/6000.0/Documentation/ScriptReference/RectTransform.htmlRectTransform.anchoredPosition(锚点不重合时 Unity 用 pivot 位置估算四个锚点位置),本次实测 HTTP 200,https://docs.unity3d.com/6000.0/Documentation/ScriptReference/RectTransform-anchoredPosition.htmlUIElements.PanelSettings(scaleMode/screenMatchMode/match/referenceResolution/referenceDpi/fallbackDpi/vertexBudget/themeStyleSheet/textSettings/renderMode 默认 ScreenSpaceOverlay),本次实测 HTTP 200,https://docs.unity3d.com/6000.0/Documentation/ScriptReference/UIElements.PanelSettings.htmlUIElements.PanelScaleMode(ConstantPixelSize / ConstantPhysicalSize / ScaleWithScreenSize 三档的官方描述),本次实测 HTTP 200,https://docs.unity3d.com/6000.0/Documentation/ScriptReference/UIElements.PanelScaleMode.htmlUIElements.UIDocument(运行时 UI 文档组件),本次实测 HTTP 200,https://docs.unity3d.com/6000.0/Documentation/ScriptReference/UIElements.UIDocument.htmlUnityEngine.UI.CanvasScaler(com.unity.ugui 2.0.0),uiScaleMode/screenMatchMode/matchWidthOrHeight/referenceResolution/fallbackScreenDPI/dynamicPixelsPerUnit 的官方字段说明,本次实测 HTTP 200,https://docs.unity3d.com/Packages/com.unity.ugui@2.0/api/UnityEngine.UI.CanvasScaler.htmlUnityEngine.UI.ScrollRect(movementType/elasticity/inertia/normalizedPosition/verticalNormalizedPosition/StopMovement/UpdateBounds/UpdatePrevData 的官方说明),本次实测 HTTP 200,https://docs.unity3d.com/Packages/com.unity.ugui@2.0/api/UnityEngine.UI.ScrollRect.htmlUnityEngine.UI.ContentSizeFitter(horizontalFit/verticalFit 与 FitMode),本次实测 HTTP 200,https://docs.unity3d.com/Packages/com.unity.ugui@2.0/api/UnityEngine.UI.ContentSizeFitter.htmlUnityEngine.UI.GraphicRaycaster(blockingObjects/blockingMask/ignoreReversedGraphics),本次实测 HTTP 200,https://docs.unity3d.com/Packages/com.unity.ugui@2.0/api/UnityEngine.UI.GraphicRaycaster.htmlUnityEngine.EventSystems.EventSystem(sendNavigationEvents/currentSelectedGameObject/firstSelectedGameObject/SetSelectedGameObject/IsPointerOverGameObject),本次实测 HTTP 200,https://docs.unity3d.com/Packages/com.unity.ugui@2.0/api/UnityEngine.EventSystems.EventSystem.htmlUnityEngine.UI.LayoutGroup(padding/childAlignment/SetLayoutHorizontal/SetLayoutVertical/SetLayoutInputForAxis),本次实测 HTTP 200,https://docs.unity3d.com/Packages/com.unity.ugui@2.0/api/UnityEngine.UI.LayoutGroup.htmlControl 类(锚点与偏移语义、屏幕尺寸变化时偏移自动更新、set_anchor/set_anchors_preset/custom_minimum_size/size_flags_*/get_combined_minimum_size),Sphinx 源文件,本次实测 HTTP 200 / 272599 字节,https://docs.godotengine.org/en/stable/_sources/classes/class_control.rst.txtContainer 类(fit_child_in_rect、pre_sort_children/sort_children 信号),Sphinx 源文件,本次实测 HTTP 200 / 10487 字节,https://docs.godotengine.org/en/stable/_sources/classes/class_container.rst.txtScrollContainer 类(SCROLL_MODE_DISABLED/AUTO/SHOW_ALWAYS/SHOW_NEVER/RESERVE 五档及 SCROLL_MODE_RESERVE 的内容尺寸常驻说明),Sphinx 源文件,本次实测 HTTP 200 / 32710 字节,https://docs.godotengine.org/en/stable/_sources/classes/class_scrollcontainer.rst.txtWindow 类(content_scale_size/content_scale_mode 三档及 CANVAS_ITEMS 与 VIEWPORT 的性能与清晰度取舍、content_scale_aspect 五档、content_scale_factor),Sphinx 源文件,本次实测 HTTP 200 / 173863 字节,https://docs.godotengine.org/en/stable/_sources/classes/class_window.rst.txtDisplayServer 类(get_display_safe_area 仅 Android/iOS 实现、get_display_cutouts 仅 Android 实现、screen_get_usable_rect 回落),Sphinx 源文件,本次实测 HTTP 200 / 670939 字节,https://docs.godotengine.org/en/stable/_sources/classes/class_displayserver.rst.txtandroidx.core.view.WindowInsetsCompat(getInsets(int)/getInsetsIgnoringVisibility(int)/getDisplayCutout/getRoundedCorner(int)/getPrivacyIndicatorBounds/getMandatorySystemGestureInsets 与 Type 位掩码),本次经代理实测 HTTP 200,https://developer.android.com/reference/androidx/core/view/WindowInsetsCompatandroid.view.DisplayCutout(getSafeInsetLeft/Top/Right/Bottom 的像素语义、getBoundingRects 的不可用区域矩形列表、API 28 起),本次经代理实测 HTTP 200,https://developer.android.com/reference/android/view/DisplayCutoutandroidx.core.view.WindowCompat(setDecorFitsSystemWindows 决定内容是否自动避开系统栏),本次经代理实测 HTTP 200,https://developer.android.com/reference/androidx/core/view/WindowCompatandroid.view.WindowInsets(系统栏与挖孔 inset 的平台原始类型),本次经代理实测 HTTP 200,https://developer.android.com/reference/android/view/WindowInsetsUIView.safeAreaInsets(安全区受导航栏/标签栏/工具栏与遮挡祖先影响、未装入层级时为零、tvOS 对应屏幕边框),开发者文档数据接口,本次实测 HTTP 200,https://developer.apple.com/tutorials/data/documentation/uikit/uiview/safeareainsets.jsonUIView.safeAreaLayoutGuide(未上屏时 guide 边缘等于视图边缘),开发者文档数据接口,本次实测 HTTP 200,https://developer.apple.com/tutorials/data/documentation/uikit/uiview/safearealayoutguide.jsonUIViewController.additionalSafeAreaInsets(自定义额外安全边距)与 UIScreen.overscanCompensationInsets(电视端过扫描补偿),开发者文档数据接口,本次实测 HTTP 200,https://developer.apple.com/tutorials/data/documentation/uikit/uiviewcontroller/additionalsafeareainsets.jsongodotengine/godot 119188 stars / 27241 forks / MIT / C++ / pushed 2026-10-28 / archived=false;Unity-Technologies/uGUI 1600 stars / 325 forks / C# / pushed 2026-10-24 / archived=false(API 未返回 license 标识);Unity-Technologies/UnityCsReference 13050 stars / 2548 forks / NOASSERTION / C# / pushed 2026-10-22 / archived=false。候选路径 needle-tools/uGUI 实测 HTTP 404,未采纳。Conversation
0 条