Mac 遥控 iPhone
最近在折腾一个 iPhone App 的自动化:Mac 上跑个程序,盯着手机屏幕看,该点哪就点哪。一开始图省事,用的是系统自带的 iPhone Mirroring(iPhone 镜像),截窗口的图来看,往窗口上发鼠标事件来点,跑起来倒也没毛病。
问题是,它跑着的时候,我的电脑就基本废了:鼠标时不时被拽过去点一下,键盘焦点也跟着跑到镜像窗口里,我在终端(用的是我自己写的 Notchy)里敲的字,搞不好就敲进手机里去了;更别说镜像窗口一旦被别的窗口挡住,截到的就是我自己的浏览器。。。程序一跑就是一两个钟头,这段时间电脑和手机都不能碰,实在受不了。我意识到,不妙,来活儿了~

最后的结果是:完全不用 iPhone Mirroring,也不用 Xcode 的 DeviceHub,Mac 直接通过 Apple 给开发者用的 CoreDevice 通道,后台截手机的屏、往手机上发触摸,鼠标、键盘、窗口一概不碰。中间踩了几个挺隐蔽的坑,值得写一篇,分享给有需要的朋友。
先说结论:镜像窗口这条路为什么走不通?
最先想到的当然是”别动鼠标,直接把点击事件投给那个进程”。macOS 有现成的 API:CGEvent.postToPid(),可以把鼠标事件直接发给指定 pid,不经过全局的事件流。
我写了个小工具,三种姿势都试了一遍:只设坐标、再把窗口号写进事件的 mouseEventWindowUnderMousePointer 字段、再先补一个 mouseMoved。结果是——iPhone Mirroring 一概不理,画面纹丝不动:
|
1 2 |
pid 75125 窗口 8451 点 (94.0, -682.0) mode b 当前前台 Notchy 鼠标 (668.18, 529.85) -> (668.18, 529.85) 前台 Notchy -> Notchy |
鼠标确实没动、前台也没换,但 App 根本没收到。用真实的鼠标事件点也不行:窗口不在前台时,第一下点击只是把窗口激活,并不会传给手机(这个之前就踩过,点击”成功”了画面却不动,还以为是坐标错了)。
Xcode 27 里 Simulator 改名成了 DeviceHub,它也能投屏真机,我抱着希望又试了一遍,结果一模一样。最后能用的只剩一个土办法:先激活窗口 → 点 → 0.1 秒内把鼠标挪回原位、把原来的前台程序切回来。实测一次 0.3 秒左右,能用,但每点一下焦点都要闪一下,治标不治本。
真正的入口:CoreDevice 的远程 HID
既然 DeviceHub 能把我在窗口里的点击传给手机,那它底层一定有个”往手机发触摸”的通道。翻了一下它链接的私有框架,在 CoreDevice 里找到了这么几个东西:
|
1 2 3 4 |
com.apple.coredevice.feature.remote.hid.digitizer com.apple.coredevice.feature.remote.hid.button com.apple.coredevice.feature.remote.universalhidservice com.apple.dt.remote.hid |
digitizer 就是触摸屏。再用 devicectl 看一眼手机支持的能力,果然在列:
|
1 2 3 4 5 |
$ xcrun devicectl device info details --device <你的设备名> | grep HID • HID Button (com.apple.coredevice.feature.remote.hid.button) • HID Digitizer (com.apple.coredevice.feature.remote.hid.digitizer) • HID Keyboard (com.apple.coredevice.feature.remote.hid.keyboard) • HID Scroll (com.apple.coredevice.feature.remote.hid.scroll) |
com.apple.dt.remote.hid 这个名字说明它是开发者磁盘镜像(DDI)里带的服务(手机上对应的进程叫 dtuhidd),也就是说,手机得开着开发者模式、挂着 DDI 才有。
接下来是怎么调用它。CoreDevice 是私有的 Swift 框架,没有 .swiftinterface,硬调很痛苦。好在开源社区已经有人把协议抓出来了:pymobiledevice3 新版本里有一组 universal-hid-service 命令,go-ios 也有对应的实现,并且在 iOS 27 上验证过。
安装注意一下 Python 版本,我机器上默认的 3.14 编译某个依赖会挂,用 uv 建个 3.13 的环境就好了:
|
1 2 |
uv venv -p 3.13 ~/.venvs/pmd3 VIRTUAL_ENV=~/.venvs/pmd3 uv pip install pymobiledevice3 |
触摸:必须先开一路”投屏”
命令行版本很简单,坐标是把屏幕归一化到 0~65535:
|
1 |
pymobiledevice3 developer core-device universal-hid-service tap -- 30146 30015 |
我第一次跑,命令正常退出、没有任何报错,手机上——什么都没发生。😂
这是整件事里最阴的一个坑:手机端要求必须有一路媒体流(也就是 Xcode 投屏用的那路视频流)在跑,触摸才会被认。没有媒体流的时候,主机注册的触摸面会被当成”外接配件”,backboardd 直接把事件扔掉,而发送那一侧照样返回成功,完全没有提示。pymobiledevice3 的 touch_session 会帮你先开流再发触摸,但它开流以后只等 0.5 秒就开始发,在我的 iPhone 16 Pro 上不够,第一下照样被吞。
所以我干脆自己写,开流以后多等一会儿:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
import asyncio from pymobiledevice3.remote.userspace_tunnel import UserspaceRsdTunnel from pymobiledevice3.remote.core_device import hid_service as H async def main(): tun = UserspaceRsdTunnel(serial="<UDID>") rsd = await tun.aopen() async with H.touch_session(rsd) as hid: # 先开媒体流, 拿到认证 await asyncio.sleep(1.5) # 0.5 秒不够, 1.5 秒稳 x, y = 30146, 30015 # 0~65535 归一化坐标 await hid.send_touchscreen(H.TOUCHSCREEN_STATE_CONTACT, x, y) await asyncio.sleep(0.07) await hid.send_touchscreen(H.TOUCHSCREEN_STATE_RELEASE, x, y) await tun.aclose() asyncio.run(main()) |
这次手机乖乖地点进去了,鼠标和前台程序全程没动。开流大概要 1.1 秒(只在建连接时付一次),之后每次点击只要 80ms 左右。拖动也一样,就是连续发 CONTACT 报告、最后发一个 RELEASE,中间没有”按下/抬起”的区分,每个 CONTACT 的意思都是”现在手指在这儿”。
另外,媒体流是”整个会话一直开着”的,所以正确的姿势是做成一个常驻进程:连接、开流、认证都只做一次,然后等着接命令。
截屏:210ms 一张,但第 15 张会卡死
看屏幕这边,devicectl 本身就能截:
|
1 |
xcrun devicectl device capture screenshot --device <你的设备名> --destination shot.png |
但每次都要重新建连接,一张 0.6 秒,太慢。pymobiledevice3 里对应的是 ScreenCaptureService,保持一条连接连续截,就快多了:原生分辨率(16 Pro 是 1206×2622)的无损 PNG,一张 200ms 出头。
然后我就被坑了:程序跑了几分钟,发现画面一直停在同一帧,一动不动。。。查下来,不是返回了旧图,而是某一次截图请求发出去以后就再也没回来,后面的请求全被它堵着。
为了确认是谁的锅,我做了个对照:同一条截图连接连续截,开着触摸用的媒体流和不开,各跑一次:
|
1 2 |
无触控流: 14 张, 卡住于 [6.7] 秒 有触控流: 14 张, 卡住于 [6.3] 秒 |
两边都是正好第 15 张卡死。一张 PNG 平均 1.19MB,14 张差不多 16MB——这个数太眼熟了,很像是接收窗口满了以后没人去补。(底层具体是哪一层的流控我没有深挖,反正现象非常稳定。)
既然是”一条连接用到 16MB 就废”,那就别让它用到 16MB:每截 8 张就换一条新连接。新开一条只要 9ms 左右,几乎没有成本:
|
1 2 3 4 5 6 7 8 9 |
async def capture(self): if self.cap is None or self.cap_n >= 8: if self.cap is not None: await self.cap.__aexit__(None, None, None) self.cap = ScreenCaptureService(self.rsd) await self.cap.__aenter__() self.cap_n = 0 self.cap_n += 1 return await asyncio.wait_for(self.cap.capture_screenshot(), timeout=3) |
改完以后连跑一分钟:
|
1 |
60 秒 240 张无卡死, 平均 250ms/张, 开连接平均 9ms, PNG 平均 1.19MB |
注意 wait_for 那个超时也别省,万一哪次真卡了,至少能重连恢复,而不是永远挂着。
有线还是 Wi-Fi?
iOS 17 以后,这些服务都要先建一条隧道才能访问。pymobiledevice3 有好几种建法,我实测下来主要是这两种:
1. userspace:在进程里用纯 Python 的网络栈自己建一条隧道,不需要 root。缺点是它走 usbmux,只认数据线连着的设备,Wi-Fi 下会直接报 usbmux has no device matching udid。
2. native:借用 macOS 自己(remoted)已经建好的那条隧道,也不需要 root,Wi-Fi 下也能用。但官方文档里写得很清楚:设备在这条隧道上只保留一个 RSD 连接,它和 remoted 会互相把对方挤掉,开着 Xcode 的时候要小心。
那 Wi-Fi 能不能用呢?我把数据线拔了,换 native 跑了三分钟截图:
|
1 |
共 29 张, 失败 0, 平均 1133ms, 最慢 1483ms |
连是能连上,但一张截图要 1 秒多,中间还隔三差五超时重连,算下来 6 秒才拿到一张新画面,完全不能用。(触摸倒是没问题,一个触摸报告才几十字节。)瓶颈就在每张 1.2MB 的无损 PNG,Wi-Fi 扛不住。所以结论是:要实时看屏幕,老老实实插线。真要走无线的话,得改成去解那路 HEVC 视频流,那就是另一个故事了。
和老代码对接:一个常驻进程 + 一个坐标换算
我原来的程序是照着 iPhone Mirroring 窗口写的:截图是 652×1440,所有坐标、所有像素判断都基于这个尺寸。换成原生截图以后,最省事的办法不是改程序,而是在常驻进程里把原生截图换算回原来的坐标系,程序那边一行坐标都不用动。
镜像窗口里的手机画面,就是原生画面缩放加平移:
|
1 |
镜像像素 = s × 原生像素 + (ox, oy) |
参数我是这么求的:同一个界面,镜像截一张、原生截一张,在静止的区域里网格搜索 s、ox、oy,让两张图逐像素的差最小。搜出来非常规整:s = 620/1206(手机画面在镜像窗口里正好 620 像素宽),ox = 16,oy = 76(左右各留 16 像素,上面是镜像窗口自己的标题条)。点击的时候反过来算,再归一化到 0~65535 就行了。
常驻进程我用 asyncio 写,开一个 unix socket,一行一条命令,回一行结果:
|
1 2 3 4 |
ping -> ok shot <path> -> ok <耗时ms> 截图并换算成旧坐标系, 写到 path tap <x> <y> -> ok drag <x1> <y1> <x2> <y2> [steps] -> ok |
协议简单到 shell 里都能直接用,调试的时候随手就能点一下、截一张,挺方便的。
无损截图的福利:识别可以做到”一个像素都不差”
这是我没想到的收获。以前用镜像窗口的时候,画面是有损的视频流,同一个数字每次截出来都有点不一样,所以识别界面上的数字和图标只能用比较粗的特征,再堆大量样本去覆盖抖动,一直不太稳,偶尔会漏认。
换成原生截图以后,同一个数字、同一个图标,在同一个位置,每次渲染出来的像素是完全一样的。我把录下来的几百帧里,界面上固定位置的数字和图标都裁出来、二值化,然后按”完全相同”归类,结果一种字形在一个位置就只有一类,同一类里动辄几十上百帧一模一样。这样建库就很简单了:每类看一眼、标一次。
识别的时候,取笔画的外接框、左上角对齐、允许错开一个像素,然后数有多少像素不一样。我统计了一下”同一个字形之间”和”不同字形之间”的最小距离:
|
1 2 3 4 5 |
同一字形最大差异 不同字形最小差异 大号数字 30 348 小号数字 45 95 (最像的一对是 3 和 8) 大号图标 12 112 小号图标 15 57 |
两边隔得很开,门槛放中间就行。拿全部录像回放验证,两千多次识别,一次都没认错,认不出来的只有动画过渡中的几帧。(顺带一提,二值化的阈值很关键:一开始我把”灰度小于 200″都算作笔画,结果旁边元素投下来的一圈浅灰阴影也算进去了,两个很像的图标就分不开了;改成黑字必须真黑、红字必须真红,立刻就干净了。)
几个坑
1. 触摸被静默丢弃:没有媒体流、或者开流以后等得不够久,触摸报告照样”发送成功”,但手机上什么都不发生。16 Pro 上我等 1.5 秒才稳。
2. 截图连接 16MB 就卡死:同一条 ScreenCaptureService 连接截到第 15 张左右,请求就一去不回。定期换连接,外加超时重连。
3. userspace 隧道只认数据线:Wi-Fi 下只能用 native,而 native 会和 Xcode / devicectl 抢连接。
4. 收尾会把别人的投屏也关了:pymobiledevice3 的 touch_session 退出时会 stop_media_stream(stop_all=True),我测试的时候 DeviceHub 里的投屏画面就跟着停了,吓我一跳。反正用了这套就不需要 DeviceHub,把它关掉就好。
5. 手机自己会锁屏:这套方案要求手机解锁、App 在前台,记得把”自动锁定”设成”永不”,不然屏幕一黑就全白干。
回头看
现在程序跑起来的时候,电脑终于是我自己的了,想开什么窗口开什么窗口,鼠标再也不会被拽走。唯一的遗憾是手机还是不能碰,毕竟它在给程序干活呢,哈哈。
下一步想试试能不能把那路 HEVC 视频流解出来,这样拔了线、走 Wi-Fi 也能实时看屏幕,就是不知道有损画面会不会把我刚建好的字形库又打回原形。。。到时候再说吧~
发表回复