iPhone Duo双屏适配实战:从布局到状态恢复的完整指南
2026/9/19 11:41:48 网站建设 项目流程

1. 先把设备形态和用户预期想清楚,再谈布局

接到 iPhone Duo 适配需求的时候,我第一反应和大多数人一样:改布局模型。约束、断点、Size Class,把界面从“固定尺寸”变成“自适应”不就完了?真正动手之后才发现,这个判断至少错了一半。布局只是整个适配工作的最表层,真正花掉我大部分时间的,是设备形态变化带来的交互语义、状态生命周期、窗口管理和渲染缓存问题。

1.1 三种典型使用形态:单屏、双页、摊开

在开始写任何代码之前,你得先明确 iPhone Duo 这类双屏可折叠设备到底有哪几种使用形态。我调研了现有折叠屏/双屏设备的用户习惯,归纳下来主要是三种:

  • 单屏形态:设备折叠或竖持时,只有一个屏幕处于活跃状态,这时候它就是一台普通手机。你的 App 在这里的表现应该和现有 iPhone 上的表现一致,不需要任何特殊逻辑。
  • 双页形态:设备展开后像一本书,左右两块屏幕各显示独立内容。这种形态下用户可能在做两件完全不同的事(左边看文档右边聊消息),也可能在做同一件事的两个环节(左边目录右边正文)。
  • 摊开形态:两块屏幕在逻辑上合并成一块完整的大画布,App 可以像在 iPad 上一样使用全部显示区域,甚至可以设计跨屏的沉浸式界面。

这三种形态不是三个静态档位,而是用户可以在几秒内来回切换的连续状态。这就意味着你的 App 不能只准备一套“手机布局”和一套“平板布局”,然后靠旋转事件去切换。它必须能够响应任意时刻的几何变化,并保持状态不丢失。

1.2 用户预期会如何改变你的功能设计

形态变化直接改变了用户对功能的预期,这是我在实际调研中感受最深的一点。

举一个笔记类 App 的例子。在单屏形态下,用户期望的是“快速记录”,所以启动就要进编辑态;在双页形态下,用户可能希望一边是目录,一边是编辑器,点目录项切换右侧内容;在摊开形态下,用户又会期望看到整页笔记的完整排版,目录、时间线、附件面板可以同时铺开。

如果你的适配只做到了“布局拉伸”,内容区域确实变大了,但功能结构还是单屏那套,用户会觉得这个 App 只是“被拉扁了”,而不是“为这台设备设计过”。所以我的建议是:适配工作的第一步,不是写代码,而是和产品经理一起把三个形态下的信息架构图画出来。先确定每个形态下用户的核心任务是什么,再决定布局和交互,顺序不能反。

2. 布局永远只是第一层:Safe Area、断点与真正的自适应

理清形态之后,才能真正开始碰布局。但即便如此,布局也不是改几个约束那么简单。双屏折叠设备给布局系统带来的冲击,比当初刘海屏大得多。

2.1 从“旋转”到“形态变化”的安全区

过去我们处理安全区,就是读取safeAreaInsets,避开刘海、圆角和底部 Home 条。但折叠设备的铰链区域会带来新的遮挡。更麻烦的是,铰链遮挡是随折叠角度变化的:完全展开时铰链部分可能是平的,屏幕可以被当成一整块用;夹角小于 180 度时,铰链两侧的显示区域会形成物理折角,内容放在折线上会扭曲、遮挡、无法点按。

这意味着安全区不再是四个静态边距,它可能是屏幕中间的一条动态区域。我目前的处理方案是:把折叠设备的几何信息(折叠角度、铰链宽度、两屏的 frame)统一抽象成一棵树,通过自定义的EnvironmentKey下发到 SwiftUI 视图树,或者通过容器的traitCollection提供。布局引擎根据这套几何信息动态计算“内容安全矩形”,而不是依赖系统的safeAreaInsets

struct FoldGeometry: Equatable { enum Posture { case singleScreen case dualPage(hingeRect: CGRect) case flat(combinedBounds: CGRect) } var posture: Posture var screenBounds: [CGRect] var hingeRect: CGRect }

