본문으로 건너뛰기
Z

dsh-device-mirror

zengxiaolou/dsh-device-mirror

Mirror an iOS Simulator or Android emulator into DSH as floating overlay windows, several at once, and tap, drag or type on the mirrored screen with the input forwarded back to the device.

설치

dsh plugin --profile web add github:zengxiaolou/dsh-device-mirror

README

Device Mirror(@local/dsh-device-mirror)

dsh-device-mirror

npm downloads license dsh plugin stars

把 iOS 模拟器 / 安卓模拟机的实时画面投进 DSH,并能直接在上面点、拖、打字。

只有一种形态:浮窗,而且可以开多个。

项说明
落位shell.overlay slot(帧级浮层),一个 slot 承载 N 个窗口
窗口每个窗口绑定一台设备,独立定位、独立拖动、独立隐藏、独立关闭
新增在窗口里的设备管理器创建,没有常驻的全局 + 按钮
依赖无

刻意不做右栏 Tab:正在操作一台设备时要的是浮在工作区之上,不是挤进和文件树、diff 共享宽度的窄栏里。

多个窗口共用一份采集:Host 的生产者按 platform:device 建、带订阅者计数,所以同一个设备开两个窗口只采集一次,两个设备开两个窗口各采一份。

窗口跟着会话走,不是全局的

项说明
归属窗口集合属于当前会话,切会话就换一套
存储localStorage 键按会话分:dsh-device-mirror:panels:<sessionId>
切走时先把当前窗口集合写回自己的键,再载入目标会话的集合
首次进入某会话自动开一个窗口;下次回到这个会话,设备与位置都还原
采集切会话会让旧窗口卸载,其流随之断开;最后一个订阅者离开 15 s 后停采集

会话 id 取自 Client 的 ctx.sessions.list 快照(current,或 retainedBy.mainView 持有者)。 服务若始终不出现,会退回到全局键 dsh-device-mirror:panels 并照常工作——这是降级,不是正常路径。

交互:标题栏常驻,按钮悬停才出

每个窗口上方有一条常驻的细标题栏(设备名 · 状态点),它同时是三样东西:

动作结果
按住标题栏拖移动窗口(整条都是把手,不是某个小点)
单击标题栏(不移动)打开该窗口的设备管理器
标题栏右侧⌨ ⟳ — ×(键盘 / 重连 / 收起成胶囊 / 关闭),常驻
导航行(标题栏下方)‹ ⌂ ▢ 返回 / 主屏 / 最近任务,常驻,不藏在键盘开关后面

拖拽把手做成"整条标题栏"是有原因的:上一版把手是悬停才出现的窄条,鼠标从画面移到条上时容易丢失,很难抓。 现在标题栏既不会遮住画面(在画面外侧),也不会消失,整条都是把手。

— 会把窗口收成一枚小胶囊(只显示设备名),点胶囊就地还原;隐藏状态随会话保存,刷新后仍是胶囊。

界面约定:控件不压在画面上

每个窗口都遵守两条硬规则,都是为了镜像能用,而不是看起来简洁:

1. 静止时只有画面。 面板本体的静止状态就是一块设备屏幕,没有常驻工具栏、指标条或坐标信息。

2. 控件永远在画面之外。 所有按钮收进一条吸附在画面顶部外侧的工具条,鼠标移上去才淡入(离开 220ms 后淡出)。 它不覆盖画面、不占据画面的点击区域,所以你在设备上点顶端/底端任何一个像素都不会落到按钮上。

每个窗口的工具条(从左到右)
设备名 ▾点击打开选择器
状态点绿=实时 / 橙=连接中 / 红=出错 / 灰=空闲
◀ ⌂ ▣BACK / HOME / APP_SWITCH
⌨切换文字输入行(第二行,仍在画面之外)
⟳ ×重连 / 关闭这个窗口

工具条是暗色 .82 不透明而不是浅色半透明:设备屏幕经常是白底,浅色玻璃会让图标全部消失。

拖动由每个窗口的常驻标题栏承担(不是画面):画面上没有任何隐形热区。位置写回 localStorage,刷新后复原。

选择器替换画面而不是叠在它上面——面板尺寸不变,底边锚定的浮窗不会被顶出视口。

