借助编程智能体将 App 适配到 iPhone Duo
用 Claude Code、Codex 等编程智能体把 iOS App 适配到 iPhone Duo:用脚本让 Xcode 27.1 模拟器开合、旋转和截图,再用实测得来的布局法则。
本文面向借助编程智能体(如 Claude Code、Codex 或 Xcode 内置智能体)将现有 iOS App 适配至 iPhone Duo 的开发者。本文聚焦于智能体单凭 Apple 官方文档无法掌握的核心知识:在无需人工操作 Device Hub 的情况下如何自动化驱动 Duo 模拟器、各硬件姿态下系统报告的具体度量数值,以及在真正审查屏幕视觉效果后依然站得住脚的布局决策。文中附带一套现成的工具包,可供智能体直接拉取运行。
文中的案例来自电影票根收藏 App Flicit 的适配实战。该 App 使用 Claude Code 历经五轮迭代完成适配,每张截图均由开发者人工审查。第四轮重点修复了手动测试模拟器时暴露的暗坑:一次特定旋转导致的界面卡死、海报被粗暴裁成正方形,以及某种姿态下三个核心页面仅适配了一页的缺陷。第五轮则对照 Apple 关于 iPhone Duo 的全部六场 Tech Talk 逐一核验,补齐了官方建议但此前遗漏的特性。文中所有数据均在搭载 Apple 芯片的 Mac 上通过 iPhone Duo 模拟器实测获得(Xcode 27.1 Release Candidate,版本号 27A9275;iOS 27.1 运行时,版本号 24A94232)。鉴于 iPhone Duo 定于 10 月 23 日发售,本文全部结论目前均基于模拟器环境,尚未在实体硬件上验证。
要点
- 必须使用 Xcode 27.1 SDK 构建,而非仅仅依赖其运行时;在做任何修改之前,务必优先运行 Apple 官方的
app-resizability技能(通过xcrun agent skills export导出)。将折叠屏专属代码置于条件编译标志之后,以确保 Xcode 27.0 工具链依然能够正常编译。 - 将工具包交给智能体。
duo.py负责开合、旋转,截取并录制每种姿态下的画面;xcode_device.py则通过 Xcode 的 device-interaction 工具模拟点按和读取 UI 层级,注意这些交互工具仅在手机闭合时完全有效。 - 针对当前正在渲染的显示屏截图,并校验图像绝非全黑。切勿在 Duo 模拟器上执行
simctl io screenConfig。 - 查询系统工具栏的停靠边缘(
toolbarVerticalEdge)。自定义标签栏应直接嵌入该侧边列中,置于系统常驻组件下方(灵动岛会随实时活动沿着该列向下延伸)。其余内容均需严格遵循安全区域。 - 针对设备姿态(Pose)设计,而非单纯针对尺寸:宽大于高时采用双栏跨页(竖版海报独占左半屏),纵向立起时收为单栏,并将分栏间距精准对齐折痕。设备平放时同样要提前对齐:系统会将折痕报告为未激活的保留区域,因此弯折时不会发生突兀的二次排版位移。在笔记本姿态下,所有可能在该姿态打开的页面均应拆为上下两部分,并完整保留其他姿态下的各项操作。
- 仅对弯折做动画。 开合与旋转均发生在系统自身的快照与旋转动画之下。展开状态下弯折手机是用户唯一会亲眼目睹 App 界面变化的时刻:仅在此处做动画,切勿在其他时机添加,且切入的新布局必须在首帧绘制已解码的图像。
- 切勿依据布局决策自身所产生的尺寸,反向作为该决策的判断条件。 Flicit 曾有一处双栏组件靠测量自身尺寸来决定折痕间距,导致旋转到竖屏时陷入死循环卡死,直至被系统 Watchdog 强杀。务必始终测量外部容器分给你的可用空间。
把这些交给你的智能体
这里的所有内容都可以让智能体直接抓取:
- 本指南的 Markdown 版本:foldhunt.com/guides/everything-your-agent-needs-for-iphone-duo/index.md。FoldHunt 的每个页面均支持
Accept: text/markdown响应头。 - 工具包,按智能体技能(Agent Skill)的格式组织:
SKILL.md规定了核心原则与命令,配套脚本就位于其同级目录。同样的文件也以 iphone-duo-simulator-skill 发布在 GitHub 上,采用 MIT 许可。除了 Xcode 之外无需额外安装环境;仅生成真机渲染图的脚本需要numpy与Pillow。
| 文件 | 作用 |
|---|---|
SKILL.md |
本指南的精简浓缩版:准备工作、硬性规则、姿态数据表、API 清单与布局法则 |
scripts/duo.py |
自动化开合与旋转模拟器,截取并录制当前渲染屏幕的画面,启动 App |
scripts/duo_fold.c |
duo.py 编译并在模拟器内部执行的 HID 底层辅助程序 |
scripts/xcode_device.py |
通过 Xcode 的 MCP 工具执行点按、滑动、文本输入,并抓取 UI 视图树 |
scripts/duo_mockup.py 与 duo3d.py |
将截图嵌入 Apple 官方边框,并在书本与笔记本姿态下进行 3D 弯折曲面渲染 |
安装为 Claude Code 的技能(其他智能体,包括 Codex,从各自的技能目录读取同一个文件夹即可):
git clone --depth 1 https://github.com/WhitenWhiten/iphone-duo-simulator-skill .claude/skills/iphone-duo-simulator
没有 git 时,也可以从本站抓取同样的文件:
d=.claude/skills/iphone-duo-simulator; mkdir -p "$d/scripts"
for f in $(curl -fsSL https://foldhunt.com/kits/iphone-duo/files.txt); do
curl -fsSL -o "$d/$f" "https://foldhunt.com/kits/iphone-duo/$f"
done
建议的第一条指令如下:阅读 iphone-duo-simulator 技能与 Apple 官方的 app-resizability 技能。先运行 app-resizability 的前置检查,使用 Xcode 27.1 构建,然后完整遍历每一种姿态并汇报视觉异常之处,在此之前不要修改任何代码。 更多技能、MCP 服务器和样机工具,详见 iPhone Duo 开发者工具目录。
工具链
必须使用 27.1 SDK 构建,不能仅靠其运行时
iOS 27.0 SDK 中完全不包含折叠屏相关的 API:其接口文件中没有 reservedRegions、ArrangementView、onHingeChange、toolbarVerticalEdge、toolbarVerticalBehavior 和 axisBehavior。这里的隐蔽陷阱在于,使用 27.0 SDK 构建的产物依然能够正常启动:27.1 运行时可以正常引导 Duo 模拟器并唤起 App。但正如 Apple 在 Prepare your app 讲座中所指出的,使用 iOS 27.0 SDK 构建的 App 在内屏上会被限制在状态栏侧边列的左侧边缘,且各类工具栏依然维持横向排布;唯有使用 27.1 SDK 构建,App 才能真正延伸至屏幕边缘,并将工具栏垂直嵌入侧边列。由于整个过程没有任何报错,智能体极易将这种未适配的旧布局误判为适配成功。
使用 ls /Applications/Xcode*.app 定位 Xcode 27.1,将 DEVELOPER_DIR 指向它,并在信任构建结果前校验 SDK。SwiftUI 的折叠屏 API 位于 SwiftUICore 中,仅当 SDK 包含这些符号时,以下命令才会输出匹配的文件路径:
export DEVELOPER_DIR=/Applications/Xcode-27.1.app/Contents/Developer
grep -rl --include='*.swiftinterface' reservedRegions \
"$(xcrun --show-sdk-path -sdk iphonesimulator)/System/Library/Frameworks/SwiftUICore.framework"
xcodebuild -scheme App -destination 'platform=iOS Simulator,id=<DUO-UDID>' build
把以下关键事实直接告知智能体,避免它在 .swiftinterface 中自行摸索拼凑:
@Environment(\.toolbarVerticalEdge)(类型为HorizontalEdge?)指示系统把栏立在侧边列时停靠在哪一侧(.trailing或.leading)。Apple 文档对它的定义很精确:它是系统在当前上下文中为竖向栏选定的首选边缘,“无论当前是否显示竖向栏”都有值,由语言区域和设备决定;只有在系统从不使用竖向栏的场合才为nil(不具备竖向栏的硬件,或使用水平栏的 Size Class 与方向,例如竖立展开的 Duo)。因此非nil的边缘只说明栏会放在哪里,并不代表它正在显示:还要同时检查该侧的安全区域内边距。任何视图均可读取该环境值,不仅限于工具栏项。UIKit 中的对等属性为traitCollection.verticalBarEdge,SwiftUI 返回nil的场合它返回.unspecified。GeometryProxy.reservedRegions(kind:options:)会返回折痕对应的.division(分割)区域,以及状态栏时钟与前置摄像头等对应的.occlusion(遮挡)区域,每个区域均包含frame、margins和isActive属性。这些 frame 位于所查询视图自身的坐标空间中;在从右到左的布局中,frame 会被镜像,除非传入layoutDirectionBehavior: .fixed。Apple 的文档称该方法返回与你的视图相交的区域,但在这一版本中,页面角落里一个 60 pt 的小方块拿到了整块屏幕上的全部区域,时钟侧边列和折痕都在内,所以请自行用视图的边界逐一检验每个 frame。必须在实际发生布局的闭包中读取该属性(即GeometryReader的闭包体或onGeometryChange变换闭包中),切勿仅在视图的onAppear中读取一次:这些区域数据需要等到首轮布局完成后才会提供,首次求值时读取结果为空。默认情况下该方法仅返回当前处于激活状态(Active)的区域:Apple 布局篇如此说明,模拟器的表现也一致,但该方法的文档却写着它返回区域“无论其当前是否处于激活状态”。无论如何,请自行检查isActive。若传入options: .includeInactive,则设备展平时同样会返回折痕数据(标为未激活,即未来发生弯折的几何位置);内屏前置摄像头在未被相机调用时也会以此方式报告。UIKit 中的对等方法为UIView.reservedRegions(kind:options:)。onHingeChange的闭包会传入变更前后的两个DeviceHingeContext。在不含铰链的普通手机上其hinge为nil;存在铰链时,hinge则提供status(.closed、.partiallyOpen、.fullyOpen;该枚举为带静态属性的结构体,因此switch必须包含default分支)以及angle(铰链角度)。状态由系统根据角度与设备方向共同判定。UIKit 中的对等组件为UIHingeInteraction,其UIHinge.angle以弧度为单位;文档还指出,角度更新的频率与精度由系统策略决定,若只需知道开合状态,应优先使用status。Apple 官方的设计原则是:将铰链角度数据留给动效与实时交互,实际布局定位则完全由保留区域(Regions)驱动。- 栏相关 API 中,工具栏项的
axisBehavior(.verticalPreferred)与.horizontalOnly,以及视图级别的toolbarVerticalBehavior(.disabled)与toolbarVerticalCompressionBehavior均为 27.1 SDK 新增。最后一个修饰符(竖向栏篇称之为toolbarCompressionBehavior)决定了在侧边列纵向空间不足时,标签栏与工具栏谁优先压缩让位。visibilityPriority与ToolbarOverflowMenu早在 27.0 中就已提供。自定义视图默认留在水平工具栏中,除非显式声明加入侧边列。
保证 Xcode 27.0 仍可正常构建
用 Xcode 27.0 构建折叠代码时,编译器会在每个 27.1 符号上报错,而报错并不总能说明原因:
error: value of type 'GeometryProxy' has no member 'reservedRegions'
error: value of type 'GeometryReader<Content>' has no member 'onHingeChange'
error: cannot find 'ArrangementView' in scope
error: generic parameter 'Value' could not be inferred
error: cannot infer key path type from context; consider explicitly specifying a root type
它们的意思都一样:这个 SDK 早于折叠 API。最后两条来自 @Environment(\.toolbarVerticalEdge),onHingeChange 那条里的类型就是你调用它的那个视图。用 Xcode 27.1 构建、而部署目标低于 iOS 27.1 时,报错是熟悉的 'toolbarVerticalEdge' is only available in iOS 27.1 or newer,用 if #available(iOS 27.1, *) 即可解决。
#available(iOS 27.1, *) 无法解决编译期问题:面对 27.0 SDK 时,reservedRegions 等符号在头文件中根本不存在。#if compiler(…) 同样无效:Xcode 27.0 与 27.1 Release Candidate 搭载的是同一个 Swift 6.4 编译器。如果团队成员或 CI 自动化构建环境仍在使用 Xcode 27.0,可使用默认开启、仅在 27.0 SDK 下清除的条件编译标志。这样后续所有新版 iOS SDK 无需再次修改配置即可自动包含折叠代码:
// Debug 配置;Release 下该条件仅保留 FOLD_SDK,27.0 时置空为 ""
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG FOLD_SDK";
"SWIFT_ACTIVE_COMPILATION_CONDITIONS[sdk=iphonesimulator27.0*]" = DEBUG;
"SWIFT_ACTIVE_COMPILATION_CONDITIONS[sdk=iphoneos27.0*]" = DEBUG;
"SWIFT_ACTIVE_COMPILATION_CONDITIONS[sdk=macosx*]" = DEBUG; // 仅限含 Mac Catalyst 目标的项目
最后一行只在 App 含有 Mac Catalyst 目标时才需要。Catalyst 使用 macOS SDK 构建,而 Xcode 27.1 RC 附带的仍是 macOS 27.0,其 Catalyst 接口中没有任何折叠屏 API,因此缺少这一行时,Catalyst 构建在两个版本的 Xcode 中都会失败。等到某个 macOS SDK 提供这些 API 后再删除它。原生 visionOS 目标处境相同(RC 中的 visionOS SDK 为 27.0),仍在使用的早于 27.0 的 SDK 也一样。
将所有调用 27.1 符号的代码集中收敛在一处,外层包裹 #if FOLD_SDK,内层使用 if #available(iOS 27.1, *),fallback 分支直接返回未修改的原视图。Flicit 设计了一个统一的状态监听 Modifier,向外发布单一的“折叠阶段状态(Fold Stage)”(包含铰链、折痕、遮挡区域以及侧边列边缘),App 其余部分仅读取该状态。这样一来,27.0 的成功构建即可作为“无符号泄漏”的直接证明,折叠逻辑的单元测试也能在任何标准模拟器上直接运行。此处有一个暗坑:在 .toolbar { } 视图构建器内部使用 if #available 依赖 buildLimitedAvailability,该特性要求 Deployment Target 为 iOS 17.5 以上。若需支持更早的系统版本,应将条件分支移至整个 .toolbar 修饰符外部。
优先运行 Apple 官方的 Agent 技能
Xcode 27.1 内置了智能体技能。通过 xcrun agent skills export --output-dir xcode-skills 可导出 10 个技能,而 xcrun agent plugin path --plugin-format claude(或 codex、gemini)则能打包包含另外 3 个技能及 Xcode MCP 服务的完整插件。其中专为 Duo 打造的是 app-resizability。当向智能体提出“为 iPhone Duo 适配我的 App”时,该技能会将其拆解为全套现代化改造任务,因为可变尺寸屏幕会将以下隐患集中暴露:替换 UIScreen.main、清理基于 interfaceOrientation 与 userInterfaceIdiom 的死板布局判断、迁移至多场景(Scene)生命周期,以及纠正假定左右两侧 Safe Area 对称的代码缺陷。此外它还会校验 Info.plist 的三项核心先决条件:提供 Launch Screen、支持 iPad 的全部 4 个方向,且未声明 UIRequiresFullScreen(该技能只报告此项而绝不删除,因为删除后 App 会立即变为可调整尺寸)。Xcode 27.0 导出的前代技能 uikit-app-modernization 仅针对 UIKit,对 Duo 的特性一无所知。
该技能聚焦的是系统动态调整窗口尺寸带来的适配问题,并未涵盖折叠形态赋予的全新交互可能(它完全不涉及 reservedRegions、铰链数据或 ArrangementView)。因此应先运行它完成基础加固,再去设计各姿态下的专属布局。在 Flicit 中,它扫描出了 3 处问题。其中一处是严重的潜在 Bug:一个 Metal 视图此前使用 UIScreen.main.scale 计算 Drawable 渲染尺寸,现已改为使用视图自身的 traitCollection.displayScale。对于仍需支持 iOS 17 的 App,还需显式注册 UITraitDisplayScale 变更监听;自 iOS 18 起,UIKit 会在已读取的 Trait 变更时自动触发 layoutSubviews。在视图尚未挂载到 Window 之前该 Scale 值为 0,此时应跳过布局计算。另外两处则是针对功能启用而非布局的 userInterfaceIdiom == .phone 判断(用于决定是否开启 iPad 所没有的实时活动,以及提醒功能是否默认打开),因此予以保留。
深度研读 Apple 的六场 Tech Talk
讲座中包含了技能所不具备的深层规范,本指南统称如下:
| 讲座 | 简称 | 技能未提及的关键信息 |
|---|---|---|
| Prepare your app for iPhone Duo | 准备篇 | 27.0 与 27.1 SDK 的差异边界;内屏不会受限于你所声明的支持方向;用于匹配屏幕圆角的 ConcentricRectangle |
| Raise the bar with iPhone Duo | 竖向栏篇 | 工具栏项移入侧边列的规则、axisBehavior、溢出优先级、单按钮 Sheet 的优化 |
| Strike a pose with adaptive layouts | 布局篇 | 保留区域(Reserved Regions)、位移重排(Displacement)、ArrangementView |
| Leverage multiple displays and scenes | 多任务篇 | 铰链 API、分屏浏览(Split View)、新窗口机制、场景配件 |
| Build a great camera experience | 相机篇 | 虚拟前置摄像头(Virtual Front Camera)、方向协调器 |
| Design for iPhone Duo | 设计篇 | 侧边列的共享元素、置顶画中画的避让、各姿态下的 Sheet 行为、笔记本形态布局规范 |
多任务篇与相机篇最容易被开发者忽略,但两场讲座均至关重要。仅有相机类 App 能够在双屏展开时在外屏显示附属视图:CameraCaptureAccessory 要求 App 在内屏全屏运行且处于活跃的相机会话中,Apple 小组实验室(Group Labs)补充说明这还需要特定的 Entitlement 授权。在外屏形态下无法唤起新窗口。相机篇介绍了虚拟前置摄像头,它能在内外部前置摄像头之间自动平滑切换,并为需要手动控制镜头的 App 提供了 AVCaptureDeviceDirectionCoordinator;它位于 AVKit,而非 AVFoundation。Flicit 的票根扫描功能使用了后置镜头配合 AVCaptureDevice.RotationCoordinator,据相机篇介绍,该协调器会在 App 跨屏移动时自动更新,因此无需任何额外适配。
用命令行开合、旋转 iPhone Duo 模拟器并截图
Device Hub 提供了姿态预设按钮、隐藏式铰链滑块(在姿态按钮上按住 Option 键)以及方向选择器,模拟器测试清单介绍了如何手动使用它们。然而智能体无法直接操作这些图形控件。simctl 自身并未提供铰链控制或屏幕旋转命令。Xcode 27 的 devicectl 虽能列出模拟器并提供 device orientation set,但在 Duo 模拟器上,无论合上还是展开,它都会返回成功提示却并未实际旋转机身,而 device orientation get 不论什么姿态都读到 portrait。在无 UI 的 Headless 模拟器环境下,修改 XCUIDevice.shared.orientation 同样毫无效果。
实际上,铰链滑块与方向选择器各自向系统发送了一个厂商自定义的 HID 事件(Usage Page 0xFF61,Usage 0x5B),其载荷为一个精简的二进制 plist。通过 simctl spawn 在模拟器内部投递完全相同的事件,即可实现设备的自动化开合与转向。Artem Novichkov 开发的 hinge 工具将铰链事件(不含屏幕方向)封装成了 Homebrew CLI;而 Meta 的 idb 则在其 HID 底层代码中从 Mac 端投递这两种事件。
| 控件 | source |
type |
value |
|---|---|---|---|
| 铰链滑块 | hinge-slider-control |
range |
角度值,0° 为闭合,180° 为完全展平 |
| 方向选择器 | orientation-picker-control |
enum |
portrait、pud、landscape-left、landscape-right |
两种载荷均包含 provider 标识:工具包与 hinge 投递的是 com.apple.Virtualization,idb 投递的是 com.apple.Virtualization.VirtualMachines。工具包中的 duo_fold.c 负责底层事件分发,duo.py 在首次运行时自动完成编译并提供外层 CLI 封装:
python3 duo.py fold close # 0°,闭合,使用外屏
python3 duo.py fold open # 180°,完全展开,使用内屏
python3 duo.py fold book # 128°,模拟器的标准书本姿态
python3 duo.py orient upright # 机身纵向立起
python3 duo.py fold laptop # 110°;机身纵向立起时即为笔记本形态
python3 duo.py path 1.0 180 140 180 # 一次带时间插值的连续翻折运动
python3 duo.py scene --fold laptop --orient upright --bundle com.example.app --out shots/
scene 命令会自动按顺序执行:折叠机身、等待铰链运动平息、旋转方向、重启 App 并截取屏幕。需要注意的是,方向命名沿用了设备的原生定义,在展开态的 Duo 上极易混淆。展开状态下的 iPhone Duo 原生方向为横向(Landscape),因此设置 portrait 会呈现折痕竖贯居中的横屏界面;而 landscape-left 才是将机身纵向立起、折痕横贯水平的形态。为避免混淆,duo.py 提供了直观的 sideways(展开横置)与 upright(纵向立起)两个别名。在 iOS 27.0 运行时上,iPad mini 模拟器会直接忽略该事件,因此该脚本不会误影响其他模拟器。
在设置每个姿态时,必须在铰链停止运动后再设置屏幕方向。若在铰链运动期间投递方向事件,事件极易被系统丢弃,因此 duo.py orient 会自动等待并连续投递两次。此外,设备在翻折前后会保持上一次的方向状态:在 Flicit 的姿态遍历测试中,刚截取完笔记本姿态就立即合上手机,外屏呈现为横置的 678×466 状态,系统侧边列停靠在 Leading 边缘;紧接着切换书本姿态时,截取到的却是纵向立起形态。这两种均为合法的物理姿态,但并非测试脚本预期的目标状态。
要从展平状态弯折,而不是从合上状态:模拟器的铰链有滞后。从合上往外打开时,App 一直留在外屏,铰链一直报告 closed,直到约 147°(设定 150°);要过了 160° 才换到内屏。从展平往回合时,内屏一直亮着,铰链报告 partiallyOpen,直到约 24°(设定 20°),只有 0° 才回到外屏。因此外屏正在出画时,duo.py fold 会先把手机展平再弯过去;已经展开的手机则直接弯折,不会多经过一次展平。从合上状态开始的 path 或 sweep 也有同样的陷阱。
path 命令专为秒级连续手势设计。若拆分为多次单独进程调用,每次启动开销接近一秒,极易导致快速弯折动作超出 App 预设的时间窗口。
系统不提供铰链角度的回读机制。 hinge get 背后运行的是 xcrun devicectl device motion hinge-angle,而 Xcode 27.0(27A266a)与 27.1 RC(27A9275)自带的 devicectl(两者均为 651.13.5 版)都会拒绝它:“Error: 2 unexpected arguments: ‘motion’, ‘hinge-angle’”。hinge 的 README 称其已在 Xcode 27.1 上验证;无论那是哪个构建版本,这个版本都没有该命令。系统的 CoreDevice 框架中已经包含铰链角度监测相关的提示信息,因此后续的 devicectl 可能会加入它:运行一次该命令即可知晓。模拟器内部没有可供监听此变更的 HID 接收器,也没有任何 plist 记录该角度。能读回的是姿态:xcrun devicectl device info displays --device <udid> 会列出两块屏幕,LCD(外屏,1398×2034)和 LCD-1(内屏,2007×2853),各带一个 "active" 真假值,加上 --stream 还会逐次打印变化,因此无需截图即可分辨合上与展开。它的 currentDeviceOrientation 跟随展开的手机,按内屏立起时的坐标命名(横置读作 landscapeRight,立起读作 portrait),但合上时无论怎么转都停在 portrait。必须在脚本中记住设定的目标角度(duo.py status 会输出最近一次设定值)。App 读到的角度与设定值有偏差,越接近展平偏差越大:设定 110° 时 App 读到 108.9°,128° 读到 125.9°,175° 读到 170.3°,只有恰好 180° 才读到 fullyOpen。
截取内屏时避免得到全黑图片
在任何姿态下,调用 simctl io <udid> enumerate 列出的输出端口中均包含两块屏幕:外屏分辨率为 1398×2034 像素,内屏为 2007×2853 像素(按 3x 渲染倍率计算,分别对应 466×678 pt 与 669×951 pt)。闭合时 App 在外屏渲染,内屏端口截取的画面为纯黑;双屏展开时则完全相反。若执行 simctl io screenshot 时未携带 --display 参数,命令会默认截取外屏,因此手机展开后将截获全黑图像。
必须每次依据像素分辨率动态匹配显示端口,切勿按名称硬编码:端口 UUID 在每次模拟器引导后均会变动;--display=internal 只是碰巧映射至内屏,而 --display=external 会直接超时。在信任截图之前务必进行校验:休眠未激活的屏幕输出为约 50 KB 的全黑 PNG,而正常渲染的屏幕截图大小为数兆字节。duo.py shot 内部完整封装了这一校验逻辑:它会解码截图顶部的行像素,轮询重试直到屏幕点亮,并标注具体点亮的屏幕名称。切换姿态后应重启 App,并为冷启动延迟预留充足时间:Flicit 在重新安装后的首发冷启动耗时比常规启动多出整整 10 秒,足以导致截图误落在启动 Splash 画面上。
Xcode 27 的 devicectl 也能按显示器截取,用 --display-unique-id 指定(ID 由 device info displays 列出)。它的 device capture screenshot 可以截取任一块屏,截图已按界面方向转正(展开横置时为 2853×2007,而 simctl 给出的是原始的 2007×2853 帧缓冲),熄屏的那块同样是全黑。device capture screen-record 录的是原始帧缓冲,每台设备一次只能录一路:第二路会立即失败,报“Host recording is already in progress”。这些 ID 同样在每次启动后变化。有 active 标记在旁,device info displays 是找出正在出画那块屏最快的办法。
录制翻折过程的视频
静态截图反映最终落地的布局形态,动态录屏才能还原布局过渡过程。simctl io <udid> recordVideo --display=<端口 UUID> 可录制指定屏幕,但在 Duo 上有两个非直观的特性:其一,单台模拟器在同一时刻仅支持录制一块屏幕,若并发开启第二个 recordVideo 录制任务,进程虽持续运行,但输出文件永远无法正常封包结束;其二,录屏输出不会跟随显示焦点自动切换,当前屏幕熄灭后它会持续录制黑屏。因此正确的做法是:优先录制当前活跃屏幕,待机身翻折点亮另一块屏幕后再动态切换录制流。duo.py record 接受一系列阶段指令并自动处理切流:
python3 duo.py record --out films/ path:1.2:0:180 rec:inner wait:4 # 展开机身,切换录制内屏
python3 duo.py record --out films/ path:2:180:128 wait:2.5 path:2:128:180 # 折叠至书本形态并重新展平
视频帧仅在屏幕内容发生变化时按需产出,帧间隔并不均匀。导出视频帧时必须采用直通模式:ffmpeg -i film.mp4 -fps_mode passthrough frames/%04d.jpg;切勿使用固定 fps 滤镜强制重采样,否则会生成大量重复旧帧,伪造出界面掉帧卡顿的假象。
在 Flicit 的实测录屏中,绝大多数过渡动画均由系统底层接管。在开合过程中,系统会生成一张模糊的高斯模糊快照遮盖双屏切换过程;在弯折机身时,模拟器会自动将倾斜背离视线的那一侧折页做暗化与模糊处理。在其掩护下,App 会在单帧内瞬时重排至新布局。唯一能被肉眼察觉属于 App 自身的动态突变,发生在弯折瞬间的二段重排:当折痕区域变为激活态时,票根列表列突兀跳动对齐至折痕,而在完全展平时又跳回原位。任何等待折痕变为 Active 状态才介入排版的双栏布局都会暴露这一视觉瑕疵;具体解决方案见第 7 节。
一次录屏包含数百帧,而瑕疵往往只集中在极少数几帧。以毫秒时间为各帧命名,逐帧与前一帧比对,并优先查看差异最大的帧:硬切是一次巨大的单帧跳变,淡入淡出是一连串微小变化,而空白帧在两端都会显著突出。
ffmpeg -i films/laptop-inner.mp4 -fps_mode passthrough -enc_time_base 1/1000 -frame_pts 1 frames/%06d.png
prev=; for f in frames/*.png; do
[ -n "$prev" ] && echo "$f $(compare -metric RMSE "$prev" "$f" null: 2>&1)"; prev=$f
done | sort -t'(' -k2 -g -r | head
修改前后均录制对比视频:第 7 节中笔记本姿态的录屏发现了一处硬切所掩盖的占位图。在模拟器中,折痕在铰链停止大约一秒后才变为激活状态,因此弯折触发的布局切换显示在弯折之后,而非弯折途中。旋转后,保留区域的更新甚至耗时更长:在认定最后一帧是 Bug 之前,务必给录屏留出足够的等待尾部。
旋转时 App 卡死的排查
无法收敛稳定的死循环布局不会立即引发崩溃。界面会停滞在旧布局上不再响应。只有当 App 被系统杀死(Watchdog 超时强杀,或人工强制退出)后,才会生成崩溃日志,初看像是 Dispatch Queue 里的崩溃。Flicit 仅在特定旋转时暴露了该问题:从横置转为竖立时卡死,反向则正常。排查链路如下:
- 崩溃报告:模拟器的报告位于 Mac 端的
~/Library/Logs/DiagnosticReports/<App>-<日期>.ips。Flicit 的报告指明异常码为0x8BADF00D,即系统 Watchdog 强杀:“scene-update watchdog transgression … exhausted real (wall clock) time allowance of 5.00 seconds”,强制退出后则记录为 “Failed to terminate gracefully”。主线程调用栈停留在 UIKit 的屏幕旋转流程中:-[UIViewController window:willAnimateRotationToInterfaceOrientation:duration:newSize:]→-[UINavigationController _layoutViewController:]→_UIHostingView.layoutSubviews(),正在渲染 SwiftUI 视图树。这证明是一轮耗尽时间配额且无法返回的死循环布局计算,而非多线程死锁。 - 对卡死进程即时采样:使用
duo.py orient upright复现卡顿,随后对模拟器中的 App 进程执行sample <pid> 3。主线程抓取到的所有堆栈均深陷在同一个ViewGraphRootValueUpdater.render之中,下挂GraphHost.flushTransactions以及不断增长的动画事务队列:表明 SwiftUI 正在单次布局轮次中疯狂提交状态变更并立即触发重绘。 - 捕获 SwiftUI 内部日志:通过
simctl spawn <udid> log show --info --debug --predicate 'subsystem BEGINSWITH "com.apple.SwiftUI"'捕获底层错误,会打印出诸如 “… tried to update multiple times per frame” 等故障信息,并直接指出涉事的 Binding。在嫌疑视图的 body 中加入Self._logChanges()(iOS 17.1+,更早版本需包裹在可用性检查中),每次 body 求值时均会打印变更的属性。Flicit 的双栏视图在 30 秒内打印了 2,450 次__frame changed。 - 具体数值印证:在涉事组件的几何变更回调中插入
Logger记录,结果显示其 Frame 正在两个固定宽度之间无限交替震荡。死循环成因与修复详见第 9 节。
仅凭 CPU 占用率无法确诊问题:App 在全新安装后的首次启动期间,可能因构建各类系统缓存而长时间占满 100% CPU,因此务必先采样验证,再归咎于布局逻辑。
分屏浏览与画中画的窗口尺寸
在 Headless 无头自动化环境下,Duo 上有两种窗口形态无法直接触发。分屏浏览(Split View)依赖从展开屏幕底部的 Home Indicator 向上拖拽手势;而置顶在顶部的画中画(PiP)则要求系统中存在实际播放中的视频源——它会动态压缩 App 的垂直高度,且一旦手机折叠即霸占半块屏幕。能够模拟触摸的 Xcode 工具在手机展开时又仅能捕获外屏(见第 4 节)。devicectl device appResize 只能走到一半。手机展开过一次之后,Duo 会多列出第三块显示器 Resizable,appResize start --preferred-size <W>x<H> 会把最前端的 App 移到上面,appResize set 可以实时调整尺寸(最大 1280×1280),device capture screenshot 也能截取它。它要求设备已解锁,并处于 devicectl 所说的 portrait 方向:横置时它会拒绝(“Rotate the device to portrait”);展开立起时,内屏上的 App 被调暗,Resizable 显示器截到的是全黑;合上时则正常工作。Flicit 按要求呈现为 475×669 和 669×455,要求 380×600 时则成了 380×667。但那只是一个普通的可调整尺寸窗口,没有侧边列、折痕,也没有 Duo 的安全区(Flicit 把标签栏放在了底部),所以它检验的是尺寸,而不是 Duo 的窗口。这些窗口应改用主动渲染的方式覆盖:在单元测试中,将待测页面置于 UIWindow 内的 UIHostingController 中,动态调整 Window 尺寸遍历所有边界规格。依据屏幕物理 pt 尺寸估算,主要尺寸涵盖:
- 横置展开:951×669;
- 置顶视频挤压:951×420;
- 分屏浏览半屏(Compact 宽度):475×669;
- 纵向立起展开:669×951;
- 笔记本姿态立起上半屏被视频占用:669×455。
陷入尺寸测量死循环的布局在此测试中将永远无法返回。Flicit 的页面在全部上述尺寸下均实现了快速收敛稳定,并确保打开的票根与未保存的影评在尺寸切换中完好保留。在两种垂直高度高度受限的紧凑窗口中,纵向票根的尺寸直接受制于可用高度,这正是笔记本姿态横向票根票样设计所专门解决的场景。
切勿执行 simctl io screenConfig
尝试使用 simctl io screenConfig 唤醒或配置内屏是极易诱发灾难的操作。在 Duo 模拟器上,执行 power on 与 geometry 不会产生任何实际效果,但接下来的下一次铰链事件会直接引发 SpringBoard、InputUI 与 chronod 在同一渲染帧内发生级联 SIGTRAP 崩溃:
EXC_BREAKPOINT (SIGTRAP), main thread
FrontBoardServices -[FBSDisplayMode _initWithWidth:height:…validityCheck:]
FrontBoardServices -[FBSDisplayConfiguration _initWithImmutableDisplay:…]
QuartzCore CA::Display::Display::update() ← ensure_displays()
此后崩溃会无限循环,直至 launchd 放弃重新拉起进程;此时 App 自身虽未崩溃但系统已彻底假死。唯一的恢复手段是对该设备 UDID 依次执行 simctl shutdown 与 simctl boot。务必将此禁令加入智能体的常驻指令集(工具包的 SKILL.md 已收录),切勿仅写在普通的源码注释中。
其他能驱动 Duo 模拟器的工具
本工具包并不是唯一的途径。下面是这些工具在 Xcode 27.1 上能做什么,短横表示工具自己没有说明:
| 工具 | 开合 | 旋转 | 点按展开的手机 | 说明 |
|---|---|---|---|---|
| duoctl | 能 | 能 | 能 | 本文于 10月11日核验过(见第 4 节);它读回的铰链角度为 null,与 hinge get 相同 |
| hinge | 能 | 不能 | 不能 | 通过 Homebrew 安装的命令行工具兼智能体技能,负责铰链事件;hinge get 在 651.13.5 版 devicectl 上失败 |
| serve-sim | 能 | – | – | 在浏览器中呈现模拟器,据其 README,为 Duo 提供开合按钮和铰链滑块 |
| Appium XCUITest driver | 能 | – | – | mobile: setSimulatedHingeAngle,10月8日合并;它不会等待布局稳定 |
| idb | 能 | 能 | – | 从 Mac 端投递两种 HID 事件 |
| Maestro | 不能 | 不能 | – | Duo 姿态支持仍是一个未关闭的需求 |
点按与读取界面:Xcode 的 MCP 工具与 duoctl
静态截图负责检验排版,业务流测试则依赖点按交互。Xcode 通过 xcrun mcpbridge 对外暴露了一套 MCP 工具,包括 DeviceInteractionStartSession、DeviceInteractionSynthesize 与 DeviceInteractionEndSession,并配备了专门的 device-interaction 技能文档。Synthesize 每次执行一条原子操作,并返回实时截图及携带每个元素可点击坐标(Hit Point)的 UI 视图树。支持的操作包括单点(t x y)、双击、滑动(t x1 y1 f x2 y2 秒数)、拖拽、多点触控、物理硬件按键、键盘输入、等待延时以及 orientation 旋转。运行前 Xcode 必须处于开启状态,首次建立握手时会弹出一次系统授权弹窗。工具包中的 xcode_device.py 便是针对这三个工具的轻量客户端:
export DEVELOPER_DIR=/Applications/Xcode-27.1.app/Contents/Developer
python3 xcode_device.py start --device <DUO-UDID> # 传入确切 UDID,避免拉起其他设备
python3 xcode_device.py look # 截图、读取 UI 树结构及可交互元素列表
python3 xcode_device.py tap "Settings" # 根据 Accessibility Label 精确点按
python3 xcode_device.py do "t 200 560 f 200 260 0.4"
python3 xcode_device.py end # 交互 Session 开销昂贵,用完务必及时关闭
需要特别注意的是,在 iPhone Duo 上,Xcode 默认固定通过外屏读取设备状态:
- 闭合形态下完全正常:能够输出真实的 466×678 截图,UI 层级完整标注 Accessibility Label 与点击坐标,点按与手势滑动均能精准落地。
- 双屏展开后端口错位:截图呈现为全黑的外屏;UI 视图树会粗暴剔除所有超出外屏物理宽度的元素(在横置展开页面中,位于侧边列内的全部内容均被丢弃);触控指令无法生效。调用
orientation landscapeLeft同样无法驱动展开态设备旋转。 - 不支持铰链指令:投递
hinge与fold均会返回“Invalid command”。
因此两套工具必须协同分工:duo.py 负责驱动各姿态下的机身开合、旋转与屏幕捕获;xcode_device.py 专职负责在手机闭合状态下模拟点按交互并审查层级。测试完整业务流时,先在闭合形态下完成交互链路,再调用 duo.py 展开机身,验证展开后遗留的状态与布局。此处存在一个典型时序暗坑:在动画进行期间读取到的 UI 树包含过期的坐标数据(Stale Hit Points)。在 Flicit 测试中,搜索栏尚在向上滑动动画过程中,脚本便读取了视图树,导致针对关闭按钮的点击偏落在按钮下方,仅造成了输入框失去焦点。在重试操作前,必须重新拉取一次视图树。
展开的手机可以交给 duoctl。它在模拟器内部把正在出画那块屏的触摸屏克隆为一个虚拟 HID 服务,再经由它发送触摸,坐标用的是界面的点。10月11日核验:duoctl tap 908 488 在展开手机的侧边列里把 Flicit 切换到了日历页,而 Xcode 的点按在那里落不下去;duoctl rotate portrait 把展开的手机立了起来;duoctl state 报出了正在出画的屏幕及其尺寸。它的 hingeAngle 读回为 null,因为它读取角度用的也是那条不存在的 devicectl 命令。只有装了 AXe 工具,它才能按标签查找元素;否则就从截图中取坐标:devicectl 的截图是界面的点乘以 3。所以,合上时用 Xcode 的工具及其视图树走一遍流程,展开时用 duoctl 和截图。
UI 树同时也是精准的度量审查工具。为验证展开搜索栏不会导致底层整个页面重新构建,智能体先将 Flicit 的“我的”页面向下滚动,点按侧边列内的搜索按钮并再次关闭,随后比对三次抓取的视图树中“Viewing Count”(观影统计)标题的绝对坐标:坐标分毫不差,证明底层 ScrollView 的滚动偏移完好保持,未被重建。
安全区、折痕与保留区域的度量数值(单位:pt)
以下数据均在遵循 Safe Area 安全区域的视图内部测得,保留区域的 frame 已从该视图的坐标换算到窗口坐标。10月11日,一个最简的 SwiftUI 探测 App 把每一行重新测了一遍,数值完全相同:
| 姿态 | 窗口分辨率 | 安全区域内边距(Insets) | 活跃遮挡区域(Active Occlusions) | 折痕区域(Crease) | 工具栏停靠边缘(Bar Edge) |
|---|---|---|---|---|---|
| 闭合 | 466×678 | trailing 84,bottom 34 | 时钟列 (382, 0, 84×170);前置镜头 (399.7, 29.3, 37×37) | 无 | trailing |
| 展开,横置,平放 | 951×669 | trailing 84,bottom 34 | 时钟列 (867, 0, 84×120) | 无 | trailing |
| 展开,横置,128°(书本) | 951×669 | trailing 84,bottom 34 | 时钟列 (867, 0, 84×120) | (455.5, 0, 40×669) | trailing |
| 展开,纵向,平放 | 669×951 | top 82,bottom 34 | 状态区 (535, 0, 134×82) | 无 | nil |
| 展开,纵向,110°(笔记本) | 669×951 | top 82,bottom 34 | 状态区 (535, 0, 134×82) | (0, 455.5, 669×40) | nil |
由上述实测数据可得出以下设计准则:
- 侧边列贯穿屏幕的全高,但系统常驻系统级元素在展开时仅占顶部 120pt,闭合时占 170pt。其下方区域完全预留给系统工具栏摆放;若 App 未声明任何工具栏项,该区域将保持空白。
- 排除内边距后,可用页面宽度在闭合时为 382pt,横置展开时为 867pt。在这一构建版本中,当宽度低于约 800pt 时,
NavigationSplitView会放弃将列表与详情并排展示,因此纵向展开状态(669pt)以及竖屏下的 iPad mini(744pt)必须采取针对性的单栏收拢策略(见第 7 节)。 - 纵向立起展开时,设备形态等同于一台竖屏的小尺寸 iPad:顶底各有一条水平工具栏,顶部附带 82pt 的状态栏区域,可用内容宽度为 669pt。准备篇讲座特别指出,内屏不会受限于你所声明的支持方向,因此切勿依据设备的物理方向(Interface Orientation)来做硬编码布局。
- 折痕宽度为 40pt,物理坐标精准位于窗口几何中心,且仅在半折状态下处于激活状态(Active)。切勿将任何交互或逻辑与折痕被激活的具体临界角度进行强绑定。
- 外屏同样支持旋转。在旋转方向未复位的情况下合上机身(例如刚刚退出纵向立起形态),外屏会呈现为横向的 678×466 尺寸,系统侧边列也会移至 Leading 边缘,时钟占据侧边列顶部 82 pt。若布局代码假定外屏永远是 382pt 宽且高大于宽,便会遭遇完全相反的容器尺寸。
若在查询时传入 .includeInactive,还会额外捕获三组基于窗口坐标的关键读数:
- 在设备平放状态下,折痕作为未激活区域(Inactive)返回,其几何坐标与激活态折痕完全一致:横置时为 (455.5, 0, 40×669),纵向立起时为 (0, 455.5, 669×40),闭合时无此区域。Apple 官方文档曾将其描述为零宽度,因此代码中必须同时兼容 0 与 40 两种宽度情况。
- 内屏前置摄像头作为一个未激活的遮挡区域返回,横置时坐标为 (677.3, 21, 58×37),纵向时为 (21, 215.7, 37×58)。仅当相机会话实际调用该镜头时,它才会切换为活跃遮挡并要求避让。
- 在机身旋转或开合瞬间,会有单帧过渡布局携带上一个姿态的历史区域数据进入新尺寸:例如纵向页面暂时携带了横置态的折痕,或外屏暂时保留了展开态的区域。在使用区域数据前,务必先结合当前页面本身的宽高比进行合法性校验。
度量代码中的一大常见陷阱是:显式忽略了安全区域的 GeometryReader 会报告零边距,因为安全区域已被其主动丢弃。
竖向栏的侧边列与安全区边距
在手机闭合以及横置展开状态下,系统会将工具栏垂直嵌入屏幕侧边的一列中(Apple 官方规范详见竖向栏指南)。页面最终是呈现出“专为侧边列精心定制”,还是“被侧边列粗暴推向一边”,完全取决于对以下规则的处理。
自定义标签栏应直接嵌入侧边列
Apple 在竖向栏篇中提到:系统原生工具栏会自动移入侧边列,而开发者自行绘制的工具栏则会保持在原位。若机械执行此原则,自定义的悬浮标签栏依然会横卧在页面底部,紧邻一条 84pt 宽、仅在顶端顶着一个时钟的狭长空白侧边列。这会导致所有页面整体向左严重偏斜。Flicit 第一轮迭代正是如此,开发者的第一直觉反馈便是“所有页面看起来都被粗暴推挤到了左侧”。
若直接将自定义标签栏顺时针旋转贴在页面边缘同样糟糕:若贴在 Trailing 边缘会与时钟发生重叠,若贴在 Leading 边缘则会遮挡核心正文。真正优雅的解法是在系统侧边列的内部(位于时钟下方)构建一条立式导轨(SideRail):正如设计篇所展示的,系统自身的标签栏同样停靠在侧边列底端。首发合作伙伴亦采取了相同方案:在展开设备上,Slack 将其标签项与搜索按钮全部垂直立在侧边列底部。

// 位于根视图中,与原有的水平悬浮标签栏并列。仅限 27.1 SDK。
@Environment(\.toolbarVerticalEdge) private var barEdge
.overlay {
if let barEdge, columnWidth > 0 { // columnWidth:barEdge 边缘对应的 Safe Area Inset
SideRail(selection: $tab, search: expandSearch)
.frame(width: columnWidth)
.padding(.bottom, 12)
.frame(maxWidth: .infinity, maxHeight: .infinity,
alignment: barEdge == .leading ? .bottomLeading : .bottomTrailing)
.ignoresSafeArea(.container, edges: barEdge == .leading ? .leading : .trailing)
}
}
- 动态读取边缘属性,切勿写死为
.trailing。在分屏浏览(Split View)下,App 控件会移至靠外的一侧,对于左侧 App 而言该侧正是 Leading 边缘。竖向栏篇还指出,在从右到左的语言中,侧边列仍留在设备的同一侧,而那一侧此时是 Leading 边缘。而在纵向立起时该属性为nil,此时自动切回水平悬浮标签栏。 - 依据该侧的 Safe Area Inset 动态确定 SideRail 宽度。边缘只说明栏会放在哪里,若该侧没有内边距,就没有侧边列,也不应显示导轨。导轨仅在该停靠边缘忽略安全区域,页面主体继续保持正常的安全内边距。
- 当 SideRail 显示时,从各子页面的滚动边距(Scroll Margin)中扣除原底部悬浮栏的高度。若搜索入口集成在标签栏中,打开搜索时直接在页面上方浮现搜索栏,避免改动页面的边距(防止触发第 9 节所述的视图标识突变)。
- 确保 SideRail 声明忽略键盘安全区(
.ignoresSafeArea(.keyboard))。否则键盘升起时导轨会被一路向上推挤,直接顶入时钟遮挡区。 - 当处于需要隐藏主导航的场景(例如全屏详情页或多选底部操作栏)时,同步隐藏 SideRail。
- 将 SideRail 设计为原悬浮标签栏的立向翻折形态,而非另起一套突兀设计。Flicit 的标签栏是一张长条电影票根:各标签整齐印在纸条上,中间开有齿孔半月槽,副券延伸出搜索入口。嵌入侧边列后,同一张票根顺势立起,副券停靠在底端,在手机开合旋转时导航仅发生空间平移,其物理隐喻始终保持高度统一。
- 逐一审查仍保留系统导航栏的下级页面。仅包含图标符号的
ToolbarItem会自动移入侧边列,极易与自定义 SideRail 发生重叠冲突;自定义 View 默认留在原处,除非主动声明进入(见下文)。
与系统共用侧边列
侧边列并非由 App 独占。Apple 设计篇明确列出了该区域的共享常驻组件:状态栏以及灵动岛(Dynamic Island)——灵动岛会随实时活动的增多沿侧边列持续向下膨胀。当系统原生工具栏在该列中空间告急时,会将多余项收进溢出菜单。自定义工具栏无法加入该系统级溢出菜单,因此必须具备自适应避让能力。Flicit 的 SideRail 原先采用固定 274pt 高度(5 个 54pt 单元格加顶部 4pt 边线),锚定在底部内边距上方 12pt 处,完全忽视了上方悬挂的元素。如今它能实时读取当前侧边列覆盖的 Active Occlusions(时钟区、镜头、灵动岛),并自动将 5 个单元格排布在最低遮挡线下方:每个单元格从 54pt 动态向下压缩至最小 44pt 触控目标。若依旧无法容纳,则整条 SideRail 优雅降级,主动让位给原有的底部悬浮栏。
// chrome:停靠侧边列上所有活跃遮挡区域的底边全局 Y 坐标
let available = pageBottom - spacing - chrome - spacing
let cell = ((available - lip) / 5).rounded(.down)
let railCell: CGFloat? = cell >= 44 ? min(54, cell) : nil // nil:降级唤回底部悬浮标签栏
在模拟器环境下无法直接观测灵动岛的膨胀:前台 App 永远无法在灵动岛中看到自身的实时活动,而在无 UI 自动化测试中又无法直接拉起其他 App 的活动。Flicit 在 Debug 构建中注入了 -columnChromeHeight <pt> 参数模拟膨胀的灵动岛:闭合态下传入 380pt 可将单元格压缩至 47pt,而在旋转外屏传入 200pt 或展开态传入 400pt 时,会成功触发降级并唤回底部悬浮栏。
自定义工具栏项进入侧边列
在 Apple 竖向栏规范中,工具栏的纯图标项目会自动移入侧边列,纯文本项目保持在水平工具栏中,而自定义 View 默认停留在水平栏中,除非显式配置 axisBehavior(.verticalPreferred) 并针对纵向轴线完成布局。在分栏视图(Split View)中,仅有详情栏(Detail)的工具项会移入侧边列,其余列保持水平。Flicit 的工具栏均为自定义视图(纸质返回按钮、关闭圆盘以及印在裁切纸片上的四个功能图标),因此在外屏详情页顶部始终横亘着一条栏,紧邻着只放着时钟的空旷侧边列。如今在主 SideRail 收起的前提下,详情页通过声明主动让返回按钮与功能纸条移入侧边列并垂直排布。当 SideRail 处于显示状态时,它们重新设为 .horizontalOnly,避免与主导航重叠。这一优化使外屏下的票根详情直接向上延伸了一个工具栏的高度。
ToolbarItem(placement: .topBarTrailing) { strip } // 嵌入侧边列时为 VStack,置顶时为 HStack
.axisBehavior(railIsAway ? .verticalPreferred : .horizontalOnly)
模态弹出页(Sheet)同样遵循此规则。闭合状态下,Sheet 的工具栏会收编为立式工具栏;展开时 Sheet 居中并采用常规水平栏;在书本形态下系统会自动将其从折痕中缝滑移至某一侧折页。若 Sheet 工具栏中仅包含单一的“关闭”或“完成”按钮,Apple 推荐声明 toolbarVerticalBehavior(.disabled)(UIKit 中为 preferredVerticalBarBehavior),避免仅为一个按钮便剥夺整整一列宽度。其文档要求把它当作稳定的选择:不要随视图状态切换。声明后 Sheet 会直接向上延伸至前置镜头下沿,状态栏也会自动避让。Flicit 针对其 10 个此类 Sheet 均启用了该优化;搜索 Sheet 的结果流此前因避让侧边列被截断,如今完全用满了外屏完整的 466pt 宽度。
什么不属于真正的适配
以下几种做法看似视觉对齐,实则粗暴违背了硬件与人机工程学约束:
- 为时钟无端顶出一条全局横带。 将页面顶部 Header 人为垫高到时钟区域的高度,虽然能防止列表卡片与时钟重叠,但却导致全站所有页面无端下沉 170pt(闭合)或 120pt(展开),而时钟在物理上仅存在于一侧边缘。
- 让常规图文直接穿透到侧边列下方。 在根视图上声明
ignoresSafeArea(.trailing)毫无效果,因为 UIKit 导航宿主会强制重读安全区域。若在子页面内部强行声明,海报图片与序列号会直接被压在侧边列下方,形成严重的文字截断。背景照片是唯一的例外:全屏背景图连同渐变暗化层可以安全贯穿到侧边列底部,但正文排版必须严格留在安全区域以内。 - 在 Leading 侧人为对称镜像一条空列。 为追求视觉上的居中而人为在左侧添加 84pt 空白,会导致闭合态下的卡片内容被挤压至文字严重折行,在展开态下更会导致双栏布局坍缩为单栏。
窄内容在整屏物理玻璃上居中
仅在 Safe Area 内边距中居中的页面,其视觉重心相较于整块物理玻璃的中线会向左偏离半个侧边列的宽度。当页面铺满内容时该偏移并不明显;但当内容受限时便十分扎眼(例如限制行宽的设置页、年度报告页,或外屏上孤立的一张卡片)。Apple 设计篇允许非滚动的强视觉页面以整块屏幕为基准居中,前提是侧边列内的控件不会遮挡任何交互元素。Flicit 将此设计延伸至可滚动页面的窄栏布局中,但施加了严格约束:位移严格受限于页面自身的留白边距(Margin),确保内容永远不会穿透到侧边列下方;且该规则仅适用于占据绝大部分窗口的主页面,绝不可滥用于列表旁边的详情窗格中:
// page:ScrollView 全局 Frame;column:内容限定宽度
let glassMidX = (stage.contentOriginX + stage.contentWidth + stage.trailingInset) / 2
let margin = max(0, (page.width - column) / 2)
let shift = min(margin, max(-margin, glassMidX - page.midX))
content.offset(x: shift) // principal 工具栏标题同步平移,确保与正文垂直对齐
在 Flicit 中,横置展开状态下的年度回顾页及其标题因此向物理中心平移了 42pt。
针对姿态重新布局,而非无脑拉伸手机界面
确保每个页面能够正常渲染是第一轮的工作;第二轮的重心是重塑体验:在横向展开、平板级纵向展开以及翻折形态下,每个页面应当呈现何种最合理的形态。
Apple 设计篇建议针对 Regular 与 Compact 两种 Size Class 进行设计,避免写死绝对宽度与断点。但在双屏展开的 Duo 上,仅靠这一条准则远远不够:无论横置还是纵向立起,内屏在水平和垂直方向上均为 Regular 类别,因此单纯依靠 Size Class 无法区分跨页与单栏。Apple 官方的自适应容器同样综合读取了宽高比与 Division 分割区域,Flicit 亦遵循此逻辑:它实时研判页面是否宽大于高,以及中缝折痕带的几何走向。它所保留的少数几个绝对阈值(跨页采用 600pt、Split View 停止分栏的约 800pt、超过 160pt 的内边距视为独立分栏),描述的均为内容本身的固有属性(如容纳两个手机宽度的分栏,或列表加详情),而非针对某块特定硬件屏幕。其中 800 是需要留意的例外:它是这一构建版本的 NavigationSplitView 放弃并排显示的宽度,并非 Apple 公布的数字,因此每换一个 SDK 都要重新测量。
横屏展开:双栏跨页,以及贴合内容的卡片

当可用宽度拉大至 867pt 时,简单的单栏布局会沦为滑稽的“巨型手机”:单张图表占据整屏,核心统计数据被推至下方极远的滚动区域。一旦内容宽度超过单台手机(Flicit 阈值定为 600pt),必须重新排布为跨页分栏:左侧数据、右侧图形,下方辅以并排子窗格。随后应着力排查因页面变宽而被不当拉伸的细节:
- 拒绝无意义的等高拉伸。 Flicit 的热力图卡片原先被强制拉伸至与旁边的数据面板等高,导致月视图中的三排座位孤零零悬浮在巨幅空白卡片中。重构后卡片紧密贴合内容尺寸,并将数据下方的链接移至热力图下方,在视觉上形成平衡。
- 为自适应拉伸元素设上限。 选座热力图在达到手机单屏标准尺寸后即停止放大,确保影厅在加宽的卡片中始终保持真实的剧场比例。
- 压缩手机上因空间不足而人为做高的图表。 手机上两组原高 160pt 的图表及顶部的筛选芯片,重构为 104pt 的紧凑图表并将筛选器收纳进小节标题中;横跨 10 个半星档位的折线图(平线居多)被替换为紧贴均值显示的柱状分布图。
- 状态维持必须脱离布局之外。 在某一种形态下做出的交互选择(例如热力图选择全年还是单月),在设备开合重排后必须无缝继承。
- 严禁让副窗格处于空置状态。 用户在列表页展开双屏时,最先映入眼帘的就是列表右侧空空如也的详情页。Flicit 原先显示一幅静态插图与提示文案;如今直接在右侧铺开最新的三张票根,各自渲染对应的主题票样,点击即可直接查看。左侧列表保持原状,右侧折页承载更多收藏内容,而非冷冰冰地要求用户做出选择。
- 并排小节采用统一的标题基准高度。 附带操作控件的标题高度往往高于纯文本标题:Flicit 的观影图表标题包含“年/月/周”分段控制器(44pt 触控高度),而评分图表仅有文本。两者并排时会导致两侧基准线错落,卡片拉伸后多出奇怪的空白地台。如今仪表盘上所有并排小节的标题最小高度统一设定为 44pt。

竖版海报独占左半屏,而非截断做成横幅
手机页面通常喜欢在顶部放置一张贯穿通栏的焦点图。Flicit 电影详情页原先正是如此:海报铺满整个宽度、顶端对齐、高 560pt,片名压在海报下沿。在竖屏手机上这能呈现海报的大部;然而在横置展开的 951pt 巨幅屏幕上,海报仅能露出顶部的 1/4。通栏横幅越宽,竖版纵向图片被严重裁切的惨状就越甚。
真正的解法是让海报独占一扇折页(Leaf)。在横置展开态下,Duo 的左半扇屏尺寸为 455×669pt,其比例几乎完美契合电影海报经典的 2:3 纵横比。因此 Flicit 的海报现在顶天立地完整立在左折页上,片名压在底部;而演职员表、电影简介与票根动作则全部收拢在右折页内,自折痕右侧平滑滚动。这正是 Vidit Bhargava 在 Movie Buzz 上的做法,且该布局能直接无缝继承至书本形态。在其他常规屏幕上,该折页宽度则动态计算为契合海报比例的自适应宽度。
// page:视图在 Safe Area 内部的全局 Frame
guard page.width >= 560, page.width > page.height * 1.15 else { return nil } // 横置展开状态
if let crease = creaseBand, // 活跃折痕,或平放时未来的折痕中缝
crease.lowerBound > page.minX + 320, crease.upperBound < page.maxX - 320 { // 两侧各保留至少一部手机的宽度
return (leaf: crease.lowerBound - page.minX, gutter: crease.upperBound - crease.lowerBound)
}
return (leaf: min(glassHeight * 2 / 3, page.width * 0.48), gutter: 0)
该准则同样适用于横屏外屏与横持的普通 iPhone。而在纵向立起的平板级宽度下(纵向展开的 Duo 或竖屏 iPad mini),页面收拢为单栏,顶图横幅沿海报比例自然拉高(最高可占屏幕全高的 3/4),确保下方依然能露出正文简介。
当从票根直接唤起该页面时,该视图会嵌套在 Split View 的 Detail 栏中,紧邻左侧列表。此时该列的 Leading Inset 是整条列表的完整宽度(455pt),盲目下穿到 Inset 下方的海报会被完全遮挡在不透明的列表下方。如今顶图仅贯穿至窗口物理边缘与侧边列,绝不穿透至任何大于常规工具栏宽度的内边距(Flicit 判定阈值为 160pt),而正文排版严格始于详情列 Leading 边缘对应的折痕线之后。
纵向立起:收为单栏,而非拆为两条局促窄栏
纵向立起时,展开内屏尺寸为 669×951pt。此时极易产生将其继续拆分为左右双栏的冲动。Flicit 日历页面此前尝试过这种做法,结果是单台手机宽度的月历旁塞进局促的当日日程,而绝大多数观影日只有一两张票根,导致整块屏幕下半部分大面积空置。纵向立起时应当坚定采用单栏布局,当日票根居中排布在月历正下方。左右分栏仅保留给宽大于高的物理形态。
该单栏最初的版本横贯全宽,并为了在单屏内同时露出月历与当日首张票根,强行将海报格子从 2:3 压缩接近正方形。功能目标虽然达成,视觉呈现却灾难性地丑陋:所有海报均被暴力裁切成正方形,开发者给出的评价是一致的难看。海报在任何场景下均必须保持神圣的 2:3。修复方案是坚守 2:3 比例,改为收窄月历整体宽度,将其限制在刚好能完整摆放 6 行海报且在标签栏上方露出当日日程的适宜宽度:约占 669pt 中的 455pt,水平居中,且绝不窄于手机单屏月历。在竖屏 iPad mini 上全宽刚好能够容纳,因此无需收窄。当布局空间产生冲突不得不妥协时,宁可收缩可用宽度留白,也绝不能拉伸扭曲内容比例。
NavigationSplitView 在原生逻辑下同样会暴露此缺陷。当总宽低于约 800pt 时,它无法并排呈现列表与具备良好可读性的详情,转而将列表悬浮在暗化的详情上方。当将展开的 Flicit 从横向旋转至纵向时,列表往往直接被强制 Dismiss 仅留下一片空白详情;直接在纵向冷启动时,列表则悬浮在一片灰色背景上。在此宽度下,应当主动为 Split View 注入 Compact 的 Size Class,促使其折叠收拢为单栏导航栈(Stack):
@Environment(\.horizontalSizeClass) private var windowSizeClass
@State private var width: CGFloat = 0
private var sizeClass: UserInterfaceSizeClass? { // 800:iOS 27.1 的 Split View 停止并排的宽度
windowSizeClass == .regular && width > 0 && width < 800 ? .compact : windowSizeClass
}
NavigationSplitView { List(…) } detail: { Detail(…) }
.environment(\.horizontalSizeClass, sizeClass)
.onGeometryChange(for: CGFloat.self, of: { $0.size.width }) { width = $0 }
如此一来,内部所有页面均遵循单一逻辑:列表承载整页,点击条目推入携带返回按钮的详情页,原本为 Compact 详情编写的代码(如隐藏底部标签栏)全部正常生效。重新横置展开时,两栏重新平铺并保持选中的项。全屏列表本身也需适配此宽度:Flicit 的票根瀑布流在能够并排容纳两张票根时展开为双列,空间不足时收为 520pt 居中单列。
随后必须全面验证在详情打开状态下的旋转回归。在横置状态下查看着某张票根并直接将手机旋转立起,Flicit 此前会直接退回列表导致票根丢失,即便 preferredCompactColumn 明确声明为 .detail:折叠后的分栏视图仅在绑定值突变为 .detail 时才触发 Push,若其原本就处于 .detail 则完全静默无响应,这也是为何直接冷启动纵向详情正常、中途旋转却失效的根源。而当它终于能够保持显示票根时,标签栏却阴魂不散地覆盖在其上方。分栏视图在收折时会在旧视图尚未销毁前预先构建新视图,而两者使用了存放在票根 Store 中的同一个所有者 ID 申请“隐藏标签栏”,导致旧视图在销毁解绑时顺带将新视图的隐藏声明一同注销了。针对上述问题的两处精准补丁:
// 在 Split View 中:携带打开的详情收折时,在下一轮 Runloop 中重新触发激活
.onChange(of: sizeClass) { _, sizeClass in
guard sizeClass == .compact, selection != nil, compactColumn == .detail else { return }
compactColumn = .sidebar
Task { @MainActor in compactColumn = .detail }
}
// 在详情视图中:将界面声明权绑定到视图实例(View),而非跨实例的数据项
@State private var barOwner = UUID()
Apple 设计篇严正要求 App 在用户开合翻转机身时必须保持连贯可预期,而上述两个严重体验 Bug 在静态截图上均极易伪装成合理的正常排版。务必在详情打开的状态下对机身进行多方向双向翻转,仔细审查每一步的表现。

半折书本形态:将分栏间隙对齐折痕
在横向半折状态下,物理折痕会作为屏幕中央一条 40pt 宽的分割带出现(具体 API 见折痕指南)。虽然可滚动文本可以安全跨越折痕,但对于双栏并排页面而言,核心议题在于两栏之间的间距(Gutter)如何安放。正确的做法是将分栏间距精准对齐折痕:将左侧栏的主宽精准限制在折痕左侧边缘,并将折痕自身的物理宽度直接作为分栏间距,同时确保两侧栏均满足自身最小可用宽度。这样左右两侧便各安一折页,宛如翻开一本真正的实体书籍。
更关键的是:在设备完全平放时同样要提前对齐。系统在默认情况下仅在机身半折时报告折痕,但若在请求时开启 .includeInactive,则完全平放状态下系统同样会提前预报折痕的未激活位置(即未来的弯折轴线)。如果组件依赖 Active 状态才介入重排,在机身弯折的瞬间,页面会在系统底层的折叠动画掩护下发生突兀的二次排版位移——这正是 Flicit 在录屏中所暴露的瑕疵。而如果从平放状态起便直接锚定在未激活折痕带上,整个弯折过程界面纹丝不动:
// 位于 GeometryReader 内部;所有 Frame 均需累加读取器的全局原点
let divisions = proxy.reservedRegions(kind: .division, options: .includeInactive)
let active = divisions.first { $0.isActive }?.frame
let resting = active == nil ? divisions.first { !$0.isActive }?.frame : nil
// 折痕在全局 X 轴的覆盖范围:激活态折痕,或平放时未来的中缝线
var creaseBand: ClosedRange<CGFloat>? {
if let seam = active { return seam.minX...seam.maxX } // 半折书本形态
guard let fold = resting, fold.height > fold.width, // 竖贯屏幕
abs(fold.height - fullPageHeight) <= 1, // 排除上一姿态遗留的脏数据
pageWidth > pageHeight else { return nil } // 仅限横置状态
let half = (fold.width > 0 ? fold.width : creaseWidth) / 2 // 硬件上平放可能为零宽
return (fold.midX - half)...(fold.midX + half)
}
// 双栏组件。务必测量外部赋予的宽度,切勿测量子栏自身的尺寸:
// 声明了 minWidth 与 maxWidth 的 Frame 会强制接受外部给予的空间,无视内容尺寸要求。
HStack(spacing: gutter?.width ?? 16) { primary.frame(width: gutter?.primaryWidth); secondary }
.frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)
.onGeometryChange(for: ClosedRange<CGFloat>.self) { proxy in
let frame = proxy.frame(in: .global)
return frame.minX...frame.maxX // 仅监听 X 轴:纵向滚动不会触发重新计算
} action: { span = $0 }
guard let crease = creaseBand, let span,
crease.lowerBound > span.lowerBound, crease.upperBound < span.upperBound else { return nil }
let primaryWidth = crease.lowerBound - span.lowerBound // 左半屏折页宽度
let gutter = crease.upperBound - crease.lowerBound // 折痕间隙本身
// secondary:折痕右侧剩下的可用空间,前提是满足最小宽度要求
Flicit 最初曾试图根据铰链角度(如大于 90°)结合屏幕几何中线自行推导折痕,但实践证明每个写死的角度阈值都是灾难之源:例如等待 150° 的阈值导致折痕计算在翻折中途突然失效断档,引发两侧栏疯狂闪烁。Apple 多任务篇强调将铰链角度留给交互动效,排版必须全权委托给保留区域;未激活保留区域在任何平放角度下均稳定存在,直至物理折痕被正式激活。上述代码保留了两个至关重要的防护:确保当前页面处于宽大于高(防止竖屏状态下横贯的折痕引发第 9 节的无限死循环),以及确保区域全高贯穿页面(防止旋转合上瞬间外屏依然携带上一姿态的折痕数据)。
对于非导航类的纯内容双栏排版,Apple 官方推荐使用 ArrangementView(UIKit 中为 UIArrangementViewController)。它接受主次两个视图,依据 Size Class、宽高比及 Division 区域自动完成并排或层叠。布局篇划定了两条红线:切勿将其置于 List 或 ScrollView 内部,也切勿在其内部嵌套 NavigationSplitView。Flicit 的双栏行原本就位于 ScrollView 内部,不符合 ArrangementView 的使用场景,因此采用上述手动对齐方案。
对于原生的 NavigationSplitView,同样需进行折痕对齐处理。在逐一打印各姿态下的 Column Frame 后,总结出了以下三大核心细节:
- 半折状态下,系统会自动定位分栏割线,精准卡在折痕起始边缘(455.67pt,即折痕 455.5pt 的像素对齐值),完全无视开发者传入的理想宽度。Apple 布局篇描述的正是这一行为:系统会以均分的 50/50 布局让两栏都保持可见。因此在平放状态下,理想宽度直接以该边缘为基准申请,方能实现平放与翻折无缝接轨。
- 平放状态下,新的理想宽度会立即生效,但系统强制要求 Detail 列保留至少约 448pt 的宽度。 在展开后 867pt 的页面中,这导致 Flicit 的列表被强行截断在 419pt 处,距离折痕尚差 36pt,直到翻折瞬间才突兀跳动归位。因此针对折叠设备,必须主动调低 Detail 列的最小宽度限制。
- 折痕随后会竖贯在 Detail 列的 Leading 边缘。 在两种姿态下均需为 Detail 列添加等于折痕宽度的 Leading SafeAreaPadding,确保票根内容完全起始于右半扇折页。
// toFold = crease.lowerBound - frame.minX,仅在 360...520 之间时采用;其余情况为 nil
NavigationSplitView {
List(…)
.navigationSplitViewColumnWidth(min: 360, ideal: toFold ?? 410, max: max(440, toFold ?? 410))
} detail: {
Detail(…)
.safeAreaPadding(.leading, crease.upperBound - detailFrame.minX) // 40pt 安全避让
.navigationSplitViewColumnWidth(min: 320, ideal: 412) // 仅限折叠设备调低阈值
}
注意 frame 与 detailFrame 均在全局坐标系中度量。无折痕存在时,维持默认理想宽度并将 Padding 置为 0。切忌在不降低 Detail 最小宽度的情况下强行加大 List 宽度,否则两栏无法共存,Split View 会直接将 List 悬浮在 Detail 上方,引发恢复逻辑的拉扯死循环。更切忌将 min 与 max 设为同一值强制固定宽度(见第 9 节)。
通过上述三重调优,Flicit 的影库在完全平放、书本微折、重新展平、闭合唤醒以及竖屏转入横屏的全部场景下,均呈现出绝对的一致性与稳定性。

网格布局:折痕穿过元素间隙,绝不可纵切单个元素
若在一整排展开屏幕上机械平铺照片或海报网格,迟早会有某一格元素恰好横跨在中缝线上,一旦机身半折,该元素便会沿中轴严重扭曲折断。布局篇给出的明确建议是:只要检测到 Division 分割区域(不论是否处于激活状态),网格列数均优先采用偶数列。Wesley Matlock 在 VinylCrate 的黑胶唱片网格中便严格贯彻了这一法则。Flicit 的电影收藏呈现为连续的胶片印样:全屏排布多行电影胶卷,每个画幅内嵌一张海报。如今每条胶卷在左右两扇折页上强制均分相同数量的画幅,将中缝折痕(及其平放预估区域)作为左右两部分画幅之间的空白走廊;而胶卷底片的外沿与打孔齿孔则平滑穿透中缝,在视觉上依然维持一条完整的连贯胶卷。具体列数依据胶卷全局跨度及折痕带动态计算:每折页列数以较窄一侧折页的容量为准;若计算导致单格过于狭窄,则自动回退至常规排版。
居中焦点元素:视觉与交互焦点主动避让折痕
Apple 布局篇严正提醒开发者仔细审计所有居中排版的布局,并以横跨书脊跨页印刷的摄影大图作为反面教材:横跨折痕的内容无法形成完整的视觉闭环,且位于中缝处的按钮极难被拇指精准触达。连续滚动的文字可以跨越折痕,但视觉注意力与拇指交互的核心焦点绝不能压在上面。Flicit 发现页原先以一组居中扇形展开的热门海报 Carousel 开篇,在横置展开时其中心海报恰好不偏不倚地卡在物理折痕上。重构后,一旦检测到折痕带穿透轮播区域(无论平放还是半折),整个扇形海报组立即向左位移整齐排列在左折页内(中心海报两侧各露一张);而该焦点影片的文字元数据卡片(片名、上映年份、片长、评分及剧情简介)则整齐占据右折页,并随左侧扇形海报的滑动旋转而实时无缝联动切换,点击任意一侧均能直接唤起影片详情。Apple 将此设计范式称为位移重排(Displacement):将核心元素平移至契合其物理属性的区域,与原位置保持临近,并将强关联的附属组件成组协同位移。系统层级的 Sheets、Alerts 与 Menus 均已全面内置了这一行为。

笔记本形态:上屏呈现,下屏交互
将双屏设备纵向立起并半折至约 110°:上半部分如同笔电屏幕般立起,下半部分平卧在桌面上。必须为上下两个半屏明确划分职能分工,并且这项工作必须覆盖所有可能在该姿态下打开的子页面,绝不仅限于你主观为其设计的那一个核心界面。Flicit 第一版仅在单个页面上实现了此形态:票根缩小至立起的上半屏,选座影厅铺在下半屏;然而一旦横滑至票根笔记或电影资料页,界面便再次暴露出横跨中缝的整页排版。
- 内容形态必须与所在半屏的长宽比高度契合。 立起的上半屏物理尺寸为 669pt 宽、约 373pt 高,呈现明显的宽屏比例。常规纵向票根在此处会被高度严重限制:Flicit 纵向票根被压缩至仅剩 192×341pt,相较于平放时的 408×720pt 显得极度缩水。重构后,上半屏切换为票根的横向票样形态(即票夹中收纳的完整入场券票样),采用专属主题绘制并放大填满整个上半屏,可用可视面积扩大了整整三倍。若你的核心业务数据没有现成的横屏形态,宁可在此处呈现具备横屏比例的关联元素,也绝不能勉强放一个微缩缩放的纵向残缺视图。
- “看的放上面,触控的放下面”。 票根主体在上半屏始终保持恒定展示,而下半屏则作为交互操作台承接各个翻页子面板:标明座位的放映厅全景、评分与观影笔记、以及电影资料。Flicit 的电影详情页在上半屏展示海报与剧情梗概,下半屏集中承载评分、演员阵容以及购票票根操作;正如播客应用 Micropod 在铰链上方展示专辑封面、在铰链下方铺设完整播放控制台一样。
- 上下两半必须各自独立滚动。 任何横跨中缝折痕滚动的页面,都会导致一半的内容落在平卧的底板上,以完全无法直视的角度令人痛苦。必须在中缝处将其阻断拆分为两个独立的 ScrollView,正如财务应用 Finma 将图表与流水列表彻底分拆两屏独立滚动一样。
- 无法适配的复杂内容应坚决放弃分拆。 Flicit 的日历页面需要展示一整月纵向海报,其高度远超任何单个半屏的物理极限,因此在笔记本形态下它坚定保持原有的整体单栏滚动,绝不为了迎合形态而将整月海报强行缩成毫无可读性的微型邮票。
- 完整保留所有交互动作。 Apple 设计篇强调:针对特定姿态重构的布局必须完整保留其他姿态下的全部交互控件与层级入口。Flicit 笔记本页面最初因将票根页切换为横向票样,意外遗失了撕票根副券的手势(这是将待看电影标记为“已看”的唯一途径)。如今在所有姿态下,票根的操作菜单中均显式补全了“标记已看”入口。
中缝分割判定必须依据 Active Division 区域的上下边界与页面的全局 Frame 进行实时计算:
// seam:激活的 division 区域(全局坐标);page:页面的全局 Frame
guard seam.width > seam.height, // 横向折痕:机身纵向立起且半折
seam.minY > page.minY, seam.maxY < page.maxY else { return nil }
let raised = seam.minY - page.minY // 折痕上方(立屏高度)
let flat = page.maxY - seam.maxY // 折痕下方(操作台高度)
guard raised >= 220, flat >= 180 else { return nil } // 确保两个半屏均具备充足的可用高度
必须在填满页面的 GeometryReader 闭包内部或通过不依赖分割结果的稳定 Frame 完成此项计算,绝对禁止依据被分割元素自身的尺寸进行反向计算。

外屏适配
外屏内部页面宽度为 382pt,略窄于常规 390pt 的 iPhone。按标准手机尺寸设计的网格在此处极易丢失一列;原则上只要每个单元格的缩放比例不低于基准宽度的 86%,就应坚持保持列数不变。对于孤立展示单张卡片的详情页,应如第 6 节所述将其居中置于整块物理玻璃上,并略微缩减绘制尺寸,防止卡片边缘触碰屏幕边界。
Apple 准备篇推荐使用 ConcentricRectangle 与 UICornerConfiguration,以完美契合 Duo 外屏的硬件大圆角。Flicit 的设计语言特意采用了手工剪纸风格,因此并未追求严格的同心圆角。核心要求是确保所有可交互内容绝不被物理圆角裁切:SideRail 的底部圆角距离外屏底边约 46pt、距离侧边约 15pt,安全内嵌于屏幕玻璃的倒角弧线之内。
当在机身旋转状态下合上手机时,外屏会呈现为横向的 678×466 尺寸,宽大于高且系统侧边列停靠在 Leading 边缘,此时必须全盘套用宽屏跨页规则而非单列手机规则。Flicit 的日历页面此前在此时同样暴露出将海报格子裁成正方形的 Bug;如今海报在全站任何场景均严格保持 2:3,电影详情页则无缝切换为海报独占单折页形态。

iPhone Duo 的待机显示(StandBy),以及在模拟器中调出它
将半折的手机如帐篷(Tent)或翻开的书本般免手持立在桌面上,iPhone Duo 便会自动进入 StandBy(待机显示)模式——在两块屏幕中的任意一块全屏呈现时钟与小组件,且无需连接任何充电器。这初看像是一个需要开发者专门针对性布局的硬件新形态。Flicit 曾深入挖掘该形态的适配路径,最终确认其对外暴露的入口仅有 WidgetKit 小组件与 Live Activities 实时活动。
系统未开放任何可供感知该姿态的公共 API。 公开的铰链 API(onHingeChange、UIHingeInteraction)仅提供基础的 status 与 angle。系统内部确实为各形态命名:SwiftUI 私有的 _HingeState.MagicPose 列出了 book 和 laptop 及二者的倒置形态、tent、openFlatFaceDown、两个横向的 magicMount 姿态以及 ambiguous,SpringBoard 据此裁定何时触发 StandBy 以及点亮哪块屏幕。但所有这些符号均属私有。WidgetKit 在 iOS 27 中新增的仅有 isDynamicIslandLimitedInWidth 属性与 .systemExtraLargePortrait 尺寸规格,并未提供任何折叠屏 StandBy 的专属标记。Apple 小组实验室(Group Labs)划定了相同的边界:普通 App 在帐篷形态下仅能在外屏呈现其常规的横向界面;而闹钟响铃时内屏漫射出的微光氛围特效受 AlarmKit 严格管控,仅限闹钟类 App 调用。准备篇讲座指明了普通 App 为此形态唯一能做的事情:外屏必须良好支持横向显示,“因为用户可能会像帐篷一样将手机立在桌面上”。
开合与旋转模拟器无法触发 StandBy。 SpringBoard 会在系统控制台中记录裁决日志,开发者可以直观目睹它如何拒绝触发:
xcrun simctl spawn <DUO-UDID> log stream --style compact \
--predicate 'process == "SpringBoard" AND eventMessage CONTAINS[c] "magic"'
Ambient magic pose event detected [ tabletopPose : Amb ; hingeState : PO ; deviceOrientation : Lr ; … ]
Updating ambient trigger state : idle [ … isChargingRequirementFulfilled : YES ; ignoreCharging : YES ;
… isMagicPoseSupported : NO ; magicPose : Amb ; magicPoseState : unsupported ]
Flicit 曾通过铰链滑块与方向选择器构造了 10 组典型姿态遍历 Duo 模拟器(涵盖闭合、70°、110°、128°、完全展平,以及全部 4 个旋转方向),日志反馈始终如一:桌面摆放姿态始终处于不确定状态(Amb),Magic Pose 均标记为不支持,待机模式触发被阻断并提示“no trigger asserted”。因为这两个底层控制滑块无法向系统传递“究竟是哪一条物理边缘贴在桌面上”,导致模拟器永远无法确认设备处于立起支撑状态。充电状态并不是阻碍原因(日志明确显示 ignoreCharging : YES),而 Device Hub 的预设按钮本质上也只是底层这两组控件的宏组合。70° 时由哪块屏出画,取决于铰链是怎么到达这个角度的(见第 3 节)。从展平往下弯,内屏继续出画,外屏保持黑屏;从合上往上打开,外屏继续出画,铰链报告合上,这是模拟器最接近“App 被立成帐篷”的状态。
devicectl 能把它调出来。 只要锁定屏幕正在显示、界面处于横向,xcrun devicectl device settings standby --show --duration 10 --device <udid> 就能在模拟器上呈现 StandBy。锁定屏幕出现之前,它一直拒绝:“StandBy needs the lock screen to be showing, and the device to be in a pose that supports StandBy.” simctl 没有锁屏命令,但侧边按钮是一个键盘事件(consumer page 0x0C,usage 0x30),可以像铰链事件一样从模拟器内部投递:按一下锁屏,再按一下显示锁定屏幕。此后,展开横置平放、横置 128°、70° 横置(正反两个方向)时,StandBy 出现在内屏;合上并转到某一个横向(landscape-right)时,出现在外屏。立起时,以及合上竖持时,device info standby 读到 unavailable。StandBy 显示期间,SpringBoard 记录下一个桌面姿态和已断言的触发器,这是开合与旋转始终达不到的。每次它都先打开 Apple 的“Welcome to StandBy”欢迎页。Xcode 的工具无法点按展开的手机,但 duoctl 可以(见第 4 节):点过“Continue”之后,内屏上的待机显示呈现了系统自带的电池和联系人小组件,没有背景,滑动可以在各页之间切换。App 自己的小组件要通过编辑待机显示来添加,这一步没有试过。另外,Apple 小组实验室(Group Labs)列出的 Device Hub 可调项中包括帐篷姿态与三维空间旋转,超出了本文用脚本驱动的这两个 HID 控件。
因此,开发者应当做的是将针对 StandBy 的通用资产准备到位:确保小尺寸小组件在系统强制剥离背景后依然具备出色的可读性(通过 showsWidgetContainerBackground 判定背景是否被移除);确保锁屏实时活动在放大至两倍尺寸后依然维持精致视觉;以及视业务需要引入特大尺寸小组件(Group Labs 专家明确指出特大组件在 Duo 的 StandBy 下具备极高价值)。至于在 StandBy 模式下的真机呈现效果,只能静待真机发售。Flicit 这一部分目前尚未完成重构:其小组件依然硬编码绘制了私有的纸质纹理背景,且尚未提供特大规格尺寸。
姿态变换之间:仅对弯折做动画
后续的一轮优化对每次姿态变化都进行了逐帧录屏比对;动画指南阐述了完整的推导过程。开合发生在系统的高斯模糊快照之下,旋转则由系统的旋转动画接管:App 在底层完成尺寸调整并落地到新布局中,用户完全看不见这个过程。唯有弯折没有任何系统过渡遮盖。由于分栏间距早已对齐静止时的折痕,Flicit 横向握持时的弯折没有引发任何可见位移。但笔记本形态则不同:票根页面与电影详情页在两个方向上均以单帧硬切切换布局,而且切入的新布局在图片淡入之前,其艺术图占位区域(票根上的黑块、电影海报上的灰块)会暴露 60 到 100 毫秒。
- 仅对弯折做动画,并在发布姿态的地方做裁决。 所谓弯折,就是折痕在激活与静止之间切换、同时窗口(即受追踪的视图加上其 Inset 安全边距)保持尺寸不变。开合与旋转均会改变窗口尺寸,而首次求值则是从无到有。Flicit 的折叠阶段将弯折状态包裹在
withAnimation中发布(采用弹簧曲线,且在开启“减弱动态效果”时不执行动画),而其他所有读数均不带动画发布。通过针对姿态的if/else切换布局的页面,随即自然获得 SwiftUI 的默认淡入淡出过渡:在录屏中大约耗时 120 毫秒。如果把.animation(_:value:)直接绑定到姿态上,它在手机弯折状态下发生旋转时也会触发,叠在系统自身的旋转动画之上。 - 在切入新布局的首帧绘制图像。 异步加载的图片视图在初次构建时始于空白,即便该图片此前早已解码完成。将图片视图最后绘制的位图保存在一份主体(body)可同步读取的小缓存中,在展示占位图之前优先查找缓存:完全匹配的请求(相同文件、像素尺寸与缩放倍率)优先,否则只要同张图片的已有最大解码尺寸达到所需尺寸的一半以上,就先用作垫底占位,待视图自身的异步解码完成后无淡入平滑替换。若存在采样像素色彩的逻辑,需确认其在垫底图下依然正常运作。Flicit 的票根标签颜色取自海报 24×24 像素的采样,录屏显示在精准解码替换垫底图时该颜色并未发生变化。
- 淡入淡出会闪烁时,改用硬切。 Flicit 的电影详情页并不能像票根页那样优雅地淡入淡出。其退出的旧布局在单帧内直接消失而不是渐变褪去;展平手机时,切入的新页面则在裸白纸底上淡入约 40 毫秒(两到三帧的空白页)。改用提前展现新页面的 Ease-Out 曲线也无法消除这一空白。该页面的两套布局均基于 ScrollView 构建,而票根页的手动排版 Pager 却能正常渐隐渐显;录屏所证止于此,更深的原因暂未探明。空白闪烁比硬切更难以忍受。电影详情页通过
.transaction(value: isLaptopLayout) { $0.animation = nil }退出了弯折动画,在图片早已绘制完成的前提下以单帧利落硬切。务必为每一个因姿态切换布局的页面逐一录屏:一个页面的淡入淡出正常,并不保证下一个页面也是如此。
// 在 stage 内部:折痕变了,窗口没变。
func isBend(to next: FoldStage) -> Bool {
(seam == nil) != (next.seam == nil) && windowSize.width > 0
&& abs(windowSize.width - next.windowSize.width) <= 0.5
&& abs(windowSize.height - next.windowSize.height) <= 0.5
}
// 在窗口根节点的唯一监听器中。
func publish(_ next: FoldStage) {
guard next != stage else { return }
guard stage.isBend(to: next), !reduceMotion else { stage = next; return }
withAnimation(.interpolatingSpring(mass: 0.9, stiffness: 280, damping: 24)) { stage = next }
}
将铰链作为交互控件:用 onHingeChange 实现手势
Apple 多任务篇划定了严谨的架构界线:实时推送的铰链高频数据仅用于驱动物理交互与视觉动效,界面的实际排版定位必须全权交给保留区域(Regions)与 Arrangement API。仅当折叠本身是完成某项物理操作最自然直观的隐喻时,才应考虑将铰链动作作为交互触发器。 在 Flicit 中,撕下待看电影票根的副券代表着将其标记为“已看”;在 Duo 上,轻轻折弯机身,票根副券便会沿打孔齿孔物理翘起,重新完全展平时便将副券利落撕下——因为在现实物理世界中,撕下票根的动作正是如此。

铰链手势必须具备极强的状态机健壮性,以彻底免疫用户在日常开合设备时的各种无意识误触(铰链指南汇总了各款铰链游戏对手势的精准过滤策略):
- 手势必须从明确的已知基准状态激活(Arm):此处设定基准状态为完全展平(铰链角度 ≥166°)。视图出现时必须立刻执行一次初始读取,切忌仅依赖变更通知:若打开页面时机身已处于展平状态,一次快速折动最开始的几次读数极易被系统底层合并抛弃,从而导致手势监听失灵漏判(Apple 文档写明,角度更新的频率与精度属于系统策略)。Flicit 将最新的
onHingeChange读数缓存在全局 Fold Stage 中,票根详情页通过onChange(of:initial: true)确保初始挂载时立即捕获。 - 必须要求明确的物理运动行程:要求折叠至 ≤148°(比展平少 32°),且必须在离开展平状态后的 2.5 秒内重新展平。
- 彻底合上机身绝非有效手势:一旦角度低于 70°,手势立即全面重置(Reset)。长时间半折置于桌上阅读同样不是手势:弯折状态超时后立即解除手势激活(Disarm)。
- 执行前二次确认,并必须保留传统交互兜底:撕下票根后弹出与手动拖拽撕票相同的确认面板,单纯依赖铰链不会静默写入任何持久化变更;且在所有设备上手动拖拽撕票交互必须完好保留。
将上述状态机逻辑完整收敛在一个纯 Swift 值类型内部,仅接收角度与时间戳作为输入;该状态机可在任何标准模拟器(哪怕是不具备铰链的机型)上运行高覆盖率的单元测试。在 Duo 模拟器中,通过单次插值脚本即可完整模拟测试该连续手势:duo.py path 1.0 180 140 180。至于该交互握在手掌中的真实物理阻尼感,唯有真机能够揭晓。
智能体极易踩中的暗坑
- 以布局决策自身所产生的结果尺寸,反向作为该决策的判断条件(自反馈死循环)。 Flicit 的双栏组件曾通过读取自身的全局 Frame,来决定是否将分栏间距对齐折痕:一旦对齐折痕,两栏总宽超出了容器所能给予的空间(633pt);而取消折痕对齐后,测得自身宽度为 538pt,又被判定为过窄无法容纳间隙。SwiftUI 会在同一轮布局中应用几何回调引起的状态变更并立即重新计算,导致组件在“开启折痕”与“关闭折痕”之间无限振荡:主线程深陷 UIKit 旋转流程无法返回,最终被 Watchdog(
0x8BADF00D)强杀。务必始终测量外部给予的可用空间:使用同时声明了最小值与最大值的 Frame(.frame(minWidth: 0, maxWidth: .infinity)会无条件接受外部空间而不管内部内容要求),且只监听真正需要的单一几何维度。排查过程详见第 3 节。电影详情页在横置展开从票根中唤起时同样复现了此 Bug:在嵌入 411pt 的详情列之前,它先以全屏宽度试探计算了一轮,选择了海报独占单折页的布局;而该折页具备固定的 446pt 宽度,直接将页面撑得比详情列还宽。详情列为了居中过宽的页面动态改动了 Insets,导致页面每一帧都在疯狂重算自身尺寸,CPU 占满且 Push 转场永久卡死在半路。在改为严格以分配给它的尺寸为基准(全屏时高度同理)度量后,单帧即可收敛稳定。 - 轻信旋转过渡帧的单一瞬时读数。 上述死循环只在旋转至竖向时触发:因为在某一过渡帧中,UIKit 报告了竖向的尺寸,但侧边栏边缘和 Safe Area 内边距仍沿用横向时的旧值,导致组件误在一个立向页面上识别出了一条横贯水平的折痕带。务必结合页面自身的宽高比核验折痕有效性(只有页面宽大于高时,才允许竖贯折痕存在),并在各种姿态之间做全量双向翻转回归。
- 外围 UI 显隐导致视图标识(View Identity)意外替换。 写成
if height > 0 { content.contentMargins(…) } else { content }的 Modifier 会在高度从 0 变动时彻底销毁重建内部整个 ScrollView。Flicit 的 SideRail 在每次打开搜索时便会触发该高度变动,导致底层所有页面被完整重建,重新播放一次进场动画,取消搜索时又重复一遍。必须无条件应用修饰符,高度为 0 时直接传 0。 - 从错误视图读取 Safe Area 内边距。 忽略 Safe Area 的
GeometryReader读出的 Insets 为 0;一次性缓存的 Insets 在姿态改变后会失效。必须在实际执行布局的位置实时读取。 - 将所有 Inset 误判为屏幕物理边缘。 在 Split View 中,Detail 栏的 Leading Inset 等同于整个 List 的宽度(展开后达 455pt)。直接下穿到 Inset 下方的图片会被完全遮挡在 List 背后。应将超出工具栏常规尺寸的大 Inset 视为页面旁侧的独立分栏(见第 7 节)。
- 保留区域数据到达滞后。 首轮布局时
reservedRegions为空。仅在onAppear中读取一次折痕的代码永远无法获取数据。 - 手势仅在变动时才注册监听。 若仅在
onChange时捕获铰链数据,直接在展平姿态下打开的页面将错过初始状态。应使用onChange(of:initial: true)。 - 依赖旧版全局状态 API。
UIScreen.main、interfaceOrientation以及基于设备类型的硬编码正是app-resizability需要替换的重构点;直接运行技能完成扫描,避免手动排查。 - 强行锁定 Split View 的列宽(
min == max)。 使用navigationSplitViewColumnWidth(min: w, ideal: w, max: w)强行锁定跟随折痕的宽度,会使分栏视图陷入 CPU 100% 狂转并卡死在 Launch Screen。应仅将折痕起始边缘作为 ideal width,并调低 Detail 最小宽度(见第 7 节)。 - 分栏视图从单栏折叠栈状态展开时产生幽灵导航栏。 展开手机或旋转至横屏时,重新展开的
NavigationSplitView会为 List 附加一个空白导航栏(含 Sidebar 开关),即使声明了.toolbar(.hidden, for: .navigationBar)。SwiftUI 在请求可见性未变时不响应隐藏;需通过.toolbar(removing: .sidebarToggle)移除开关,并在尺寸类型转为 Regular 时,在下一个 Runloop 轮次中将导航栏可见性显式来回翻转一次。实机录屏证明该翻转过程精准落在系统的模糊交接遮罩之内。 - 放任 Split View 悬浮遮挡列表。 宽度低于 800pt 时
NavigationSplitView会浮起列表遮挡详情,甚至在竖屏时直接丢弃列表。应在此阈值下强制注入 Compact SizeClass 将其收折为单栏栈(见第 7 节)。 - 分栏视图在详情页打开状态下收折异常。 在
preferredCompactColumn已为.detail时折叠收拢,系统可能错误退回列表,因为收折只有在 Binding 发生变化时才触发 Push。应在收折时先将绑定设为 List,并在下一主线程任务中改回.detail。此外,隐藏外部 Chrome 的声明应按 View 实例隔离,避免新旧视图共用 Identifier 导致旧视图析构时误解除了新视图的隐藏声明(见第 7 节)。 - 依据机械铰链角度进行布局决策。 设置固定的角度阈值极易在翻折过渡区间产生断层,导致列宽来回闪烁。应改用
.includeInactive保留区域(见第 7 节)。 - 自定义栏假定侧边列完全独占空白。 灵动岛会因 Live Activities 持续向下侵占侧边列。每次均需动态计算侧边列的遮挡区域并在拥挤时优雅降级(见第 6 节)。
- 姿态专用布局意外遗漏核心操作。 在切换为笔记本形态等专用布局时,切勿因更换视图形态而剥夺了其他姿态下的基础操作入口(见第 7 节)。
- 对姿态变换做动画。 基于姿态绑定的 Spring 动画,或围绕每次读数包裹
withAnimation,会在手机开合或旋转时、在系统自身的过渡之上,以及启动首帧时重复触发。仅对弯折做动画(第 7 节)。 - 新布局首帧图像空白。 为新姿态构建的视图会从头加载图像并首先显示占位图,即使该图片片刻前刚刚显示在屏幕上。硬切能掩盖这一问题;淡入淡出则会将其暴露(第 7 节)。
- 生成截图后未做有效性校验。 全黑黑屏、闪屏页与空数据状态均可生成有效 PNG 文件。必须预先灌入真实测试数据,并人工或通过视觉模型审查每张图片。
使用 Apple 官方产品边框生成真机渲染图
Apple Design Resources 提供了 iPhone Duo 的官方产品边框素材(包含闭合、横置展开与纵向立起展开),但并未提供任何半折姿态的素材。工具包中的 duo_mockup.py 负责将平面截图合成嵌入这些平面边框之中;而书本形态与笔记本形态的 3D 效果则交由配套的 duo3d.py 软件渲染器生成。duo3d.py 内部将内屏建模为“平面—圆弧—平面”的可展曲面(包含 36pt 的转角圆弧段),沿机身厚度挤出成型,从官方边框图中提取物理轮廓,通过正投影针孔相机捕捉,并仅对平面素材未覆盖的内壁立面实施光照着色。本指南中的书本形态、笔记本形态以及撕票根手势渲染图均出自该工具。
python3 duo_mockup.py cover.png closed.png --pose closed
python3 duo_mockup.py open.png book.png --pose book --deg 128
python3 duo_mockup.py upright.png laptop.png --pose laptop --deg 110
官方边框素材需由开发者自行准备。工具包内部未内置任何 Apple 知识产权艺术资产:请从 Apple Design Resources 自行下载 iPhone Duo 产品边框,签署许可协议后解压至 ~/Library/Application Support/apple-duo-bezels/(或通过 --bezels 参数显式指定目录)。协议允许开发者生成自己 App 的产品宣传渲染图,但严禁对边框源 PNG 进行二次分发,这亦是本方案不提供在线网页版的核心考量:Web 工具势必需要在公网服务器托管 Apple 边框资源。若需要在浏览器中制作样机图,开发者工具目录中收录了 Duo Studio、Duo Mock 与 Fold Preview(采用自建 3D 模型),以及通过命令行渲染的 Frames 工具。
交付给智能体的开发简报
在智能体触碰业务工程的第一行源码之前,请将以下简报直接注入其上下文:
- 将
DEVELOPER_DIR指向 Xcode 27.1 构建环境,先确认 SDK 中包含reservedRegions符号,再信任后续的编译产物。 - 在动手设计任何布局之前,必须完整运行 Apple 官方的
app-resizability技能,包括其前置校验项。 - 使用
duo.py驱动模拟器的开合与旋转;机身闭合时,使用xcode_device.py模拟点按交互并审查 UI 视图树,展开时改用 duoctl。严禁在 Duo 模拟器上调用simctl io screenConfig。 - 按像素分辨率动态匹配并截取各屏幕画面,校验所得 PNG 绝非纯黑。机身闭合时外屏渲染,双屏展开时内屏渲染。
- 提前向 App 灌入真实测试数据,语言环境需与目标截图语言完全吻合。
- 任何代码改动后,必须完整遍历全部五种核心姿态:闭合、横置展开、书本、纵向展开与笔记本形态;每个姿态均需等待铰链完全平息后再设置屏幕方向。在详情页打开状态下,执行任意两种姿态间的双向翻转验证:App 必须保持原有的导航状态并维持流畅响应。人工或调用视觉模型审查每张截图,并通过
duo.py record录制姿态切换过程:系统底层的高斯模糊遮罩褪去后仍在跳动的元素,均属未收敛的布局缺陷。出现两姿态画面冲突时,优先打印相关视图的全局 Frame,切忌凭空猜测原因。 - 恪守系统的 Safe Area 安全边距。除纯背景底图外,严禁任何内容下穿至侧边列下方,且绝不可穿透至 Split View 列表下方。严禁为侧边时钟预留整条通栏空白顶栏。
- 若标签栏为自定义绘制,动态读取
toolbarVerticalEdge并将标签项嵌入该侧边列中,置于时钟与系统常驻组件下方;侧边空间拥挤时优雅降级为水平栏。 - 针对高视觉度的窄内容,在留白边距(Margin)允许范围内向整块物理玻璃中心居中对齐,且仅适用于占据整屏的主页面。
- 宽大于高时:坚决采用双栏跨页、紧贴内容的卡片容器、设上限的自适应单元格、紧凑型图表、统一的标题基准高度、竖版海报独占单折页,且列表右侧绝不留空详情。纵向立起时:坚决收为单栏,坚守内容固有比例(宁可收窄整体宽度留白,也绝不能拉伸变形海报),低于约 800pt 时强制将 Split View 收折为单栏栈。简单放大拉伸手机布局是错误的,参考 iPad 布局往往是对的。
- 绝对不要人工绘制折痕。交互控件避开折痕,正文可以跨越折痕,将双栏间距与网格缝隙精准对齐折痕(平放与半折同理),基准完全取自系统报告的未激活折痕保留区域,而非机械的铰链角度。视觉焦点与拇指交互核心避让折痕,其附属说明协同位移。
- 将每种硬件姿态视为独立的专用布局:书本姿态提供左右双折页,笔记本形态提供立起浏览屏与平放操作台(各自独立滚动),覆盖所有可能在该姿态打开的页面。在宽大于高的立屏上呈现内容的横向形态。任何姿态下完整保留所有功能操作。
- 铰链手势严禁在闭合中途或半折常驻时触发,必须具备二次确认,且必须保留传统触控路径兜底。
- 使用条件编译标志隔离 27.1 API,保证 Xcode 27.0 工具链依然能够正常编译,并在其上执行单元测试。对 Mac Catalyst 也要清除该标志,因为即使在 Xcode 27.1 中,它的 SDK 也没有这些 API。
- 绝对不要依据布局决策自身改变的尺寸反向作为该决策的判断条件。务必测量外部赋予的可用空间,并结合页面自身的宽高比核验姿态读数。
- 不要试图在代码中检测 StandBy 姿态:无公开 API 支持,开合与旋转模拟器也无法触发(
devicectl device settings standby --show可以,须在锁定屏幕上)。仅需确保 Widget 与 Live Activities 做好大尺寸与去背景适配。 - 仅在自身没有霸占侧边列的前提下,将自定义工具栏项声明移入侧边列(
axisBehavior(.verticalPreferred)并垂直排布);单按钮的模态表单坚决禁用竖向工具栏。 - 通过单元测试主动模拟渲染模拟器无法触达的窗口尺寸(分屏浏览半屏以及置顶画中画压缩态),动态调整窗口并执行验证:确保所有页面能够快速收敛稳定并完好保持状态。
- 仅对弯折做动画:即折痕在激活与静止间切换、同时窗口保持原有尺寸。开合与旋转均由系统接管。切入的新布局必须在首帧绘制已解码的位图,如果页面的淡入淡出会闪烁,则改用硬切。在修改前后均进行逐帧录屏。
速查表
| 目标 | 可行的方案 |
|---|---|
| 定位 Duo 模拟器 | duo.py devices;绝不新建或克隆模拟器 |
| 驱动开合或旋转 | duo.py fold close|open|book|laptop|0..180(从合上进入半折时会先展平),随后执行 duo.py orient sideways|upright |
| 进入笔记本形态 | 先执行 orient upright,再执行 fold laptop(约 110°) |
| 截取展开态内屏 | duo.py shot、simctl io <udid> screenshot --display=<2007 宽端口 UUID>,或 devicectl device capture screenshot --display-unique-id <LCD-1 的 ID>;校验绝非纯黑 |
| 录制开合过程 | duo.py record --out films/ path:1.2:0:180 rec:inner wait:4;单设备单次仅限录制单屏 |
| 模拟点按与读取 UI | 手机闭合时:xcode_device.py start、look、tap "<标签>"、end;展开时:duoctl tap <x> <y> |
| 回读铰链 | Xcode 27.1 RC 中读不回角度(devicectl 没有 device motion);devicectl device info displays 会标出哪块屏处于 active,即合上还是展开 |
| 恢复假死崩溃的模拟器 | 依次执行 simctl shutdown <udid> 与 simctl boot <udid> |
| 获取系统竖向栏的停靠侧 | @Environment(\.toolbarVerticalEdge):首选边缘 .trailing 或 .leading,即使没有栏在显示也有值;使用水平栏时为 nil。同时检查该侧的内边距 |
| 读取中缝折痕 | proxy.reservedRegions(kind: .division).first(默认仅返回激活区域) |
| 平放时定位未来折痕 | proxy.reservedRegions(kind: .division, options: .includeInactive):未激活区域即为未来折痕轴线 |
| 读取时钟区与前置镜头 | proxy.reservedRegions(kind: .occlusion) |
| 双栏间距对齐折痕 | 主栏宽度设定为折痕 minX,折痕宽度作为间距;平放时采用未激活区域 |
| 自定义栏避让灵动岛 | 计算侧边列活跃遮挡区域底边;向下压缩单元格至 44pt,若依然拥挤则降级为水平栏 |
| 自定义工具项嵌入侧边列 | 声明 .axisBehavior(.verticalPreferred) 并垂直布局;声明 .horizontalOnly 则强制保留在水平栏 |
| 单按钮模态表单优化 | 在内容上修饰 .toolbarVerticalBehavior(.disabled) |
| 覆盖分屏与置顶画中画 | 在测试中将宿主 Window 尺寸动态在 475×669 与 951×420 之间变换;所有页面必须稳定收敛(devicectl device appResize 能调整真实 App 的尺寸,但没有 Duo 的侧边列和折痕) |
| 阻止 Split View 悬浮遮挡 | 宽度低于约 800pt 时,显式为其注入 Compact 的 horizontalSizeClass |
| Split View 对齐折痕 | 将折痕起始边缘作为 List 的 ideal width,切忌 min == max;折叠设备 Detail 最小宽度设为 320pt 并加折痕宽度的 Leading Padding |
| 分栏展开后重藏列表导航栏 | 声明 .toolbar(removing: .sidebarToggle),在 Size Class 转为 Regular 时将导航栏可见性来回翻转一次 |
| 收折分栏时保持详情展示 | 将 preferredCompactColumn 设为 List 并立即在下一主线程轮次改回 .detail;Chrome 隐藏权按 View 实例分配 |
| 消除布局自反馈死循环 | 使用 .frame(minWidth: 0, maxWidth: .infinity) 测量外部赋予的宽度(页面高度同理),且仅监听必要轴向 |
| 定位卡死故障根因 | 审查 ~/Library/Logs/DiagnosticReports 下的 .ips 崩溃日志、执行 sample <pid>、插入 _logChanges() 并捕获 SwiftUI 故障流 |
| 展开屏展示纵向海报 | 海报独占左折页,右折页滚动正文;纵向立起时采用大比例通栏横幅;嵌套于分栏中时严格止于列表边缘 |
| 对姿态变换做动画 | 仅对弯折(折痕 激活 ↔ 静止,窗口尺寸保持不变)做动画,在发布 stage 处配合 withAnimation;开启“减弱动态效果”时不执行动画 |
| 布局切换后不出现占位图 | 在首帧绘制已缓存的位图:优先使用完全匹配的请求,否则使用同张图片尺寸至少为其一半的已解码位图 |
| 排查录屏中的跳变 | 使用 -fps_mode passthrough 逐帧提取,再用 compare -metric RMSE 逐帧比对,按差异降序排列 |
| 笔记本形态拆分页面 | 依据折痕 minY 与 maxY 划分上下两屏;上下两半各自构建独立 ScrollView |
| 网格元素完整避开折痕 | 左右两折页分配相同列数,将中缝折痕作为分列走廊 |
| 在 Duo 上观察待机模式 | 在锁定屏幕上、界面横向时执行 devicectl device settings standby --show --duration 10;单靠开合与旋转无法触发,且它先打开欢迎页,duoctl 可以点过去。App 仅能通过优化 Widget 与 Live Activities 间接入驻 |
| 跨版本 Xcode 兼容编译 | 条件编译标志默认开启,针对 [sdk=iphonesimulator27.0*] 与 [sdk=iphoneos27.0*] 显式置空;含 Mac Catalyst 目标时 [sdk=macosx*] 也要置空 |
| 获取 Apple 官方 Duo 技能 | 在 Xcode 27.1 下执行 xcrun agent skills export,提取 app-resizability |
| 生成官方边框渲染样机图 | 执行 duo_mockup.py <截图> <输出> --pose closed|open|book|upright|laptop,边框素材需自备 |
开发者常问的问题
Claude Code 或 Codex 能把 App 适配到 iPhone Duo 吗?
能,前提是补上 Apple 文档没有提供的两样东西:驱动模拟器的手段,以及每个姿态报告的数值。把本指南的工具包作为技能交给智能体,再加上 Xcode 27.1 自带的 Apple app-resizability 技能。Flicit 就是这样借助 Claude Code 分五轮完成适配的,每张截图都由人来判断(第 1 节)。
怎样用命令行开合、旋转 iPhone Duo 模拟器?
simctl 两者都做不到,devicectl device orientation set 报告成功却什么也没转。改为从模拟器内部投递 Device Hub 自己的 HID 事件:用工具包的 duo.py fold book 和 duo.py orient upright,或者用 duoctl、hinge(第 3 节)。
iPhone Duo 模拟器的截图为什么是全黑的?
模拟器有两块屏,只有一块在出画:合上时是外屏,展开时是内屏。不带 --display 的 simctl io screenshot 截的是外屏,手机一展开就是黑的。按像素尺寸挑选屏幕(2007×2853 是内屏),或者用 devicectl device info displays 找出处于 active 的那块,再用 devicectl device capture screenshot 截取(截图)。
能在模拟器里读到铰链角度吗?
在 Xcode 27.1 RC 中,无法从 App 外部读取:hinge get 和 duoctl 调用的 devicectl device motion hinge-angle,在 651.13.5 版 devicectl 中并不存在。devicectl device info displays 能分辨合上与展开;在 App 内部,onHingeChange 会报告角度,比设定值略小几度(第 3 节)。
Xcode 为什么报“value of type ‘GeometryProxy’ has no member ‘reservedRegions’”?
构建用的是 Xcode 27.0 的 SDK,它早于折叠 API。请用 Xcode 27.1 构建;要让 27.0 继续可构建,就用一个仅在其 SDK 下清除的条件编译标志(第 2 节)。
iPhone Duo 的安全区和折痕是多少点?
合上时窗口为 466×678,尾侧边缘内边距 84 点、底部 34 点。展开横置时为 951×669,内边距相同;立起时为 669×951,顶部 82 点。折痕宽 40 点,位于窗口正中,只在半折时处于激活状态(第 5 节)。像素数据见屏幕尺寸指南。
模拟器能调出待机显示(StandBy)吗?
开合与旋转始终触发不了。只要锁定屏幕正在显示、界面处于横向,devicectl device settings standby --show --duration 10 就能把它调出来(待机显示)。
能在模拟器里测试分屏浏览吗?
无头环境下不行,因为它需要从主屏幕指示条往上拖。可以改为在调整尺寸的窗口中按分屏浏览的尺寸渲染 App,或者在手机合上时用 devicectl device appResize 调整真实 App 的尺寸(窗口尺寸)。
本指南的局限
本指南所有数据均基于单一模拟器构建版本测得(iOS 27.1,版本号 24A94232):铰链角度、遮挡区域矩形、Split View 折叠阈值、Detail 列最小宽度底线、工具栏停靠边缘、铰链的滞后,以及旋转过渡帧中的瞬时布局,均属实测经验总结而非官方承诺规范,在后续新版本中可能发生调整。笔记本形态下的横向票样以及纵向立起时收窄的日历排版,均属 Flicit 特定的设计取舍,并非不可逾越的通用规则。Xcode 的 device-interaction 工具在 Duo 上的视界范围未来亦可能更新。铰链手势的各项参数阈值是通过底层控制滑块调优得出的;真实机械铰链的电容读数与手掌开合的阻尼手感,唯有待 10 月 23 日真机发售才能确证。笔记本形态渲染图是将 Apple 官方平面素材进行对称弯折的简化产物,而实体手机在物理上依靠下半部平放支撑。录屏展示的是模拟器的过渡动效,并非真机表现,且模拟器中折痕在铰链停止约一秒后才变为激活状态,因此笔记本姿态的淡入淡出是在弯折之后而非弯折途中被录下的。分屏浏览与画中画置顶视频是在模拟调整尺寸的窗口中估算验证的,未在真机多任务环境实装,因此左侧 App 将侧边列移至 Leading 边缘的真实动态仍未在自然状态下被直接目睹。灵动岛随实时活动膨胀是通过 Debug 遮挡区域模拟测试的;Apple 所述平放状态下的零宽度折痕在代码中已完备处理,但模拟器中实测报告的宽度为 40pt。StandBy 只通过 devicectl 调出过,而且只显示过系统自带的小组件,因此待机模式在双屏上的具体小组件呈现逻辑仍属未知;本文的所有结论均尚未在物理硬件上接受最终检验。devicectl 相关结论基于 651.13.5 版,duoctl 的基于 0.2.0 版,引用的 Apple 文档以 10月11日的发布版本为准。
继续阅读

iPhone Duo 模拟器:获取、折叠、测试你的 App
如何获取 Apple 的 iPhone Duo 模拟器、把它折到任意姿态或铰链角度、测试你的 App,以及没有 Mac 也能试用的模拟器。

把你的 iPhone Duo App 提交到 App Store
Apple 于 10 月 5 日开放了 iPhone Duo App 的 App Store 提交。该用哪个版本的 Xcode 构建、新的截图尺寸、2027 年 4 月的新规定,以及如何告诉 App Store 你的 App 已为折叠屏做好准备。

用 React Native、Flutter 和 Web 开发 iPhone Duo
从 React Native、Flutter、网页和 WebView 的角度看折叠屏:每种技术栈免费得到什么、哪些桥接库能暴露铰链和折痕,以及哪些仍然需要 Xcode。