这套数据的来源,可以是系统在旋转和折叠时触发的一系列几何回调。有了它,布局代码才能回答“我到底该把内容放在哪块矩形里”这个问题。纯靠系统 SafeArea 是做不出这个效果的,必须自己维护一层几何模型。

2.2 语义化断点:别用像素宽度判断形态

我在很多项目里见过类似的代码:if UIScreen.main.bounds.width > 700 { /* 平板布局 */ }。这在过去勉强能用,但在折叠设备上,屏幕宽度在 320pt 到 800pt 之间是连续变化的,而且一个宽度值可能对应完全不同的形态。比如 700pt 可能是小尺寸平板,也可能是双页形态下单屏加上一半铰链的宽度。你没法用像素宽度判断出用户当前处于什么姿态。

正确的做法是做语义化分类。先把“维度”定出来:

  • 设备形态:单屏 / 双页 / 摊开
  • 横向尺寸类别:compact、medium、expanded
  • 内容可用宽度:由几何模型计算出的实际内容矩形宽度

然后把三者组合成一个布局环境值,View根据这个值选择不同的布局分支。这套思路和 Android 的“最小宽度适配”在精神上是一致的,但 iOS 生态里没有系统级支持,只能自己封装。

enum LayoutClass: Equatable { case phoneCompact case phoneRegular case tablet case duoDualPage case duoFlat } struct LayoutEnvironment { var layoutClass: LayoutClass var contentWidth: CGFloat var contentHeight: CGFloat }

2.3 Size Class 为什么不够用

有人说,既然 iOS 系统已经有horizontalSizeClassverticalSizeClass,直接用不行吗?我的结论是:不够用,但也不能完全抛弃。

Size Class 只有 compact 和 regular 两个档位,在折叠设备上会出现一个很尴尬的情况:单屏形态是 compact,双页形态下每个屏幕可能还是 compact,而摊开形态变成 regular。也就是说,Size Class 能告诉你“现在是手机风格还是平板风格”,但它区分不了“我是双页模式下的左屏”和“我是单屏模式下的整个屏幕”。

我的习惯做法是:保留 Size Class 作为基础适配,凡是能够用系统 trait 适配的,就优先用系统 trait;凡是需要在“双页 vs 单屏”这种系统 trait 表达不了的维度做区分的,再上自定义的LayoutEnvironment。两者结合,而不是非此即彼。

3. 真正的难点:连续变化的窗口几何与状态一致性

布局只是表象,真正折磨人的是窗口几何的连续性变化。过去 iOS 设备的尺寸变化发生在旋转的一瞬间,而且有系统转场动画帮忙过渡。折叠设备的尺寸变化则可能持续好几秒,而且中间会经历铰链遮挡、边界偏移、内容跳动等一连串中间态。这时候你的代码要处理的就不是“旋转完成后的最终尺寸”,而是“任意时刻的中间尺寸”。

3.1 viewWillTransition 的局限与更可靠的观察方式

多年以来,我们在 UIKit 里监听旋转用的是viewWillTransition(to:with:)。这套回调在“旋转”场景下是可靠的,但在折叠场景下,我遇到了几个问题:视图控制器不一定会收到变化通知,因为承载它的 window 尺寸变了,但视图控制器的 trait 没变;连续变化过程中回调会被大量触发,每次都走完整布局流程会造成性能抖动。

我现在更倾向在 SwiftUI 里用onChange(of: geometry.size)配合GeometryReader,或者在 UIKit 里用一个专门的容器视图监听layoutSubviews中的尺寸差异,再向外广播。

struct AdaptiveContainer<Content: View>: View { let content: (CGSize, LayoutEnvironment) -> Content var body: some View { GeometryReader { proxy in let size = proxy.size let env = layoutEnvironment(for: proxy) content(size, env) } } }

这样写的好处是,所有尺寸变化都是数据驱动的,中间态也可以正常响应。副作用是onChange触发频率非常高,所以布局计算必须足够轻量,不能把重活放在 body 里同步执行。

3.2 过渡期的中间态:铰链遮挡与动画抖动