唯一的例外是非 live 状态:连不上或还没选设备时,屏幕中央显示状态卡 + 重试按钮。这张卡属于画面内容,不受 hover 控制(否则打开面板会是一片空白,没有任何线索)。


效果

右栏设备镜像

示意图(按主题令牌渲染):右栏一个 设备镜像 面板,顶部选设备,中间是模拟器实时画面,工具栏可切换 点按 / 拖动 / 文字 / 截图。

生命周期:什么时候起、什么时候停

设备不是常驻进程,面板不是常驻窗口。约定如下:

时点行为
会话里还没有窗口右下角出现一枚虚线胶囊「设备镜像 +」,点它在本会话开一个窗口
窗口里加设备单击标题栏 → 设备管理器 → 选一台运行中的 / 启动一台关机的 / 新建一台模拟器
新建模拟器 · iOS选模板(124 种)+ 系统版本 → simctl create → 自动绑定并启动(实测冷启动 6.9 s)
新建模拟器 · Android选系统镜像 + 机型 → avdmanager create avd → 启动 emulator -avd → 等到 sys.boot_completed 再把 AVD 名换成 adb 序列号绑定(实测首启 50–68 s)
再开一个窗口设备管理器底部的「再开一窗」,新窗口仍属于当前会话
窗口换设备每个窗口各选各的,互不影响;同一设备开两个窗口共用一份采集
拖窗口工具条拖动,位置写回 localStorage,刷新后复原,并被夹在视口内
设备没开机状态卡直接给 「启动并连接」,一键 boot + 自动重连(靠设备状态变化触发,不用再点重连)
关窗口只销毁这个窗口;最后一个订阅者离开 15 s 后才停采集,并收掉设备上的 screenrecord
切换会话窗口集合整体换掉;旧窗口卸载、流断开,设备本身不动
关掉插件 / reloadSIGKILL 自己起的 adb / ffmpeg / simctl,不留孤儿

采集分辨率:跟面板尺寸和 DPR 走

帧是 1:1 画进面板的,所以采集宽度必须匹配"面板 CSS 宽 × 像素比":

视口高DPR面板 CSS 宽iOS 采集宽Android 采集宽
9002344900(1.3× 超采样)720(编码器上限)
9001344440440
7602281760720

三个独立的质量决定,都是量出来的:

决定依据
缩放下采样用 lanczos同尺寸下 Laplacian 锐度 920 → 989(+7.5%),体积只多 1.4%
采集 1.3× 超采样720 → 900,最终显示尺寸下锐度 989 → 1123(+13.6%);到 1000 就不再涨,故 1.3× 是拐点
质量 q10(原 q13)+1.2% 锐度、+14% 体积,比色度 4:4:4 划算(444 只多 0.1% 锐度却多 30% 体积)

Android 采集封顶 720,且输出不超过采集宽。 这里踩过一个坑:把输出提到 900 后,模拟器编码器在 --size 900x2000 下 3 秒只出 2 帧(720x1600 有 19 帧),画面直接冻住。所以 Android 采集 720、 输出也用 720(2x 屏要 688,正好 1:1),绝不放大。

宽度按 40px 步长量化,避免布局动一像素就把生产者拆了重建。

导航键:两端能力不一样,不能假装一样

键AndroidiOS 模拟器
返回 ‹input keyevent KEYCODE_BACK读无障碍树找导航栏返回按钮(label 是上一页标题,固定在左上 16,62 44x44),点它的中心;找不到才退化为左边缘右滑
主屏 ⌂KEYCODE_HOMEidb ui button HOME(硬件键,实测卡在过渡动画时也能用)
最近任务 ▢KEYCODE_APP_SWITCH不支持,按钮置灰并说明原因

实测:iOS 返回 36.1% 画面变化(确实回到上一级)、主屏 91.4%(回到桌面); APP_SWITCH 会得到 400 {ok:false, unsupported:true, error:'the iOS Simulator has no app-switcher API; use Android for that key'}。

另外两条实测过的死路,记下来免得再试:idb ui key <code> 退出码 0 但什么都不做; 左边缘返回手势在模拟器卡在过渡动画时无效(而硬件 HOME 仍然有效,能把它救回来)。

