10 月 13 日,我在为日记应用 Lanternly 准备发布时第一次跑起了 iPhone Duo 模拟器。模拟器本身很有用,去到那一步却不轻松。普通的 Xcode 27.0 里根本没有 Duo 机型。运行时下载时一行进度都不显示。Simulator 消失了,换成了 DeviceHub,我需要的操作有一半没法在命令行完成。
这里汇总了一路上用得上的东西:怎么找到模拟器,怎么分别截取两块屏幕,怎么用脚本折叠和旋转设备,以及怎么拿到用于 App Store 的 iPhone Duo 截图。应用本身需要改哪些地方,写在 iPhone Duo 适配问题一文里。
iPhone Duo 模拟器在哪里#
Duo 机型只存在于 Xcode 27.1。我把 Release Candidate(版本 27A9275)装在正式版 27.0 旁边,没有替换它,并用 DEVELOPER_DIR 指定要用的 Xcode:
export DEVELOPER_DIR=~/Downloads/Xcode.app/Contents/Developer # 27.1 RC
xcrun simctl list devicetypes | grep -i duo
#> iPhone Duo (com.apple.CoreSimulator.SimDeviceType.iPhone-Duo)在 Xcode 27.0 里,同样的命令什么都找不到。搜索联想里有“iPhone Duo simulator missing”,第一个答案就是:机型随 Xcode 27.1 一起提供,而不是随运行时。
第二个答案是 iOS 27.1 运行时。没有它,列表里能看到机型,但没有可用来创建它的系统:
xcodebuild -downloadPlatform iOS
#> Downloading iOS 27.1 Simulator (24A94232) (arm64): 22,0% (1,73 GB of 7,85 GB)一共 7.85 GB。我第一次加了 | tail -20,看起来像卡住了:tail 要等命令结束才会输出。把输出写到文件后,进度就正常显示了。确认安装完成:
xcrun simctl runtime list | grep 27.1
#> iOS 27.1 (24A94232) - … (Ready)创建设备并找到两块屏幕#
UDID=$(xcrun simctl create "iPhone Duo" \
com.apple.CoreSimulator.SimDeviceType.iPhone-Duo \
com.apple.CoreSimulator.SimRuntime.iOS-27-1)
xcrun simctl boot "$UDID"
xcrun simctl io "$UDID" enumerateenumerate 会打印一长串端口。需要的是类型为 Integrated 的两块屏幕:
| 屏幕 | --display 名称 | 像素尺寸 |
|---|---|---|
| 外屏(折叠) | primary | 1398 × 2034 |
| 内屏(展开) | primary-1 | 2007 × 2853 |
这正好是 App Store Connect 里 iPhone Duo 截图位的尺寸。两块屏幕分别截取:
xcrun simctl io "$UDID" screenshot --display=primary outer.png
xcrun simctl io "$UDID" screenshot --display=primary-1 inner.png刚启动时设备是折叠的,所以内屏截出来是一个黑色矩形。这不是错误。
取代 Simulator 的 DeviceHub#
Xcode 27 用 DeviceHub(Xcode.app/Contents/Applications/DeviceHub.app)取代了 Simulator 应用。窗口底部有一排按钮:旋转,以及三种姿态:折叠、书本、展开。Duo 的姿态只能在这里切换。
我原本指望 simctl,查了一下:它没有旋转和折叠的命令。有一个 io screenConfig,可以单独关闭某块屏幕:
xcrun simctl io "$UDID" screenConfig --display=primary power off
#> Screen 1 power state set to off这并不会改变姿态。外屏熄灭,内屏依旧是黑的,因为设备仍是折叠状态。
用脚本点击 DeviceHub 的按钮#
接下来想用 AppleScript 控制 DeviceHub,没有成功:System Events 能看到进程,但窗口数是 0,菜单栏也拿不到,而同一会话里 Finder 读取正常。真正管用的是 CoreGraphics 的鼠标事件。一个十来行的 Swift 小工具:
import CoreGraphics
import Foundation
// click x y — 点击屏幕上的一个点(坐标单位为 point)
let a = CommandLine.arguments
let p = CGPoint(x: Double(a[1])!, y: Double(a[2])!)
let src = CGEventSource(stateID: .hidSystemState)
CGEvent(mouseEventSource: src, mouseType: .mouseMoved, mouseCursorPosition: p, mouseButton: .left)?.post(tap: .cghidEventTap)
usleep(150_000)
CGEvent(mouseEventSource: src, mouseType: .leftMouseDown, mouseCursorPosition: p, mouseButton: .left)?.post(tap: .cghidEventTap)
usleep(80_000)
CGEvent(mouseEventSource: src, mouseType: .leftMouseUp, mouseCursorPosition: p, mouseButton: .left)?.post(tap: .cghidEventTap)用 swiftc -O click.swift -o click 编译。坐标取自 screencapture -x 的截图:Retina 屏上是像素,除以 2 得到 point。
几个花了时间的小地方:
- 点击前先把窗口调到前台(
tell application "DeviceHub" to activate),再等一两秒。不等的话,第一次点击有时会落空。 - 竖屏时姿态图标是旋转后画的。按钮顺序不变,只是更难认出来。
- 如果机身是旋转的,折叠后外屏会变成横屏。我会一直按旋转,直到
--display=primary的截图宽度变成 1398。 - DeviceHub 的菜单栏也能用同样的方式点击:
Controls → Home、Device → Touch ID → Enrolled。通过 System Events 发送的快捷键到不了它那里。
不用 DeviceHub 也能旋转#
在 iPhone 和 iPad 上,从应用内部旋转更方便。我加了一个调试用的启动参数,向场景请求指定方向:
#if DEBUG
// -orientation landscape,portrait — 启动后每 4 秒执行一步
static func applyOrientationFlag() {
let args = CommandLine.arguments
guard let i = args.firstIndex(of: "-orientation"), i + 1 < args.count else { return }
for (step, name) in args[i + 1].split(separator: ",").enumerated() {
let mask: UIInterfaceOrientationMask = name == "portrait" ? .portrait : .landscapeRight
DispatchQueue.main.asyncAfter(deadline: .now() + Double(step + 1) * 4) {
for scene in UIApplication.shared.connectedScenes.compactMap({ $0 as? UIWindowScene }) {
scene.requestGeometryUpdate(.iOS(interfaceOrientations: mask))
}
}
}
}
#endifxcrun simctl launch "$UDID" <bundle> -orientation landscape,portrait 会让屏幕来回旋转,步骤之间的截图能看出应用在宽度变化时的表现。在 iPhone 18 Pro Max 上有效。iPadOS 27 的 iPad 上应用运行在窗口里,系统会忽略旋转请求。
用于 App Store 的 iPhone Duo 截图#
截图位尺寸和屏幕缓冲区一致,所以原始画面直接来自 simctl io screenshot。拍摄前先整理设备:
xcrun simctl status_bar "$UDID" override --time 9:41 --batteryLevel 100 \
--batteryState discharging --wifiBars 3 --cellularBars 4
xcrun simctl privacy "$UDID" grant microphone <bundle>旧速查表里的 discharged 会被 simctl 拒绝,合法的值是 charging、charged 和 discharging。
还有三件事是看到成品截图才发现的:
- 系统弹窗。 重新安装应用后,录音界面弹出了麦克风权限请求。
simctl privacy … grant可以去掉它。 - Touch ID。 在菜单里勾选
Device → Touch ID → Enrolled之前,设置界面会如实显示生物识别不可用。重启模拟器(比如切换语言时)会清掉这个勾选。 - 调试标签。 我的调试版里,聊天标题栏会显示回复来源(“· simulator”),设置里会显示本地存储模式。启动参数
-storeShots只在拍摄时把它们隐藏,发布版不受影响。
还有最重要的一条规则,我曾经犯过错。某个设备的截图位只能放这台设备上拍的画面。Duo 模拟器出现之前,我把 iPad 的画面套进了 Duo 的尺寸,后来只好删掉。截图承诺的是用户在自己屏幕上会看到的东西,而在 Duo 上他们看到的是另一种布局。App Store 其他素材的尺寸和格式,见 Creative Assets 指南。
没能做到的#
用手势进入 Split View。 在 Duo 内屏上,和 iPad 一样,第二个应用要从 Dock 拖进来。用 CGEvent 模拟的鼠标滑动,长了会打开应用切换器,短了什么都不发生。这个场景还要手动验证。窄半屏下的逻辑,部分由应用在外屏本来就运行于窄窗口这一点覆盖,但这是推断,不是测试。
用命令行切换姿态。 折叠、书本和展开只能用 DeviceHub 的按钮切换。如果你的 CI 跑在模拟器上,姿态测试暂时还搬不过去。
从零开始测试 Duo 的顺序是:在主力 Xcode 旁边装 Xcode 27.1,把输出写到文件里下载 27.1 运行时,simctl create 和 enumerate,姿态则用 DeviceHub 加一个小小的点击工具。应用在这些屏幕上出了什么问题、我是怎么修的,写在适配一文里;设备的整体思路见 iPhone Duo 指南。



