基于公司项目总结的适配经验,仅供参考,具体实现要多结合项目调整。
iPhone Duo 是一台可折叠的 iPhone,有三种常见形态:
- 外屏:合上时使用的窄屏,宽度属于 compact,用起来接近普通直板 iPhone。
- 内屏:展开后的大屏。横持时铰链竖向切开屏幕,竖持时铰链横穿屏幕。内屏两种持握方向的水平 size class 都是 regular,以及左右分屏显示,宽度属于 compact。
- 半折:内屏折成一定角度,铰链从「不生效」(inactive)变成「生效」(active),跨铰链的内容会被折痕切开。
另一个新东西是系统竖条 :外屏和内屏横持时,状态栏、Wi‑Fi、摄像头这些系统元素会收进屏幕一侧的竖条,不再横在顶部。竖条宽 84pt(模拟器实测,随系统版本可能变化),而且竖条出现后,系统只管自己的 UINavigationController 导航栏,App 自绘的导航栏和 TabBar 不会被系统挪进去,要自己处理。
本文记录我们适配 Duo 时定下的原则、背后的几何原理、模块怎么拆、踩过的坑,以及还没做完的部分。文中所有数据来自 iOS 27.1 的 iPhone Duo 模拟器,目前还没有真机数据。
一、设计原则
1.1 不判设备型号
布局决策里不出现 userInterfaceIdiom、UIInterfaceOrientation,也不判断「是不是 Duo」。所有判定只走一个出口 DuoEnvironment,它读的是 trait(size class、竖条在哪一边)和系统给的几何区域。
DuoEnvironment 里有一个 Posture 枚举(外屏 / 内屏竖持 / 内屏横持等),但它只用于日志、调试和验收,禁止用于布局。原因是分屏:内屏左右分屏后,每个窗口拿到的是 compact,按「内屏就是大屏」去布局会直接错。布局只认 size class 和实际可用宽度,这样分屏、外屏、直板机自然走同一条路。
1.2 compact 是基线
直板机、Duo 外屏、分屏后的窄窗口,水平 size class 都是 compact,这些情况一律保持现有布局,逐像素不变。
这一点不靠逐个页面检查,而是由算法结构保证:列数算法第一步就是「不是 regular 宽度,直接返回基线列数」,后面的计算根本不会执行。适配代码只在 regular 分支里生效。
1.3 几何量只取系统 API
需要的几何量只从系统公开 API 取:
safeAreaLayoutGuide:竖条已经算进 safe area;layoutGuide(for: .bar(onEdge:extent:)):系统竖条 / 顶部横栏的布局区域,已让开状态栏和摄像头;reservedRegions(kind:options:):铰链(.division)和摄像头遮挡(.occlusion);NSCollectionLayoutEnvironment.container.effectiveContentSize:列表的有效内容宽度。
其中 traitCollection.verticalBarEdge、reservedRegions(kind:options:)、layoutGuide(for: .bar(onEdge:extent:)) 自 iOS 27.1 起可用,低版本走「无竖条、无铰链」分支。
不写「竖条距顶多少」「铰链在 x 多少」这类实测常量。但有两处是认下的例外:
- 状态栏图标位置。内屏竖持时,设计要求自绘顶部行和 Wi‑Fi 图标中线像素级对齐,而状态栏图标位置没有公开 API。我们用了两个实测常量:Wi‑Fi 中线在 safe area 顶边之上 34pt、右侧时间 / Wi‑Fi / 电池一簇宽 120pt(均为模拟器实测,随系统版本可能变化)。120 风险更高,蜂窝信号、电池百分比、实时活动都可能让这一簇变宽。系统升级或真机首测时必须复核。
- TabBar 竖排的两个常量 。竖排 TabBar 顶部不高于 96pt(
verticalMinTop),pill 中线距屏幕边 48pt(verticalBarCenterInset),两者都是模拟器实测,随系统版本可能变化。之所以要实测,是因为竖条下statusBarFrame只报 2pt 高(模拟器实测),不可用。这是存量技术债,计划迁到 bar layout region。
1.4 网格默认偶数列,无竖向铰链时例外
网格的列数只由 ColumnPolicy 计算,默认只取偶数。HIG 要求偶数列,是为了半折时内容沿铰链对半分,不会有一列卡片被折痕切开。
例外是网格(瀑布流、书架网格、搜索榜单等)在不存在竖向铰链时允许奇数列。典型场景是内屏竖持:铰链横穿屏幕,切的是行与行之间,奇偶列对它没有影响。这时按宽度算出 3.6 列,取 3 列,而不是强行降到 2 列。
判据只读 reservedRegions(kind: .division, options: .includeInactive)。带上 .includeInactive 是为了全展开(铰链 inactive)和半折(active)算出同样的列数,不会一折就重排。列表类(单列 / 双列的结果列表)不受这条例外影响,内屏竖持仍是 1 列。
列间距沿用页面原有常量,不因列数变化而变化。
1.5 全展开不避让铰链,半折避让待完成
全展开时铰链是 inactive,屏幕是一整块连续的显示区域,内容不避让铰链,4 列按整宽均分。
半折时铰链 active,自定义浮层(确认弹窗、Toast 等)如果正好居中,会骑在铰链上。这部分还没做完 :目前只给少数弹窗加了宽度留白,统一的「避让到半屏」出口尚未实现,而且模拟器无法控制半折姿态,实际效果未验证。
1.6 不挂功能开关
项目里新功能一般挂开关、默认关。Duo 适配刻意不挂:compact 分支逐像素不变(见 1.2),regular 分支本身是在修布局问题。挂开关等于默认保留一个已知错位的布局,没有意义。
1.7 没有测试 target,用 selfCheck 代替
工程没有单元测试 target。几何判断和列数算法都写成纯函数,各自带一个只在开发构建里编译的 selfCheck(),用固定输入核对输出,例如设计基准宽 375 下网格最小列宽应为 163.5、列表应为 343。
DuoSelfCheck.run() 汇总所有模块的自检,挂在调试菜单里手动运行。它刻意不用 assert:一个失败就崩的话只能看到第一个错误,汇总列出全部失败项更适合排查。新增纯函数模块时要在 run() 里补一行。
二、设计原理
2.1 size class 与铰链 reserved region
先看几组模拟器实测数据(随系统版本可能变化):
| 形态 | window | 水平 size class | 竖条 | 页面 safe area(上/左/下/右) |
|---|---|---|---|---|
| 内屏横持 | 951×669 | regular | trailing | 0 / 0 / 34 / 84 |
| 内屏竖持 | 669×951 | regular | 无 | 82 / 0 / 82 / 0 |
| 外屏 | 466×678 | compact | trailing | 0 / 0 / 34 / 84 |
可以得出两点:
- 内屏竖持也是 regular。所以「直板机和外屏不变」只能靠 compact 保证,内屏竖持该排几列只能由宽度算法决定,size class 区分不出横持和竖持。
- 铰链不体现在 size class 里,要从 reserved region 读。
铰链通过 reservedRegions(kind: .division, options: .includeInactive) 拿到,每个区域有 frame 和 isActive。内屏横持时铰链竖向,x 455.5、宽 40;内屏竖持时铰链横向,y 455.5、高 40(模拟器实测,随系统版本可能变化)。宽高比就能判断方向。全展开 isActive 为 false,半折为 true。
这个 API 有两个坑:
- 启动后约 1s 才可读,而且没有变更通知(模拟器实测,随系统版本可能变化)。冷启动时第一次布局读到的是空,如果把「没有铰链」这个结论缓存下来,region 就绪后也不会再更新。所以判定放在每次布局时重算,只缓存肯定结论(「有铰链」),不缓存否定结论。
- 要从 window 根视图读。列表的 supplementary view、启动初期的视图上读不到区域,统一从 window 的根视图取。
2.2 竖条计入 safe area:窗口和页面不一样
系统竖条直接算进 safe area。内屏横持时 window 951 宽,竖条一侧的 inset 是 84(模拟器实测,随系统版本可能变化)。这带来一个很省事的结论:列数算法只吃容器的有效宽度,不需要另外扣竖条。内屏横持时列表可用宽 867(951 − 84),网格算出 4.7 列取 4,列表算出 2.4 列取 2;内屏竖持可用宽 669,网格 3.6 取 3,列表 1.8 取 1(均为模拟器实测宽度推算)。
页面里的内容也只需要水平贴 safeAreaLayoutGuide,背景通铺到竖条下,可滚动内容止于竖条边缘,这正是 HIG 的要求。
但「竖条算进 safe area」要区分是谁的 safe area:
- 未分屏、外屏、右半屏分屏:竖条的 inset 在 window 自己的 safe area 里,window 和其中所有页面都能读到。
- 左半屏分屏 :window 在竖条那一侧的 safe area 是 0,竖条让位只由系统给导航控制器里的页面。实测页面左侧 84、window 左侧 0、window 469×669(模拟器实测,随系统版本可能变化)。
所以「有竖条,但 window 在竖条那一侧 safe area 为 0」就是左半屏的判据。这时按 window safe area 布局的元素(比如挂在 window 级的 TabBar 计算)会和页面对不上,需要单独处理。另外,取竖条宽度时只读父控制器 view 的 safe area(扣掉父控制器自己写的 additional),不读页面自己的,原因在问题修复部分会讲到。
2.3 偶数列为什么只在竖向铰链时成立
偶数列的目的只有一个:半折时,左右两半各放整数列,折痕落在列间距里。这个目的成立的前提是铰链竖向切开内容,也就是内屏横持。
内屏竖持时铰链横穿屏幕,切的是行与行之间。列数是 2 还是 3,折痕都只会落在某一行附近,偶数列换不来任何好处。反过来,如果竖持也强制偶数:可用宽 669 算出 3.6 列,取偶只能是 2 列,每张卡片约 310pt 宽,是直板机 163pt 的 1.9 倍,封面被放得很大,一屏只能看到很少几本书。改成 3 列后卡片约 201--204pt,和横持 4 列时的 196--199pt 接近(均为模拟器实测,随系统版本可能变化)。
因此判据设计成「存在竖向铰链才取偶」。读区域时带 .includeInactive,全展开和半折列数一致;如果还没读到铰链(启动初期),按「无竖向铰链」算。内屏横持原始结果 4.7 → 4 本来就是偶数,所以这个兜底不影响横持。
2.4 导航转场期间竖条判据会丢
系统给 verticalBarEdge 是按页面的位置判断的:页面盖住竖条那一侧的屏幕边缘才有竖条判据。
右侧竖条时,用户手势返回,上一页从左侧滑入,起始位置在 x −277(模拟器实测,随系统版本可能变化),根本碰不到右边缘,于是整个手势过程中它的 verticalBarEdge 都是 nil,松手回到 x 0 才恢复。左侧竖条时上一页仍盖着左边缘,判据不丢。
如果页面跟着判据实时重排,现象就是:返回手势一开始,按钮从竖条漂到顶部横排,导航栏高度在 64 → 48 → 64 之间抖,内容左右跳。所以转场期间页面要挂起对竖条判据的响应,转场结束后取一次最新判据,和已生效的一样就什么都不做。具体做法见封装设计部分。
2.5 内外屏切换:size class 先变,bounds 后变
展开或合上时,trait(包括 size class)先变,window 尺寸大约 60ms 后才变(模拟器实测,随系统版本可能变化)。中间还会出现不自洽的组合,比如「951 宽 + 没有竖条」「compact + leading 竖条 + 左侧 safe area 148」,几十毫秒后才稳定。
这对 compositional layout 是个问题。以从内屏切到外屏为例:size class 变成 compact 的那一帧,列表 bounds 还是旧的 871,section provider 按 compact 算出 2 列、每列 413 宽;随后 bounds 变成 386,系统认为布局已经算过,不会再调 section provider,卡片就停在 413 宽,超出屏幕。
结论有两条:
- 判定不能只看 trait 变化那一刻的值,要在布局时用当时的实际宽度算。
- 布局对象要在「宽度和上次排版不同」时强制重算,不能依赖系统自己判断。这就是后面
AppCompositionalLayout的由来。
三、架构设计
3.1 模块总览
Duo 相关代码集中在一个目录里,分五块:
| 模块 | 职责 |
|---|---|
环境判定 DuoEnvironment |
竖条在哪一边、size class、铰链(active / 竖向 / 横向)、顶部行偏移、状态栏右侧让位、竖条一侧收回量、系统竖条宽、trait 观察、诊断用 Posture |
列数策略 ColumnPolicy |
纯函数:可用宽度 → 列数、列宽 |
尺寸常量 DuoMetrics |
设计基准宽 375、最小列宽派生、网格 / 列表两套 spec、状态栏实测常量 |
| 竖条组件 | NavBarAxisResolver(横排 / 竖排判定)、VerticalActionRail(竖条按钮列)、TabBarLayoutResolver(双 pill 摆放,纯值类型) |
| 调试自检 | DuoGeometryProbe(打印 bar region、reserved region、横向过滚动视图数)、DuoSelfCheck(汇总各模块 selfCheck()),仅开发构建 |
依赖是单向的:DuoMetrics 和 DuoEnvironment 在底层;ColumnPolicy 只吃数值和 trait;竖条组件依赖环境判定;调试模块只读不写。模块之外还有三个接入点:宽度变化强制重算的 AppCompositionalLayout、页面基类 AppViewController(竖条按钮编排、收回量写入、转场挂起)、AppTabBarController(tab 根页收回量、旋转时的硬阴影保护)。
3.2 环境判定:DuoEnvironment
页面不直接读 trait,也不直接调 reserved region,全部经过 DuoEnvironment。最基础的两个判定如下,用到的 API 都是 iOS 27.1+,低版本一律按「没有竖条、没有铰链」处理,布局等于直板机。
swift
import UIKit
enum DuoEnvironment {
enum VerticalBarEdge { case leading, trailing }
/// 系统竖条在哪一边;nil = 没有竖条(iOS 27.1+)
static func verticalBarEdge(for traits: UITraitCollection) -> VerticalBarEdge? {
guard #available(iOS 27.1, *) else { return nil }
switch traits.verticalBarEdge {
case .leading: return .leading
case .trailing: return .trailing
default: return nil
}
}
/// 是否存在竖向铰链(高 > 宽,含 inactive,全展开与半折一致)
static func hasVerticalDivision(in view: UIView) -> Bool {
guard #available(iOS 27.1, *) else { return false }
return view.reservedRegions(kind: .division, options: .includeInactive)
.contains { $0.frame.height > $0.frame.width }
}
/// 列数算法拿不到视图,统一从活跃 window 的根视图读
static func hasVerticalDivision(in window: UIWindow?) -> Bool {
guard let window else { return false }
return hasVerticalDivision(in: window.rootViewController?.view ?? window)
}
}
铰链方向用宽高比判断:内屏横持时铰链宽 40、高 669,竖持时宽 669、高 40(模拟器实测,随系统版本可能变化)。同一套读法再加一个「宽 ≥ 高」就是横向铰链判定,内屏竖持的顶部行上提用它。
3.3 列数策略:ColumnPolicy
ColumnPolicy 是纯函数,输入只有宽度、是否 regular、是否有竖向铰链和 spec,没有任何 UIKit 状态,所以能直接拿固定数值自检。
swift
import UIKit
enum ColumnPolicy {
static func columns(availableWidth: CGFloat, isRegularWidth: Bool,
hasVerticalDivision: Bool = true, spec: ColumnSpec) -> Int {
let baseline = max(spec.baseline, 1)
let upper = max(spec.maxColumns, baseline)
// compact 直接返回基线:直板机、外屏、分屏窄窗口逐像素不变
guard isRegularWidth, availableWidth > 0, spec.minimumColumnWidth > 0 else { return baseline }
let raw = Int(((availableWidth + spec.spacing)
/ (spec.minimumColumnWidth + spec.spacing)).rounded(.down))
guard raw > baseline else { return baseline }
// 默认取偶;网格在没有竖向铰链时(内屏竖持)保留奇数
let keepsOdd = spec.allowsOddColumnsWithoutVerticalDivision && !hasVerticalDivision
let adjusted = keepsOdd ? raw : raw - raw % 2
return min(max(adjusted, baseline), upper)
}
static func columnWidth(availableWidth: CGFloat, columns: Int, spacing: CGFloat) -> CGFloat {
let count = CGFloat(max(columns, 1))
return max(floor((availableWidth - spacing * (count - 1)) / count), 0)
}
}
hasVerticalDivision 默认 true,也就是不知道时按最保守的「取偶」走。实际接入时还有一个 trait 版本:从 traitCollection 取 size class,只有 spec 允许奇数列且是 regular 时才去读铰链,列表类 spec 不碰 reserved region。可用宽度用 effectiveContentSize.width − 2 × 页边距,竖条已经在 safe area 里扣掉了。
3.4 尺寸常量:DuoMetrics
DuoMetrics 里唯一的设计尺寸是基准宽 375,最小列宽由它派生,意思是「直板机 375 宽上的列宽就是每列至少要有的宽度」:
swift
import UIKit
struct ColumnSpec: Equatable {
var baseline: Int // compact 下的现状列数
var maxColumns: Int
var minimumColumnWidth: CGFloat
var spacing: CGFloat
var allowsOddColumnsWithoutVerticalDivision = false
}
enum DuoMetrics {
static let designBaseWidth: CGFloat = 375
/// (375 − 2 × 页边距 − (基线 − 1) × 列间距) / 基线
static func minimumColumnWidth(baseline: Int, spacing: CGFloat, horizontalInset: CGFloat) -> CGFloat {
let count = CGFloat(max(baseline, 1))
return (designBaseWidth - horizontalInset * 2 - spacing * (count - 1)) / count
}
static func gridSpec(spacing: CGFloat, horizontalInset: CGFloat = 16) -> ColumnSpec {
var spec = ColumnSpec(baseline: 2, maxColumns: 4,
minimumColumnWidth: minimumColumnWidth(baseline: 2, spacing: spacing,
horizontalInset: horizontalInset),
spacing: spacing)
spec.allowsOddColumnsWithoutVerticalDivision = true
return spec
}
static func listSpec(spacing: CGFloat, horizontalInset: CGFloat = 16) -> ColumnSpec {
ColumnSpec(baseline: 1, maxColumns: 2,
minimumColumnWidth: minimumColumnWidth(baseline: 1, spacing: spacing,
horizontalInset: horizontalInset),
spacing: spacing)
}
}
页边距 16、列间距 16 时,网格最小列宽 (375 − 32 − 16) / 2 = 163.5,列表 343。网格基线 2 列、上限 4 列,列表基线 1 列、上限 2 列。状态栏那两个实测常量(34 / 120)也放在这里,集中在一处,方便系统升级时复核。
3.5 竖条组件
NavBarAxisResolver:导航按钮横排还是竖排的唯一判据,读的就是verticalBarEdge。沉浸页(阅读器)另有一个placement判定,区分竖条、顶部横栏、页面内三种落点;普通页面不调它。VerticalActionRail:竖条里的按钮列,三组按钮:返回 / 关闭、功能按钮贴顶,另一组贴底(只给没有竖排 TabBar 的页面用,避免和 TabBar 抢位置)。按钮统一 40×40、间距 20;纵向贴系统竖条布局区域,横向在拿到系统竖条宽时按 TabBar pill 中线对齐(见 4.3 末尾)。TabBarLayoutResolver:两个 pill(主 tab、搜索)的横排 / 竖排摆放,纯值类型,用物理坐标。竖排从底往上摆,横向铰链穿过整组时挪到铰链上方,放不下退回横排。
3.6 调试自检
DuoGeometryProbe 和 DuoSelfCheck 只在开发构建里编译,挂在调试菜单。探针打印当前页面的 bar region、reserved region、statusBarFrame,以及「横向可滚范围大于 0 的竖向列表」个数,验收时这个数必须是 0。自检汇总各纯函数的 selfCheck(),列出全部失败项而不是断言崩溃。
四、封装设计思路
4.1 纯函数化,用 selfCheck 代替测试
能写成纯函数的都写成纯函数:列数、列宽、最小列宽、收回量公式、TabBar 摆放。依赖视图的部分(读 trait、读 region、读 safe area)只做「取数」,取到的数交给纯函数算。好处是没有测试 target 也能验证,自检输入取自实测宽度推出的数值,例如可用宽 835(867 减两侧页边距)时网格应为 4 列。
4.2 单一判定出口
每类判定只有一个出口:竖条看 NavBarAxisResolver / DuoEnvironment.verticalBarEdge,列数看 ColumnPolicy,顶部行偏移看 DuoEnvironment.topRowOffset,竖条宽看 DuoEnvironment.systemVerticalBar。页面不自己判形态。这样系统行为变了(比如真机上竖条宽不是 84),只改一处。
4.3 移动同一个按钮实例,不重建
竖条出现时,自绘导航栏里只有图标的按钮被移进 VerticalActionRail,竖条消失时再移回去,始终是同一个实例。点击回调、两态图标、禁用态都不用重新绑定,调用点一个字不改。带文字的按钮留在横排(Apple 的规则)。
移动时有三件事要处理:
- 尺寸协议。竖排统一 40,横排原来可能是 48 或 64。列不认具体按钮类型,只认一个协议:收进来时记下原直径、放大到 40,摘下时还原。
- 先清按钮自身的尺寸约束 。按钮自己的 width / height 约束 firstItem 是按钮本身,
removeFromSuperview()带不走它,不清就带着 48 进列,把 40 压掉。 - 回横排时清掉列的定位约束 。列贴着系统竖条区域的那几条约束,竖条消失后还留着就会和新布局冲突,所以三组都空时直接移除。另外「撑满到底部」那条等式降到
.high,配一条 required 的「不超过底部」:折叠、旋转的中间帧里竖条区域和窗口尺寸不一定同步,required 等式可能无解。
下面的片段只用原生约束(iOS 27.1+ ,.bar(onEdge:) 要显式写 NSDirectionalRectEdge,否则和 UIRectEdge 重载有歧义):
swift
import UIKit
protocol VerticalBarResizableButton: UIView {
var circleDiameter: CGFloat { get }
func updateCircleDiameter(_ diameter: CGFloat)
}
final class VerticalActionRail: UIStackView {
private var pinned: [NSLayoutConstraint] = []
private var originalDiameters: [ObjectIdentifier: CGFloat] = [:]
func setItems(_ items: [UIView]) {
arrangedSubviews.forEach { view in
if let button = view as? VerticalBarResizableButton,
let d = originalDiameters.removeValue(forKey: ObjectIdentifier(button)) {
button.updateCircleDiameter(d)
}
view.removeFromSuperview()
}
items.forEach { item in
item.removeConstraints(item.constraints.filter { $0.firstItem === item && $0.secondItem == nil })
if let button = item as? VerticalBarResizableButton {
originalDiameters[ObjectIdentifier(button)] = button.circleDiameter
button.updateCircleDiameter(40)
}
addArrangedSubview(item)
}
if items.isEmpty { NSLayoutConstraint.deactivate(pinned); pinned = [] } // 回横排清定位约束
}
@available(iOS 27.1, *)
func pin(to host: UIView, edge: NSDirectionalRectEdge) {
let guide = host.layoutGuide(for: .bar(onEdge: edge, extent: 40))
NSLayoutConstraint.deactivate(pinned)
translatesAutoresizingMaskIntoConstraints = false
let fill = bottomAnchor.constraint(equalTo: guide.bottomAnchor)
fill.priority = .defaultHigh // 撑满是「希望」,中间帧可牺牲
pinned = [centerXAnchor.constraint(equalTo: guide.centerXAnchor),
topAnchor.constraint(equalTo: guide.topAnchor),
bottomAnchor.constraint(lessThanOrEqualTo: guide.bottomAnchor), fill]
NSLayoutConstraint.activate(pinned)
}
}
真实实现里列由三组 stack 组成,空白处的点击会穿透下去;横向位置在有系统竖条宽度时按 TabBar pill 中线(48,模拟器实测,随系统版本可能变化)对齐,而不是竖条几何中线 42,保证按钮和竖排 TabBar 在同一条线上。
4.4 收回量按公式算,只读父控制器的 safe area
竖条让位后,pill 到内容的距离比 pill 到屏幕边的距离大,看起来内容离竖条太远。做法是在竖条那一侧的 additionalSafeAreaInsets 写一个负值,把内容往竖条收一点:
收回量 = 竖条宽 + 页边距 − 2 × pill 中线
以竖条宽 84、页边距 16、pill 中线 48 为例(模拟器实测,随系统版本可能变化):84 + 16 − 96 = 4,左半屏内容起点 100 → 96。竖条宽、pill 口径、页边距任何一个变了自动跟着算,不写死 4。
竖条宽只读父控制器 view 的 safe area 减去父控制器自己的 additional;没有父控制器(modal 根页)时读 window 的 safe area。绝不读页面自己的 safe area,因为页面刚写完 additional、safe area 还没重算时两者不同步,差值会逐轮放大。写入时先撤回上次写的量再写新值,不碰别处写的上下 inset。
swift
import UIKit
enum VerticalBarTrim {
/// 竖条在哪一侧、多宽:读父控制器(导航控制器)的 safe area,不随页面转场移动
static func systemBar(for vc: UIViewController, isLeft: Bool) -> CGFloat {
let insets: UIEdgeInsets
if let parent = vc.parent, parent.isViewLoaded {
let total = parent.view.safeAreaInsets, extra = parent.additionalSafeAreaInsets
insets = UIEdgeInsets(top: 0, left: total.left - extra.left,
bottom: 0, right: total.right - extra.right)
} else {
insets = vc.view.window?.safeAreaInsets ?? .zero
}
return isLeft ? insets.left : insets.right
}
static func trim(barWidth: CGFloat, pageInset: CGFloat, pillCenter: CGFloat) -> CGFloat {
guard barWidth > 0 else { return 0 }
return max(0, barWidth + pageInset - pillCenter * 2)
}
/// 先撤回上次写入量,再写新值;只动竖条那一侧
static func apply(_ trim: CGFloat, isLeft: Bool, to vc: UIViewController, last: inout CGFloat) {
var insets = vc.additionalSafeAreaInsets
if isLeft { insets.left += last - trim } else { insets.right += last - trim }
vc.additionalSafeAreaInsets = insets
last = trim
}
}
子控制器的父控制器 safe area 已经含了页面的收回量(例如 80),代入公式得 0,不会重复收。片段里的「哪一侧」在真实实现里由竖条边和布局方向换算成物理左右。
4.5 唯一写入方
additionalSafeAreaInsets 是多方都可能想写的东西(TabBar 让位、竖条收回量),所以规定只有两个写入方,各管一类:
- tab 栈的根页 由
AppTabBarController写,和 pill 让位合并成一次写入。收回量按选中 tab 量到的竖条统一算、写给所有根页。未选中的根页不在屏幕上,自己量不到竖条会得 0,切 tab 时内容会左右跳 4pt。 - 其余页面 由
AppViewController写,记住上次写入量,先撤回再写。
导航控制器自己的 additional 固定写 0,它的 safe area 就是系统给的值,这也是 4.4 能放心读父控制器的前提。不允许出现第三个写入方。
4.6 宽度变化时强制重算的布局
2.5 讲过,内外屏切换时 size class 先变、bounds 后变,系统按旧宽度算完之后不会再调 section provider。解决办法是一个很小的子类,记住上次真正排版用的宽度,宽度不同就失效:
swift
import UIKit
final class AppCompositionalLayout: UICollectionViewCompositionalLayout {
/// 上一次真正排版时用的宽度
private var preparedWidth: CGFloat?
override func prepare() {
super.prepare()
preparedWidth = collectionView?.bounds.width
}
override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool {
// 只比较宽度:滚动只改 origin,不会多出失效
if let preparedWidth, preparedWidth != newBounds.width { return true }
return super.shouldInvalidateLayout(forBoundsChange: newBounds)
}
}
按容器宽度算布局的列表一律用它替换 UICollectionViewCompositionalLayout,初始化写法不变。配套要求是高度缓存的 key 带上列数,否则 2 列算出来的高度会被 4 列复用。
4.7 转场期挂起,而不是冻结约束
2.4 讲过,返回手势期间上一页会丢竖条判据。处理方式是:已经出现过的页面在 viewWillAppear / viewWillDisappear 时如果有 transitionCoordinator,就挂起对竖条判据的响应(不重建导航栏、不改 safe area、导航栏顶部行不重算),转场结束后在无动画块里取一次最新判据,和已生效的一样就什么都不做。首次进栈的页面不挂起。
swift
import UIKit
class AppViewController: UIViewController {
private var hasAppearedOnce = false
private(set) var isHoldingForTransition = false
private var appliedBarEdge: Int? // 已生效的竖条判据(示意)
override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated); holdIfNeeded() }
override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated); holdIfNeeded() }
override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated); hasAppearedOnce = true }
private func holdIfNeeded() {
guard hasAppearedOnce, !isHoldingForTransition, let coordinator = transitionCoordinator else { return }
isHoldingForTransition = true
coordinator.animate(alongsideTransition: nil) { [weak self] _ in self?.endHold() }
}
private func endHold() { // 含交互式返回被取消
isHoldingForTransition = false
guard view.window != nil else { return } // 已离开屏幕:等下次出现再恢复
UIView.performWithoutAnimation {
if currentBarEdge() != appliedBarEdge { reloadBars() }
}
}
/// 竖条 trait 变化回调里先判断:挂起期间不响应
func verticalBarTraitDidChange() {
guard !isHoldingForTransition else { return }
reloadBars()
}
func currentBarEdge() -> Int? { nil } // 真实实现读 verticalBarEdge(iOS 27.1+)
func reloadBars() { appliedBarEdge = currentBarEdge() }
}
我们先试过「冻结约束、事后解冻」,被废弃了:它只拦住了按钮,导航栏留白、页面自己的顶部行还在各自读判据,解冻时又动一次,返回上一页能看到抖动。挂起的是「响应判据」这件事本身,所以所有依赖判据的布局一起停。页面如果自己按竖条判据算顶部行,要覆写一个挂起 / 恢复回调,恢复时补一次同步。沉浸页(阅读器)不继承这个基类,目前没有这层保护。
五、覆盖场景
下表的几何数值均为模拟器实测,随系统版本可能变化(iOS 27.1 iPhone Duo 模拟器,尚无真机数据)。
| 形态 | size class / 竖条 | 顶部行 | 列数(网格 / 列表) | 导航按钮位置 | TabBar | 备注 |
|---|---|---|---|---|---|---|
| 直板机 | compact / 无竖条 | 不变 | 2 / 1(基线) | 顶部横排 | 底部横排双 pill | 设计基线,逐像素不变 |
| 外屏 | compact / 有竖条(宽 84) | 顶部 safe area 为 0,下移 16 | 2 / 1(compact 恒为基线) | 纯图标按钮进竖条,从上往下排;有文字的留在横排 | 竖排,在竖条下段,从底往上摆 | 内容止于竖条边缘,竖条一侧收回 4 |
| 内屏竖持 | regular / 无竖条,有横向铰链 | 提进状态栏行:行中线对齐 Wi‑Fi 中线,右侧让开时间 / Wi‑Fi / 电池与摄像头;导航栏总高 80 | 3 / 1(无竖向铰链,网格不取偶) | 顶部横排(阅读器另走顶部横栏区域) | 底部横排 | 状态栏图标位置无公开 API,用实测常量 34 / 120,未经真机验证 |
| 内屏横持 | regular / 有竖条,有竖向铰链 | 顶部 safe area 为 0,下移 16 | 4 / 2(取偶) | 进竖条 | 竖排 | 竖向列表用 .scrollableAxes,无左右弹簧 |
| 左半屏分屏 | 有竖条,但 window 在竖条那一侧 safe area 为 0 | 首颗竖排按钮中线对齐标题行中线 | 窗口为 compact 时取基线(2 / 1),由算法结构保证;分屏下实际 size class 与列数未验证 | 所有页面(含二级页)保留左侧让位带,图标按钮排进让位带,横向中线 48 | 根页 TabBar 在让位带下段 | 内容起点 100 → 96;状态栏内嵌判据未验证 |
| 右半屏分屏 | 竖条让位在 window 自己的 safe area 里 | 同未分屏的竖条形态 | 同左半屏:compact 取基线,实际列数未验证 | 贴系统竖条区域 | 同竖条形态 | 不命中左半屏判据;状态栏内嵌判据未验证 |
| 半折 | 铰链 active | 未验证(预期同对应全展开形态,未实测) | 与全展开一致(判据含 inactive 铰链) | 未验证(预期同对应全展开形态,未实测) | 横向铰链穿过整组时挪到铰链上方,放不下退回横排 | 自定义浮层避让待完成 ;book / tabletop 实际效果未验证(模拟器无法控制姿态) |
全展开时铰链是 inactive,内容按整宽均分,不做避让。半折时确认弹窗、Toast 这类浮层会骑在铰链上,目前只给版本更新弹窗和确认弹窗加了宽度留白,统一的避让出口还没做。
5.1 验收矩阵
涉及布局的改动,每项都截图对照:
| 维度 | 要求 |
|---|---|
| 直板机 | 与改动前一致 |
| 外屏 | 与改动前一致;竖条 / TabBar 正常;内容不越过竖条 |
| 内屏竖持 | 网格 3 列 / 列表 1 列;横排栏正常 |
| 内屏横持 + 竖条 | 瀑布流 4 列 / 列表 2 列;内容不越过竖条;无左右弹簧 |
另外做反复切换检查:展开、合上、旋转来回切,无崩溃、无残影;用几何探针确认横向可过滚动的视图数为 0。折叠 / 展开过程中根页列表是否跳动、竖排 TabBar 的点击行为,目前只看了截图,未验证;全页面、四环境的全量回归也还没做。
六、问题修复汇总
下面按主题归并适配过程中修过的问题,每条写现象、根因、修复。文中数值均为模拟器实测,随系统版本可能变化。
6.1 Safe area 与收回量
收回量逐轮放大,左半屏返回根页时内容铺满
- 现象:左半屏分屏下 pop 回根页,收回量从 −4 变成 −68,一路放大到 −324,根页 safe area 被截到 0,内容瞬间铺满屏幕。
- 根因:竖条宽按「页面 safe area − 页面 additional」算。刚写完 additional 时 safe area 还没重算,两者不同步,差值算错;下一轮又以错值为基础写入,误差逐轮放大。转场中页面被移开时,页面 safe area 还会短暂变成 0。
- 修复:竖条宽只读父控制器 view 的 safe area,并扣掉父控制器自己的 additional。导航控制器的 additional 固定写 0,且不随转场移动,所以它的 safe area 就是系统给的值。没有父控制器的 modal 根页改读 window 的 safe area。
切 tab 时内容左右跳 4pt
- 现象:切到另一个 tab,内容先按 0 收回,随后变成 −4。
- 根因:未选中的根页不在屏上,读不到竖条,各自算出的收回量都是 0。
- 修复:由 TabBar 容器按选中 tab 量到的竖条统一计算,写给所有根页。
TabBar 让位叠成 148
- 现象:左半屏分屏时内容和 pill 之间多出一段空白,pill 中线偏到 40(右侧竖条时是 48)。
- 根因:左半屏时系统只给导航控制器里的页面留 84 的竖条让位,window 和 TabBar 自己左侧的 safe area 是 0。TabBar 只看自己,把竖条当成 0 宽,又给根页补了 64,和系统的 84 叠成 148。
- 修复:pill 让位按内容页的系统 safe area 取较大值。修复后竖条宽 84、pill 中线 48、补让位 0,和右侧竖条镜像一致。
pill 到内容与 pill 到屏幕边距离不一致
- 现象:竖条形态下,pill 外侧到屏幕边和内侧到内容的距离不同,看起来不居中。
- 根因:内容只贴 safe area,没考虑 pill 在竖条里的实际位置。
- 修复:收回量按公式算:竖条宽 + 页边距 − 2 × pill 中线距屏幕边(84 / 16 / 48 时为 4),以负值写进竖条那一侧的
additionalSafeAreaInsets。写之前先撤回上次写入的量。
页面内容越过竖条
- 现象:列表、横滑卡片、页面头部延伸到竖条下方。
- 根因:页面内容容器水平方向贴的是 superview,没有贴 safe area。
- 修复:页面基类的内容容器水平方向统一贴
safeAreaLayoutGuide。按 HIG,背景通铺,可滚内容内缩。
6.2 竖条约束与按钮
竖条消失后残留约束挤压内容
- 现象:折叠、旋转或竖条换边之后,正文容器被挤成窄条,按钮列跑到屏幕边外。
- 根因:竖排按钮列的约束锚在系统竖条布局区域上。竖条消失后这些约束还在,和新姿态下的区域冲突,Auto Layout 打破冲突时牺牲哪一条无法预期。
- 修复:在设置按钮的方法里,三组按钮都为空时清掉按钮列自己的全部约束。清理放在组件内部,不依赖调用方记得「取消固定」。
按钮自身尺寸约束没清,进竖条后仍是 48
- 现象:阅读器按钮进竖条后实测宽 48,竖排列要求的 40 不生效。
- 根因:
make.size.equalTo(...)这类约束的 firstItem 是按钮自己,约束挂在按钮上,removeFromSuperview()带不走。 - 修复:把按钮交给竖排列之前,先清掉 firstItem 是按钮本身的约束,尺寸改由
intrinsicContentSize决定。用一个尺寸协议负责进列时放大到 40、摘下时还原。
姿态切换中间帧约束无解
- 现象:姿态切换时偶发约束冲突,按钮位置错乱。
- 根因:中间帧里竖条区域和窗口尺寸不一定同步更新,一条 required 的「撑满到底」等式在那一刻可能无解。
- 修复:这条等式降到
.high,另加一条 required 的bottom ≤。最坏情况退化成「贴顶 + 不越底」。
按钮列未固定时刷约束冲突日志
- 现象:控制台反复输出
Unable to simultaneously satisfy constraints。 - 根因:横排时(或回横排清掉约束后)按钮列没有固定位置,UIKit 把 0 高的 frame 转成
height == 0的 autoresizing 约束,和内部「两组间距 ≥ 20」的 required 约束冲突。 - 修复:按钮列关闭
translatesAutoresizingMaskIntoConstraints。
竖排按钮位置依赖实测常量
- 现象:早期按实测距离摆按钮,换形态就要重新量。
- 根因:没有用系统给的竖条布局区域。
- 修复:按钮列改用
layoutGuide(for: .bar(onEdge:extent:))定位,这块区域已经让开状态栏、摄像头。左半屏是例外:系统区域落在让位带之外,横向改按 pill 中线从屏幕边量。
6.3 滚动与布局
竖向列表出现左右弹簧
- 现象:内屏横持时,竖向列表能横向拖动,出现 84pt 的左右弹簧。
- 根因:
contentInsetAdjustmentBehavior = .always把竖条 safe area 加进左右 inset,横向可滚范围大于 0。 - 修复:改用
.scrollableAxes+alwaysBounceVertical = true,竖条交给 compositional layout 按 safe area 内缩 section。几何探针加了「横向可过滚动视图数」检查。早期试过用 KVO 锁横向滚动,已废弃。
内外屏切换后卡片停在旧宽度
- 现象:从内屏切到外屏,卡片超出屏幕;返回上一页再进来才恢复。
- 根因:size class 先变、bounds 后变。trait 变化触发重算时 bounds 仍是内屏宽(871),section 按「871 + compact」算出 2 列、每列 413 宽;之后 bounds 变成 386,系统没有再调 section provider。
- 修复:新增一个 compositional layout 子类,记住上次排版时的宽度,宽度不同就让布局失效。只比宽度,滚动不会触发。业务列表统一换成它,高度缓存的 key 带上列数。
分页列表宽度错乱
- 现象:内外屏切换或旋转后,书架两个分页的宽度错乱,实测 cell 669、列表 817(817 = 669 + 148)。
- 根因:分页库已经按新宽度显式写了列表 frame,子页面根视图默认的 autoresizing 又把宽度差叠加了一次。
- 修复:分页列表根视图
autoresizingMask = [],尺寸只认分页库写入的 frame。
内屏竖持网格卡片过大
- 现象:内屏竖持时网格只有 2 列,卡片约 310pt 宽,接近直板机的 1.9 倍。
- 根因:列数一律取偶,但偶数列只在铰链竖向切开内容时才有意义;内屏竖持铰链横穿行间,奇偶无影响。
- 修复:只有存在竖向铰链时才取偶,判据读包含 inactive 的铰链 region,保证全展开和半折列数一致。首页瀑布流、全量书籍瀑布流、书架网格、搜索榜单改为 3 列,卡片约 201--204pt;列表不受影响。
6.4 时序与缓存
铰链判定缓存了否定结论,冷启动顶部行不上提
- 现象:内屏竖持冷启动,首页 logo 行停在原位,顶部多出一块空白,4 次冷启动 4 次复现。
- 根因:铰链 reserved region 启动后约 1s 才可读,且没有变更通知。原实现把 false 缓存 1s,region 就绪后的那次布局恰好落在缓存期内,读到旧的 false,之后再没有布局触发。
- 修复:只缓存 true,false 每次重查。判定在布局时重算,region 统一从 window 根视图读(列表 supplementary view 和启动初期的 window 上读不到)。
列表停在顶部时改 contentInset 平移两次
- 现象:形态切换后列表顶部内容偏移了两倍的差值。
- 根因:系统可能已经自行调整过 offset,再按差值减一次就平移了两遍。
- 修复:停在顶部时直接对齐到新顶部,
offset = -adjustedContentInset.top。下拉刷新中曾尝试只改 inset,结果首页顶部内容被推出屏幕,已撤回;刷新中恰逢形态切换属于已知限制。
第三方调试工具干扰主 window 几何
- 现象:dev 包里内外屏切换后 window 停在旧尺寸,控制台有 UIKit「BUG IN CLIENT OF UIKIT」警告;内屏旋转时还出现黑边。
- 根因(当前判断):调试工具用
UIScreen.main.bounds建自己的 window。Duo 有两块屏,UIScreen.main不一定是 scene 所在的屏,干扰了主 window 的几何。只影响 dev 构建。 - 修复:加调试浮层开关,Duo 上默认关;冷启动后隐藏调试 window 并断开它的 windowScene,旋转时也隐藏。
6.5 转场
旋转时硬阴影露出卡片外
- 现象:旋转或 Duo 内屏旋转时,实心硬阴影整块露在卡片外。
- 根因:
shadowPath不参与 UIView 隐式动画,转场一开始就跳到终点尺寸。 - 修复:尺寸随屏宽变化的大卡片遵循一个「转场隐藏阴影」协议。TabBar 容器在
viewWillTransition中把阴影透明度压到 0,completion 中还原(包括取消,连续旋转只在最后一次结束后还原)。网格卡片、搜索结果卡片都已接入。没有改用底板视图(外沿抗锯齿和 layer shadow 不同),也没有给shadowPath补动画(依赖未公开的动画 key)。
手势返回时按钮漂移、导航栏抖动
- 现象:右侧竖条下手势返回,按钮先搬到顶部、松手再搬回竖条;导航栏高度 64 → 48 → 64 上下抖,收回量左右跳 4pt。
- 根因:系统按页面位置给竖条判据。手势返回中的上一页被移到 x −277,碰不到竖条,判据整段为 nil。左侧竖条时上一页仍盖着左边缘,没有这个问题。
- 修复:已出现过的页面在导航转场期间(存在
transitionCoordinator)挂起,不响应判据变化。转场结束后在无动画块里取一次最新判据,和已生效的相同就什么都不做。首次进栈的页面不挂起。「冻结约束、事后解冻」方案只拦住了按钮,解冻时还会再动一次,已废弃。阅读器不继承页面基类,这层保护尚未覆盖。
6.6 其它
底部抽屉被竖条上段系统元素压住
- 现象:抽屉全宽,右边伸到竖条下方,停在 96 时被时间 / Wi‑Fi 压住。
- 根因:抽屉顶部留白只按无竖条的情况设定。
- 修复:有竖条时顶部留白加到 130。判据是「有没有竖条」,不是设备型号。
阅读器引导气泡指偏
- 现象:分屏下引导卡片的气泡尾巴没有指向目标按钮。
- 根因:阅读器不走页面基类,按钮进了让位带,引导层仍按原位置摆。
- 修复:阅读器正文与控件层水平贴安全区,分屏时按钮列进让位带;引导卡片水平跟随高亮按钮。
左半屏方案走过的弯路
- 现象:最初让二级页铺满、按钮留在顶部,往 additional 写负值抵消整条 84。
- 根因:这种做法和根页、TabBar 的让位口径不一致,切页面时布局跳变。
- 修复:废弃该方案,改为所有页面保留让位带,按钮和 TabBar 共用 pill 中线,首颗按钮与标题行对齐。
6.7 经验:先拿数据,再改代码
排查「window 停在旧尺寸、分页宽度错乱」时,前三轮都是凭现象改的:给分页 invalidate 并对齐整页偏移、把列表约束钉到 cell、连续三帧同步列表 frame。三轮都无效,第二轮还造成整页错位和黑边;强改 window.frame 能掩盖现象,但没有采用。后来改成每一轮先用几何探针打出 window、分页容器、列表的实际 frame 再动手,才分别定位到 autoresizing 叠加和调试工具的 window 两个根因。Duo 的转场中间态会出现几十毫秒的不自洽组合(比如 951 宽却没有竖条),只看截图很容易误判,日志里的时序数据比现象更可靠。
七、可优化建议
下面各条都是已知、有记录的缺口。「待完成」指还没做,「未验证」指做了但没有实测结论。
7.1 TabBar 竖排改用系统布局区域(待完成)
- 现状:竖排 TabBar 的位置用了两个模拟器实测常量:顶部下限 96、pill 中线距屏幕边 48。原因是竖条下
statusBarFrame只报 2pt,没法用。导航栏按钮列已经改用layoutGuide(for: .bar(onEdge:extent:)),TabBar 还没跟上。 - 改进方向:改成和按钮列一样,从系统竖条布局区域取位置,删掉这两个常量。迁移后还要复核左半屏下的 pill 中线口径,因为按钮列横向现在对齐的就是这条中线。
7.2 状态栏图标位置常量需要真机复核(未验证)
- 现状:内屏竖持时顶部行要与 Wi‑Fi 对齐,并让开右侧图标。状态栏图标位置没有公开 API,所以用了两个实测常量:Wi‑Fi 中线在 safe area 顶边之上 34,右侧图标簇宽 120。两个值都来自 iOS 27.1 模拟器截图逐像素测量,没在真机上验证过。120 的风险更大:蜂窝信号、电池百分比、Live Activity 都会让图标簇变宽,顶部行可能会和图标重叠。
- 改进方向:真机首测时用几何探针打印状态栏信息,再截图量图标位置;每次系统升级都复核一遍。如果以后系统提供状态栏布局区域,就改用系统 API。
7.3 半折态浮层避让(待完成)
- 现状:全展开时铰链 inactive,不需要避让。半折时铰链 active,确认弹窗、Toast 这类居中浮层会骑在铰链上(横持 x 455--495)。目前只给版本更新弹窗和确认弹窗加了宽度留白,环境层还没有「避让半区」出口。抽屉、阅读器浮层等已移交其他负责人。模拟器无法控制 book / tabletop 姿态,所以半折的实际效果也未验证。
- 改进方向:在环境层加一个出口,根据 active 的 division 返回可用半区,浮层统一按它摆放,不让各浮层自己读铰链。
7.4 阅读器未覆盖转场挂起(待完成)
- 现状:转场期挂起挂在页面基类上。阅读器是沉浸页,不继承这个基类,所以手势返回时按钮漂移、布局抖动的问题在阅读器里仍可能出现。
- 改进方向:把挂起逻辑从基类抽成可组合的小组件,阅读器单独接入。
7.5 阅读器标题页未跟随安全区(待完成)
- 现状:标题页覆盖层仍铺满整个视图,没有像正文、控件层那样水平贴安全区,文字可能压到竖条下面。它挂在默认关闭的开关后面,目前没有用户可见影响。
- 改进方向:打开这个开关前,按阅读器其它层的口径改成水平贴安全区。
7.6 硬阴影转场保护的覆盖边界
- 现状:旋转时隐藏硬阴影的处理挂在 TabBar 容器的
viewWillTransition上,有两个边界:modal 呈现的页面不在这棵视图树里,不受保护;转场途中新出列的 cell 不会被隐藏。 - 改进方向:modal 容器补一个同样的入口;新出列的 cell 在 configure 时查询「是否处于转场中」,决定阴影的初始透明度。
7.7 RTL 未验证(未验证)
- 现状:布局约束统一用 leading / trailing,但 RTL 下的实际效果没有测过。分页容器与分段控件的方向关系、竖排 TabBar 的表现都没有结论。TabBar 摆放用的是物理坐标,理论上只影响横排 pill 的左右顺序。
- 改进方向:用 RTL 伪语言跑一遍验收矩阵,重点看竖条在 leading 一侧时的收回量和按钮列。
7.8 真机首测与全量回归(待完成)
- 现状:全部几何数据来自模拟器,没有真机数据。四个环境的编译验证和全页面验收矩阵截图都还没做。另有几项只看了截图、没有操作:折叠 / 展开过程中根页列表会不会跳,竖排 TabBar 能否点击、push 后能否隐藏,分屏下状态栏内嵌判据。
- 改进方向:拿到真机后按验收矩阵逐项截图对照,同时复核 7.1、7.2 里的实测常量。
7.9 effectiveContentSize 近似值待复核(未验证)
- 现状:几何探针里的
effectiveContentSize不是直接读的,而是用bounds − adjustedContentInset近似。 - 改进方向:在实际布局的 section provider 里打印真实值,与探针结果对照,确认列数推导的输入没有偏差。
7.10 调试工具 window 问题的根因(未验证)
- 现状:「window 停在旧尺寸」的根因目前判断为调试工具用
UIScreen.main.bounds创建自己的 window。Duo 有两块屏,UIScreen.main不一定是 scene 所在的那块屏。修复是 Duo 上默认关闭调试浮层。「关闭后不再复现」目前仍待验证,生产配置的对照因为数据量不同,结论也有偏差。这个问题只影响 dev 构建。 - 改进方向:补一轮开 / 关浮层的对照验证,确认根因后再决定是否给调试工具打补丁,而不是一直关着它。
7.11 把 selfCheck 迁成单元测试
- 现状:工程没有 test target,纯函数(列数、列宽、收回量等)的正确性靠 Debug 菜单里手动触发的
selfCheck()汇总,不进 CI。 - 改进方向:建一个 test target,把现有 selfCheck 用例原样迁成单元测试;列数算法还可以加基于属性的测试,检查「compact 恒为基线」「结果总在 基线, 上限 内」这类不变量。
7.12 结语
Duo 适配的主体是在几何上守住两条线:compact 布局逐像素不变;regular 下所有尺寸都从系统 API 推出来。还没收口的问题集中在三类:系统没有公开 API、只能用实测常量的地方,模拟器测不到的姿态,以及没接入页面基类的沉浸页。拿到真机后,应先处理这三类。