我的模拟器哪来的位置?

有些 UI 测试要求模拟器开始时没有位置。这一点,光靠 xcrun simctl location clear 保证不了:只要 Simulator app 开着,Features ▸ Location 里又选了位置,模拟器开机几秒后就会收到这个位置,而 clear 清不掉已经收到的位置。所以跑这类测试前,要先退出 Simulator app(或者把 Features ▸ Location 设成 None),再重启模拟器。

示例 app 和文中所有脚本都放在 GitHub 上:blog-examples/simulator-app-location。测试环境是 macOS 26.6.2、Xcode 26.5,模拟器是 iPhone 16 Pro(iOS 18.5)。

经过

我在用 coding agent 开发一个 iPhone app,由 Codex 写代码,并在我的 Mac 上用模拟器跑 UI 测试。其中有个测试要检查 app 等待定位时的界面,所以模拟器一开始不能有位置。测试脚本会先重启模拟器,再执行 simctl location clear。测试确认界面上还没有地图,才用 XCTest 提供的 XCUIDevice.shared.location 放入位置。

有一天,这一步怎么跑都失败:测试还没放入位置,地图就已经出来了。同一份代码早上还能通过,问题显然出在环境上。查下来,Mac 上开着一个 Simulator app,是 Codex 无意中打开的。当时另一个工具读不到界面层级,Codex 想恢复,就调用了 XcodeBuildMCP1 的 open_sim(),顺手把 Simulator app 打开了。把它退出,测试就又通过了。

测试是修好了,可我还是想知道:明明重启又清除了,位置是从哪来的?

位置是从哪来的

模拟器上的位置,测试自己能控制的有两个来源:脚本里的 simctl location,和测试代码里的 XCUIDevice.shared.location。但还有两个来源,测试管不到。

一个是 Simulator app 的 Features ▸ Location 菜单。这个设置整个 Simulator app 共用一份,存在 Mac 的用户偏好设置里,谁上次选了什么,它就一直是什么。可以用 defaults read com.apple.iphonesimulator LocationMode 查看:5101 是 None,5102 是 Custom Location。不过这些数值没有公开文档,换个 Xcode 版本可能就不一样了。只要 Simulator app 开着、这里又选了位置,模拟器一开机,过几秒它就开始每秒发一次这个位置。我遇到的就是这样:位置比脚本的 clear 早到了好几秒。

另一个是模拟器自己留着的最后一个位置。simctl location clear 只能让新的位置不再进来,已经收到的那个它收不回,下一个要定位的 app 马上就能拿到。要清掉它只能重启;而重启之后想保持没有位置,Simulator app 还得关着,或者设成 None。

这样一来,我的清除步骤其实一点用都没有:位置早就到了,清除又清不掉。

怎么验证的

为了排除是我自己 app 的问题,我们写了一个最简单的 app,叫 LocationProbe:只有一个页面,显示收到的坐标,以及打开后第几秒收到。它的核心就是遍历 Core Location 的异步位置流 CLLocationUpdate.liveUpdates(),每收到一个位置记一条日志(已简化):

Swift
for try await update in CLLocationUpdate.liveUpdates() {
    guard let location = update.location else { continue }
    let seconds = Date().timeIntervalSince(launchedAt)
    coordinate = String(format: "%.6f, %.6f", location.coordinate.latitude, location.coordinate.longitude)
    logger.log("Location \(coordinate, privacy: .public) at \(seconds, format: .fixed(precision: 1), privacy: .public) s")
}

每个实验在 scripts/ 里都有对应的脚本。脚本会先编译、安装 LocationProbe,并授予它定位权限。

实验一:位置从哪来?

scripts/observe-location.sh <UDID> <output-folder> 会按我测试脚本的方式准备模拟器,然后打开 LocationProbe,60 秒内每 5 秒截一张图,最后导出系统日志。<UDID> 是模拟器的标识,用 xcrun simctl list devices 能查到。脚本的核心部分如下(已简化):

Shell
xcrun simctl shutdown "$UDID"
BOOT_TIME=$(date '+%Y-%m-%d %H:%M:%S')
xcrun simctl boot "$UDID"
xcrun simctl bootstatus "$UDID"
xcrun simctl location "$UDID" clear
xcrun simctl launch "$UDID" com.example.LocationProbe
xcrun simctl io "$UDID" screenshot t05.png    # 之后每 5 秒截一张
xcrun simctl spawn "$UDID" log show --start "$BOOT_TIME" --info --debug --style compact \
    --predicate 'subsystem == "com.example.LocationProbe" OR process == "locationd"'

我们跑了三次:Simulator app 关着;开着,Features ▸ Location 设为 None;开着,选了旧金山的一个自定义位置。

Simulator app 关着
Simulator app 关着
Simulator app 开着,选了自定义位置
Simulator app 开着,选了自定义位置

开机后第 5 秒。左边 Simulator app 关着;右边开着并选了自定义位置,屏幕上已经有坐标,状态栏也出现了定位箭头。

