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

把 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 |
| 切换会话 | 窗口集合整体换掉;旧窗口卸载、流断开,设备本身不动 |
| 关掉插件 / reload | SIGKILL 自己起的 adb / ffmpeg / simctl,不留孤儿 |
采集分辨率:跟面板尺寸和 DPR 走
帧是 1:1 画进面板的,所以采集宽度必须匹配"面板 CSS 宽 × 像素比":
| 视口高 | DPR | 面板 CSS 宽 | iOS 采集宽 | Android 采集宽 |
|---|---|---|---|---|
| 900 | 2 | 344 | 900(1.3× 超采样) | 720(编码器上限) |
| 900 | 1 | 344 | 440 | 440 |
| 760 | 2 | 281 | 760 | 720 |
三个独立的质量决定,都是量出来的:
| 决定 | 依据 |
|---|---|
| 缩放下采样用 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 步长量化,避免布局动一像素就把生产者拆了重建。
导航键:两端能力不一样,不能假装一样
| 键 | Android | iOS 模拟器 |
|---|---|---|
返回 ‹ |
input keyevent KEYCODE_BACK |
读无障碍树找导航栏返回按钮(label 是上一页标题,固定在左上 16,62 44x44),点它的中心;找不到才退化为左边缘右滑 |
主屏 ⌂ |
KEYCODE_HOME |
idb 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 仍然有效,能把它救回来)。
操作手感:延迟和反馈
"不流畅"通常不是帧率,而是按下去没有任何回应。两端各自的真实延迟:
| 平台 | 一次点击的端到端耗时 | 里面是什么 |
|---|---|---|
| Android | 90–120 ms | adb shell input tap 本身 ~100 ms(几何信息已缓存,不再每次探测) |
| iOS | 270 ms | idb 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 帧:
| 平台 | 采集方式 | 实测(跑出来的) |
|---|---|---|
| Android | adb exec-out screenrecord --output-format=h264 → ffmpeg 转 JPEG |
采集 19.4 fps;经插件按 10 fps 节流后交付 8.9 fps,首帧 67 ms(靠快照预热) |
| Android | adb exec-out screencap -p 轮询(不采用) |
3.7 fps(4 路并发) |
| iOS | 4 路并发 simctl io screenshot --type=jpeg → ffmpeg 缩放 |
热态 15.5 fps;冷启首帧约 2.1 s |
| iOS | simctl io recordVideo |
不支持写 stdout,不可直播 |
| iOS | idb 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_key |
BACK / HOME / APP_SWITCH(iOS 的 APP_SWITCH 返回 unsupported) |
device_mirror_find |
按无障碍文字定位元素,返回像素与归一化中心 |
device_mirror_snapshot |
存一张整屏截图并返回路径,供 read_image 查看 |
device_mirror_describe |
整屏无障碍树:文字、标签、类名、可点击、像素与归一化中心 |
device_mirror_app |
install / launch / terminate / open_url |
device_mirror_logs |
iOS 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-helper |
Xcode 工程发现/脚手架、xcodebuild 构建、装包、启动、日志、无障碍定位 |
@zhz8888/dsh-android-emulator-helper |
Gradle 工程发现/脚手架、构建、装包、启动、日志、无障碍定位 |
分工建议(不要合并成一个大插件):
| 层 | 归属 |
|---|---|
| 看与摸:实时镜像、按会话分窗、点/滑/输入、创建与启动设备、截图 | 本插件 |
| 造与跑:工程脚手架、编译、装包、启动 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
装完刷新页面:右下角出现 + 按钮,并自动开好第一个镜像窗口。
使用
- 会话里还没有窗口时,点右下角虚线胶囊「设备镜像 +」开第一个
- 单击窗口标题栏 → 设备管理器:选运行中的设备、启动关机的模拟器,或新建模拟器
- 新建时选模板与系统版本 → 「创建并启动」,新机自动绑定到当前窗口并出流
- 管理器底部「再开一窗」可以在本会话再开一个窗口,各自绑不同设备
- 画面出来后:点一下 = 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。
拆这个顺序踩了两次坑,都是同一类:微信手动列举依赖会漏。
- 第一次漏
MIN_FPS等 7 个采集选项常量 → 流请求 500; - 补完后漏
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。
No comments yet. Be the first to write one.