操作手感:延迟和反馈

"不流畅"通常不是帧率,而是按下去没有任何回应。两端各自的真实延迟:

平台一次点击的端到端耗时里面是什么
Android90–120 msadb shell input tap 本身 ~100 ms(几何信息已缓存,不再每次探测)
iOS270 msidb ui tap --duration 0.1,其中 ~250 ms 是 python 启动,砍不掉

超时(机器被别的程序压满时)会明确回 504 <kind> timed out — the device or this machine is too busy to answer, 并在画面上方短暂显示一条红色提示——失败不会静默。

修复前的 Android 是 190–230 ms:POST /input 每次都重新跑 adb devices -l + wm size + AVD 探测。 现在几何信息按 platform:device 缓存 60 s,任何 boot/create/shutdown 都会让它失效。iOS 也从 620 ms 降到 270 ms。

但 100–270 ms 的往返仍然可感知,所以界面上做了本地手势回显: 按下立刻画出触点,拖动时画出虚线轨迹,抬手后触点放大淡出 240 ms。 设备那帧要晚到才更新,但操作不会显得没反应。

实时拖动做不到。 adb shell input motionevent DOWN/MOVE/UP 退出码 0 却不产生任何手势 (实测截图零变化,带 touchscreen source 也一样),所以滑动仍然是抬手时整段发出。 scrcpy-server 那条路能做到实时手势,但需要额外引入一个二进制。

Android 实时手势:模拟器 gRPC

adb shell input 每次是一个一次性进程,注入完就没有了,所以拖动只能"抬手后整段重放"。 模拟器另外提供 EmulatorController/sendTouch,接受触摸流,可以让拖动跟着指针走。

实现没有引入任何依赖:gRPC 就是 HTTP/2 + 5 字节长度前缀,而这两个消息各只有三个 varint, 所以线格式和传输都是手写的(host/emulator-grpc.js,Node 内置 http2)。

环节做法
启动自己启动模拟器时挑一个空闲端口并加 -grpc <port>,启动成功后登记 serial→port
接管用户手动启动的模拟器:在 8554–8559 探测已在监听的桥,且必须只有一个设备连接(桥不带 serial,两个设备时接管会驱动错机器)
手势按下发 down、移动按 33ms 节流发 move、抬手发 up;首次被拒则该窗口永久回落到整段滑动
失败没有桥时回退到原来的 input swipe,行为与之前一致

实测:路由级 down→8×move→up,像素变化 17.7–95.8%,全部返回 realtime: true; 重载插件后自动接管已在运行的模拟器(诊断里会出现 android:grpc bridge adopted on port 8554)。

为什么 iOS 不用 idb 的 h264 流(试过,回退了)

idb video-stream --format h264 --udid <udid> 是可用的——这一点是对的,我实测 8 秒拿到 167 帧(1206×2622,≈21 fps), 开头 00 00 00 01 27 是标准 Annex-B 的 SPS。之前判"不可用"是选错了参数:--format mjpeg 确实挂 (grpclib StreamTerminatedError),simctl io recordVideo - 到 stdout 被 Apple 删除了。

但它救不了流畅度,因为帧是成批到达的。 直接测 idb video-stream 的字节到达节奏:

指标实测
6 秒内数据块65 个
间隔 > 80ms 的停顿22 次
最长停顿696ms(把模拟器窗口切到前台再测:24 次 / 783ms,不是窗口降频)

累计帧数确实高(约 15–21 fps),但它们挤在每 250–700ms 一波的簇里。而交付层是"每 minGap 只写最新一帧", 于是一簇只出一帧 → 交付 1.8–2.0 fps,比截图路线的 8.3 fps 更差。

试过 -f image2pipe + -flush_packets 1(成批发生在 idb 传输层,不在 ffmpeg 输出层,无效)。 试过把整簇都发出去,但那一簇里的帧代表过去 500ms 的不同瞬间,发出去就是"抖一下再冻住"。

结论:h264 路线的价值是"帧更新鲜",不是"更平滑"。若要用,得先解决 idb 的成批推送 (或换一条真正逐包交付的通道)。当前保留截图路线,代码已回退。

