Device Mirror:把 iOS 模拟器 / 安卓模拟机画面投进 DSH 浮窗

前言

在 DSH 里调试智能体时,最常见的需求是「直接在模拟器上点一点,看看响应」。常见的做法是开一个独立的模拟器窗口,再在 DSH 里开一个浏览器 tab,来回切。

这种来回切换不仅打断思路,而且模拟器窗口和 DSH 面板之间没有统一的坐标系,拖动、点击操作很难复用。Device Mirror(@local/dsh-device-mirror)提供的是另一种形态:把模拟器的实时画面直接铺进 DSH,并能在画面上直接点、拖、打字。

它是什么

Device Mirror 是一个 DSH 插件,负责把 iOS 模拟器或 Android 模拟器的实时画面以浮窗形式投进 DSH。浮窗位于 shell.overlay slot,一个 slot 承载多个窗口,每个窗口绑定一台设备。

插件维护者为 zengxiaolou。

核心功能

1. 浮窗形态与设备管理

  • 落位:浮窗使用 shell.overlay slot,每个 slot 承载 N 个窗口。
  • 窗口绑定:每个窗口绑定一台设备,独立定位、独立拖动、独立隐藏、独立关闭。
  • 新增设备:在窗口里的设备管理器创建,没有常驻的全局 + 按钮。
  • 依赖:无额外依赖。

2. 会话归属与存储

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

3. 交互设计

标题栏常驻

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

  • 拖动:按住标题栏拖,整条都是把手。
  • 打开管理器:单击标题栏(不移动)。
  • 右侧按钮:⌨ ⟳ — ×(键盘 / 重连 / 收起成胶囊 / 关闭),常驻。
  • 导航行:‹ ⌂ ▢(返回 / 主屏 / 最近任务),常驻,不藏在键盘开关后面。

工具条吸附画面外侧

每个窗口遵守两条硬规则:

  1. 静止时只有画面:面板本体的静止状态就是一块设备屏幕,没有常驻工具栏、指标条或坐标信息。
  2. 控件永远在画面之外:所有按钮收进一条吸附在画面顶部外侧的工具条,鼠标移上去才淡入(离开 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
羽毛球分组比赛记分
小程序二维码

欢迎使用《羽毛球分组比赛记分》微信小程序

Xiaoye