引言#
在云真机测试平台的开发过程中,各端的 Agent 实现通常是重中之重,其中 iOS 由于系统闭源、协议复杂、工具链变化快,整体实现成本和维护成本都显著更高。
本篇文章以最近一次对云真机 iOS 端 Agent 升级改造 为主线,从三个层次展开:
- 先从系统设计的角度梳理:要支撑云真机场景,一个 iOS Agent 需要具备哪些能力;
- 再结合过去几年团队的落地实践,说明这些能力是如何真正实现出来的;
- 最后,以本次升级为切入点,介绍 iOS 17 之后协议栈的变化,以及我们如何演进以适配更新的 iOS 版本。
项目全貌与架构#
核心问题:我们要做什么?#
我们的目标只有一句话:让平台能够稳定地通过自动化用例驱动这些设备执行。
为了支撑这个目标,iOS 端需要实现以下能力:
-
设备接入与管理
- 自动发现 / 下线 iOS 设备
- 维护设备状态(在线、空闲、占用、错误)
- 采集基础信息(UDID、型号、系统版本、电量等)
-
App 生命周期管理
- 安装 / 卸载 App
- 启动 / 关闭 / 重启 App
- 清理数据、恢复到“干净环境”
-
远程手动操作
- 屏幕画面采集和推流
- 鼠标 / 键盘操作注入
- 截图、录屏
-
自动化测试能力
- 稳定地驱动 UI:点击、滑动、输入、断言
- 承接上层测试框架(XCUITest / Appium 等)的调用
-
日志与诊断
- App crash log、系统日志
- 性能指标(CPU、内存、流量、FPS 等)
-
平台级能力(Host 视角)
- Session 管理、设备锁
- 心跳上报、任务执行状态上报
- 监控、告警、自愈(重启 WDA、重连设备等)
这些能力看上去都很“常规”,但在 iOS 封闭的生态下,要把它们在真机上跑起来,至少还要翻过三座大山:
- 屏幕获取:iOS 不允许后台录屏
- 模拟操作:iOS 不允许应用见互相控制
- 远程传输:平台的指令如何下发到设备,设备的结果如何回传给平台
为了解决这前两个问题,业界通用的方案是 Facebook 开源的 WebDriverAgent(WDA)。 WDA 是一个运行在 iOS 中的 App。它利用 Apple 提供的 XCTest 框架,XCTest 拥有极高的权限,可以获取屏幕截图和模拟点击。 WDA 启动后,会在手机上开启一个 HTTP 服务器,外部可以通过发送 HTTP 请求来控制手机。
但是,iOS 的安全性禁止直接通过公网的 HTTP 请求来控制手机,因此需要用 PC 作为 host 端,通过 USB 线连接手机,将平台下发的指令转发给设备,这也就是最经典的 host-device 架构,我们的实现即采用了这种架构。
系统全景#
3. 设备接入与管理#
原理层:iOS 设备是怎么连到 host 上的#
先不谈云真机,只看一个最日常的场景: 把 iPhone 用数据线插到一台 Mac 上,然后:
-
在 Finder 里能看到这台 iPhone;
-
在 Xcode 里能看到它出现在 Devices 列表;
-
一些命令行工具(比如 ideviceinfo)能打印出 UDID、系统版本、设备名称等信息。
这些操作背后,核心就是两块:usbmuxd 和 lockdown。
usbmuxd:把 USB 变成“可以复用的网络管道”
当你把 iPhone 插到 Mac 上时,大致会发生这些事:
-
操作系统识别到有一台新的 USB 设备接入;
-
usbmuxd(USB Multiplexing Daemon)这个系统进程接手,给这台设备分配一个内部 ID;
-
各种上层程序(Finder、Xcode、命令行工具)并不会直接操作 USB 驱动,而是:
- 通过一个 Unix Domain Socket(通常是 /var/run/usbmuxd)连接到 usbmuxd;
- 让 usbmuxd 帮自己“连到这台设备上的某个端口”。
可以把它理解成: usbmuxd 就是跑在本机上的“小型路由器”,专门负责把多路 TCP 连接复用到一根 USB 线上。
lockdown:负责“配对”和“讲出自己是谁”
有了 usbmuxd 打开的通道之后,还需要有一个在设备上的“服务”来回答问题,这个角色就是 lockdown。
对 iPhone 来说,lockdown 是一个常驻服务,负责:
设备配对(pairing)和信任关系管理;
提供设备的基础信息(名称、系统版本、型号、序列号等);
告诉上层:“还有哪些服务可以用”(类似一个服务目录)。
对 Mac 上的工具来说,典型流程是:
-
通过 usbmuxd 建立到 iPhone 上 lockdown 端口的连接;
-
通过 lockdown 完成配对(第一次插入时会弹出“是否信任此电脑”的对话框);
-
配对成功后,发送请求获取各种设备信息。
很多常用的工具,其实就是包了一层 lockdown 协议,例如:
-
Xcode 的设备列表;
-
Finder 里的设备信息面板;
-
以及本文后面要讲到的 tidevice、pymobiledevice3。
可以用一句话概括整个链路:
iPhone ←→ USB 线 ←→ usbmuxd ←→(lockdown 协议)←→ 各种上层工具
也正是因为这套机制存在,后面我们在 Host 侧才能“像普通工具那样”列出设备、读取信息,再在这个基础上去做更复杂的事情(装包、起 WDA、抓日志等)。
tidevice 版本:我们以前是怎么做设备管理的#
在老版本里,Host 上的本地 Agent 主要通过 tidevice 做了三件事:
-
基于 usbmuxd 的设备枚举(设备发现)
-
管理设备的脚本每隔固定周期(例如 10 秒)调用:
from tidevice import Usbmux mux = Usbmux() devices = mux.device_list() -
这行代码本质上就是去访问本机的
/var/run/usbmuxd,拿到当前所有通过 USB 接入的 iOS 设备列表。 -
Agent 会把这个列表和自己维护的“已接入设备集合”做一次 diff:
- 新出现的 UDID → 认为是“新设备插入”,走接入流程;
- 消失的 UDID → 认为是“设备拔出或异常”,走清理流程。
-
-
基于 lockdown 的设备信息获取(识别设备是谁)
-
对于每一个新发现的 UDID,Agent 会用 tidevice 创建一个
Device对象并读取其info字段:from tidevice import Device d = Device(udid="00008120-00061C621409A01E") info = d.info # 实际来自 lockdown 的 all values name = info.get("DeviceName") model = info.get("ProductType") ios_version = info.get("ProductVersion") -
这一步背后,其实就是通过 usbmuxd 建立到 lockdown 端口的连接,然后用 lockdown 协议请求“设备的完整信息”。
-
我们会把这些字段整理成自己的设备结构(UDID、名称、机型、系统版本、序列号等),一方面用于在平台上展示,另一方面也作为后续注册和调度的基础数据。
-
-
为每台设备建立本地“控制单元”(端口转发 + 设备主进程)
在拿到“有哪些设备”“它们是谁”之后,Agent 会为每台设备做两件关键的接入动作:
-
端口转发:让本机可以访问设备上的 WDA/MJPEG
-
从端口池(例如
9200–9900)里挑选一个空闲端口; -
通过 tidevice 启动转发命令,把本地端口映射到设备上的 WDA/MJPEG 端口(通常是 9100):
tidevice relay <free_port> 9100 -
这样一来,后续无论是云控页面还是本地脚本,只要访问
http://localhost:<free_port>,就能打到这台设备上的 WDA/MJPEG 服务。
-
-
启动设备专属主进程:把所有操作“托管”出去
- 在
install/device_detection.py里,每当发现一台新设备,Agent 会为它启动一个独立的 Python 进程(devicemain.mainFunc),并通过队列拿到这个进程的PID; - 这个
PID会同时写进pids/<udid>.pid文件和内存映射表,用来之后做健康检查和清理; - 设备主进程启动后会完成:
- 向服务端注册设备信息、获取逻辑层面的
uid; - 基于
uid建立 WebSocket 长连接,持续接收指令; - 把收到的指令分发给各类脚本(截图、点击、安装/卸载、录屏、性能采集等),脚本内部再通过
facebook-wda和刚才的端口转发去操作设备。
- 向服务端注册设备信息、获取逻辑层面的
- 在
-
3.3 iOS 17 之后的变化:为什么 tidevice 顶不住#
从 iOS 17 开始,Apple 在 Xcode 15 里正式引入了新的 CoreDevice 栈,用它来统一 Mac ↔ iOS 设备的通信,替代之前那套“东拼西凑”的设备支持文件 + 旧协议组合。 Stack Overflow +1
几个关键点:
- CoreDevice 接管了开发者相关服务
-
之前很多服务是直接通过 lockdown + 固定 service name 暴露出来;
-
iOS 17 开始,调试、挂 DDI、开发者工具这类功能,统一走 CoreDevice 这条栈。
- 引入 Remote Service Discovery(RSD)+ 隧道机制
- 设备在 USB 之上“虚拟”了一层 RSD / 虚拟网卡 / QUIC 隧道,开发者工具要先连上这个隧道,再在隧道里发现和访问服务。
- 旧路径逐步收紧 / 变行为“只剩 lockdown”
- 社区里不少逆向项目都提到:iOS 17 上通过旧的 usbmuxd 接口,基本只剩 lockdown 还在“说话”,其他开发者相关服务流量几乎都不见了。
而 tidevice 依然停留在“直接对接 usbmuxd + 旧服务”的时代,没有实现 CoreDevice / RSD / 隧道那一整套新机制。
因此当我们利用 tidevice 管理 iOS17 及以上的新设备时,lockdown 还在,所以还能通过 tidevice 列出设备、拿到基本 info;但真正需要开发者能力的操作(起 WDA、装 DDI、跑测试)统统失败
pymobiledevice3 的实现#
4. App 安装与启动(C2)#
本节回答: “服务端说:把这个 IPA 装到这台机器上、启动它、结束后清干净——Host 和 Device 怎么配合完成?”
4.1 平台在 App 维度的要求#
- 安装/卸载: 支持从 Host 本地或远程存储拉取 IPA,并安装到指定设备;
- 运行控制: 按 bundle id 启动 / 停止 App,必要时获取对应 PID;
- 环境清理: 卸载 App 或清理沙盒目录,保证 Session 之间不会互相污染。
4.2 基于 tidevice 的实现(旧方案)#
在 iOS 16 及以前,tidevice 封装了 installation_proxy 和部分 process control 服务,接口调用相对简单。
安装 App(简化示例)
from tidevice import Device
device = Device(udid="...")
device.app_install("path/to/app.ipa")
启动 / 停止 App
pid = device.app_start("com.example.demo")
print(f"App started with PID: {pid}")
device.app_stop("com.example.demo")
在 Host 代码中,这些调用被封装在 DeviceProviderTidevice 内,再由 Session 层根据指令调度:
- 安装过程拆分为多个阶段(上传 / 校验 / 安装 / 验证)上报进度;
- 启动前后与 WDA 状态挂钩(例如先确保 WDA 正常、再启动 App)。
4.3 iOS 17 的影响(实践层面)#
随着 iOS 17 / CoreDevice 引入:
- 依赖旧 instruments 接口的
app_start等调用在部分机型上出现不可用或行为不稳定; - 即便安装本身仍然使用 installation_proxy,多数情况下我们更倾向于“整体迁移到新的 DVT / CoreDevice 路径”,而不是同时维护两套行为。
这直接导致:
- 对于新购的 iOS 17+ 设备,基于 tidevice 的 App 管理无法满足生产环境要求;
- 不得不寻找在“接近 Xcode 行为”的新实现。
4.4 基于 pymobiledevice3 的新实现#
在新方案中:
- 安装依旧使用
InstallationProxyService; - 进程控制(启动 / 停止 App)则通过 DVT / ProcessControl 实现。
安装 App 示例(简化版)
from pymobiledevice3.services.installation_proxy import InstallationProxyService
# 前提:lockdown_client 已初始化
with InstallationProxyService(lockdown=lockdown_client) as iproxy:
iproxy.install_from_local("path/to/app.ipa")
启动 App 示例
from pymobiledevice3.services.dvt.dvt_secure_socket_proxy import DvtSecureSocketProxyService
from pymobiledevice3.services.dvt.instruments.process_control import ProcessControl
# 前提:lockdown_client / rsd 已初始化
dvt = DvtSecureSocketProxyService(lockdown=lockdown_client, rsd=rsd)
process_control = ProcessControl(dvt)
pid = process_control.launch(
bundle_id="com.example.demo",
arguments=[],
environment={"ENV_VAR": "1"},
kill_existing=True
)
print(f"App started with PID: {pid}")
Host 侧对外暴露的仍然是 DeviceProvider.launch_app / DeviceProvider.uninstall_app 之类统一接口,只是在实现中替换了底层调用。
5. 自动化与远程控制:WDA 链路(C1 + C4)#
这一节聚焦: “点击是怎么从远端一路传到 iOS 屏幕上的?自动化脚本又是怎么驱动 UI 的?”
5.1 需求拆解#
从 Host 的视角,我们需要提供:
- 一套 对上统一的“UI 操作接口”:
- 点击 / 滑动 / 输入 / 查找元素 / 获取 UI 树;
- 对外兼容常见自动化协议:
- WebDriver / Appium;
- 能够把远端传来的坐标变成设备屏幕上的真正触摸。
5.2 为什么离不开 WDA#
iOS 的安全模型决定了:
- 普通 App 无法跨进程控制其他 App;
- 能够自动化 UI 的唯一正路是 XCTest / UI Testing 相关能力。
[!NOTE] WDA(WebDriverAgent) 一个基于 XCTest 的“测试 Runner”,运行在 iOS 设备上,暴露 WebDriver 协议(HTTP + JSON)。 它内部通过 XCTest 和 Accessibility API 控制 UI。
在 Host 侧,整条操作链大致如下:
Host 这边的工作主要包括:
- 启动 WDA;
- 建立端口转发,让远端能访问
http://host:port/…; - 对上封装 facebook-wda-client / Appium 相关调用统一接口。
5.3 tidevice 时代的 WDA 管理(旧方案)#
在旧方案中,我们基本依赖 tidevice 提供的能力:
- 使用 tidevice 调用 instruments / testmanagerd 启动
WebDriverAgentRunner; - 使用 tidevice 进行端口映射,将设备 8100 → Host 本地端口;
- 上层使用
facebook-wda-client按 WebDriver 协议发起请求。
示例(极简版,仅说明调用链):
from tidevice import Device
device = Device(udid="...")
# 启动 WDA(Runner bundle id 一般类似于 com.facebook.WebDriverAgentRunner.xctrunner)
device.xctest(bundle_id="com.facebook.WebDriverAgentRunner.xctrunner")
Host 侧 WdaManager 负责:
- 检查 WDA 是否已启动;
- 定期调用
/status做健康检查; - 异常时尝试重启。
5.4 iOS 17 后的挑战#
随着 iOS 17 引入 CoreDevice 相关机制,我们在实践中遇到:
- 通过旧的 testmanagerd / instruments 路径启动 WDA 的成功率下降;
- 即便 WDA 启动了,端口转发在某些情况下表现不稳定。
结合 tidevice 本身缺乏对新协议的完整支持,我们选择把 WDA 链路全面迁移到 pymobiledevice3 封装的 DVT / 隧道之上。
5.5 pymobiledevice3 下的 WDA 管理(新方案)#
在新方案中,WDA 启动与连接流程大致如下:
- 建立与设备的安全隧道;
- 通过 RSD 获取 DVT / testmanagerd 等服务;
- 使用 DVT ProcessControl 启动
WebDriverAgentRunner; - 使用 pymobiledevice3 提供的 usbmux forward 做端口转发;
- 上层继续使用 facebook-wda-client 访问
http://localhost:port/。
启动 WDA 示例(简化)
from pymobiledevice3.services.dvt.dvt_secure_socket_proxy import DvtSecureSocketProxyService
from pymobiledevice3.services.dvt.instruments.process_control import ProcessControl
dvt = DvtSecureSocketProxyService(lockdown=lockdown_client, rsd=rsd)
process_control = ProcessControl(dvt)
pid = process_control.launch(
bundle_id="com.facebook.WebDriverAgentRunner.xctrunner",
arguments=[],
environment={
"USE_PORT": "8100",
"MJPEG_SERVER_PORT": "9100"
},
kill_existing=True
)
print(f"WDA PID: {pid}")
端口转发示例(命令行)
python3 -m pymobiledevice3 usbmux forward 8100 8100 --udid <UDID>
上层使用 facebook-wda-client
import wda
client = wda.Client("http://127.0.0.1:8100")
client.click(100, 200)
client.swipe(100, 500, 100, 100)
在 Host 内部,这些被包装在 WdaManager / WdaClient 中,Session 层只需要“要一个可用的 WDA endpoint”。
6. 日志与性能监控(C5)#
6.1 实时日志(Syslog / 控制台日志)#
对于排查问题、分析崩溃非常重要。
在 tidevice 方案中,可以通过其内建命令 / 接口获取日志流;
在迁移到 pymobiledevice3 后,我们使用其 SyslogService:
from pymobiledevice3.services.syslog import SyslogService
syslog = SyslogService(lockdown=lockdown_client)
for line in syslog.watch():
# 可以按需打标签并转发到日志系统
print(line)
Host 中的 LogCollector 模块通常会:
- 将日志按设备 / 会话进行分流;
- 同时输出到本地日志和集中式日志系统;
- 提供基本过滤能力(例如按 bundle id 过滤)。
6.2 性能监控(Instruments / DVT)#
性能数据需要通过 Instruments / DTX 协议获取,技术细节较重。简要说明思路:
- tidevice 方案:使用其封装的 perf 功能获取 CPU/内存等;
- pymobiledevice3 方案:通过其 Instruments / DVT 封装与设备建立会话,再订阅对应通道。
伪代码示例(仅说明接口形态):
from pymobiledevice3.services.dvt.instruments.instruments import InstrumentsService
instruments = InstrumentsService(lockdown_client)
# 启动某个性能监控通道
instruments.start_monitoring(channel="Activity Monitor")
# 后续从 instruments 中读取指标数据并上报
在 Host 层,我们一般会:
- 把性能指标聚合到统一监控系统;
- 给每台设备打“健康分”(CPU 持续 100%、温度过高等情况标记为风险);
- 为自动化任务产生性能报告做准备。
7. Host 侧稳定性与自愈(C6)#
Host 侧如果不做稳定性治理,再好的协议栈也会被“现实世界”的 USB 掉线、WDA 卡死打败。
7.1 Session 与设备锁#
从 Host 视角,Session 可以简单理解为:
- “在某个时间段内,这个 UDID 被谁占用、跑了什么”。
SessionManager 的职责包括:
- Session 创建 / 结束;
- 给设备加锁 / 解锁:
- 同一时刻同一设备只允许一个活跃 Session;
- 提供超时回收机制:
- 如一段时间没有心跳就释放 Session、重置设备。
7.2 端口映射管理#
在大量设备场景下,端口转发会成为一片“雷区”,常见问题:
- 转发进程泄露、端口未释放;
- Host 重启后,残留进程占用端口。
典型做法:
def add_device(self, device_info):
port = self.get_free_port() # 例如 9200
cmd = (
f"pymobiledevice3 usbmux forward {port} 9100 "
f"--serial {device_info['udid']}"
)
subprocess.Popen(cmd, shell=True)
return port
配套策略:
- 启动 Host Agent 前先扫描并清理旧的 usbmux forward 进程;
- 为每个转发进程记录元信息,定期校验其是否仍与某个 Session 关联。
7.3 自愈机制#
常见异常场景:
- USB 掉线;
- WDA 崩溃 / 卡死;
- Host 自身进程异常退出。
典型自愈策略:
- 进程守护
- 为 Host Agent / 设备子进程 / 关键转发进程建立 watchdog;
- 使用 PID 文件或 supervisor 工具监控是否存活。
- WDA 自愈
- WdaManager 定期访问
/status; - 若连续多次失败,尝试:
- 重启 WDA;
- 重建端口映射;
- 若仍失败,将设备标记为
Error,交给人工 / 后台任务进一步排查。
- WdaManager 定期访问
- 设备隔离
- 对异常频繁的设备做黑名单 / maintenance 标记,不再分配给新 Session;
- 提供运维接口来手工重置设备(重启 / 刷机等)。
8. 总结与演进路线(只看 iOS 侧)#
从 Host–Device 角度看,一个 iOS 云真机平台的演进,大致可以概括为:
-
第一阶段:tidevice 为主的快速落地
- 使用 tidevice 封装的 lockdown / instruments 等能力:
- 完成设备接入与管理(C3);
- 完成 App 安装与启动(C2);
- 完成 WDA 启动与基础自动化(C1/C4);
- 提供基本日志 / 性能采集(C5)。
- 优点:简单、上手快、社区 ecosystem 完整;
- 局限:对 iOS 17 之后的协议与行为变化支持有限。
- 使用 tidevice 封装的 lockdown / instruments 等能力:
-
第二阶段:基于 pymobiledevice3 的能力重构
- 在 Host 内部引入清晰的抽象层(DeviceProvider/WdaManager/SessionManager 等);
- 使用
DeviceProviderPym替换 tidevice 版本,实现:- 基于 CoreDevice / RSD / 隧道的设备接入;
- 通过 DVT / ProcessControl 的 App 启动 / WDA 启动;
- 更贴近 Xcode 的日志与性能数据获取;
- 在上层接口不变的前提下完成底层“换心”。
-
未来方向(从 Host–Device 视角)
- 持续跟进 iOS 18+ 的协议与工具链变化;
- 在性能与可观测性方面使用更多开发者服务通道(例如更完整的 instruments 通道组合);
- 优化视频采集与编码链路,在 Host 利用硬件加速,进一步降低延迟。
如果你是新加入的同学,建议阅读顺序:
- 通读第 1 章,理解 Host–Device 需要做哪些事;
- 看第 2 章,建立脑中的“Host–Device 架构图”;
- 根据你负责的方向:
- 设备接入 / App 管理 → 重点看第 3、4 章;
- 自动化 / 远控 → 重点看第 5 章;
- 日志 / 性能 / 稳定性 → 重点看第 6、7 章;
- 最后再回来看第 8 章,把 tidevice → pymobiledevice3 的演进脉络串起来。
Next Reads
