多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

iPhone Duo双屏适配实战:从布局到状态恢复的完整指南

iPhone Duo双屏适配实战:从布局到状态恢复的完整指南 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 系统已经有horizontalSizeClass和verticalSizeClass直接用不行吗我的结论是不够用但也不能完全抛弃。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 AdaptiveContainerContent: 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 拖拽、复制粘贴与跨屏连续操作双页形态下跨屏拖拽是用户最自然的操作把左侧列表里的一条消息拖到右侧窗口里打开把左侧选中的图片拖到右侧文档中插入。这里要用到UIDragInteraction和UIDropInteraction而且要支持跨 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。
返回列表