Simulator appFeatures ▸ Location模拟器收到位置LocationProbe 拿到位置
关着任意没有没有
开着None没有没有
开着旧金山的自定义位置开机约 5 秒后起每秒一次,直到清除打开后 0.1–0.2 秒

第三次的日志能看出先后顺序。下面是删节过的日志,另外补上了脚本执行的命令:

输出
14:01:59.119  simctl boot
14:02:04.148  locationd      Accepting New Location
14:02:04.182  locationd      Received an initial location (pinned), unregistering
14:02:05.154  locationd      Accepting New Location
... (once a second)
14:02:11.175  locationd      Accepting New Location
14:02:11.616  simctl location clear
14:02:12.177  simctl launch
14:02:13.779  LocationProbe  Location 37.785834, -122.406417 at 0.1 s

可以看到,脚本执行清除时,位置早就到了。

实验二:为什么清除没用?

scripts/leftover-location.sh <UDID> <output-folder> 开始前,Simulator app 是关着的,菜单里选了一个位置。脚本一共六步,每步结束后重新打开 LocationProbe,观察 15 秒。下面的 reboot 代表关机、开机并等待开机完成:

Shell
reboot; xcrun simctl location "$UDID" clear                       # 1
xcrun simctl location "$UDID" set -37.813600,144.963100           # 2  墨尔本
xcrun simctl location "$UDID" clear                               # 3  只清除,不重启
xcrun simctl location "$UDID" set -37.813600,144.963100; reboot   # 4  只重启,不清除
open -a Simulator; reboot                                         # 5  打开 Simulator app
osascript -e 'quit app "Simulator"'; reboot                       # 6  退出 Simulator app(AppleScript,相当于 ⌘Q)

脚本输出:

输出
Rebooted and cleared:                          no location (0 in 15 s)
Set Melbourne with simctl:                     Location -37.813600, 144.963100 at 0.1 s (3 in 15 s)
Cleared, not rebooted:                         Location -37.813600, 144.963100 at 0.1 s (1 in 15 s)
Set Melbourne again, rebooted, not cleared:    no location (0 in 15 s)
Simulator app open, rebooted, not cleared:     Location 37.785834, -122.406417 at 0.1 s (3 in 15 s)
Simulator app quit, rebooted, not cleared:     no location (0 in 15 s)

清除只能挡住后续的更新(15 秒内从三次变成一次),最后那个位置还在。重启则能把它清掉,不管它是怎么来的。前提是 Simulator app 没开着,否则它又会把位置发过来。

实验三:真正的 UI 测试里也这样吗?

scripts/run-probe-test.sh <UDID> <output-folder> 用同样的方式准备模拟器,然后运行 LocationProbe 自带的 UI 测试。这个测试打开 app,等 10 秒,再把 app 显示的内容打印出来:

Shell
xcodebuild build-for-testing -project LocationProbe.xcodeproj -scheme LocationProbe \
    -destination "id=$UDID"
# ...像实验一那样重启、清除,然后:
xcodebuild test-without-building -project LocationProbe.xcodeproj -scheme LocationProbe \
    -destination "id=$UDID"
输出
# Simulator app closed
PROBE coordinate: No location yet
PROBE detail: Waiting for a location

# Simulator app open, custom location
PROBE coordinate: 37.785834, -122.406417
PROBE detail: First location 0.2 s after launch

换成 xcodebuild 跑测试,结果也一样:只要 Simulator app 开着、又选了位置,被测 app 一启动就已经有位置了。

我现在的做法

如果 UI 测试在本机跑,又要求一开始没有位置,我现在会做两件事:

  1. 跑测试时,Simulator app 必须关着。 由测试脚本来检查,不指望人或 agent 记得。
  2. 这类测试开跑前,先重启模拟器。 光清除不够:模拟器已经收到的位置,比如前一个测试设的,清除收不回来。本机为了快,我一直复用同一台模拟器,不会每次都新建或抹掉,前面留下的位置很可能还在。

放进测试脚本里,就是这几行:

Shell
if pgrep -x Simulator > /dev/null; then
    echo "Quit the Simulator app first: its Features > Location setting can override simctl location clear." >&2
    exit 1
fi
xcrun simctl shutdown "$UDID" 2> /dev/null || true
xcrun simctl boot "$UDID"
xcrun simctl bootstatus "$UDID"
xcrun simctl location "$UDID" clear

这些我还没在 CI 上验证过。托管 CI 的每个 job 通常都从一台全新的机器开始,也不会打开 Simulator app,第一条应该天然满足。但同一个 job 里的测试共用一台模拟器,前一个测试设的位置可能留给后一个,所以重启这一步最好还是留着。

Footnotes

  1. XcodeBuildMCP 现已改名 MobileBuildMCP,是一个开源的 MCP 服务,让 agent 可以操作 Xcode 和模拟器。Codex 是通过 OpenAI 的 Build iOS Apps 插件用上它的。 ↩