交付节流:跟着帧到达走,而不是定时采样

原来用 setInterval 每 100ms 查一次"有新帧吗"。实测生产者 15.4 fps,交付只有 5.2 fps—— 因为 iOS 的 3 个采集工人几乎同时抓屏,帧成批到达,大半 tick 落在空档里。

改成尾沿节流:每来一帧就安排在 上次写 + 最小间隔 写出,不漏到达;同时把 3 个工人的启动 错开 60ms,让帧流变匀。实测 5.2 → 7.8 fps(上限 10)。

改这个时发现一个更深的问题:Producer.publish 根本不通知 subscribers——它一直是纯引用计数, 所以挂上去的回调只在订阅那一刻被调用一次。修好后到达驱动的写入才真正成立。

Android 的天花板是 screenrecord 本身。 持续拖拽期间 screenrecord 只有 ~6 fps (720x1600、6 秒 34 帧),交付 ~3 fps。试过让 ffmpeg fps=10 重采样摊平突发,结果更差 (34 帧 → 14 帧,因为原始时间戳不规则),已放弃。

交互流畅度的取舍

决定为什么
拖动只写 transform,不走 React state早前每次 pointermove 都 setState,整棵树(含 blob 图)每像素重渲染一次,拖起来发黏
采集宽按需计算(面板 CSS 宽 × DPR,步长 80,封顶 900)原来写死 380,而 2x 屏上 344 CSS 宽的面板要 688 设备像素——放大 1.8 倍必然糊;现在默认取 720,1:1 显示
窗口宽度按视口高度收缩20:9 手机在 344px 宽时约 760px 高,短窗口会把底部裁掉;现在宽度让步、比例不变
设备管理器内容自适应高度早前被撑成手机比例,两行设备留下巨大空白、底部按钮贴在最边缘像被裁了

iOS 的帧率有天花板。 它是 simctl io screenshot 轮询,不是视频流;单次抓屏 ~150–250 ms, 4 路并发在空闲机器上曾测到 13–17 fps,在 load average 129 的机器上只有 6 fps(8 路并发反而更差,5.4 fps,已饱和)。 提高帧率只能换技术路线(ScreenCaptureKit 抓 Simulator 窗口),不是调参能解决的。

刻意不做的事:

  • 不自动关你的模拟器。 收起面板只停采集,设备保持原状——关掉别人的机器是有损操作,不属于插件的权力。
  • 不自动开安卓模拟器。 adb 没有一步启动接口;emulator -avd 要自己管 AVD 名与进程生命周期,放进来会让插件承担一个它管不好的职责。安卓设备需在外部启动。
  • 不在全局按钮上做设备管理。 设备属于某个会话、某个窗口,把它挂在全局浮层上就分不清"这是我哪个会话的机器"。入口只在窗口里。
  • 不常驻采集。 面板收起后 15 s 内没再打开就停,不做后台常开。

为什么是 MJPEG

两个平台的采集原语不对称,取二者的最小公倍数只能是 JPEG 帧:

平台采集方式实测(跑出来的)
Androidadb exec-out screenrecord --output-format=h264 → ffmpeg 转 JPEG采集 19.4 fps;经插件按 10 fps 节流后交付 8.9 fps,首帧 67 ms(靠快照预热)
Androidadb exec-out screencap -p 轮询(不采用)3.7 fps(4 路并发)
iOS4 路并发 simctl io screenshot --type=jpeg → ffmpeg 缩放热态 15.5 fps;冷启首帧约 2.1 s
iOSsimctl io recordVideo不支持写 stdout,不可直播
iOSidb video-stream本机 Xcode 26.6 下输出 0 字节,已坏

帧产率由服务端定时器决定(fps 参数),生产者只负责"覆盖最新一帧"。慢客户端不会堆陈旧帧——这是"跟手"而不是"延迟 5 秒"的关键。

静止画面不出帧,以及为什么需要预热

screenrecord 与 recordVideo 都是变化驱动:画面静止时一帧都不出。这对镜像是好事(不浪费),但意味着刚打开时没有首帧。

