前言¶
在 DSH 里调试智能体时,最常见的需求是「直接在模拟器上点一点,看看响应」。常见的做法是开一个独立的模拟器窗口,再在 DSH 里开一个浏览器 tab,来回切。
这种来回切换不仅打断思路,而且模拟器窗口和 DSH 面板之间没有统一的坐标系,拖动、点击操作很难复用。Device Mirror(@local/dsh-device-mirror)提供的是另一种形态:把模拟器的实时画面直接铺进 DSH,并能在画面上直接点、拖、打字。
它是什么¶
Device Mirror 是一个 DSH 插件,负责把 iOS 模拟器或 Android 模拟器的实时画面以浮窗形式投进 DSH。浮窗位于 shell.overlay slot,一个 slot 承载多个窗口,每个窗口绑定一台设备。
插件维护者为 zengxiaolou。
核心功能¶
1. 浮窗形态与设备管理¶
- 落位:浮窗使用
shell.overlayslot,每个 slot 承载 N 个窗口。 - 窗口绑定:每个窗口绑定一台设备,独立定位、独立拖动、独立隐藏、独立关闭。
- 新增设备:在窗口里的设备管理器创建,没有常驻的全局
+按钮。 - 依赖:无额外依赖。
2. 会话归属与存储¶
- 窗口集合:属于当前会话,切会话就换一套。
- 存储:
localStorage键按会话分:dsh-device-mirror:panels:<sessionId>。 - 切走时:先把当前窗口集合写回自己的键,再载入目标会话的集合。
- 首次进入:自动开一个窗口;下次回到这个会话,设备与位置都还原。
- 采集:切会话会让旧窗口卸载,其流随之断开;最后一个订阅者离开 15 s 后停采集。
3. 交互设计¶
标题栏常驻¶
每个窗口上方有一条常驻的细标题栏(设备名 · 状态点),同时是三样东西:
- 拖动:按住标题栏拖,整条都是把手。
- 打开管理器:单击标题栏(不移动)。
- 右侧按钮:
⌨ ⟳ — ×(键盘 / 重连 / 收起成胶囊 / 关闭),常驻。 - 导航行:
‹ ⌂ ▢(返回 / 主屏 / 最近任务),常驻,不藏在键盘开关后面。
工具条吸附画面外侧¶
每个窗口遵守两条硬规则:
- 静止时只有画面:面板本体的静止状态就是一块设备屏幕,没有常驻工具栏、指标条或坐标信息。
- 控件永远在画面之外:所有按钮收进一条吸附在画面顶部外侧的工具条,鼠标移上去才淡入(离开 220 ms 后淡出)。
工具条从左到右依次为:
- 设备名 ▾:点击打开选择器
- 状态点:绿=实时 / 橙=连接中 / 红=出错 / 灰=空闲
◀ ⌂ ▣:BACK / HOME / APP_SWITCH⌨:切换文字输入行⟳×:重连 / 关闭这个窗口
工具条是暗色 .82 不透明,避免在白底屏幕上消失。
4. 生命周期与操作流程¶
什么时候起、什么时候停¶
- 会话里还没有窗口:右下角出现一枚虚线胶囊「设备镜像 +」,点它在本会话开一个窗口。
- 窗口里加设备:单击标题栏 → 设备管理器 → 选一台运行中的 / 启动一台关机的 / 新建一台模拟器。
- 新建模拟器 · iOS:选模板(124 种)+ 系统版本 →
simctl create→ 自动绑定并启动。 - 新建模拟器 · Android:选系统镜像 + 机型 →
avdmanager create avd→ 启动emulator -avd→ 等到sys.boot_completed再把 AVD 名换成 adb 序列号绑定。 - 再开一个窗口:设备管理器底部的「再开一窗」,新窗口仍属于当前会话。
- 窗口换设备:每个窗口各选各的,互不影响;同一设备开两个窗口共用一份采集。
- 拖窗口:工具条拖动,位置写回
localStorage,刷新后复原,并被夹在视口内。 - 设备没开机:状态卡直接给「启动并连接」,一键 boot + 自动重连。
- 关窗口:只销毁这个窗口;最后一个订阅者离开 15 s 后才停采集,并收掉设备上的
screenrecord。 - 切换会话:窗口集合整体换掉;旧窗口卸载、流断开,设备本身不动。
- 关掉插件 / reload:
SIGKILL自己起的adb/ffmpeg/simctl,不留孤儿。
采集分辨率¶
帧是 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,采集 1.3× 超采样,质量 q10。
Android 采集封顶 720,且输出不超过采集宽。
宽度按 40px 步长量化。
5. 导航键¶
两端能力不一样,不能假装一样:
| 键 | Android | iOS 模拟器 |
|---|---|---|
返回 ‹ |
input keyevent KEYCODE_BACK |
读无障碍树找导航栏返回按钮(label 是上一页标题),点它的中心;找不到才退化为左边缘右滑 |
主屏 ⌂ |
KEYCODE_HOME |
idb ui button HOME |
最近任务 ▢ |
KEYCODE_APP_SWITCH |
不支持,按钮置灰并说明原因 |
6. 操作手感与延迟¶
一次点击的端到端耗时:
- Android:90–120 ms,其中
adb shell input tap本身 ~100 ms。 - iOS:270 ms,其中 ~250 ms 是 python 启动。
超时会明确回 504 <kind> timed out — the device or this machine is too busy to answer,并在画面上方短暂显示红色提示。
拖动做不到实时:adb shell input motionevent DOWN/MOVE/UP 退出码 0 却不产生任何手势,所以滑动仍然是抬手时整段发出。
Android 实时手势:模拟器 gRPC 提供 EmulatorController/sendTouch,接受触摸流,可以让拖动跟着指针走。实现没有引入任何依赖,是手写的。
7. 为什么 iOS 不用 idb 的 h264 流¶
idb video-stream --format h264 可以用,但帧是成批到达的,交付层是「每 minGap 只写最新一帧」,所以一簇只出一帧 → 交付 1.8–2.0 fps,比截图路线的 8.3 fps 更差。
8. 交付节流¶
原来用 setInterval 每 100 ms 查一次「有新帧吗」,实测生产者 15.4 fps,交付只有 5.2 fps。
改成尾沿节流:每来一帧就安排在 上次写 + 最小间隔 写出,不漏到达;同时把 3 个工人的启动错开 60 ms,让帧流变匀。实测 5.2 → 7.8 fps(上限 10)。
改这个时发现一个更深的问题:Producer.publish 根本不通知 subscribers,修好后到达驱动的写入才真正成立。
Android 的天花板是 screenrecord 本身:持续拖拽期间 screenrecord 只有 ~6 fps(720x1600、6 秒 34 帧),交付 ~3 fps。
9. 交互流畅度的取舍¶
拖动只写 transform,不走 React state,避免整棵树重新渲染。
安装与启用¶
从 npm 安装:
npm install dsh-device-mirror
在 DSH 的插件目录中运行:
dsh plugin add dsh-device-mirror
启用插件后,在 DSH 会话中右下角会出现「设备镜像 +」胶囊,点击即可在当前会话中打开浮窗。
适用场景与注意¶
适合需要在 DSH 里直接操作模拟器的场景:调试智能体 UI、测试手势、验证输入反馈等。
插件以当前 dsh 进程权限运行,安装前应检查源码与许可证。
短结尾¶
Device Mirror 提供了一种更连贯的调试方式:模拟器画面直接在 DSH 浮窗中显示,点击、拖动、打字都在同一空间完成。它不占用额外的屏幕空间,也不需要切换窗口,适合需要在 DSH 里频繁操作模拟器的开发者。
- 目录页:https://www.skillhub.cn/plugins/zengxiaolou/dsh-device-mirror
- GitHub:https://github.com/zengxiaolou/dsh-device-mirror