简介:面向 iOS 17 及以上系统、希望在不越狱设备上研究虚拟定位与模拟跑步打卡的开发者,这份跨平台命令行工具给出了一套可参考的实现思路。它围绕在线拾取路径、跑步参数设置与设备连接等环节组织代码,将定位、路由、隧道、运行控制等职责拆分到不同模块,适合具备一定 Python 基础、想了解 iOS 虚拟定位工具结构与跨平台 CLI 设计的读者研读。压缩包共 16 个文件,以 9 个 Python 脚本为核心,辅以 yaml/yml 配置、txt 路径数据、依赖清单、README 说明与许可证等,整体仅约 15KB,轻量且目录划分清晰。目前已有 716 人学习。读者可从中获取在线路径配置、参数调节、模块解耦与依赖组织等方面的参考,并借说明文档理解项目边界;同时需注意此类技术可能违反运动应用条款,宜用于学习与合规研究,不应替代真实锻炼。
1. iOS 17 之后还能免越狱改定位吗:开发者模式下的位置模拟到底怎么跑
做跑步类 App 的人大概都遇到过一个尴尬:轨迹纠偏、配速曲线、分段计时这几块逻辑要反复验证,真去操场跑十公里显然不现实。于是 iOS 17 之后的免越狱虚拟定位方案就成了刚需——不越狱、不改系统、不装描述文件,靠苹果自己留给开发者的调试通道把坐标塞进locationd,再用 GPX 回放出模拟跑步打卡的整条轨迹。麻烦在于 iOS 17 把这条通道从老的 usbmux 明文服务换成了 RemoteXPC 隧道,很多团队手里那套基于 libimobiledevice 的idevicesetlocation直接哑火,这才是真正卡人的地方。
这套跨平台方案解决的正是这个断层:Windows、macOS、Linux 三端都能拉起隧道,单点定位、GPX 轨迹回放、清除复位全覆盖,设备重启即恢复真实定位。适合做定位功能自测的开发、需要复现特定路线的测试,以及要给业务方演示轨迹效果的工程师。有一点得先摆明白:它只作用于 GPS 层,加速度计、陀螺仪、步频这些传感器数据它一概不碰,需要真实运动数据的正式场景请守住平台规则,别拿它当长期方案。
2. 打通隧道:三端环境搭建与 DVT 服务的握手过程
2.1 为什么必须走 DVT,而不是 WDA 或描述文件
先把这个选择的理由说清楚,不然后面报错会一头雾水。iOS 上改定位的路径其实有三条:装带定位模拟功能的描述文件(需要签名、有有效期、重启失效)、用 WebDriverAgent 这类自动化框架加壳(依赖签名的 runner,iOS 17 上签名成本很高)、以及苹果给 Xcode 用的 DVT(Developer Tools)服务里的 LocationSimulation 接口。第三条是官方调试链路,不涉及任何 App 层注入,设备端只认开发者调试会话,所以既不用越狱,也不会在设备上留下常驻组件。
DVT 的问题在于它的传输层。iOS 16 及以前,DVT 服务挂在 usbmux 的明文端口上,用idevicesetlocation一条命令就能搞定。iOS 17 开始,苹果把开发者服务迁移到 RemoteXPC,需要先建立一条基于虚拟网卡的隧道,拿到 RSD(Remote Service Discovery)地址和端口,之后所有 DVT 调用都要带上这对地址。这就是为什么你照着两年前的教程敲命令,会卡在Could not connect to device或者一直转圈。
提示:隧道是每台设备、每次重连都会变的,RSD 端口不是固定值,别把它写死在脚本里。
2.2 三端安装与配对流程
核心工具是pymobiledevice3,一个纯 Python 实现的设备通信库,把 usbmux、lockdown、DVT 这些底层协议都封好了,也是目前少数跟进了 RemoteXPC 的开源实现。安装它之前先确认 Python 版本在 3.8 以上,我一般用 3.11,3.12 在某些依赖上还会碰到编译问题。
# 建议放在独立虚拟环境里,避免和系统 Python 的依赖打架 python -m venv .venv source .venv/bin/activate # Windows 用 .venv\Scripts\activate pip install --upgrade pip pip install "pymobiledevice3[usbmuxd]"装完之后三端各有一段前置工作,这部分最容易翻车:
| 平台 | 前置依赖 | 常见缺失表现 |
|---|---|---|
| Windows | Apple 移动设备支持服务(一般随 iTunes 或 Apple Devices 装上)、wintun.dll放到 pymobiledevice3 可执行目录 | 隧道启动报WinTun not found |
| macOS | 系统自带 usbmuxd,无需额外安装,隧道需 sudo | 普通权限下 RSD 端口拿不到 |
| Linux | usbmuxd包、libimobiledevice工具链、当前用户加入plugdev组 | 设备列表空,lsusb能看到但工具识别不到 |
配对这一步在 iOS 16 之后多了一个动作:设备要在「设置 → 隐私与安全性 → 开发者模式」里打开开关,然后重启一次。很多人以为这个开关只对 Xcode 有用,实际上它决定了调试类服务能不能被拉起,不开的话后面隧道能建、DVT 调用会被直接拒绝。
设备连上后先验证识别:
# 列出当前所有已连接设备,拿到 UDID 和连接类型 pymobiledevice3 usbmux list输出里会给出Identifier(也就是 UDID)、DeviceName和ConnectionType(USB 或 Network)。如果这里是空的,先别往下走,问题一定在驱动或配对信任,不在隧道。
2.3 建立 RSD 隧道并验证握手
iOS 17 必须单独起一个隧道进程,它会创建一张虚拟网卡并返回 RSD 地址端口。这个进程要占住终端,所以常规做法是后台运行并把日志重定向到文件。
# 需要管理员/root 权限,因为它要创建虚拟网卡 sudo pymobiledevice3 remote tunneld --daemon --log-level INFO # 另开一个终端,查看隧道是否建起来 pymobiledevice3 remote tunneld --status拿到 RSD 之后,用一条最轻的命令验证开发者服务是否活着:
# 用 RSD 地址和端口访问设备,确认 DVT 服务可枚举 pymobiledevice3 developer dvt simulate-location clear --rsd 127.0.0.1 51234clear是最安全的试探命令,它只是把定位恢复到系统默认。如果这条命令返回成功,说明整条链路全通了,后面设坐标、放轨迹都只是换参数的事。反过来,如果这里报Service not found,八成是开发者模式没开;报连接超时,是 RSD 端口取错了或者隧道进程已经退出。
3. 坐标注入的三种粒度:单点、GPX 与程序化插值
3.1 单点设置:最快的连通性验证
单点定位适合两类场景:一是确认链路是否真的生效,二是某些业务只需要一个固定位置(比如验证「附近门店」的排序逻辑)。命令极其简单,经纬度按 WGS-84 传,小数点后至少六位,少一位误差就是百米级别。
# 参数顺序是 纬度 经度,和国内地图常用的「经度在前」相反,这里栽过很多人 pymobiledevice3 developer dvt simulate-location set --rsd 127.0.0.1 51234 -- 31.230416 121.473701执行完不要只盯着命令行看返回码,去设备上打开系统地图,蓝点会平滑滑到目标位置,带一个脉冲圈。设备端不会弹任何提示,也不会留下开关,这也是它比描述文件方案干净的地方。想复位直接再执行一次clear子命令,或者拔掉数据线重启定位服务。
参数上有一个细节值得单独说:--rsd后面的地址端口组合必须和tunneld输出的完全一致,包括 IPv6 地址里的方括号。有些版本的输出是fdXX:...:1这种,脚本里做字符串拼接时容易把方括号吃掉,导致连接直接失败。我一般让脚本从--status的 JSON 输出里解析,而不是人工抄。
3.2 GPX 回放:把配速写进轨迹文件
GPX 是 GPS 交换的标准格式,苹果的模拟器从 Xcode 时代就支持它,DVT 接口自然也认。它的价值在于「时间维度」:每个trkpt可以带一个<time>时间戳,播放时设备端会按时间戳推进,而不是瞬间跳过去。这就是模拟跑步打卡和单纯改坐标的本质差别。
<?xml version="1.0" encoding="UTF-8"?> <gpx version="1.1" creator="route-builder" xmlns="http://www.topografix.com/GPX/1/1"> <trk> <name>morning-run</name> <trkseg> <!-- 每个点带时间戳,设备端按时间差推进,配速由相邻点距离/时间差决定 --> <trkpt lat="31.230416" lon="121.473701"><ele>8.0</ele><time>2024-05-01T07:00:00Z</time></trkpt> <trkpt lat="31.230916" lon="121.473701"><ele>8.0</ele><time>2024-05-01T07:00:18Z</time></trkpt> <trkpt lat="31.231416" lon="121.473701"><ele>8.2</ele><time>2024-05-01T07:00:36Z</time></trkpt> <trkpt lat="31.231916" lon="121.473701"><ele>8.4</ele><time>2024-05-01T07:00:54Z</time></trkpt> </trkseg> </trk> </gpx>播放命令:
# play 会异步推进,命令本身很快返回,轨迹在设备端按时间轴跑完 pymobiledevice3 developer dvt simulate-location play --rsd 127.0.0.1 51234 ./morning-run.gpx这段 XML 里有几个点必须对上。<time>用的是 UTC,末尾那个Z不能省,写成带时区偏移的+08:00在部分版本上解析会失败。上面四个点纬度每步 0.0005 度,约 55.6 米,间隔 18 秒,换算下来配速大概是 5 分 24 秒每公里,属于正常慢跑区间,业务侧的配速校验不会报警。
还有一个容易忽略的地方:GPX 点太密或者太稀疏都会出问题。一秒一个点,轨迹平滑但文件巨大;三十秒一个点,业务侧算出来的瞬时速度会跳变,某些带异常检测的 App 会判为「非人类移动」。我的经验值是 3 到 5 秒一个点,跑十公里也就两三千个点,文件几百 KB。
3.3 程序化插值:用 Python 生成任意路线
手工编 GPX 不现实,实际项目里都是拿起点终点或者一条折线,按目标配速反推时间戳。这里给出一个能直接用的生成器,核心是 Haversine 距离加上分段等距插值。
import math from datetime import datetime, timedelta, timezone R = 6371000.0 # 地球平均半径,米 def haversine(lat1, lon1, lat2, lon2): """两点球面距离,返回米。业务上足够,不需要上椭球模型。""" p1, p2 = math.radians(lat1), math.radians(lat2) dp = math.radians(lat2 - lat1) dl = math.radians(lon2 - lon1) a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2 return 2 * R * math.asin(math.sqrt(a)) def interpolate(points, speed_mps, start_time, interval_s=4): """ points: [(lat, lon, ele), ...] 折线控制点 speed_mps: 目标速度,米/秒。360 秒/公里 对应 2.778 interval_s: 采样间隔,秒。3~5 之间比较自然 """ step = speed_mps * interval_s # 每个采样点的推进距离 out, t = [], start_time for i in range(len(points) - 1): la1, lo1, e1 = points[i] la2, lo2, e2 = points[i + 1] seg = haversine(la1, lo1, la2, lo2) n = max(1, int(seg // step)) # 这一段要切几刀 for k in range(n): r = k / n out.append((la1 + (la2 - la1) * r, lo1 + (lo2 - lo1) * r, e1 + (e2 - e1) * r, t)) t += timedelta(seconds=interval_s) out.append((*points[-1], t)) return out def write_gpx(pts, path): head = ('<?xml version="1.0" encoding="UTF-8"?>\n' '<gpx version="1.1" creator="route-builder" ' 'xmlns="http://www.topografix.com/GPX/1/1"><trk><trkseg>\n') with open(path, "w", encoding="utf-8") as f: f.write(head) for lat, lon, ele, t in pts: # 统一转 UTC 并以 Z 结尾,避免时区解析差异 ts = t.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") f.write(f'<trkpt lat="{lat:.6f}" lon="{lon:.6f}">' f'<ele>{ele:.1f}</ele><time>{ts}</time></trkpt>\n') f.write("</trkseg></trk></gpx>\n") if __name__ == "__main__": route = [(31.230416, 121.473701, 8.0), (31.235416, 121.473701, 9.5)] generated = interpolate(route, speed_mps=2.778, start_time=datetime.now(timezone.utc)) write_gpx(generated, "run_1km.gpx") print(f"points={len(generated)}")逻辑上有三点值得说明。haversine用球面近似,在城市尺度上误差在千分之几,对配速计算完全够用,上 Vincenty 属于过度设计。interpolate里n = seg // step是向下取整,意味着每段末尾会有一个略小于step的余量,累积长了会让配速略微偏快,想更精确可以改成按总长统一分配。时间戳用datetime.now(timezone.utc)起算而不是硬编码日期,是因为某些业务会校验轨迹时间和当前时间的时间差,写死一个去年的日期会直接触发「历史数据」判定。
参数上speed_mps是最关键的一个。跑步配速换算:5 分 00 秒每公里是 3.333,6 分 00 秒是 2.778,7 分 30 秒是 2.222。走路大概 1.2 到 1.4,骑车 5 以上。填错这一项,业务侧的配速曲线会立刻暴露。
4. 封装成工具:把命令行包成可复用的跨平台脚本
4.1 为什么用 subprocess 包 CLI 而不是直接调 Python API
pymobiledevice3有 Python API,但版本间改动比较大,LocationSimulation的构造签名和 DVT provider 的获取方式在 3.x 到 4.x 之间都调整过,锁版本又会让新设备支持滞后。所以我一般用subprocess调它的命令行——CLI 的参数是相对稳定的契约,出问题也容易在终端里复现。
参数设计上,一个够用的封装只需要这几项:
| 参数 | 含义 | 默认值 | 说明 |
|---|---|---|---|
--rsd-host/--rsd-port | 隧道地址端口 | 自动探测 | 手工指定用于排障 |
--udid | 目标设备 | 首台 | 多设备时必须指定 |
--mode | set/play/clear | play | 单点、回放、复位 |
--gpx | 轨迹文件路径 | 无 | play模式必填 |
--timeout | 回放超时秒数 | 轨迹时长 + 60 | 防止子进程挂死 |
4.2 一个能跑的实现
import argparse import json import subprocess import sys import time def get_rsd(): """从 tunneld --status 的 JSON 输出里解析 RSD 地址端口,避免手工抄错。""" raw = subprocess.run(["pymobiledevice3", "remote", "tunneld", "--status"], capture_output=True, text=True, timeout=15).stdout try: info = json.loads(raw) except json.JSONDecodeError: sys.exit("tunneld 未运行或输出格式异常,先执行 sudo pymobiledevice3 remote tunneld --daemon") if not info: sys.exit("隧道列表为空,设备可能已断开") first = info[0] if isinstance(info, list) else next(iter(info.values())) # 不同版本字段名可能是 rsd_address/address,做个兼容 host = first.get("rsd_address") or first.get("address") port = str(first.get("rsd_port") or first.get("port")) return host, port def run(cmd, timeout): """统一入口:打印完整命令便于复现,非零退出码直接抛出。""" print("[cmd]", " ".join(cmd)) p = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) if p.returncode != 0: print(p.stdout) print(p.stderr, file=sys.stderr) raise RuntimeError(f"exit={p.returncode}") return p.stdout def main(): ap = argparse.ArgumentParser() ap.add_argument("--mode", choices=["set", "play", "clear"], default="play") ap.add_argument("--gpx") ap.add_argument("--lat", type=float) ap.add_argument("--lon", type=float) ap.add_argument("--timeout", type=int, default=3600) args = ap.parse_args() host, port = get_rsd() base = ["pymobiledevice3", "developer", "dvt", "simulate-location"] if args.mode == "clear": run(base + ["clear", "--rsd", host, port], 30) elif args.mode == "set": if args.lat is None or args.lon is None: sys.exit("set 模式需要 --lat 与 --lon") run(base + ["set", "--rsd", host, port, "--", f"{args.lat:.6f}", f"{args.lon:.6f}"], 30) else: if not args.gpx: sys.exit("play 模式需要 --gpx") # 回放是异步的,这里给足超时,结束后主动 clear 归位 run(base + ["play", "--rsd", host, port, args.gpx], args.timeout) time.sleep(1) run(base + ["clear", "--rsd", host, port], 30) if __name__ == "__main__": main()这段代码的关键设计在于两处。get_rsd()从状态接口解析而不是让用户手填,直接消掉了「端口抄错」这一大类问题;末尾的自动clear是血泪教训换来的——回放结束后如果不清除,设备会一直停在轨迹终点,下次连上做别的测试时位置是错的,排查半天才发现是上一次没复位。
timeout参数别设太小。一段 30 分钟的轨迹回放,命令本身是异步返回的,但如果底层实现里带阻塞式等待,超时设成 60 秒就会在半路被杀掉,设备停在轨迹中间。稳妥做法是按轨迹实际时长算,留 60 秒余量。
4.3 定时与断线恢复
长时间回放最容易碰上的是进程被系统挂起后隧道失效。常见做法是加一个守护循环:每 30 秒调一次tunneld --status,发现设备消失就退出并提示重连,而不是傻等。Windows 上还要注意笔记本的 USB 选择性挂起,息屏后 usbmux 连接会断,把电源计划里「USB 选择性暂停」关掉能省掉很多莫名其妙的掉线。
5. 避坑与排查:五类实际翻车记录
5.1 连接与隧道类问题
现象一:usbmux list一直是空的。原因是驱动层没就位。Windows 上多半是只装了 iTunes 的旧版驱动,或者 Apple 移动设备服务被优化软件禁用了;Linux 上是当前用户不在plugdev组,或者usbmuxd服务没起。解决顺序是:Windows 装 Apple Devices 应用(微软商店版本即可)后重插数据线,设备上点「信任」;Linux 执行sudo systemctl restart usbmuxd并把用户加组后重新登录。
现象二:隧道起来了,但 DVT 调用报Service not found。这是开发者模式没开。iOS 16 之后这个开关藏在「隐私与安全性」里,打开后必须重启设备才生效。另一种可能是设备锁屏了——锁屏状态下调试服务会被挂起,解锁再试。
现象三:昨天好好的,今天 RSD 端口连不上。隧道是每次连接重新分配的,端口全变。要么重启tunneld重新取,要么用前面脚本里的自动解析。把端口硬编码进配置文件的,第二天必然翻车。
5.2 轨迹与业务校验类问题
现象四:轨迹回放完了,App 里显示的位置还在原地。先分清是谁的问题。打开系统地图看蓝点有没有动:动了说明定位注入成功,问题在 App 侧——很多跑步类 App 用的是自己的定位 SDK,做了坐标系转换(WGS-84 转 GCJ-02 之类),还有些做了缓存和去抖,位置更新有几十秒延迟。没动则是注入没生效,回到现象二排查。
现象五:轨迹跑通了,但被判定为异常。根因几乎都在物理合理性上。第一是速度,相邻点算出来的瞬时速度如果超过 10 米每秒,任何带风控的业务都会标记;第二是海拔,平地跑步ele却在一分钟内上下几十米,同样可疑;第三是传感器数据对不上——这是这套方案的天花板,虚拟定位只改 GPS,加速度计读到的还是静止状态,带运动传感器融合校验的业务一定识别得出来。第三点没法靠调参绕过去,只能接受。
注意:上述第五条的边界是硬约束,不是配置问题。把方案用在需要真实运动数据的正式场景里,风险和责任都在使用者这边。
6. 让轨迹更像真的:插值平滑与端到端验证方法
调通之后,真正决定成败的是「像不像」。我一般做三件事来验证。第一件是速度曲线自检:把生成的 GPX 读回来,逐点算相邻点的距离除以时间差,输出一条速度序列,看有没有尖刺。人跑步的速度是连续变化的,起跑慢、中途稳、最后冲刺,一条完全水平的直线反而假。可以在插值时给speed_mps加一个正负 5% 的缓变扰动,用正弦函数按时间加权就行,一两行代码的事。
第二件是时间戳对齐。生成的轨迹时间基准如果是datetime.now(),跑到一半就变成「未来时间」了,某些业务会校验轨迹点时间不得晚于当前时间。做法是让起始时间往前推,正好让最后一个点落在当前时刻附近。这一点我在一个模拟项目上栽过,排查了两小时才发现是时钟偏移。
第三件是端到端对照。开两个终端,一个跑回放,另一个用pymobiledevice3 syslog live抓设备日志,过滤locationd关键字,能看到定位更新的真实频率。日志里的时间间隔和你 GPX 里设定的采样间隔对不上,说明设备端做了节流,这时候把采样间隔调大反而更接近真实。
| 验证项 | 方法 | 合格标准 |
|---|---|---|
| 速度连续性 | 逐点算瞬时速度 | 无超过 8 m/s 的尖刺 |
| 时间合理性 | 检查末点时间戳 | 不晚于当前 UTC 时间 |
| 采样频率 | syslog 过滤 locationd | 与设定间隔偏差小于 50% |
| 坐标精度 | 系统地图目视 | 蓝点落在目标路段上 |
最后说个习惯。从那以后我每次跑轨迹回放,都强制先走一遍clear → set 单点 → 目视确认 → play → clear这条五步流程,不管多急都不跳步。单点那一步看似多余,但它能在十秒内告诉你链路到底通不通,省掉后面「轨迹跑了但没效果」时的两小时瞎猜。工具是死的,流程是拿来兜底的。希望帮到你。
本文还有配套的精品资源,点击获取