所以 Android 生产者启动时先做一次 screencap -p + 一次性 ffmpeg 缩放,把这张快照当作首帧发出去;之后屏幕一变,h264 立刻接管。这就是上表里 67 ms 首帧的来源。

画面静止时表现就是画面停住——正确行为,不是卡死。判断"还活着"请看面板的帧计数。


Agent 工具:默认的调用方式

这个插件以前只有界面。现在它同时是 Agent 操作模拟器的默认入口——有工具,才谈得上"优先用它"。

工具作用
device_mirror_devices列出 iOS 模拟器 / 安卓设备与 AVD,含状态与原生分辨率
device_mirror_boot启动模拟器或 AVD(幂等;安卓以 detached 启动,插件 reload 不会杀掉它)
device_mirror_tap点按。可给归一化 x/y,也可以给文字标签,先定位再点
device_mirror_swipe两点间滑动(归一化)
device_mirror_text向聚焦控件输入文字
device_mirror_keyBACK / HOME / APP_SWITCH(iOS 的 APP_SWITCH 返回 unsupported)
device_mirror_find按无障碍文字定位元素,返回像素与归一化中心
device_mirror_snapshot存一张整屏截图并返回路径,供 read_image 查看
device_mirror_describe整屏无障碍树:文字、标签、类名、可点击、像素与归一化中心
device_mirror_appinstall / launch / terminate / open_url
device_mirror_logsiOS simctl spawn … log show,安卓 logcat -d,可按子串过滤
device_mirror_avd安卓 AVD:list / start / stop / create

坐标一律归一化 0..1,和浏览器里那套完全一致——Agent 不需要知道对面是点还是像素。

工具描述里显式写了 "prefer this over raw adb/simctl",所以模型在选择时会被引导到这里。 真正约束力的做法是在 ~/.agents/rules 里加一条规则并重跑 sync-rules.sh;那是全局配置改动,需要你点头。

与模拟器 helper 插件的关系

本机另装了两个 helper:

插件它擅长的
@zhz8888/dsh-ios-simulator-helperXcode 工程发现/脚手架、xcodebuild 构建、装包、启动、日志、无障碍定位
@zhz8888/dsh-android-emulator-helperGradle 工程发现/脚手架、构建、装包、启动、日志、无障碍定位

分工建议(不要合并成一个大插件):

层归属
看与摸:实时镜像、按会话分窗、点/滑/输入、创建与启动设备、截图本插件
造与跑:工程脚手架、编译、装包、启动 App、拉日志helper 插件

已移植(12 个工具):设备列举与启动、点/滑/输入/按键、无障碍定位与整屏 dump、 单帧截图、应用生命周期(装/启动/终止/开 URL)、日志、安卓 AVD 的 list/start/stop/create。 命令都取自 helper 插件的真实调用,两端走同一套参数。

仍未移植:xcodebuild / gradle 构建、工程发现、App 脚手架。 理由不是偷懒——构建的输出是产物与失败日志,一次编译可能阻塞几分钟,和"看着设备操作"完全不是同一类失败模式; 混在一起会让一次构建失败拖垮镜像会话。这两个能力继续由 helper 插件提供,分工不变。

接口

前缀 /api/device-mirror,全部同源校验(跨源 403)。

方法路径说明
GET/devices列出 adb 在线设备与全部 CoreSimulator 设备(含 Booted 标记、原生分辨率)
GET/stream?platform=&device=&fps=&width=&quality=multipart/x-mixed-replace JPEG 流
POST/input{platform, device, kind: tap|swipe|text|key, x, y, x2, y2, duration, text, key}
POST/boot{platform:'ios', device:<udid>} 启动模拟器,轮询到 Booted 后返回;已启动时立即返回 bootedInMs:0
POST/shutdown同上,关闭模拟器
GET/catalog可用的设备模板(124 种)与已安装的系统版本,供新建模拟器用
POST/create{deviceTypeIdentifier, runtimeIdentifier, name?} → simctl create,返回新 udid(不 boot)
GET/android/catalog已安装的系统镜像与可选机型(来自 SDK 目录 + avdmanager list device)
POST/android/create{imageId, deviceId?, name?} → avdmanager create avd,返回 AVD 名(不启动)
POST/android/boot{name} → detached 启动 emulator -avd,等到系统就绪后返回 adb 序列号
GET/state生产者、订阅者、帧计数、最近错误、客户端启动信标
GET/report客户端两半的自报信标(诊断用)