双页形态下,两块屏幕之间存在一个铰链区域,这个区域可能是物理弯曲的,也可能显示内容但交互失效。如果你的 App 在双页形态下选择“跨屏展示”,那布局引擎就必须知道铰链区在哪,并且把视图拆成“左屏内容”“铰链区留白”“右屏内容”三段分别处理。

更麻烦的是折叠过程中的中间态。用户在慢慢合上屏幕的时候,铰链区的宽度和位置是连续变化的。如果布局系统每收到一次变化就立刻重排,界面会出现频繁抖动。我的做法是给几何变化加一个低通滤波:短时间内的小幅变化不做重排,变化超过阈值才触发布局,在动画事务里完成过渡。

class FoldGeometryObserver { private var pending: FoldGeometry? private var lastCommit: FoldGeometry? func geometryDidChange(_ new: FoldGeometry) { pending = new // 等待下一帧,合并同一帧内的多次变化 DispatchQueue.main.async { [weak self] in self?.commitPendingChangesIfNeeded() } } }

3.3 尺寸变化时的数据与缓存策略

几何变化不只是布局的事,它还会让各种缓存失效。我踩过的坑包括:列表的 estimated row height 缓存导致滚动位置漂移、图片解码尺寸和显示尺寸不匹配导致模糊、UICollectionViewLayout的缓存刷不干净导致 cell 错位。

我的建议是建立一个统一的“几何变化通知中心”。当设备形态发生变化时,所有注册过的缓存都收到一个invalidate()信号,主动清理与尺寸相关的缓存。做得好不好,直接决定用户折叠/展开那一刻是顺滑还是卡顿。

4. 双屏并列场景:多窗口、多实例与跨屏交互

前面说的还只是“一块连续画布”上的问题,iPhone Duo 真正独特的地方是双页形态——两块屏幕可以承载两个独立的任务。这已经不是简单的布局问题,而是应用架构层面的多窗口问题。

4.1 双页模式:你的应用可能需要同时展示两个页面

在双页形态下,用户最常见的操作是“左边保持一个页面,右边打开一个新页面”。如果你做的是阅读器,那就是左页目录右页正文;如果你做的是邮件客户端,那就是左页列表右页详情。这有点像 iPadOS 的 Split View,但又不完全一样,因为两个页面是系统级并列的,而不是用户在 App 内手动拖出来的分屏。

我见过两种实现思路。一种是在同一个 window 里用两个容器视图控制器硬拼,这个实现的优点是状态管理简单,缺点是无法利用系统级的多窗口能力,Apple Pencil 拖拽和外接键盘焦点处理都会比较别扭。另一种是真正使用多个UIWindowScene,让两屏各跑一个 scene,这个做法的优点是可以天然隔离状态,缺点是跨 scene 同步状态需要额外的 IPC 通道。

我目前倾向于第二种,因为 iPadOS 已经把多场景生命周期管理做得很成熟了,复用系统能力比自己从零搭稳定得多。代价是状态同步要专门设计。

4.2 多实例状态同步与 Scene 生命周期

双页形态下,同样是“打开邮件详情”这个动作,左屏和右屏可能各自打开不同邮件,也可能打开同一封。如果打开的是同一份草稿,左右屏同时编辑,就涉及实时同步。视图层的同步是来不及的,必须回到底层数据模型。

我的方案是:每个屏幕的 UI 绑定同一个数据源,数据源本身支持多订阅者。左屏做修改,数据源发通知,右屏通过Combine订阅实时刷新。为了避免循环调用,要确保刷新操作不触发写回。这个架构用起来和 SwiftUI 的@Observable配合得不错,但要注意别把整个对象图都共享,否则一场跨屏编辑就会变成一场竞态灾难。

4.3 拖拽、复制粘贴与跨屏连续操作

双页形态下,跨屏拖拽是用户最自然的操作:把左侧列表里的一条消息拖到右侧窗口里打开,把左侧选中的图片拖到右侧文档中插入。这里要用到UIDragInteractionUIDropInteraction,而且要支持跨 scene 的拖拽,这需要正确的 activity 和 item provider 设置。

跨屏连续操作还带来了一个手势冲突问题。比如用户在左屏向右边缘滑动返回,这个手势如果一直滑到了右屏区域,系统到底该把哪个页面 pop?我的经验是:返回手势的判定必须限定在触发屏幕的可达范围内,不要为了追求跨屏连续性而让手势跨过铰链。否则用户会频繁误触,体验非常差。

5. 状态恢复是一等公民:比崩溃恢复更频繁的几何变化

折叠设备上最隐蔽的坑,是状态丢失。我接到适配需求时,以为状态恢复只是“App 被系统杀掉之后恢复现场”的老问题。实际测试之后才发现,iPhone Duo 的折叠/展开过程本身就会触发类似重建的流程,用户随时可能因为一个手势就丢了当前页面。

5.1 折叠展开的“伪销毁”问题

当设备从双页形态切到单屏形态,系统可能会把多余的 scene 收回去,这个场景的视图控制器经历“从有到无”;当用户再次展开,scene 重新创建,视图重新装载。这个过程用户体验是连续的,但代码层面它跟“销毁再重建”几乎没有区别。

如果只依赖系统默认行为,你大概率会发现:滚动位置回到顶部、输入框内容丢了一半、模态框弹不出来、导航栈被重置。这些问题的根源是,视图控制器被销毁时没有及时把“不可再生状态”持久化。

5.2 如何设计可靠的状态恢复数据结构

我最终落地的方案是两层结构。

第一层是场景级状态:保存当前设备形态、当前场景对应的业务对象 ID、导航栈路径、每个页面的 scroll 偏移。这一层用系统提供的NSUserActivity或者自定义的状态表持久化,在 scene 重建时读取并恢复。

第二层是瞬时资源状态:比如正在播放的视频进度、录音的时长、键盘输入框的当前文本。这些状态走常规恢复流程不现实,多半需要特殊处理。我的做法是建立一份“防丢失寄存器”,在sceneDidEnterBackground和几何变化前主动写入,恢复时优先读取。

struct SceneRestorationPayload: Codable { var posture: FoldPosture var navPath: [String] var scrollOffsets: [String: CGFloat] var draftText: String? var mediaProgress: Double? }

5.3 实测中的常见翻车点

状态恢复这块,我踩过的坑有个典型:恢复时不是所有系统回调都能保证顺序。viewDidLoad的时候去取恢复数据,有时候数据还没就绪;scene(_:willConnectTo:options:)的时候界面还没创建。我的对策是不要急着在视图加载早期消费恢复数据,而是先把数据挂在 scene 上,等视图已经准备好、且第一帧布局完成以后再去套用。宁可慢一帧,也不要读到一个半初始化状态。

另外,连续折叠展开多次之后,某些系统缓存会导致正在恢复的视图控制器收到旧的状态。这部分我没有完全可控的方案,目前是靠重启 App 时清掉历史 payload 来兜底。如果你有更好的思路,欢迎一起交流。

6. 交互与可触达性:屏幕变大的同时,拇指没有变长

布局和状态都稳定之后,下一个要解决的是交互热区问题。双屏展开之后屏幕变得很宽,但用户的手指长度没有变。如果你的关键按钮还放在屏幕顶部或远端,单手握持模式下根本点不到。这个问题的解法不只是“把按钮变大”,而是要重新设计交互模型。

6.1 手势热区与远端控件可达性

我的方案是引入“可达区域”概念:根据当前握持姿势,把屏幕划分为“拇指舒适区”“拇指可伸展区”“远端区”。主要操作按钮放在舒适区,次要操作放在可伸展区,远端区域只放展示性内容。

实现上可以做一个ReachabilityAwareContainer,把手势热区动态绑定到设备姿态和握持检测结果上。当然,握持检测在 iOS 里没有官方 API,目前比较实用的是根据屏幕触摸事件的分布去推断,或者干脆做可配置项,让用户自己选择左手/右手模式。

6.2 键盘、触控笔与外设的输入适配

双屏形态下,虚拟键盘会占据左屏或者右屏的一块区域,输入框如果刚好在另一块屏上,用户会感觉键盘和输入框隔了一整块屏幕。我在适配时把输入框的聚焦逻辑改成了“跟随键盘所在屏幕”,也就是说,键盘在哪边弹出,输入组件就尽量在哪边激活。

外接键盘的焦点管理也很重要。硬件键盘方向键在双页模式下应该能跨屏导航,UIKeyCommand要在场景层面统一注册,而不是每个页面各搞一套。Apple Pencil 的连续书写在摊开模式下体验很好,但要注意跨屏书写时笔触点坐标是全局坐标系,映射到本地视图时要做转换。

6.3 无障碍适配:动态字体、VoiceOver 与焦点管理

很多人做适配时把无障碍放在最后,但在折叠设备上,无障碍问题会被放大。动态字体在窄屏上可能只影响换行,在双页形态下可能导致某个屏幕的内容被压缩到无法阅读。我建议把动态字体测试纳入适配验收标准:最大字号 + 双页形态下,内容必须仍然可读可操作。

VoiceOver 的双屏表现也要专门验证。焦点从一个屏幕跳到另一个屏幕时,读屏顺序是否符合用户预期;跨屏的手势操作有没有对应的无障碍替代路径;键盘和 Apple Pencil 的输入是否都能通过 VoiceOver 完成。这些问题一旦出现,就是在正式发布后被投诉的隐患。

7. 性能与调试:几何变化造成的隐藏开销

最后说性能。折叠设备的几何变化频繁且连续,渲染层会遇到很多新问题,这些在普通 iPhone 上根本不会出现。

7.1 分辨率与视口变化对渲染缓存的影响

shouldRasterize是我最先遇到的坑。开启离屏渲染缓存可以提高滚动性能,但视口尺寸一变,缓存内容就失效了,而且失效的时机不统一,会导致部分图层短暂花屏。我最后的策略是:在几何变化期间全局禁用 rasterize,变化结束后再恢复,而不是试图精确控制每个图层的缓存重建。

Metal 和 SpriteKit 的场景也要注意 drawable size 的更新。很多渲染引擎在窗口尺寸变化时不会自动重建渲染目标,需要手动处理。我做过一个测试,折叠一次屏幕,Metal 渲染器如果没正确处理 drawable 尺寸变化,画面会直接卡死在旧分辨率。

7.2 自动布局重算开销

大屏上的视图层级如果比较深,每次几何变化都会触发整棵视图树的重排,性能开销非常可观。我的建议是:把稳定性要求高的视图层级用更轻量的方式组织,能不用 Auto Layout 的尽量不用;需要频繁变化的区域做独立的布局容器,避免把整个页面都拖进重排。

7.3 调试矩阵设计:用最少设备覆盖最多问题

适配工作需要一套可以重复执行的验证流程。我按形态和尺寸整理了一个矩阵,每次改动后跑一遍,能快速定位问题是出在布局、状态、交互还是渲染层。

验证项单屏形态双页形态摊开形态
安全区与铰链遮挡基础重点重点
布局断点切换基础重点重点
状态恢复基础重点重点
跨屏拖拽/同步不涉及重点重点
动态字体与 VoiceOver基础重点重点
渲染缓存与性能基础重点重点

真机测试时,我会把折叠角度分成几档(完全合上、半折叠、完全展开)逐档检查;模拟器里则用多组自定义分辨率模拟不同形态。自动化截图对比是一个性价比很高的手段,能快速发现布局越界和内容遮挡。

8. 收尾:一点实际体会

在整个适配过程中我最大的感受是:布局模型只是入场券。真正决定适配质量的,是你对设备形态、用户预期、状态生命周期、交互可达性、渲染性能这些深层问题的理解。与其拿到设备就动手改约束,不如先花一两天时间把“这个 App 在每种形态下应该呈现什么语义”想清楚。

我个人在实际操作中会建议团队先做一次“形态走查”:把产品经理、设计师、核心开发拉到一个房间里,对着设备逐一过三种形态的交互稿,把每个动作的流转路径画出来,再开始排期。这个环节省下来的返工时间,远比想象中的多。

另外一个小技巧:适配过程中每天都要用“最快路径”测试一次状态恢复。不需要完整操作流程,只要能快速折叠、展开、打开任意页面、看一眼导航栈是否还在,就能帮你尽早发现状态丢失问题,而不是等到最后联调时才炸出几十个 bug。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询