Device Mirror(@local/dsh-device-mirror)
把 iOS 模拟器 / 安卓模拟机的实时画面投进 DSH,并能直接在上面点、拖、打字。
只有一种形态:浮窗,而且可以开多个。
项 · 说明 落位 · shell.overlay slot(帧级浮层),一个 slot 承载 N 个窗口 窗口 · 每个窗口绑定一台设备,独立定位、独立拖动、独立隐藏、独立关闭 新增 · 在窗口里的设备管理器创建,没有常驻的全局 + 按钮 依赖 · 无
刻意不做右栏 Tab:正在操作一台设备时要的是浮在工作区之上,不是挤进和文件树、diff 共享宽度的窄栏里。
多个窗口共用一份采集:Host 的生产者按 platform:device 建、带订阅者计数,所以同一个设备开两个窗口只采集一次,两个设备开两个窗口各采一份。
窗口跟着会话走,不是全局的
项 · 说明 归属 · 窗口集合属于当前会话,切会话就换一套 存储 · localStorage 键按会话分:dsh-device-mirror:panels: 切走时 · 先把当前窗口集合写回自己的键,再载入目标会话的集合 首次进入某会话 · 自动开一个窗口;下次回到这个会话,设备与位置都还原 采集 · 切会话会让旧窗口卸载,其流随之断开;最后一个订阅者离开 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 退出码 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 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 ,启动成功后登记 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 是可用的——这一点是对的,我实测 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 不需要知道对面是点还是像素。