坐标一律是归一化 0..1:客户端不需要知道对面是点还是像素,Host 在 injectAndroid / injectIos 里换算一次。

  • Android:adb shell input tap|swipe|text|keyevent,坐标 = 设备像素(1080×2400 → (0.5,0.5) = 540,1200)
  • iOS:idb ui tap|swipe|text|key,坐标 = 逻辑点(1206×2622 px ÷ 3 → 402×874 pt,(0.5,0.5) = 201,437)

⚠️ iOS tap 必须带 --duration

idb ui tap 的默认按压时长太短,UISwitch 会静默丢弃这次点击: 同一坐标连点 10 次只翻转 1 次;加 --duration 0.05~0.2 后 15/15 全中。 两种情况的 exit code 都是 0,所以不报错、不退码、UI 不动——最难查的那类失败。

本插件对所有 iOS tap / key 统一加 --duration 0.1,代价约 +100 ms。 idb ui describe-all 也验证可用(353–424 ms,返回逻辑点坐标),但本插件用的是更便宜的 idb describe --json 取 width_points / height_points。


安装

# 在 DSH 里:
plugin_manager  action=install_bundle  target=/绝对路径/dsh-device-mirror

装完刷新页面:右下角出现 + 按钮,并自动开好第一个镜像窗口。

使用

  1. 会话里还没有窗口时,点右下角虚线胶囊「设备镜像 +」开第一个
  2. 单击窗口标题栏 → 设备管理器:选运行中的设备、启动关机的模拟器,或新建模拟器
  3. 新建时选模板与系统版本 → 「创建并启动」,新机自动绑定到当前窗口并出流
  4. 管理器底部「再开一窗」可以在本会话再开一个窗口,各自绑不同设备
  5. 画面出来后:点一下 = tap,按住拖 = swipe,⌨ 切出文字输入行与三个导航键

拖动某个窗口的标题栏可移动它;窗口集合、位置与隐藏状态按会话存在 localStorage(键 dsh-device-mirror:panels:<sessionId>),换会话就是另一套窗口。


