dsh-mobile-use · 把真手机装进 DSH
右栏就是你手机的画面:点一下是设备上点一下,圈一处再写一句话,带圈图和圈内控件一起回到会话。
模型不用截图也知道屏上有哪些字、哪个按钮能点、刚才点了之后变了什么。装一次,DSH 里所有会话都能用——不需要 MCP 行、不需要额外 agent、不上传任何画面。
git clone https://github.com/regrevia/dsh-mobile-use.git dsh-mobile-use
# 在 DSH 里:plugin_manager action=install_bundle target=$HOME/dsh-mobile-use
环境变量(都可选):ADB(adb 路径)、ANDROID_SERIAL(默认设备)、FFMPEG(转码路径)。见 .env.example。
宿主需要 adb(Android platform-tools);镜像还要 ffmpeg。设备端辅助程序(uiautomator2)随包附带,首次使用自动推送。
三个真正不同的地方
① 实时镜像,不是截图循环。 侧栏「实时ADB」走 screenrecord H.264 → 宿主 ffmpeg 抽帧转 fMP4 → MSE 播放:首帧 1380 ms,画面一变到上屏 +163 ms,静止时约 1 帧/秒(内容驱动),最后一个观众走后留 20 秒,重开实测 2440 ms → 18 ms。点按按真实媒体矩形归一化,缩放、平移、横竖屏同一套算法。
② 圈选标注,把「这里」说清楚。 在画面上圈一块,连圈图 + 圈内控件 + 前 12 秒操作时间线 + 最近 6 秒 6 帧拼图一起推回当前会话。拼图取自内存最近 40 帧,265 ms 建好、112 KB,不碰设备、不 seek 还在写的录屏文件。
③ 读界面,不截图。 设备端常驻 uiautomator2:adb_ui 一次 120 ms 返回「文字 + 资源 id + 中心坐标 + ref」,压成 200–1300 token 纯文本;同样一次确认,screencap -p 要 0.5–3.2 s、0.3–8.9 MB,再花一次带图推理。自绘/Flutter 文本树为空时自动补一层本机 Vision OCR 兜底。不想走视觉模型时还有两条免视觉通道:adb_look 把屏幕压成颜色网格并给出区域差异(热 32–41 ms),adb_ocr 直接认右栏直播帧(热 110–116 ms)。
作品牌差异一句话:同类插件多在让 DSH 的网页适配手机或让手机连回电脑,这个方向相反——把手机装进 DSH,让 AI 直接操作真机。
同一块地盘上还有谁
手机方向在 DSH 生态里已经有人做,各自解决的其实不是同一件事(摘自插件市场当前介绍):
- 从手机连回电脑:
liguobao/ds-harness-remote、wenbin-wb/dsh-bridge —— 把正在跑的会话推到手机浏览器里继续,手机是显示器。
- iOS 设备上的操作:
ZSeven-W/dsh-ios —— 模拟器或 USB 连接的 iPhone,按无障碍标识或 OCR 操作,带可点的侧栏面板。
这个插件是第三条:Android 真机、画面进右栏、标注带回去。差别不在「工具数量」,而在模型看到的就是设备本身——文本树 120 ms 给出文字与坐标,画面一变 163 ms 上屏,圈一处就把「这里」变成一条带图的消息。
为什么
原做法是 adb exec-out screencap 出 PNG,再 read_image 让模型看图。一次「确认页面对不对」要花:一次截图进程 + 一张几百 KB~9 MB 的图 + 一次带图的模型步。
实测(小米平板 8,3200×2136,Android 15,2026-10)对比同一动作的两种取法:
| 取法 | 耗时 | 返回体积 | 模型侧成本 |
|---|
adb -s X exec-out screencap -p | 0.5 s(纯色页)/ 3.2 s(相机页) | 0.3–8.9 MB PNG | 1 张图 ≈ 1.5–2 k token + 一次带图推理 |
adb -s X exec-out uiautomator dump /dev/tty | 0.33 s(静态页)/ 2.2 s(持续动画页) | 8 KB XML | 压成 460–2872 字符 ≈ 200–1300 token 纯文本 |
uiautomator dump --compressed | 2.1–2.4 s | 5.6 KB | 不要用,慢 6 倍换 30% 体积 |
dumpsys activity 取前台 Activity | 0.07–0.09 s | 一行 | 判断「有没有进目标页」用它 |
logcat -d -t 200 | 0.11–0.14 s | 数 KB | 看报错用它 |
耗时不是全部:截图那条路每次还要一次 read_image 往返,文本树直接把「文字 + 资源 id + 中心坐标」写进上下文,点击不再需要算坐标。
工具
| 工具 | 作用 |
|---|
adb_ui | 读界面文本树:ref、控件类型(语义角色:Text / Btn / Img / Input / Switch / List …)、文字、#资源id、@中心坐标,尾部 * 可点击 / ~ 可滚动 / [x] 勾选 / [off] 不可用。支持 filter、full、depth(只列到第 N 层)、max_nodes(默认 400,打满会明说「已截断」并给收窄办法)、wait_for_text / wait_for_id / wait_for_activity / wait_activity_gone(等离开启动页/登录页)/ wait_until_gone(替代 sleep + 反复截图);within_ref / within_rect 只看一块(列表里某一行的内部结构、抽屉里的按钮),框外控件不占行、深度按框内重算;等待也能用 wait_for_xpath / wait_xpath_gone |
adb_wait | 只等条件、不返回整棵树,省 token(回执带「N 次轮询」看等待花了多少 dump)(within_ref / within_rect 只看框里的控件,等某一行里的按钮):text / id / desc / gone / texts(全出现)/ any_texts(任一出现)/ activity / activity_gone(等离开启动页/登录页)/ stable(界面静止)。超时会说清停在哪个页面、能点什么;until 等同一个控件进入某状态(enabled / clickable / checked / focused)才返回,灰按钮不会误判成等到;dismiss: true 被权限/升级/评分弹窗挡住时自动点掉确认按钮(最多 2 次)再接着等,回执列 dismissed;没等到会自动留一张截图(shot.path,只失败才抓);也能等 xpath 命中 / xpath_gone 消失;列表刷到 ≥N 项用 count_min(配 text/id/desc/xpath) |
adb_tap | 按 ref / text / id / desc / point="x,y" 点击(within_ref 限定在某一行/容器里找,点行内按钮不用先算坐标),long: true 长按;可带 wait_for_text / wait_for_id / wait_until_gone / wait_for_activity / wait_activity_gone 点完直接等结果,或 wait_stable 等界面静止再回执「变了没变」;回执里 changed 说变没变,reason 给原因码(keyboard-covered / obscured / unclickable / / / / ), 直接列出这次多/少了哪几行;没生效还会自动留一张截图();点后等待也吃得下 / ; 点完留一张截图(省一次 ); 顺手把点完的新树带回来(判变化时本来就读过,ref 属于新快照,接着点下一项不用再 ) |
adb_wait 这类等待不是死等:前 600ms 每 120ms 读一次,界面一直没变就退到 240→600ms 一次,10 秒超时下省掉一多半 dump;超时前还会补一次带 OCR 的读,自绘界面不会被判成「没有」。
文字匹配前会去掉零宽字符(\u200b、BOM、方向标记)并把连续空白归一成一个空格:屏上明明写着却「找不到」的情况,多半是标题里夹了看不见的字符。
失败回执是统一的:前台页面 + 屏上前几行 + 截图路径,再加上 crash 缓冲里有没有 FATAL EXCEPTION(有就直接点名去看 adb_crash)——「没找到控件」和「应用崩了」不用再自己排查。
性能
同一台测试机(小米平板 8,3200×2136,Android 15,USB 直连,宿主 macOS,2026-10)上的实测。下表数字均为实测;约 1 帧/秒 是内容驱动下的实测近似值,不是估算。
| 能力 | 旧做法 | 现在 | 实测 |
|---|
| 截一张图 | exec-out screencap -p 出 PNG | exec-out screencap -j 设备端直出 JPEG,按右栏显示宽度缩放 | PNG 3.2 s / 8.9 MB → 330 ms;900 px 34 KB,600 px 19 KB |
| 读界面文本树 | uiautomator dump 每次冷启动 | uiautomator2 常驻服务直接读树 | dump 350 ms → 120 ms |
| 单条 adb 命令 | 每条 execFile 起一次 adb | 每台设备一条常驻 adb shell 通道 | 33 ms → 20 ms |
| 侧栏直播 | JPEG 长轮询逐帧抓(保留作兜底) | screenrecord --output-format=h264 + ffmpeg 转 fMP4 + MSE | 960×640;首帧 1380 ms;画面一变到上屏 +163 ms;空闲约 1 帧/秒(screenrecord 内容驱动) |
| 近 6 秒回看 | 连拍截图再拼 | 本地环形录像抽 6 帧拼图,不碰设备 | 6 帧拼图 365 ms |
| 多步操作 | 一步一个工具往返 | adb_do 一次调用串起多步 | 2 步 318 ms |
| 侧栏重开 | 每次重新起流、等首帧 | 最后一个观众走后录屏留 20 秒 | 冷开首帧 2440 ms → 20 秒内重开 18 ms |
| 只截一块 | 截整屏再自己裁 | adb_shot region 在宿主侧裁 | 640×420 13 KB(整屏约 80 KB),732 ms |
| 判断界面变没变 | 每次重截整屏再比像素 | adb_look 采样推流里的最新一帧,不碰设备 | 冷启(截图 648 ms)1096 ms → 热帧 164 ms(帧龄 34 ms),约 6.7× |
| 读某块区域的字 | 截整屏再识别 | adb_ocr region 从推流帧裁出那一块,只识别它 | 1000×600 区域 121 ms(p50,连续 460 轮浸泡) |
- 截图这条路省的是设备编码、回传体积和浏览器解码三段:PNG 3.2 s / 8.9 MB,JPEG 330 ms,900 px 34 KB、600 px 19 KB;右栏只显示几百 px 宽,按显示宽度缩放后体积和延迟一起降。
- 读界面默认走文本树:uiautomator2 常驻 120 ms,比朴素
uiautomator dump 的 350 ms 快近 3 倍,返回的仍是可点的文字 + 资源 id + 坐标。
- 每条命令的开销在通道不在命令:常驻 shell 把起进程的 33 ms 压到 20 ms。
- 直播按内容驱动:H.264 960×640 在画面静止时约 1 帧/秒,变化到上屏 +163 ms;首帧 1380 ms 含页面加载与建流;MSE 起不来时仍退回 JPEG 长轮询。
- 侧栏重开几乎是零成本:录屏与最后一段 IDR 都还在,20 秒内重连直接用,实测 2440 ms 降到 18 ms;超过宽限才真正停掉。
- 区域裁切省的是 token 而不是设备时间(整屏
screencap 仍要跑),但一张 640×420 的图比整屏小一个量级,模型看细节反而更准。
- 起流失败的两个真实原因都已处理:本地
adb 被强杀后设备端 screenrecord 会活到 --time-limit,占住编码器让下一次开流必失败;同一毫秒起两个录屏也会互相打架。现在开流/停流都先清残留,且只有真故障才置错误。
- 窗口层不用额外 dump:uiautomator2 的
dumpWindowHierarchy 一次返回所有可见窗口(前台页 + 悬浮桌宠 + 状态栏 + 输入法),实测 96–113 ms / 25.7 KB / 59 节点(桌面 + 桌宠同时在屏)。adb_ui 仍只读前台页,adb_window 把同一份 XML 按 depth 0 切成窗口——全程多花的只有 CPU,不多一次设备往返。
adb_watch 的采样上限由整树 dump 决定:请求 10 Hz 实际约 4 Hz(每次约 100 ms dump + 间隔),所以它报的是实测平均间隔而不是标称频率;要再快只能减少树体积,目前没做。
- 悬浮窗的文字树是真信息:桌宠气泡里的「没听清,按住再说」直接从无障碍树读到,不用 OCR、不用截屏;桌宠尺寸就是
#petRoot FrameLayout 的 bounds(实测 462×594)。
adb_recent 命中直播内存帧时 365–443 ms 且不碰设备;没人看直播时要现场录 4 秒(整条约 5 s),换来的是「真的是刚才」而不是几分钟前的旧段
adb_motion 两条路径的成本差得很远:磁盘段够新时 380 ms 出结论(不碰设备),要现录则 3.4–4.7 s(其中录屏占 3.4–4.4 s);因此先用「刚才几秒」的磁盘段,只有确实没有新段才现场录,并在文案里写明是现录还是几分钟前的旧段。
adb_do 2 步 318 ms 说明多步流程的瓶颈仍是设备侧动作,不是工具往返。
安装
# 一次性:装进当前 profile(对所有会话生效)
# 在 DSH 里用 plugin_manager:action=install_bundle, target=$HOME/dsh-mobile-use
装完 plugin_manager list_plugins 里应出现 include:adb(enabled=true、fiberPhase=active)。
配置(可选,写在 bundle patch 的 config 下):
- id: adb
name: dsh-mobile-use
config:
serial: "" # 固定设备序列号;留空则取 ANDROID_SERIAL,再退回第一台 online 设备
adb: /opt/homebrew/bin/adb # 可选;不写按常见路径探测
卸载:plugin_manager action=set_plugin, target=adb, enabled=false,或移除 bundle。
自测
node selftest.mjs # 只跑只读工具(安全)
node selftest.mjs --tap # 额外真点一次,会动设备
node selftest.mjs --serial emulator-5554
已知边界
-
改了 mirror.js / ocr.js 后要同步改 index.js 顶部的 ?v= 后缀:宿主热重载只重读入口文件,被它 import 的模块仍走模块缓存,不加后缀改了不生效(实测:/state 的 screen 一直是竖屏 2136x3200,加上 ?v= 重载后变 3200x2136)。
-
中文输入:adb shell input text 不支持非 ASCII,adb_input 会明确报错而不是静默吞字。中文走剪贴板或 App 内的粘贴。
-
动画页读树慢:uiautomator dump 内部会等界面空闲,相机预览这类持续刷新的页面要 ~2 s;静态页只要 0.33 s。这种页面优先用 adb_app foreground(0.09 s)判断页面,只在真要读控件时 adb_ui。
-
ref 有时效:每次 adb_ui 重新编号,超过 2 分钟或界面变了就失效,会报错提示重新读,不会点错位置。
-
多设备:默认第一台 online 设备;同时插着平板和模拟器时用 config.serial 或 --serial 明确指定。
-
同机两个宿主:GUI 插件和长稳脚本各起一个宿主连同一台设备时,设备端清场由持锁的那个宿主负责(锁在 /tmp/dsh-mobile-use-host.lock),两个宿主不会互杀 recorder。但一台设备的硬件编码器只有一路:两边同时推流会互相抢帧,画面每次都要冷启(实测冷回退 1096 ms,单宿主热帧 164 ms / 帧龄 34 ms)。要在同机跑长稳脚本,用 ADBM_STREAM_SWEEP=0 关掉脚本侧的清场,并避免两边同时看画面。
两个容易踩的坑
热重载后侧栏挂不上:宿主热重载会重新执行页面侧模块,但页签登记处是页面级的单例,旧登记还在,新登记会抛 tab type id "adb-mirror" is already registered,页签行建出来了、内容却永远不挂载。现在遇到这个冲突直接整页重建一次(30 s 内最多一次,有弹窗时不动),重建后登记处是干净的,want 还在,页签自动开、自动出画面。插件更新后如果侧栏没反应,按一次 ⌘R 即可——这是目前唯一需要人动手的地方。
右栏归属:右栏是全局唯一可见栏,插件只在「右栏正挂着你发起的那个会话」时才占它;屏幕上是别的会话时改用定向接口只往发起会话自己的布局里建页签,不展开、不切页签、不碰别人正在看的那一栏。
更新日志
1.4.34(侧栏再窄也不裁画面;关窗就断流)
- 画面按容器缩放:侧栏只有几百像素宽、或设备是平板这类宽高比时,之前会按图片原始像素铺开再被裁成左边一条;现在画框永远钳在容器内、
object-fit: contain 留边,整屏都在。
- 只留一条拉流路:MSE 起不来时先停掉那条连接再切抓帧,不再两条路同时拉;「画面没有响应,正在重新连接」不再卡在空转。
- 原生视频页同一处几何一起修:
#video 铺满舞台、object-fit: contain,竖屏平板比例也完整显示。
1.4.33(卡片说人话:省的是 token,不是清晰度)
市场卡片重写成五件能验证的事,不再是一段参数说明:
- 看一次屏是 200–600 token 的文本树,不是上千 token 的截图(约省 60–85%),热帧判断 100–200 ms。
- 右栏实时镜像,圈一处写一句话,带圈画面与圈内控件一起回到对话。
- 原始 H.264 直进 WebCodecs,不转码;p50 2 ms、丢帧 0,解不了自动退 MSE。
- 声音一个开关,默认关、按序列号记住;开着才多一条音频 socket,关窗一起回收。
- 关窗 3 秒内解码器/录屏/设备端 server 全没,闲置通道 90 秒自清,关掉的请求不会自己弹回来。
声音开关的「记住」在这一版才真正生效:刷新后偏好会重新同步给宿主,按钮保持上次状态,出声仍等一次手势。
1.4.32(声音:一个开关,默认关,开了才为它付代价)
- 能听设备的声音了:播放器多一个「声音」按钮,点开就是把设备的输出声音(
audio_source=output,48 kHz 立体声 s16le 裸 PCM)接到浏览器播放。实测 22 秒连续收 1047 个包、每包 4096 字节、包间隔 21.33 ms,与视频通道并行不抢配速。
- 默认关,且按序列号记住:关着时 scrcpy-server 根本不建音频采集(启动参数里连
audio_codec / audio_source 都不出现),延迟、CPU、内存与 1.4.31 完全一致。这是全局唯一一个音频控件;地址栏 ?audio=1 / ?audio=0 可临时覆盖。
- 设备给不了就自己退:设备端采集失败(被通话占用、ROM 不支持)时音频流直接结束并把开关复位,按钮置灰——不会留一个点了没反应的开关。
- 关窗照样回收:音频订阅者计入通道回收判定,关窗后音频连接随视频一起收,设备端不留采集进程。
1.4.31(裸流直通默认开启,画面路径不再过转码)
- 直通默认走 WebCodecs:宿主把 scrcpy 的原始 H.264 直接推到页面,
VideoDecoder 解码上屏,不再经 ffmpeg 转 JPEG。实测宿主写出到拿到帧 p50 2 ms / p90 2–3 ms,26 秒稳态一条连接、解码器只配置一次、丢帧 0。
- 修掉每 3 秒重连一次的真凶:页面那组「连上了没 META 就重连」的 MSE 看门狗在直通模式下恒成立,把健康连接当卡死掐掉,20 秒内重配解码器 9 次、
max 抖到 368 ms。直通分支现在跳过这组判据,cfg 从「跟连接数一起涨」变成恒为 1。
- 冷启动更稳:静态画面下补要三次关键帧;连不上 10 秒先退回 MSE 给画面,不会一直黑屏。
- 两处可见性:页面 3 秒诊断上报带
stream.avc(连接数、重配数、解码数、丢帧、队列峰值、错误),/state.avcEnds 留最近 8 次直通连接结束原因。
- 解不出画面时一律自动退回 MSE,用户不需要改参数;
?avc=0 可强制慢路径做对比。
1.4.30(关窗顺手收掉编码器,关过就别自己弹回来)
- 关窗真的把 scrcpy 通道收掉了:以前只停录屏,通道(设备端一个编码器 + 宿主一条 adb 进程)还留着。现在关窗后 3 秒确认没人回来就
stop 掉,设备端 server 按 scid 一并收,ps 上 com.genymobile.scrcpy.Server 归零。
- 闲置通道 90 秒自清:没人看、也没人手碰过就收,下次开栏懒启动(约 1 秒)。
ADBM_CHANNEL_IDLE_MS 可调。
- 用户亲手关过的请求不再自动开栏:
/want 带 closed 标记,同一请求不会把右栏自己弹回来;下一次明确要求才重开。
1.4.29(延迟口径不再误报)
/stream/info 不再用「启动时刻减分片 PTS」算延迟(两个原点不同,读出来动辄 7 秒)。改成 frameAgeMs:最后一帧到达距今多久,持续变大才是真卡住;画面上屏延迟看页面里的 lag_ms。
- 手册里对应的口径说明同步改掉。
1.4.28(操作手册 + 转发口随设备清理)
1.4.27(关窗先回话,再收尾)
/close 先应答再收尾:收尾里有一条同步 adb(清场/量尺寸),设备忙时能把「关窗」拖到 7 秒。现在先返回 {"closed":…},收尾放到下一拍。
- 关窗 beacon 直接停流:用户明说不看了,不再留 2.5 秒给重开侧栏;还有观众或镜像仍被要求时照旧不停。
1.4.26(追帧不来回切、缩放收成一个按钮)
- 追帧加回差:落后 0.45 s 才跳尾,小幅漂移用 1.25× 追,追进 60 ms 放回 1×。以前每帧都可能改倍速,改一次卡一下;现在只在跨过两条线时改。
- 缩放一个按钮轮流放大:适应 / 2× / 3× 合成一个,标签写「按下会变成什么」,面板少两行。
1.4.25(关窗当场收工)
- 关页即停:播放页在
pagehide / beforeunload 用 sendBeacon 打一次 /close,宿主只收这一次的那台设备,且只在不剩观众时才收;别的会话还在看的镜像不受影响。以前关窗要等 2.5 秒宽限 + 心跳超时,现在关窗那一刻就开始收。
- 新增
/close?serial=… 路由,同时把镜像最后观看时间清零、按需停分段录制,并让播放流走「观众走光」的收尾路径。
1.4.24(状态探测不再续命镜像)
- 探测不再续命:
/watching 以前任何一次查询都会把观众的计时顶到现在,于是关掉窗口后只要还有进程在查状态,镜像就永远不算空闲。现在只有带 watch=1 的调用才算「有人真的在看」,纯查询只读不回写。实测:纯探测 3 秒后设备上录屏 0 个、pid 文件 0 个;显式 watch=1 起录后 10 秒无人再要,自己收干净回到 0。
- 段边界兜底:分段录制每段开头再看一眼有没有观众,没人就不再起新的一段——即使停止调用漏了也不会白录。
1.4.23(关掉镜像窗口就收工)
| 项 | 之前 | 现在 |
|---|
| 关掉镜像窗口后的采集链 | 退订只排一个「空闲回收」定时器,宽限沿用视觉 keepalive:开着镜像再关掉,设备和宿主的录屏、转封装、JPEG 桥还要再占最多 10 分钟 | 镜像和视觉两条需求分开算:最后一个观众断开只等 2.5 秒(够扛住侧栏刷新/换页重连),且 15 秒内没真读过帧就把 keepalive 压回短宽限。实测断开后 3 秒 stop(),宿主 ffmpeg 0 个、设备 screenrecord 0 个、pid 标记文件已删;下次打开照常冷启(adb_look 673 ms 出图) |
| 面板按钮 | 输入框旁另有一个「文本」按钮,和回车重复;Tab 键在手机上几乎用不到 | 去掉这两个,保留发送/最近/清除 + 回车提交 + 返回/桌面/回车/删除 + 缩放一档 |
| 分段录制的 pid 文件 | 每段一个 /data/local/tmp/dsh-mobile-use-rec-<serial>-<tag>-<n>.pid,只写不删:看 6 分钟攒 32 个,重启后仍留着 | 一次观看复用同一个 pid 文件,段尾连同片段一起删;开录前扫一遍同 serial 的旧文件,pid 已死的直接清(活着的不碰)。实测连续 3 段 pid 文件始终 1 个,关窗 10 秒后 0 个 |
1.4.22(视觉流不再 175 s 后自己死掉)
| 项 | 之前 | 现在 |
|---|
| 只看视觉时的转封装背压 | 没人开镜像时仍起 ffmpeg 转封装,没人读它的 mp4 就背压,进而 pause 掉录屏 stdout(JPEG 支路共用同一条管道)⇒ 断帧 ⇒ 看门狗杀录屏重开,1 分钟 4 次、每次 1 s 空窗 | 没镜像观众就不起转封装(少一个常驻 ffmpeg);万一背压只丢转封装,不掐采集链。静置 4 分钟实测:录屏 + JPEG 解码各 1 个进程、PID 不变、sessions 停在 1 |
adb_look / adb_ocr 的免截图快路径 | 只看 JPEG、不进镜像观众数:录满 RECORD_LIMIT(175 s)正常退出后没人续录,之后的视觉读又掉回 screencap(实测 619–1096 ms) | 宽限内有人读过帧就照样续录,冷启一次能热一整个 keepalive(默认 10 min);宽限外照旧释放,不常驻 |
1.4.21(换设备不再攒缓存)
| 项 | 之前 | 现在 |
|---|
| 换设备/重启模拟器 | 树快照、屏尺寸、dump 队列、常驻 shell、转发口都按序列号存,旧设备的那些一直留到进程退出 | 每 5 分钟核对 adb devices,只清「已不在列表、且没有活跃推流」的设备;在线设备不动,adb 返回空表时整轮跳过 |
1.4.20(双宿主不再互杀 recorder)
| 项 | 之前 | 现在 |
|---|
| 同机两个宿主 | 双方「开录前清场」都按 marker 扫设备,会杀掉对方正在用的 recorder,来回重建 | 清场前先读宿主锁:同机已有活的第二宿主就不清设备端,交给持锁那个;对方退出后自己接手(实测交接只收掉遗留的 q6drq0-7,GUI 自己的 recorder 不动) |
1.4.5(市场可安装)
| 项 | 之前 | 现在 |
|---|
| 市场卡片徽章 | 显示「未声明宿主要求」,点安装没有依据 | npm 包里声明 engines.dsh >= 0.2.0-rc.1(与 node >= 20),徽章按已发布 manifest 判定 |
| README 开头 | 一上来是 1.2 的故障表,看不出它是什么 | 先说清它做什么——实时镜像、圈选标注、免截图读界面;版本记录移到文末 |
1.2(实测:画面不再播 6 秒后卡死,单帧 168 ms 上屏)
| 故障 | 之前 | 现在 |
|---|
| 播几秒后永久卡住 | 客户端拼包每帧 Array.from + [].concat,1 MB 关键帧下是 O(n²),追不上服务端 2 MB 积压被断开,重连又补同一个 GOP,看起来就是「播 6 秒定格」 | 拼包改成「追加只拷贝一次、解析用游标」,实测连续滑动 4 秒 111 帧上屏、readyState 4、缓冲持续前进 |
画面全黑、控制台 APPEND_FAILED | sourceopen 里先 addSourceBuffer 再把 INIT 排进队列,排队分片先喂给解复用器 | 先把 INIT 排到队首再挂 sourceBuffer,实测首屏直接可播(3200x2136) |
| 延迟没法归因 | 只有「帧数」计数,分不清是采集慢还是播放器落后 | stream.info() 新增 lastPts / ptsAgeMs(最近一片的解码时间与年龄);页面 window.__adbm 暴露 meta/init/media/appends/appendErr/buffer 计数,归因一次就看出来 |
| 端到端延迟 | 未测 | 边滑动边用 requestVideoFrameCallback 量已上屏帧:最小 152 ms、中位 168 ms、最大 436 ms(含一次重连追赶) |
1.1.1(实测:宿主不再被清理路径打崩,右栏只开给发起会话)
| 故障 | 之前 | 现在 |
|---|
| 空闲回收 / 子进程回调抛异常 | 回调里调了不存在的方法(killStale)就冒到顶层,DSH 直接退出码 1 | 全部定时器与子进程回调走统一兜底 guardCall,异常只写诊断日志;stop() 幂等,清理方法缺失或对象已销毁时也照样停完不抛;test/lifecycle.test.mjs 固定 8 种形状 |
| 右栏开错会话 | 广播式 open + 标题相近就认领,可能开进别人正在看的会话 | 只认 mounted 严格相等的发起会话;拿不到会话 id 直接拒绝打开;空会话不发广播 |
| 页面重复执行 | 客户端 bundle 被执行两次 → duplicate factory,整条 web boot 失败 | 客户端入口只注册一次(失败回滚标记可重试);页面版本号与 client.rev 同源,不再来回重载 |
1.1(实测:动作不再点到旧界面,画面从第一帧就能解)
| 能力 | 之前 | 现在 |
|---|
| 点按时效 | 2 分钟内的快照都照点,界面一变就点空 | 超过 1.5 s 先重读一次树核对目标;目标没了、挪动超过 48 px 就报 stale_snapshot,不点旧坐标 |
| 多个同名控件 | 静默点第一个(常点到父容器) | 报 ambiguous_target 并给数量,改用 ref 挑;同一文字多份时按离旧位置最近认领,不误判 |
| 超时重试 | 读命令超时会重放;写命令也可能重放一次 | 只有严格只读的白名单会重试(含 2>&1 不算写);input/am/pm/重定向/find -delete/sed -i/logcat -c/wm size WxH/date -s 一律按写算,超时报「设备可能已执行,不重放」 |
| u2 服务 | 固定端口 9008,冲突就残废;jar 判断恒为真 | adb forward tcp:0 动态拿本机端口并记住归属;jar 按退出码判断、推送失败即报错;冷启动实测 1.1 s |
| 首帧解码 | 观众可能先收到媒体分片、后收到 moov,画面起不来 | 初始化段先到先发,未到就不发解不了的分片;首屏 META→INIT→MEDIA,实测首帧 2.4 s、无重复帧 |
| GOP 缓存 | 只按条数截断,可能把 IDR 截掉、新观众从 GOP 中间起播 | 按字节封顶(4 MiB)且只留「IDR 起头」的连续段,超限整段丢弃等下一张 IDR |
| 长跑内存 | 常驻 shell 缓冲只增不清,每条命令叠在上一条输出后面 | 每条命令读完清缓冲、单条输出封顶 8 MiB;连续 12 次 dumpsys 宿主 RSS 不涨 |
0.9(实测:侧栏不再「一卡一跳」,面板能自己操作)
| 能力 | 之前 | 现在 |
|---|
| 画面来源 | 每 200 ms 抓一张 JPEG(设备侧 screencap 一帧 867 ms) | H.264 裸流 → 主机 ffmpeg 抽帧转 JPEG,12 s 采样 22.9 fps,帧间隔 p50 41 ms |
| 首取帧 | 等下一拍抓帧 | 有缓存帧时 2 ms 返回;没缓存时平均等 46 ms(长轮询 ≤2500 ms,不空转) |
| 点按坐标 | 按显示宽度一个比例算,缩放/转屏后偏 | 按下位置归一化到真实媒体矩形再乘设备分辨率,缩放/平移/横竖屏都用同一套 |
| 看细节 | 只能看整屏缩略图 | Ctrl/⌘+滚轮 1–4×、2×/3×/「适应」按钮、2× 以上 Shift 拖动平移,边界自动夹住 |
| 键盘 | 只能点截图 | 画布吃键盘:回车/返回/删除/Tab/方向键直达设备 |
| 文字 | ASCII 直送,中文无解 | /text 走 u2 setClipboard + 长按粘贴,输入框回车即发,失败回填草稿 |
| 与对话协同 | 得回头读聊天记录才知道刚点了哪 | 面板每 2 s 读 /actions,对话里刚做的操作显示成一行(-1.2s 点击 @640,1200) |
页面自报现场(/diag)现在带构建号:/state.build 与 client.rev 对不上时,面板会自己拉新版本,插件更新后不用再按 ⌘R。
0.8(实测:看的成本降下来、不看就完全停)
| 能力 | 之前 | 现在 |
|---|
| 侧栏每帧体积 | 1618×1080 JPEG ≈ 140 KB | 1024×684 · 21–48 KB(右栏显示宽度只要几百 px,多余像素白解码) |
| 侧栏帧率 | 5 fps,每 120 ms 一拍 | 2.8–3.4 fps,200 ms 一拍、2 张抓帧并发;实测 /state 报 3.4 帧/秒 |
| 没人在看侧栏 | 仍在抓帧、仍在推 | 0 次抓帧:没有观看者时抓帧泵直接空转,/state 的 watchers 到 0 |
| 切走 / 最小化 / 面板滚出视野 | 照常占 CPU | 页面侧 IntersectionObserver + visibilitychange 判定,断开直播流,重新被看见再自动接上 |
| 盲等重开 | 靠 sleep 后反复试 | 页面不在前台也能用 openTabIn(发起会话, kind) 定向把页签建进该会话自己的布局(实测 tabsIn 回 guide,adb-mirror),回到该会话时 800 ms 一次的挂起请求立刻展开并出画面,不用再叫一次 |
0.7(实测:看不清字也能读、动效给数字、标注不再偏)
| 能力 | 之前 | 现在 |
|---|
| 界面只有自绘/Flutter、文本树空 | 只回一句「没有可读文字」 | adb_ui 自动补一层本机 Vision OCR(ocr:"force" 可强制):实测 force 全流程 1771 ms(截屏 0.36 s + 3.5 MB 传输 + 识别 1.58 s,21 段),只当兜底、不当默认 |
| 截屏取字 | exec-out screencap -p 3.0–3.1 s / 8.5 MB | 设备端 screencap -j 落盘再 cat:0.36 s / 3.5 MB(PNG 路径保留作回退) |
| 判断「变大变小不协调」 | 连拍截图靠眼睛 | adb_anim 给数字:每 150–250 ms 一帧的中心 / 宽×高表,加上超调 px、方向反转次数、平均步长、峰值步长、平滑度、稳定耗时 |
| 卡顿/被杀/内存涨 | 截图猜 | adb_proc:top 最吃 CPU、mem 看单包 PSS 明细、list 列进程、audit 对账宿主解码器与设备端录屏/scrcpy 进程 |
| 内嵌 H5 定位不准 | 只能读控件树 | adb_web:CDP 直读 DOM(list / dom 选择器 / eval),返回 CSS 矩形与换算好的设备坐标,可直接喂 adb_tap point= |
| 侧栏标注位置偏 | wm size 报竖屏 2136x3200、画面是横屏 1618x1080,标注整体缩到左上角 | 按真实画面横竖对齐后再换算:3200/1618 = 1.978,标注/点按与设备坐标一致 |
adb_anim 读的是控件树坐标:列表滚动、页面转场这类合成器驱动的动画可能全程报「没变」,那种情况改用侧栏「最近 6 秒」拼图看。OCR 走本机 Vision(macOS),一个 worker 进程、空闲 45 s 自杀;只支持 macOS。
0.6(实测:一个回合跑一串动作、截图 0 ms 复用、精确落点)
| 能力 | 之前 | 现在 |
|---|
| 「点开→等加载→输入→发送」 | 4–6 次工具调用、4–6 个模型回合 | adb_do 一次调用(实测 4 步:home→等「设置」→点→等「WLAN」= 1614 ms / 1 回合) |
| 侧栏在看画面时截图 | 0.5–3.2 s(screencap + 转码) | 1 ms(直接拿直播里最新一帧,liveFrame) |
| 点完再确认界面 | 2 次调用(tap + adb_ui) | 1 次:adb_tap 带 wait_for_text / wait_for_id / wait_until_gone,返回命中/超时与当前 Activity |
| 看「点完到底变了什么」 | 整棵树重读 | adb_ui changed_only:只给 + 新增 / - 消失,没变化时一行「界面没有变化」 |
| 系统开关(桌面/返回/最近/锁屏/亮屏/转屏/通知栏/快捷开关/夜间) | 界面里找按钮,或自己拼 input keyevent | adb_action 一条命令(实测 home 126 ms) |
| 点文字落到哪 | 最近可点祖先的中心 | 祖先大得离谱(>6 倍面积)且中心离文字远时点文字自己:MIUI 桌面点「设置」实测从 1600,1068 修到 2283,1986 |
| 侧栏路由 | 只靠端口 | 回环 + 同源三重校验(peer / Host / Origin·Sec-Fetch-Site),伪造来源实测 403 |
0.5(实测:常驻 shell、真人轨迹、动效回看)
| 能力 | 之前 | 现在 |
|---|
| 轻点耗时(含工具往返) | 120–151 ms | 61 ms(首次 101 ms,建通道成本;之后 61) |
adb_sh 每命令开销 | 70–90 ms(新起 adb) | 2–4 ms(常驻 shell 提交,命令本身另算) |
| 曲线/长按拖动 | input swipe 只能直线 | adb_gesture 逐点 DOWN/MOVE/UP(实测 5 控制点 / 16 步 / 请求 600 ms → 1161 ms 返回) |
| 标注回传给模型 | 圈选图 + 圈内控件 | 再加操作时间线(-3.2s 点击「开始」@640,1200)与最近 6 秒 6 帧拼图(265 ms 内建好,112 KB) |
常驻 shell:插件为每台设备保一条 adb shell 交互通道,命令串行排队、用随机标记切分输出;超时/断开自动重建并退回一次性 adb shell,不会卡死。退出时统一关闭。
adb_gesture 的段数是 duration/40(3–40 段):设备端每条 input motionevent 有约 35 ms 进程下限,所以 600 ms 的手势实际 1.1 s 左右;要更顺就减点、拉长时间。多指(双指缩放)input motionevent 不支持,需要 u2 gesture 或 root sendevent。
标注回传落点:~/.dsh/adb-mirror/annotations/。拼图取自内存里最近 40 帧(约 8 秒,5 fps),不 seek 录屏文件——录屏是 720×1280 且末段还在写,seek 会空手。
0.4(实测,小米平板 8,已 root)
- 直播 4.5–5.0 帧/秒:连续看 20 s 收到 121 帧(5.0 帧/秒),
/state 报的实测帧率 4.3–4.9。做法:3 张 screencap -p 并发轮流补位,每张一出就交给一次 ffmpeg png_pipe 转 JPEG,按 200 ms 固定节拍把最新帧写给侧栏。
- 没人看时零开销:关掉侧栏后实测
frames 停在 159 不再增长,设备端 screencap 进程消失;设备上的 10 s 分段录像照旧走,本机只留最近 3 分钟。
- 本机开销:转码约 16 ms CPU/帧(≈8% 单核),每帧约 54 KB、本机 240 KB/s,全在回环地址上。
| 试过 | 实测 | 结论 |
|---|
| 一张一张抓 | 单张 0.65 s → 1.4 帧/秒 | 不够 |
| 3 张并发抓 | 3–4 路并发 0.61–0.68 s → 4.3–4.9 帧/秒 | 采纳 |
常驻 ffmpeg image2pipe 吃 PNG | 喂 1 张后 3 s 不出字节;-flush_packets / -analyzeduration 0 也无效,只在 stdin 关闭时一次性吐出 | 不能用于直播,改每帧起一次 png_pipe |
| 有帧才推 | 并发抓帧成簇完成,画面被削到 1.2–1.7 帧/秒 | 改为固定 200 ms 节拍重发最新帧 |
| 抓帧时的设备侧占用 | screencap -p 峰值约 1 核 | 只在有人看时抓 |
0.3(实测,小米平板 8,已 root)
- 读界面提速 15 倍:设备端常驻 uiautomator2(openatx,Apache-2.0)走
app_process,adb_ui 从 2343 ms 降到 136–153 ms,同样返回文本树与 ref。首次使用自动 push vendor/u2.jar + 装 vendor/app-uiautomator.apk 并拉起服务,之后复用;进程死了下次调用自愈。
- 实时ADB 侧栏:直播 2.8–3.4 帧/秒(
/state 实测 3.4,单帧 21–48 KB),画面上能直接点、能圈一块把「这里 + 设备坐标 + 前 12 秒操作时间线」连同截图推回当前会话(实测注入成功,返回 {"ok": true});本机留最近 3 分钟分段(10 s 一段 × 18 段,实测 17 段 212 KB)。全部在本机,不经过外部服务。
镜像的实测结论(都踩过)
| 事实 | 实测 | 结论 |
|---|
screenrecord --output-format=h264 - 静止画面 8 s | 8.7 KB、只出 1 帧 | 直播不能只靠录像 |
同一路 h264 加 -vf fps=4 | ffmpeg 直接 -22、0 字节 | 单帧被 fps 滤镜丢掉,删掉滤镜 |
改为 screencap -p 一帧 | 0.7–1.2 s | 直播改走截图续帧,稳 |
screencap -p 的字节头 | PNG 签名(不是 JPEG SOI) | 先找 89 50 4e 47 再交 ffmpeg png_pipe |
设备端分段录制 10 s → cat 拉回 | 单段 8–16 KB、可 ffprobe 出时长 | 环形缓冲走分段文件,不走管道 |
0.2(实测数据,小米平板 8,已 root)
新增能力:
adb_state:一次往返拿前台 Activity、焦点窗口、窗口栈、键盘、方向、分辨率、亮屏、电量、root。实测 262–376ms(比截图 + 读图快,且弹窗/键盘判断更准)。
adb_sh root=true:以 su -c 执行;实测可读 /data/data/com.example.app/shared_prefs(非 root 对照为 Permission denied)。
adb_tap 自动落最近可点祖先 + dry_run 预演落点(先确认再点,避免点错返工)。
adb_device 增加设备画像(ro.build.characteristics、sdk、root)。
测量记录(同一设备,逐条实测)
| 动作 | 耗时 | 备注 |
|---|
adb_state(7 段 dumpsys 合并一次 shell) | 262–376ms | 含 5 次 dumpsys + wm size |
dumpsys window(设备侧) | 0.03–0.05s | 含 mCurrentFocus / mFocusedApp + 18 个窗口 |
dumpsys activity activities | grep mResumedActivity | 时有时无 | MIUI 上不可靠,已改用 window 的 mFocusedApp |
uiautomator dump(静态页 / 动画页) | 0.33s / 2.2s | 动画页仍是最慢的一环 |
screencap -p PNG | 0.49–0.58s(90KB) | 相机预览页曾达 3.2s(8.9MB) |
| 原始 RGBA screencap(想省 PNG 编码) | 2.05–2.38s(27MB) | 比 PNG 慢,已放弃 |
| 关掉动画缩放后 dump | 2.05s → 1.64s | 只省约 20%,不是银弹 |
踩过的坑
- 合并 shell 命令里
echo ##A 会被 sh 当注释,整条命令静默不执行 → 标记改成 @A@。
- 前台 Activity 不能用
dumpsys activity activities 抓(MIUI 缺行),统一走 dumpsys window 的 mFocusedApp;焦点窗口 ≠ 前台 Activity 时判定为有弹窗/浮层。
路线图(未实现,按收益排序)
- scrcpy(~120k★)视频流:连续 H.264 + 精确输入,单帧约几十毫秒,适合扫码/相机预览这种截图极慢的页面。
- WebView CDP:拿到 DOM 文本与 JS 状态,替代 WebView 内稀疏的文本树。
- 手表/眼镜:靠
ro.build.characteristics=watch 等做设备画像;圆屏按内切矩形裁剪点击坐标;走 adb pair/adb connect 的无线 ADB;GL 自绘界面文本树为空时回退截图。均【未核实】。
- root 直读:
shared_prefs/files 已可读;数据库需 push 一个静态 sqlite3 二进制(当前系统无 sqlite3)。
许可
代码 MIT。第三方组件见 THIRD_PARTY_NOTICES.md。