ZaatarLABS
联系我们 →
← 全部文章
ZaatarLABS · 工程

iOS 27的可调整大小窗口,在四款已上架应用中弄坏了什么

可调整大小的iPhone窗口,悄无声息地让所有用于布局的UIDevice idiom判断失效——同一个问题,四个代码库,一个基于size class的修复。

阅读约5分钟

计划之外的问题

我是在做The Smart Budget的iOS 27适配时发现它的,但问题不在Budget本身——而在The Smart Billing的SceneDelegate里。那个文件在启动时读取一次UIDevice.current.userInterfaceIdiom == .pad,用结果来决定是构建分栏视图根控制器,还是构建标签栏。

iOS 27让iPhone应用可以调整大小。一旦如此,idiom判断就不再回答它被用来回答的那个问题了。它告诉您运行在什么硬件上,却不告诉您此刻窗口有多宽。用户可以用窄窗口启动应用——idiom说是.phone,于是构建了标签栏——然后把窗口拉宽到通常应该显示侧边栏的尺寸。根控制器永远不会被重建。他们会在一个容得下分栏视图的窗口里看到一个标签栏。

我审查了这套产品中的全部四款应用。每一款里都出现了这个模式。

这个判断原本在做什么

每一处idiom判断都隐含着一个假设:硬件决定布局。iPhone就是一栏,iPad就是两栏。这个假设在足够长的时间里足够接近事实,以至于没人质疑。

这个替代指标在Mac Catalyst上早就是错的。在Catalyst上,UIScreen.main报告的是物理显示器,而不是应用窗口。The Smart Coach把一个搜索结果列表的高度上限设为UIScreen.main.bounds.height的55%。在大显示器上开着一个小应用窗口时,这个上限可能超过整个窗口的高度。视图并没有明显坏掉——只是悄悄地按错误的数字确定了尺寸。

iOS 27的可调整大小窗口,在iPhone上暴露出了同一类错误。

Billing的修复

The Smart Billing的idiom用法最为集中:SceneDelegate中分栏视图与标签栏的选择、两处拨号按钮的显示与否,以及三处用于sheet高度和键盘辅助栏尺寸的UIScreen.main读取。

SceneDelegate的改动最具结构性。新代码读取horizontalSizeClass,并在窗口上注册特征变化:

window?.registerForTraitChanges([UITraitHorizontalSizeClass.self]) { [weak self] in
    self?.updateRootViewControllerIfNeeded()
}

ifNeeded这个守卫很重要。调整大小时,这个回调会被连续触发。没有守卫的话,拖动的每一个像素都会拆掉导航栈并重建根控制器,丢弃导航状态。这个守卫会先检查size class是否真的发生了切换,然后才做任何事。

拨号按钮是另一个问题。它被藏在userInterfaceIdiom != .pad后面,这是在用一个布局信号去回答一个能力问题——这台设备能打电话吗?在iOS 27之前,这就已经是错误的判断方式。它被替换成了canOpenURL(tel:),这才是真正要问的问题。

那几处UIScreen.main则更多是机械性的改动。两处sheet高度上限改用了containerRelativeFrame。数字键盘辅助栏原本就调用了sizeToFit(),所以用零宽度初始化它,让它自己测量尺寸就够了。

八个端到端测试,在iPhone上零失败。Mac Catalyst干净构建。

Coach的修复

The Smart Coach在InvoiceFlowLayout里有一个更隐蔽的情况。在Layout.sizeThatFits的实现中,proposal.width为nil表示系统在问“您想要多宽?”——而不是“和屏幕一样宽”。旧代码用显示器宽度来回答,然后按这个尺寸换行,而布局实际上从未被赋予过这个尺寸。

修复:提议为nil意味着不受约束,也就意味着只有一行。报告实际使用的宽度,而不是显示器的宽度。

GlobalSearchView的结果上限问题更容易看出来:

// Before
.frame(maxHeight: UIScreen.main.bounds.height * 0.55)

// After — GeometryReader reads the view's actual container
resultsList(maxHeight: proxy.size.height * 0.55)

这个文件里本来就有一个GeometryReader用于topPadding,读取的是一个不会随窗口大小变化的安全区域边距。结果上限需要自己的读取器,因为调整大小时必须重新计算它,而在body中读取的计算属性并不保证会重新运行。

十八个端到端测试全部通过。Mac Catalyst干净构建。

保留下来的部分

并非每一处idiom判断都需要替换。The Smart Coach里有十处。七处与真正决定这个问题的isSourceTypeAvailable检查一起守护相机访问。两处隐藏拨号按钮。一处在SceneDelegate中选择分栏视图根控制器,而在那里,UISplitViewController本来就会在紧凑宽度下自动折叠。

这十处全部保留。有没有相机是硬件事实,调整窗口大小改变不了它。在那里用size class替换userInterfaceIdiom,就是在用手边随便一个数字去回答错误的问题。

由此得出的规则是:idiom判断适合回答能力问题,不适合回答布局问题。麻烦在于,布局问题被当作能力问题来表述的时间太久了,以至于在代码里,这种区别并不明显。

Catalyst的问题早就存在

是iOS 27的适配工作暴露了这个bug,但这个bug早于iOS 27。在Catalyst上,UIScreen.main一直测量的是显示器。任何用UIScreen.main.bounds来确定尺寸、又在Catalyst上运行的应用,都在悄悄地测量错误的东西。视图只是在某些地方略有偏差,看起来可能还挺正常。

如果您支持Catalyst,又还没审查过您的UIScreen.main读取,那么您今天就有这个bug。

如果重来,我会怎么做

我会在第一款应用一开始就引入一个DeviceCapability类型,而不是任由能力问题以idiom判断的形式散落在各个视图控制器里不断累积。等到我面对四个代码库时,拨号按钮的显示逻辑已经在Billing里被复制到了两个地方,在Coach里又单独写了一遍。它们都判断userInterfaceIdiom,都需要修改。

一个基于canOpenURL(tel:)的canPlaceCalls属性只要三行代码。复制一处idiom判断也是三行,但它回答的是另一个问题。只有当idiom不再是可靠的替代指标时,您才会注意到。

给读者

在您的代码库里搜索userInterfaceIdiom。把结果分成布局决策和能力判断两类。布局决策需要改用size class和registerForTraitChanges。能力判断大概没问题——但要逐一确认它回答的是硬件问题,而不是伪装成能力问题的布局问题。

然后搜索UIScreen.main.bounds。如果您支持Catalyst,这些读取现在就已经是错的。iOS 27会在iPhone上暴露出同样的问题。

registerForTraitChanges的守卫——只在size class真正变化时才重建——是最容易漏掉的部分。没有它,一次拖动会在每一个点上触发回调,您可能在调整大小的每一个像素上都丢掉导航状态。系统不会替您做防抖。

作者:Omar Al Homaidi · ZaatarLABS · @zaatarlabs