已知边界

  • iOS 触摸依赖 idb。idb 在这台机器上部分子命令已失效(video-stream 确认坏);ui tap 的通断见 test 与运行时 /state 的 lastInput。失败时面板底部会红字显示原始错误,不静默。
  • iOS 输入坐标系是逻辑点,由 idb describe 或"截图尺寸 ÷3"推断;推断值会以 assumedScale 出现在 /devices,猜错时点按会偏移。
  • 静态画面不出帧(见上)。想看"是否还活着"请看面板的帧计数和状态点。
  • 采集按 platform:device 引用计数:所有窗口离开同一设备 15 s 后才停采集;停止时会连同设备上的 screenrecord 一起收掉。
  • 插件卸载 / reload 时会 SIGKILL 掉自己起的 adb / ffmpeg / simctl 子进程,并对安卓端补一刀 pkill -INT screenrecord(杀掉本地 adb 不会让设备上的录制退出)。
  • iOS 与 Android 的创建是两套工具链。 iOS 用 simctl create(秒级);Android 用 avdmanager create avd + emulator -avd,首次启动实测 50–68 秒,且必须等到 sys.boot_completed 才能出流——只等 adb 看见设备是不够的(实测此时 wm size 会报 Can't find service: window)。
  • AVD 名不是设备 id。 停止的 AVD 只能按名字寻址,而推流/点击要的是 adb 序列号;/android/boot 负责这个换算并返回序列号,窗口绑的是序列号。
  • 启动的模拟器不归插件管。 emulator 以 detached 方式拉起,插件 reload 不会杀掉它——那是用户的机器,不是采集子进程。
  • /boot 已实测:真实冷启动 1514 ms,二次调用幂等返回 bootedInMs:0。
  • 未实测:simctl boot 在"已启动"时的报错文案(只按 current state: <State> 片段容错,文案变了会退化成 502);60 s 超时 → 409 的分支需真卡住才能触发;Shutting Down 中间态下 shutdown 可能返回 502。

已知的实现细节与边界

这一节记录审计时确认过的东西,免得以后重新推一遍。

资源

项现状
生产者重启handleStream 在等首帧之前就启动生产者。若首帧超时(504)或浏览器提前断开,没人会订阅,所以两个失败出口都会 armIdleStop()——否则 3 个 simctl 会一直跑。刻意不在启动时就计时:首帧预算 20 s 比空闲宽限 15 s 长,早计会误杀"只是慢"的生产者
首帧等待中断等待结束后先看 res.destroyed,已断开就直接返回,不再往死 socket 写
Blob URL每帧新建一个,上一帧延迟 1.2 s 回收;关闭窗口时最后一张也显式回收(之前会泄漏一张解码后的图)
子进程面板卸载 / 插件 reload 时 SIGKILL 自己的 adb/ffmpeg/simctl,安卓端另补一刀 pkill -INT screenrecord

失败可见性

场景表现
流断了但画面还在画面会冻结在最后一帧,所以状态栏下方出一条红条「连接中断:<原因>」+ 重试
输入失败 / 超时画面上方短暂红条,超时回 504 并写明"设备或这台机器太忙"
平台不支持某键400 {unsupported:true},按钮本身在 iOS 上就置灰
/state生产者、订阅者、帧数、重启次数、最近错误、客户端信标

明确不做 / 评估过但不值得

项结论
客户端按帧拆子组件减少重渲染当前 7–8 fps × 少数窗口,实测重渲染成本在毫秒内;拆分会把流状态机撕成两半,收益不抵复杂度
打开流时复用几何缓存热态打开已 0.22 s,其中探测约占 0.1 s,不值得引入缓存失效问题
Android 帧率screenrecord 在本机持续拖拽时约 6 fps(720x1600、6 秒 34 帧),是编码器上限;fps 重采样反而更差(34 → 14 帧)
iOS 实时拖动adb input motionevent 与 iOS 均无实时手势通道,滑动只能抬手整段发

代码结构

Host 半从单文件拆成了入口 + 两个模块。两个模块都通过依赖注入拿到 Host 的助手, 不反向 import 入口文件——没有循环依赖,也没有「声明在使用之后」的 TDZ 风险。

文件行数职责
index.js现数apply、路由表、模拟器生命周期与 AVD 创建、/state
host/devices.js现数设备发现、几何换算、输入注入(含 iOS 返回定位)
host/producers.js现数Producer 基类、iOS/Android 采集、ffmpeg 管道、空闲停止
host/tools.js现数12 个 Agent 工具 + 移植来的设备操作
host/emulator-grpc.js现数模拟器 gRPC 触摸桥(手写 protobuf + 内置 http2)
client.js现数浮窗、管理器、手势回显、实时触摸

每个模块都通过依赖注入拿到 Host 的助手,不反向 import 入口文件——没有循环依赖, 也没有"声明在使用之后"的 TDZ 风险。两个工厂的调用顺序要紧:createDevices 必须在 createProducers 之前,因为后者要拿 simulatorState。

拆这个顺序踩了两次坑,都是同一类:微信手动列举依赖会漏。

  1. 第一次漏 MIN_FPS 等 7 个采集选项常量 → 流请求 500;
  2. 补完后漏 LatestFrame / encodeSingleFrame / simctlFirstError / simulatorState → 还是 500。

两次都是运行时报错(没有静默),但都逃过了单测——因为单测只覆盖 JSON 路由,没有覆盖 /stream。 现在有一条静态检查守在测试里:扫描 host/*.js 的每个标识符,凡是入口文件声明过、 却没在本文件声明/注入/导入的,直接失败。这条检查当场抓出了那 4 个符号。

开发

npm test   # host 25 项(JPEG 帧重整、尺寸解析、路由面、跨源拒绝、boot/shutdown、iOS 与 Android 创建校验)
           # client 31 项(模块契约、multipart、只注册浮窗、多窗口增删拖隐藏、按会话隔离、AVD 名→序列号、导航键常驻、手势回显、控件不进画面)

test 里用真实 http.Server 调 webServer.register 交回来的 handler,不需要起 DSH。

관련 플러그인