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 / gone / page-switched / no-response / same-screen),delta 直接列出这次多/少了哪几行;没生效还会自动留一张截图(shot.path);点后等待也吃得下 wait_for_xpath / wait_xpath_gone;shot: true 点完留一张截图(省一次 adb_shot);tree: true 顺手把点完的新树带回来(判变化时本来就读过,ref 属于新快照,接着点下一项不用再 adb_ui) |
adb_input |
text(ASCII,空格自动转义)、key(ENTER/BACK/DEL…)、submit(enter / search,输完直接提交)、ime(auto / clipboard / adb,ADBKeyboard 已装且是当前输入法时中文走广播更快)、swipe、clear;回执给 method(ime/paste/input)、field(输入框回读)、verified,verify: true 时 ASCII 直发也回读一次,不用再补 adb_ui |
adb_gesture |
真人轨迹:points 折线逐点 DOWN/MOVE/UP,duration_ms、hold_ms;曲线拖拽、按住再拖;滑完可等文字或 wait_for_xpath / wait_xpath_gone |
adb_app |
list(42 个可启动应用 + 前台,找包名第一步)、info(versionCode/versionName + 前台)、install(-r -d)、start、stop、clear、uninstall、foreground |
adb_sh |
设备端任意命令,输出截断到 20k 字符;日志、dumpsys |
adb_shot |
只在要看颜色/布局/相机画面时截图,默认缩到长边 900 px,返回路径给 read_image;region="x,y,w,h" 只回一块(看桌宠、小图标时体积降一个量级);带 ref 只截那个控件(实测桌宠页一个图标 126×126 / 1.6 KB,整屏 600×900 / 39 KB) |
adb_look |
不看图也能看清画面:把屏幕压成颜色网格(每格一个字母)、列出同色块的位置与占比、给出与上一次的差异;region 只看一块、cols/rows 调网格密度。canvas/游戏/自绘界面、文本树没有文字时用它——比 adb_shot 快、不花图片 token |
adb_ocr |
直接读屏上的文字,不花图片 token:给 ref 或 region 只读那一块,回一批带坐标的文字,坐标可直接给 adb_tap point=。优先认右栏直播帧(热 110 ms),文本树能读时优先用 adb_ui |
adb_device |
列设备、状态、型号、当前选中设备与分辨率 |
adb_state |
一次往返:前台 Activity、焦点窗口、窗口栈、键盘、方向、分辨率、亮屏、电量、root(125–195 ms:七条 dumpsys 并发跑,串行要 371 ms) |
adb_do |
一串动作一次跑完(最多 20 步):点 / 滑 / 输入 / 按键 / 系统动作 / 等文字,任何一步失败就停并报第几步;每步可 retry 重来注入前的偶发失败;失败时自动留一张截图到 ~/.dsh/adb-evidence/<序列号>/(只留最近 20 张);步骤也能带 until(等同一控件进入某状态)与 dismiss(本步等待时自动点掉挡路弹窗);步骤也能用 xpath 一次定位点掉,tap/id 步与等待/核对步都可配 within_ref(或坐标框 within_rect)只在一行里找、只在一行里等「已关注」;找目标也能用 assert_activity/assert_activity_gone(核对页面)/desc/cls/exact/regex/index/long_ms(长按时长)(同一文字匹配到多个时点第几个,从 0 开始)(和 adb_tap 一致,只有描述的图标按钮、半截类名的控件都能点);输入步可写 clear: true 先清空聚焦输入框;某步写 on_fail: "continue" 就允许它失败(记一笔跳过,后面照跑);同一步要连做几次写 repeat(1–20,配点击/滑动/按键/系统动作/scroll_to,连点「下一集」不用抄十遍);整串跑完可加 shot: true 顺手留一张截图、tree: true 顺手把跑完的界面读回来(ref 归新快照);scroll_to 还能写成对象(text/id/desc/cls/xpath + direction/until/dismiss/max_swipes),滑到「真的能点」的那个再动手;每步还能 wait_for_xpath / wait_xpath_gone / assert_xpath / assert_xpath_gone / assert_count_min(配 count_xpath)当条件,收尾 assert_no_crash 读 crash 缓冲区跟开始时比,多了就当场报并留证 |
adb_action |
系统动作一条命令:home / back / recents / lock / wake / rotate / notifications / quick_settings / night_mode;可带 wait_for_text / wait_until_gone,动作和确认一次调用做完;等待支持 wait_for_xpath / wait_xpath_gone |
adb_file |
设备 ↔ 宿主文件:action 给 ls / pull / push,只放开 Download/Pictures/DCIM/Documents/Movies/Music/Android\ media 与 /data/local/tmp;pull 默认落 ~/.dsh/adb-files/<设备>/,两次都比对 md5,回执写字节数与是否一致 |
adb_pinch |
双指缩放:mode 给 in / out,percent 给幅度、steps 给步数;走设备端 u2 的 pinchIn / pinchOut,可带 wait_for_text / wait_for_xpath 等一次等到位 |
adb_record |
录一段屏幕并取回宿主:seconds 给 2–30 秒(默认 5),阻塞到录完,返回 mp4 绝对路径与字节数,设备上的临时文件自动删;只观察不注入 |
adb_clipboard |
action=read 取剪贴板原文(复制不上屏的内容也拿得到),action=write 写入 text 并自动回读确认;长文本走粘帖比逐字输入快几十倍 |
adb_crash |
最近的崩溃与 ANR:FATAL EXCEPTION 摘要、进程名、首段堆栈,外加 dropbox / /data/anr 现场(有 root 时);用户只说「闪退了」时先查它 |
adb_recent |
「刚才那几秒」的 6 帧拼图:动效比例/位移/卡顿这类单帧看不出的问题用它。优先用直播内存帧,没有就现场录一小段(约 +4.5 s),都不行才回落到磁盘段并注明「不是刚刚」 |
adb_find |
按选择器一次拿全部命中:text/id/desc/cls/pkg AND、exact/regex/text_matches、clickable/enabled/visible/checked/selected/scrollable/focused/editable、index、within_ref(按 ref 框裁,xpath 也认)、同处去重;也能直接喂 Appium 写法 xpath(//*[@text="登录"]、//LinearLayout/node[2]、//*[contains(@text,"登")]、//*[@clickable="true"][1]、[last()]、not(@attr)、@attr!="值"、starts-with(@attr,"值")、A and B / A or B、反向轴 .. / following-sibling:: / preceding-sibling::;@text/@desc 同样自动去掉零宽字符),在已 dump 的节点上本地求值,实测 94 节点 19–266 µs。没找到自动留截图 |
adb_scroll_to |
滚到目标出现:给 text/id/desc 一路滑到看见为止,回执带滑动次数、命中节点和是否到底;until 让灰着的同名项继续滑,dismiss 自动点掉挡路弹窗(最多 2 次);不用模型自己算滑动次数 |
adb_anim |
动画/过渡诊断:开 animation_scale 相关的 debug 开关看窗口动画、过渡、Animator 时长,定位「它到底在动什么」 |
adb_proc |
进程/内存:top / mem / list 一次 shell 拿回,判卡顿、被杀、内存涨,不用截图猜;audit 对账宿主子进程与设备端录屏/scrcpy(查残留、查谁在占编码器) |
adb_events |
系统事件:logcat 里按 tag/pid 抓崩溃、ANR、Activity 切换,回执按时间倒序 |
adb_web |
内嵌 H5:CDP 直读 DOM,返回 CSS 矩形和换算后的设备坐标,可直接喂 adb_tap point=;App 没开 WebView 调试时明确报错 |
adb_window |
按窗口看界面:一次 dump 列出所有可见窗口(前台页、悬浮窗/桌宠、状态栏、输入法),给包名、尺寸、位置和窗口内控件树。adb_ui 只看前台页文字,悬浮窗/桌宠这类独立窗口用它;实测 236 ms(含前台 Activity 查询) |
adb_watch |
采样一个控件的几何随时间变化:id/text/class 选靶,给出宽高与位置序列、最大单步、抖动次数、多久稳住,用来量化「刚才那个东西大小/位置变化不协调」。动画在 WebView 画布内部时几何不变,会如实说明并指向 adb_motion |
adb_motion |
量化「刚才那几秒」的动效:传 region="x,y,w,h" 或 target(文字 / 资源 id 子串,自动取控件框),逐像素比对给每帧变了多大、变在哪、抖了几下、多久稳住。圈定区域时用「外缘半径」衡量变大变小(桌宠大小动画就量这个),全屏时只认最大连通块并说明判不准;变化帧少于 4 帧或幅度太小时拒绝下抖动/回弹结论 |
adb_mirror |
侧栏「实时ADB」:低帧率直播 + 手点 + 圈选推回会话;status / peek / recent / stop |
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_do2 步 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。
工具不出现(unknown tool "adb_sh")
装完看不到 adb_* 工具时先跑自检,它会点名是哪一步没生效:
npx dsh-mobile-use-doctor # 默认查当前 profile
node doctor.mjs --profile ~/.dsh/profiles/desktop --json
自检覆盖三种静默失败——它们都不会在启动日志里留痕迹:
- 包只写进了 profile 的
dependencies,没进dsh.profile.bundles:本包的cordis.patch.yml不会被应用,profile patch 里写- id: adb也落在一个不存在的行上,Loader 安静跳过; node_modules/dsh-mobile-use是link:且源码目录移动/改名,入口解析不到;- 改了
package.json只重开会话没重启宿主。
配置正常时自检给出 结论:配置没问题,并提示重启宿主;1.4.36 起加载成功会在启动日志里留一行 [dsh-mobile-use] 已加载,工具再消失就能直接分清「没加载」还是「加载了没注册」。
自测
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.40(空闲时不再空转)
- 空闲对账每 30 秒对每条空流调一次
stop(),旧版每次都完整走一遍换代 + 设备端清场(adb 往返)+ 子进程清扫:宿主静置也一直在做设备 I/O,generation每 30 秒 +1(实测空闲两分钟 +4)。现在stop()记下收尾时刻,通道 / 子进程 / 观众全空时直接返回,start()再复位;重载后空闲十分钟generation停在 2(旧版同期 +20)。 - 面板换版本时的「正在载入新版本…」删掉:自动重载不需要告知,只留「页面代码没有更新,重新打开这一栏」这条真要人动手的。
- 设备端录屏收尾(
stream.js)和宿主侧(mirror.js)改成同一套判据:进程已经自己退了回gone:,pid 文件空回skip:no-pid,都算干净收尾;只有「进程还在、但不是 screenrecord」才写一条错误。 - 新增
test/idle-stop.test.mjs(7 项静态守卫),npm test现在 17 个文件全绿。
1.4.39(设备端清场不再杀掉自己)
- 设备端清场一直有条隐蔽的自杀路径:
pkill -9 -f 'dsh-mobile-use-scrcpy-server.jar'是按整条命令行匹配的,而这条命令本身就跑在一条含同样字样的sh -c里 —— pkill 先把宿主这条 shell 一起杀了(实测退出码 137),后面的echo ok永远执行不到,清场永远是「失败」。现在四处清场(启动扫 scrcpy jar / 启动扫 u2 服务 / 开录前扫残留 recorder / 设备端进程收尾)都改成插[]的模式或按 PID 列表收并跳过$$,test/sweep.test.mjs静态守住这几处不许再写裸名字。 - 上一代宿主崩掉时设备上的 uiautomator2 常驻服务会一直留着(实测一只
com.github.uiautomator占 106 MB 内存、一整天不退)。启动清场现在一并收掉它,下次读界面时ensureU2On自己重新拉起;ADBM_U2_SWEEP=0可关。 - JPEG 桥接注册表
__dshAdbJpeg按 serial 分桶,流收掉后只删 child 不删桶,长跑按设备数只增不减。stop()现在把整桶删掉。 - 录屏收尾把「进程已经自己退了」当成干净收尾:管道一关
screenrecord自己就退出,旧代码这时拿 pid 去核对进程名当然对不上,于是每次正常收尾都往/state.error写一条「录屏收尾没拿到 pid」,GUI 上看着像故障。现在先看进程还在不在,不在就不记;只有「进程还在、但不是 screenrecord」才报。 - 新增
test/recorder.test.mjs(4 项):守住这条分支,不许再把正常收尾写成错误。 - 新增
test/sweep.test.mjs(16 项):三份宿主文件里不许出现会被自己匹配到的裸清场名字,必须按 PID 收并跳过$$。
1.4.38(死掉的 JPEG 桥接会自己重建)
enableJpeg()以前只看jpegChild !== null就当作解码器还在跑。子进程被kill -9时exit事件偶尔晚到(stdio 已断、exit 还没派发),这一支就永远短路返回true:桥接再也起不来,只能重启整个宿主。- 现在先看子进程真的死没死(
exitCode/signalCode/destroyed),死了就closeJpeg()再走重建;重建沿用 2 秒节流,死得快也只是每 2 秒重试一次,不会打成死循环。 - 重建后走
jpeg-rekey:给新解码器补一份参数集和关键帧起点,不用等自然 GOP,桥接一活就有画面。 closeJpeg()清掉jpegStartedAt,避免info()把上一代刚起 8 秒内算成starting;jpegOpenAt保留作节流。test/liveness.test.mjs增两项:死子进程被摘掉、活子进程不被误杀。
1.4.37(死掉的镜像不再假装在动)
/state.live改成看最新一帧的年龄(2.5s 内),不再「手里有过帧就一直算活」。JPEG 桥接 EPIPE 或退出后旧帧和旧错误还留着,以前/state一直报live:true+err:"JPEG 管道断开",宿主机上却没有任何 ffmpeg/scrcpy,排查时分不清「在动」和「早就死了」。AVC 直连没有 JPEG 子进程,所以判据不能看子进程在不在,只看帧新不新。- 多两个诊断字段:
alive与jpegState(off/starting/live/stalled/dead)。 - 自愈:真收到帧就把上一代的
JPEG ...故障记录清掉,不留旧错;设备类故障(adb: device offline)不动。 test/liveness.test.mjs:10 项判据覆盖 dead/off/live/starting/stalled、帧年龄判定与错误清理。
1.4.36(工具不出现时能自己查出来)
- 新增
doctor.mjs(npm run doctor/npx dsh-mobile-use-doctor):逐项检查 profile 依赖、dsh.profile.bundles、node_modules入口、包内 bundle patch、宿主加载锁(/tmp/dsh-mobile-use-host.lock的 pid 是否还活着)、adb 与在线设备,失败的项直接给修复命令。issue #1 那种「依赖在、bundles 里没有,patch 落空」会被点名。 apply()遇到没有tools注册表时不再把异常抛给 Loader(过去会被吞掉、启动日志全空),改为写一行可搜到原因后返回;加载成功也会留一行[dsh-mobile-use] 已加载。- 新增
test/doctor.test.mjs:11 项判据,覆盖未安装 / 未进 bundles / 断链 / 配置齐全四种 profile。
1.4.35(Gemini 下工具不再被整包拒绝)
adb_action的degrees改成字符串枚举("0"/"90"/"180"/"270"):Gemini / Vertex 的函数 schema 只认字符串枚举,整数枚举会让整个请求在 schema 校验阶段被拒(The request was rejected)。执行时用Number()转回数字,老模型传数字也照常工作;不传仍是「转到下一个方向」。- 新增回归测试:扫描所有工具的入参/出参 schema,枚举值必须是字符串。
1.4.34(侧栏再窄也不裁画面;关窗就断流)
- 画面按容器缩放:侧栏只有几百像素宽、或设备是平板这类宽高比时,之前会按图片原始像素铺开再被裁成左边一条;现在画框永远钳在容器内、
object-fit: contain留边,整屏都在。 - 只留一条拉流路:MSE 起不来时先停掉那条连接再切抓帧,不再两条路同时拉;「画面没有响应,正在重新连接」不再卡在空转。
- 原生视频页同一处几何一起修:
#video铺满舞台、object-fit: contain,竖屏平板比例也完整显示。
1.4.33(卡片说人话:省的是 token,不是清晰度)
市场卡片重写成五件能验证的事,不再是一段参数说明:
- 看一次屏是 200–600 token 的文本树;同一屏截图 1k token 起,密排页到 6k。热帧判断 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(操作手册 + 转发口随设备清理)
- 新增 docs/agent-guide.md:判定顺序、镜像生命周期、资源不变量、延时口径、排障入口。
pruneGoneDevices一并清掉自己建过、设备已不在的 adb 转发口。
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。首次使用自动 pushvendor/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。
No comments yet. Be the first to write one.