跳转至

Android ADB 截图与 AI 坐标定位源码分析

官方链路分析版本:Midscene 1.10.5,基线提交 2a8a5bb4 Custom Action 补充版本:Midscene 1.10.6,项目侧实验实现 分析日期:2026-07-16,补充日期:2026-07-23 范围:@midscene/android 的截图和点击实现、@midscene/core 从截图到 AI 定位坐标的处理链路,以及项目侧使用 Android CLI Layout Custom Action 替换坐标定位的扩展方案

1. 结论摘要

Midscene Android 的核心工作方式是“截图驱动的视觉定位”,不是 Android UIAutomator/XML 驱动:

  1. AndroidDevice.screenshotBase64() 从真机取得屏幕位图。
  2. Core 根据截图尺寸、设备逻辑尺寸和 screenshotShrinkFactor 建立坐标换算关系。
  3. 规划模型读取当前截图并决定下一步动作,例如 TapInputSwipe
  4. 目标坐标可以在规划阶段直接返回,也可以由独立的定位模型二次看图后返回。
  5. 模型返回的 bbox/point 先被模型适配器统一成截图像素 bbox,再取 bbox 中心点。
  6. 中心点从“发送给模型的截图坐标”换算为“设备逻辑坐标”,最后换算成 ADB 使用的物理像素坐标。
  7. Android 最终通过 adb shell input ... 执行点击或滑动。
  8. Midscene 官方提供 customActions 扩展口。Custom Action 被加入与 TapInput 同级的 Action Space,并不是在官方 Tap 内部替换 locator。
  9. 项目侧的 AndroidLayoutTap 参数 schema 没有 Midscene locate 字段,因此 TaskBuilder 不会创建 Planning/Locate 任务,而是直接调用 Custom Action 的 call()
  10. Custom Action 每次执行时调用 android layout --device <serial> --pretty,使用当前 Layout JSON 的 textcontent-desccenter 计算坐标,再通过 ADB 点击。
  11. 该方案只替换“动作目标的坐标定位”。AI 规划、任务截图、视觉断言和 Layout 失败后的视觉回退仍然可以使用截图与多模态模型。

默认截图实际上仍然基于 Android 的 screencap:第一优先级的 appium-adb.takeScreenshot() 在当前锁定的 appium-adb@12.12.1 中执行的是 adb exec-out screencap -p。Midscene 另外提供了设备文件加 adb pull 的回退路径,以及可选的 scrcpy H.264 视频流截图路径。

2. 端到端调用链

flowchart TD
    A[AndroidAgent.aiAct] --> B[TaskExecutor.action]
    B --> C[Agent.getUIContext]
    C --> D[commonContextParser]
    D --> E[AndroidDevice.screenshotBase64]
    E --> F{scrcpy 已启用且可用?}
    F -->|是| G[H.264 关键帧]
    G --> H[ffmpeg 解码为 JPEG]
    F -->|否或失败| I[appium-adb.takeScreenshot]
    I --> J{截图有效?}
    J -->|否| K[adb shell screencap 到设备文件]
    K --> M{命令执行失败?}
    M -->|否| L[adb pull 到本机]
    M -->|是| N[yadb forceScreenshot]
    N --> L
    J -->|是| O[PNG Buffer]
    H --> P[Base64 截图]
    L --> P
    O --> P
    P --> Q[计算截图尺寸、方向和缩放比]
    Q --> R[必要时缩图并转 JPEG]
    R --> S[规划模型选择动作]
    S --> T{规划结果已带坐标?}
    T -->|是| U[模型适配器统一为 PixelBbox]
    T -->|否| V[AiLocateElement 二次视觉定位]
    V --> U
    U --> W[PixelBbox 转 Rect 并取中心点]
    W --> X[截图坐标除以 shrunkShotToLogicalRatio]
    X --> Y[逻辑坐标换算为物理像素]
    Y --> Z[adb shell input swipe/tap]

主要入口:

3. ADB 获取截图

3.1 默认路径:adb exec-out screencap -p

AndroidDevice.screenshotBase64() 默认先调用 adb.takeScreenshot()

1
2
3
4
AndroidDevice.screenshotBase64()
  -> appium-adb ADB.takeScreenshot()
  -> adb exec-out screencap -p
  -> PNG Buffer

appium-adb 的版本固定在 packages/android/package.json。在 12.12.1 中,takeScreenshot() 使用 exec-out,避免先在设备上写临时文件,返回值直接是 PNG Buffer。

Midscene 收到 Buffer 后调用 validateScreenshotBuffer(),校验内容包括:

  • Buffer 不能为空。
  • 文件头必须是 PNG 或 JPEG。
  • 默认不得小于 1024 字节。
  • minScreenshotBufferSize 可以调整大小阈值,设为 0 只关闭最小尺寸检查,不关闭空 Buffer 和图片格式检查。

这里没有像素内容检查。因此一张尺寸正常、格式合法但内容全黑的 PNG 仍会被视为有效截图。

3.2 回退路径:设备文件 + adb pull

以下情况会进入文件回退路径:

  • adb.takeScreenshot() 抛出异常。
  • 返回 Buffer 未通过格式或大小校验。
  • 配置了 displayId,此时会跳过 takeScreenshot(),以便明确控制截图显示屏。
  • 主路径连续三次返回“格式或大小无效”的 Buffer,之后会直接跳过主路径。

回退流程位于 device.ts

1
2
3
4
adb shell screencap -p [-d displayId] /data/local/tmp/ms_<id>.png
adb pull /data/local/tmp/ms_<id>.png <local-temp-file>
读取本机文件并校验
异步执行 adb shell rm 删除设备临时文件

需要注意,takeScreenshotFailCount 只在“主路径成功返回 Buffer,但 Buffer 校验失败”时递增。adb.takeScreenshot() 自身抛错不会增加计数,因此网络/ADB 命令异常不会触发三次后永久跳过主路径。

设备临时文件使用独立的 execFile(adb, ...) 异步删除,避免清理命令占用主 ADB 连接并阻塞后续操作。

3.3 yadb 强制截图

如果 adb shell screencap 命令本身执行失败,Midscene 调用 forceScreenshot()

1
2
3
4
5
app_process \
  -Djava.class.path=/data/local/tmp/yadb \
  /data/local/tmp \
  com.ysbing.yadb.Main \
  -screenshot <device-path>

它的设计目标是补充普通 screencap 无法工作的场景。不过当前逻辑只有在 screencap 命令抛错时才进入 yadb;如果 screencap 成功生成了一张合法但全黑的图片,不会触发 yadb 回退。

另外,yadb 以预编译二进制形式下载,具体截图实现不在本仓库源码中。不能仅根据调用注释假设它一定能绕过所有厂商限制或 FLAG_SECURE

3.4 多显示屏

Android 设备配置支持:

  • displayId:为 screencapinput 指定显示屏。
  • usePhysicalDisplayIdForScreenshot:截图时使用物理显示屏 ID。
  • usePhysicalDisplayIdForDisplayLookup:读取尺寸、方向时使用物理显示屏 ID。

屏幕尺寸优先从 dumpsys display 中读取指定显示屏;无法匹配时回退到 wm size。相关实现见 getScreenSize()

3.5 可选 scrcpy 路径

设置 scrcpyConfig.enabled: true 后,AndroidDevice.connect() 会初始化 scrcpy。初始化失败会把当前适配器标记为不可用,本次设备生命周期内不再重试,然后继续使用 ADB 截图。

scrcpy 路径的关键行为:

  1. 将 scrcpy server 推送到设备。
  2. 通过 @yume-chan/adb-scrcpy 启动 H.264 视频流。
  3. 配置 audio: falsecontrol: falsemaxFps: 10
  4. 缓存 SPS/PPS 和最近的 IDR 关键帧。
  5. 截图时最多等待 300ms 获取新关键帧;静态页面没有新帧时使用最近缓存帧。
  6. 将 H.264 关键帧交给 ffmpeg 解码为 JPEG。
  7. 30 秒无活动后默认断开流。

源码入口:

scrcpy 的优势是连续截图时不需要每次启动 screencap 和传输整张 PNG。代价是依赖 ffmpeg、scrcpy server 和 H.264 解码;每张最终被使用的截图仍需执行一次 ffmpeg 解码。

3.6 UI Observer 的连续帧

普通 aiAct() 使用单张代表截图。agent.startObserving() 用于捕获 toast、动画等短暂状态:

  • scrcpy 可用时,观察期间只缓存原始 H.264 关键帧引用,结束后再解码真正需要的帧。
  • scrcpy 不可用时,按间隔重复调用 screenshotBase64()
  • 帧数达到上限后进行抽稀,但保留内容变化点。
  • 最终把时间序列截图一起交给 assert/extract 模型。

实现见 UIObserveropenScrcpyFrameSource()

4. 截图进入 AI 前的处理

4.1 逻辑尺寸与截图尺寸

Android 同时存在两种尺寸:

  • 物理尺寸:例如 1080 x 2400,来自 wm sizedumpsys display
  • 逻辑尺寸:物理尺寸除以 density ratio,density / 160,例如 360 x 800

AndroidDevice.size() 返回逻辑尺寸。Core 再读取实际截图宽高,并计算:

dpr = screenshotWidth / logicalWidth
shrunkShotToLogicalRatio = dpr / screenshotShrinkFactor

部分设备会报告错误方向。commonContextParser() 会比较逻辑尺寸和截图尺寸的横竖屏关系;不一致时交换逻辑宽高后再计算比例。

4.2 缩图与格式转换

screenshotShrinkFactor 控制发送给模型的截图尺寸:

modelImageWidth  = physicalScreenshotWidth  / screenshotShrinkFactor
modelImageHeight = physicalScreenshotHeight / screenshotShrinkFactor

缩图可以降低图像 token、网络传输和报告体积,但会降低小图标和细文字的定位精度。

未缩放时,如果截图是 PNG,Core 会以 90 质量转成 JPEG;已经是 JPEG 的 scrcpy 截图不会重复转换。相关实现见 commonContextParser()

4.3 模型专用预处理

prepareModelImage() 按模型适配器执行额外预处理。目前典型场景是 Qwen 2.5:将图片右侧和底部补白到 28 的倍数。

系统同时记录:

  • preparedSize:真正发给模型的尺寸,包含 padding。
  • contentSize:原始有效截图区域。

模型坐标先相对 preparedSize 解析,再裁剪到 contentSize,避免点击补白区域。

5. AI 如何得到目标坐标

5.1 Android 路径不依赖 UI XML

AndroidDevice.getElementsInfo() 返回空数组,getElementsNodeTree() 也返回空树。也就是说,当前 Android 主流程不会读取 UIAutomator XML,也不会用 resource-id/text 节点反查坐标。

模型看到的是截图和自然语言任务,坐标来自视觉模型对位图的理解。ADB 只负责:

  • 获取截图。
  • 获取屏幕尺寸、方向和 density。
  • 执行模型规划后的点击、滑动、按键和输入命令。

5.2 规划阶段直接定位

当规划模型和默认定位模型使用同一个 default slot,且未开启 deepThink 时,includeLocateInPlanning=true。规划提示词会要求模型在动作参数中同时返回目标描述和坐标,例如:

1
2
3
4
5
6
7
8
9
<action-type>Tap</action-type>
<action-param-json>
{
  "locate": {
    "prompt": "the Search button",
    "bbox": [120, 180, 260, 240]
  }
}
</action-param-json>

规划结果经 normalizePlanningActionLocateFields() 调用模型适配器,把原始 bboxpoint 转为统一的 locatedPixelBbox

TaskBuilder 仍会创建一个 Locate 任务,但它优先使用规划阶段的 locatedPixelBbox,不再发起第二次 AI 定位请求。

5.3 独立定位模型二次定位

以下场景通常只在规划结果中保留 prompt,然后调用 AiLocateElement() 二次看图:

  • 配置了独立 planning slot。
  • 开启 deepThink
  • 需要 deepLocate 精细定位。

独立定位提示词要求模型返回 JSON:

1
2
3
4
{
  "bbox": [xmin, ymin, xmax, ymax],
  "errors": []
}

实际字段、顺序和坐标范围由模型适配器决定。提示词会明确告诉模型返回像素坐标、0-1000 归一化坐标,或者 0-1 归一化坐标。

5.4 模型坐标适配

不同视觉模型的原始坐标协议不同,Midscene 使用 LocateResultAdapter 统一处理:

模型 family 原始形状 顺序 坐标范围
gpt-5 bbox [xmin, ymin, xmax, ymax] 截图实际像素
qwen2.5-vl bbox/point xy 截图实际像素,图片可能补白到 28 的倍数
qwen3-vl / qwen3 / qwen3.5 / qwen3.6 bbox xy 0-1000
doubao-vision / doubao-seed bbox/point xy 0-1000
gemini bbox [ymin, xmin, ymax, xmax] 0-1000
glm-v bbox xy 0-1000
kimi point xy 小于等于 1 时按 0-1,否则按截图像素
vlm-ui-tars* bbox/point xy 0-1000

适配链路位于 model-locate-result

  1. bboxbbox_2dpoint 字段提取原始值。
  2. 将字符串、嵌套数组等结果解析为有限数字。
  3. 根据 xy/yx 顺序重排。
  4. 根据 normalizedBy 映射到图片像素。
  5. point 结果扩成一个小 bbox。
  6. 验证坐标顺序、有限值和图片边界。
  7. 将 padding 区域裁剪到真实内容边界。

归一化到像素的核心公式是:

pixel = round(normalizedValue * (imageSize - 1) / normalizedBy)

使用 imageSize - 1 是因为内部 PixelBbox 使用包含端点的像素索引。例如一张宽 200 的图片,归一化值 1000 会映射到最右侧像素 199。

5.5 deepLocate

deepLocate 会先寻找较大的搜索区域,再裁剪并放大该区域做第二次精确定位:

  1. 先使用规划阶段粗 bbox、调用 section locator,或执行一次全图 AiLocateElement 找到目标区域。
  2. 扩大搜索区域。
  3. 裁剪原始截图。
  4. 将裁剪图放大 2 倍交给定位模型。
  5. 将局部坐标除以放大倍数,再加裁剪区域 offset,映射回完整截图。

对应实现:

6. 从 AI bbox 到 ADB 点击坐标

6.1 bbox 转中心点

统一后的 PixelBbox 格式是包含端点的:

[left, top, right, bottom]

它先转换为:

width  = right - left + 1
height = bottom - top + 1

再由 generateElementByRect() 计算中心:

centerX = left + floor((width - 1) / 2)
centerY = top  + floor((height - 1) / 2)

偶数宽高时,中心点取中间四个像素中的左上像素。

6.2 截图坐标转逻辑坐标

动作执行前,parseActionParam() 将模型截图坐标除以 shrunkShotToLogicalRatio

logicalX = round(modelScreenshotX / shrunkShotToLogicalRatio)
logicalY = round(modelScreenshotY / shrunkShotToLogicalRatio)

这一步同时消除:

  • Android density 带来的物理像素/逻辑像素差异。
  • screenshotShrinkFactor 带来的图片缩放差异。
  • scrcpy maxSize 带来的视频流缩放差异,因为比例基于实际截图尺寸重新计算。

6.3 逻辑坐标转 ADB 物理像素

AndroidDevice.adjustCoordinates() 计算逻辑尺寸与物理尺寸的 X/Y 比例:

1
2
3
4
5
scaleX = logicalWidth  / physicalWidth
scaleY = logicalHeight / physicalHeight

physicalX = round(logicalX / scaleX)
physicalY = round(logicalY / scaleY)

X/Y 分开计算,因此也支持非等比的自定义逻辑尺寸。

6.4 完整数值示例

假设:

  • 物理屏幕和原始截图:1080 x 2400
  • Android 逻辑尺寸:360 x 800
  • screenshotShrinkFactor = 2
  • 发给模型的图片:540 x 1200
  • GPT 返回 bbox:[90, 300, 210, 420]

计算过程:

dpr = 1080 / 360 = 3
shrunkShotToLogicalRatio = 3 / 2 = 1.5

bbox width  = 210 - 90 + 1 = 121
bbox height = 420 - 300 + 1 = 121
截图中心点 = (150, 360)

逻辑坐标 = (150 / 1.5, 360 / 1.5) = (100, 240)

scaleX = 360 / 1080 = 1/3
scaleY = 800 / 2400 = 1/3
ADB 物理坐标 = (100 / (1/3), 240 / (1/3)) = (300, 720)

最终点击点 (300, 720) 与原始 1080 x 2400 截图上的目标中心一致。

6.5 ADB 点击命令

Tap 动作从 locate 结果读取中心点,经 tapPoint() 换算后执行:

adb shell input [-d displayId] swipe x y x y 150

这里使用起点和终点相同、持续 150ms 的 swipe 来模拟点击,而不是直接使用 input tap。双击则使用两次 input tap,中间间隔 50ms。

相关源码:

7. Custom Action 如何替换截图坐标定位

7.1 它作用在什么位置

Custom Action 作用在 Midscene 的 Action Space 与设备动作执行边界。它没有修改截图方法、规划模型或官方 AiLocateElement(),而是注册了一组新的动作类型:

1
2
3
AndroidLayoutTap
AndroidLayoutScrollUntil
AndroidLayoutReplaceText

规划模型可以在官方 Tap 和这些 Custom Action 之间选择。两条执行链的分叉发生在模型输出动作类型之后:

flowchart TD
    A["aiAct 收到自然语言操作"] --> B["获取当前截图和 UIContext"]
    B --> C["规划模型读取截图、任务和 Action Space"]
    C --> D{"模型选择哪种动作?"}

    D -->|"官方 Tap"| E["TaskBuilder 发现 locate 字段"]
    E --> F["创建 Planning / Locate 任务"]
    F --> G["规划内 bbox 或 AiLocateElement 看图定位"]
    G --> H["截图坐标转逻辑坐标和物理坐标"]
    H --> I["AndroidDevice.tapPoint"]

    D -->|"AndroidLayoutTap"| J["TaskBuilder 未发现 locate 字段"]
    J --> K["直接调用 Custom Action.call"]
    K --> L["android layout --pretty"]
    L --> M["按 text 和 content-desc 匹配当前节点"]
    M --> N["读取节点当前 center"]
    N --> O["adb shell input tap"]

因此,“Custom Action 修改截图定位行为”更准确的说法是:

它没有改写 Midscene 原 locator,而是为同一个操作目标提供了另一条不经过 locator 的设备动作路径。

7.2 注册链路

项目侧实现首先使用官方 defineAction() 定义动作。每个动作包含:

1
2
3
4
5
name         模型输出的动作类型
description  告诉模型何时使用
paramSchema  约束模型需要返回的参数
sample       给模型的参数示例
call         真正执行设备操作的函数

示例实现入口位于:

1
2
3
4
5
6
tests/midscene/src/android-layout-actions.mjs
  -> createAndroidLayoutActions()
  -> actions: [AndroidLayoutTap, AndroidLayoutScrollUntil, AndroidLayoutReplaceText]

tests/midscene/src/native-suite.mjs
  -> new AndroidDevice(serial, { customActions: layoutActions.actions })

官方 AndroidDevice 构造函数保存 options.customActions,然后在 actionSpace() 中合并:

const customActions = this.customActions || [];
return [...defaultActions, ...platformSpecificActions, ...customActions];

Agent 再读取设备 Action Space,并将其交给 TaskExecutor

1
2
3
4
5
6
const baseActionSpace = this.interface.actionSpace();
this.fullActionSpace = [...baseActionSpace, defineActionSleep()];

this.taskExecutor = new TaskExecutor(this.interface, this.service, {
  actionSpace: this.fullActionSpace,
});

对应源码:

这个扩展点属于 Midscene 官方公开能力,所以业务项目不需要 fork 或修改 @midscene/core 才能加入新的定位方式。

7.3 AI 为什么知道可以调用它

Planning Prompt 不是写死一份固定动作列表。systemPromptToTaskPlanning() 会遍历当前设备的 Action Space,把每个动作的名称、描述、参数 schema 和 sample 拼进系统提示词:

1
2
3
const actionDescriptionList = actionSpace.map((action) => {
  return descriptionForAction(action, ...);
});

因此模型看到的不只是:

1
2
3
Tap
Input
Scroll

还会看到:

1
2
3
AndroidLayoutTap(target, kind?)
AndroidLayoutScrollUntil(target, kind?, maxAttempts?, safeY?)
AndroidLayoutReplaceText(fieldLabel, value)

项目侧又通过两层提示增加动作选择约束:

  1. AndroidAgent.aiActionContext 声明原生 UI 优先使用 Android Layout Custom Action。
  2. NativeMidsceneExecutor.operationPrompt() 在每次操作中重复声明点击、滚动和输入对应的首选动作。

Prompt 只负责引导,不足以证明模型一定照做。执行完成后,NativeMidsceneExecutor 还会读取报告中的 Action Space tasks:

  • 没有调用要求的 Custom Action,测试失败。
  • 没有 Layout miss 却直接使用官方视觉动作,测试失败。
  • 出现当前测试操作之外的动作,测试失败。

对应实现位于:

tests/midscene/src/native-executor.mjs
tests/midscene/src/midscene-action-audit.mjs

所以当前方案是“Prompt 引导 + 执行记录审计”,不是只靠一句提示词。

7.4 跳过截图 Locate 的关键:参数 schema 没有 locate

官方 Tap 的参数 schema 是:

1
2
3
const actionTapParamSchema = z.object({
  locate: getMidsceneLocationSchema(),
});

locate 是一个带有 Midscene locator 标记的特殊 schema。TaskBuilder.handleActionPlan() 会调用 findAllMidsceneLocatorField() 扫描动作参数:

1
2
3
4
5
找到 locate 字段
  -> 创建 Planning / Locate task
  -> 使用规划 bbox 或 AiLocateElement
  -> 把结果替换成 LocateResultElement
  -> 再执行 Tap.call()

项目侧的点击动作故意使用普通字符串参数:

1
2
3
4
paramSchema: z.object({
  target: z.string(),
  kind: z.enum(actionKinds).optional(),
})

这个 schema 中不存在 Midscene locator 字段,因此 locateFields 是空数组,TaskBuilder 不会插入 Locate task。参数经过 Zod 校验后直接执行:

const actionFn = action.call.bind(this.interface);
const actionResult = await actionFn(param, taskContext);

对应源码见 TaskBuilder 调用 Action。这就是 Custom Action 能真正绕开官方截图坐标定位的源码原因。

7.5 Custom Action 内部如何得到坐标

AndroidLayoutTap 的执行链是:

模型输出 target 和 kind
  -> runExpected()
  -> controller.tap()
  -> queryLayout()
  -> android layout --device <serial> --pretty
  -> JSON.parse(stdout)
  -> targetAliases()
  -> elementScore()
  -> findElement()
  -> parseCenter(element.center)
  -> adb shell input tap x y

当前匹配依据主要是:

条件 基础分数
text 与 target 完全相等 120
content-desc 与 target 完全相等 118
content-desc 以 target 开头 112
text 包含 target 92
content-desc 包含 target 90
节点可点击 额外 +8
navigation 且描述包含 tab 额外 +12
chip 且可 check 额外 +10

只有分数达到 90 才允许点击。每次动作前都会重新执行 android layout,所以 Custom Action 没有缓存旧坐标;控件移动或尺寸变化后,只要语义标签仍可匹配,就会使用新 Layout 中的中心点。

Layout JSON 同时保存到测试产物中,例如:

<case-id>-001-tap-before.json
<case-id>-002-scroll-until-attempt-0.json

这让报告中的坐标来源可以追溯,而不是只能看到模型返回了一个 bbox。

7.6 三个动作分别替换了什么

Custom Action 替换的官方视觉路径 当前实现
AndroidLayoutTap Tap.locate -> bbox -> center 查当前 Layout,匹配具名节点,点击 center
AndroidLayoutScrollUntil AI 反复判断目标是否出现并决定滚动 每轮查 Layout,未进入安全区域就执行固定 ADB swipe
AndroidLayoutReplaceText AI 找输入框坐标后执行 Input 找 field label,再选择其下方暴露的输入节点,点击、清空并输入

它们只覆盖当前定义的操作类型。视觉断言仍使用 agent.aiAssert();系统启动、清数据、Back 等操作走确定性的 Android/ADB 系统动作。

7.7 为什么仍然能在报告里看到截图

Custom Action 并没有让整个 aiAct() 变成无截图流程:

  1. Planning task 执行前仍然构建 UIContext,其中包含当前截图。
  2. 规划模型仍然需要截图理解页面状态,并决定调用哪个动作、使用什么语义 target。
  3. 每次重新规划都会附带最新截图。
  4. aiAssert() 仍然通过截图判断业务结果。
  5. Midscene 报告会保存 planning、action 前后状态和断言证据。

被省掉的是官方动作中独立的“截图找 bbox/point”步骤:

阶段 纯 Midscene Android Layout Custom Action
规划前截图
AI 理解任务和页面
AI 生成动作 Tap AndroidLayoutTap
Planning/Locate task 有,或使用 planning 内 bbox
坐标来源 多模态模型读取截图 android layout JSON
坐标换算 截图 bbox -> 逻辑坐标 -> 物理坐标 直接使用 Layout 当前 center
结果断言截图

因此它降低的是定位 token 和视觉误差,不会消除规划和断言的图像 token。

7.8 Layout miss 后如何回退视觉定位

以下场景 Android Layout 可能找不到目标:

  • Canvas、自绘控件或游戏画面。
  • WebView 没有把内部元素暴露到 Android 可访问节点树。
  • 图标按钮没有稳定的 text/content-desc
  • 元素被遮挡、未进入可见区域或标签已经改名。

controller 在找不到高置信度目标时抛出 AndroidLayoutTargetError。外层 runExpected() 将其转换为:

1
2
3
4
5
{
  "success": false,
  "recoverable": true,
  "error": "No high-confidence match for ..."
}

同时通过 task.planningFeedback 告诉下一轮规划:只允许为当前失败操作使用一次 Midscene 内置视觉动作。Core 的 TaskExecutor 会收集 action task 的 planningFeedback,与最新截图一起送入下一轮 Planning。

这条回退链是:

flowchart LR
    A["AndroidLayoutTap"] --> B["查询 Layout"]
    B --> C{"高置信度目标存在?"}
    C -->|"是"| D["ADB 点击"]
    C -->|"否"| E["返回 recoverable layout miss"]
    E --> F["planningFeedback"]
    F --> G["下一轮 Planning + 最新截图"]
    G --> H["仅本操作允许内置视觉 Tap"]
    H --> I["执行记录审计"]

需要注意:success: false 是 Custom Action 的业务返回值,不等同于 JavaScript 抛错。是否继续规划由 Midscene 的 planning loop、shouldContinuePlanning 和反馈共同决定;外层审计负责防止模型静默绕过 Custom Action。

7.9 页面结构变化时的行为

Custom Action 对“坐标变化”和“交互流程变化”的适应能力不同:

页面变化 行为
按钮移动或尺寸变化 每次重新读取 Layout 和 center,通常不受影响
层级改变但 text/content-desc 不变 通常仍能匹配
按钮改名 旧 target 匹配失败,进入视觉回退或重新规划
按钮变成折叠菜单中的菜单项 旧的一步点击计划可能先失败;AI 需要重新规划为“打开菜单,再点菜单项”
出现多个同名节点 当前取最高分节点,仍有误点风险
目标变成 WebView/Canvas 自绘 Layout 可能完全不可见,需要视觉回退

Plan Cache 的命中键不包含截图或 Layout。即使页面结构变化,只要任务 prompt 和 context 没变,也可能先复用旧计划。Custom Action 会使用最新坐标,但不会自动把“一步按钮点击”改成“两步菜单操作”。结构或流程变化时应重建 Plan Cache,并用结果断言防止假通过。缓存细节见 Midscene AI 任务缓存源码分析

7.10 如何证明实际使用了 Android Layout,而不是截图定位

不能只看报告里的截图,因为 Custom Action 模式本来仍会为 Planning 和 Assert 截图。应同时检查以下证据:

  1. 报告中的 Action task subTypeAndroidLayoutTapAndroidLayoutScrollUntilAndroidLayoutReplaceText
  2. 对应操作之前没有 Planning / Locate task。
  3. 产物目录存在该操作生成的 *-before.json Layout 文件。
  4. Custom Action 返回结果包含 matchedcenterscorelayout 文件名。
  5. 汇总指标满足 layoutQueryCount >= androidLayoutCustomActionCalls
  6. 如果出现官方 Tap,必须同时存在对应的 recoverable layoutMiss,否则策略审计应让测试失败。

tests/midscene/src/midscene-action-audit.mjs 已按 Action task 类型分类并统计:

1
2
3
4
customTasks          AndroidLayout*
builtInVisualTasks   Tap/Input/Scroll/...
locateTasks          Planning / Locate
layoutMisses         Android Layout 可恢复失败

这组证据比“模型说自己使用了 Android CLI”可靠,因为它来自实际执行任务和命令产物。

7.11 设计边界

这个方案的本质是把职责拆成三层:

1
2
3
AI Planning      决定下一步做什么,以及语义目标是什么
Android Layout   将语义目标解析为当前设备坐标
ADB              执行点击、滑动和输入

优点:

  • 使用 Midscene 官方扩展口,不需要修改 Core。
  • 坐标来自当前 Layout,不依赖 AI 估算 bbox。
  • 页面只移动位置时不需要重建坐标缓存。
  • Layout JSON、匹配分数和 ADB 坐标都能留作审计证据。
  • Layout 不可用时仍能保留 Midscene 视觉回退。

限制:

  • AI 仍通过截图规划,规划 token 没有被完全消除。
  • 匹配依赖可访问语义,content-desc 质量会直接影响稳定性。
  • kind 当前主要用于加分,不是严格的控件类型校验。
  • 同名元素、动态文案、折叠菜单和跨页面流程仍需要断言与重规划。
  • 当前是项目测试层的实验实现,不是 Midscene 内置的通用 Android locator adapter。

8. 失败保护和已知边界

8.1 已有保护

  • 截图校验空 Buffer、图片文件头和最小体积。
  • 无效主截图连续出现三次后跳过主路径。
  • scrcpy 初始化或截图失败后自动回退 ADB。
  • 模型坐标必须是有限数字,且不得超出对应坐标系统范围。
  • bbox 必须满足 right >= leftbottom >= top
  • 模型 padding 区域会裁剪到真实图片内容范围。
  • 设备方向与截图横竖屏不一致时,会交换逻辑宽高后计算比例。
  • 每次动作前把截图坐标统一转换为逻辑坐标,避免缩图后直接误点。

8.2 仍需关注的边界

  1. 合法黑屏不会触发回退 当前校验不做亮度、熵或像素方差检测。受 FLAG_SECURE、锁屏、视频 DRM 或厂商策略影响的合法黑图会直接发给模型。

  2. yadb 只在命令异常时触发 screencap 命令成功但图片内容错误时,不会自动重试 yadb。

  3. 失败时本机临时文件可能残留 回退截图只有成功走到方法末尾才异步删除本机临时文件;读取或校验中途抛错时可能残留文件。

  4. 屏幕信息默认缓存 cachedScreenSizecachedOrientationcachedAdjustScale 默认缓存到 destroy()。测试过程中旋转屏幕、切换外接显示屏或动态修改分辨率时,建议设置 alwaysRefreshScreenInfo: true

  5. 视觉定位不保证控件语义 Android 路径没有 resource-id、accessibility node 或 XML 的确定性辅助。相同文字、很小的图标、遮挡、动画和低清缩图都会降低定位稳定性。

  6. bbox 中心不一定是最佳可点击区域 系统固定点击 bbox 中心。模型如果返回整个列表项、文字和按钮的组合区域,中心点可能落到不可点击位置。定位提示词因此强调返回紧凑的目标区域。

  7. 远程 ADB 的 scrcpy 连接范围 ScrcpyDeviceAdapter 当前连接 127.0.0.1:5037。远程设备需要先出现在本机 ADB server 中;它不会直接复用 remoteAdbHost/remoteAdbPort 创建 scrcpy transport。

9. 调试建议

9.1 判断使用了哪条截图路径

启用 Midscene debug 日志后关注:

1
2
3
4
5
6
android:device
android:scrcpy
android:scrcpy-adapter
commonContextParser
ai:inspect
agent:task-builder

典型日志:

  • Attempting scrcpy screenshot...:正在使用 scrcpy。
  • Scrcpy screenshot failed, falling back...:scrcpy 失败,切换 ADB。
  • Taking screenshot via adb.takeScreenshot:主 ADB 路径。
  • Fallback: taking screenshot via shell screencap:设备文件回退路径。
  • screencap failed, using forceScreenshot:进入 yadb。
  • calculated dprshrunkShotToLogicalRatio:坐标比例。
  • planResult:规划模型原始动作。
  • resRect:模型坐标统一后的截图 Rect。
  • executing action:动作执行前的最终参数。

9.2 坐标偏移排查顺序

  1. 保存并检查原始截图是否黑屏、旋转或被裁剪。
  2. 对比 shotSize 与实际发送给模型的图片尺寸。
  3. 检查模型 family 是否配置正确,避免用错 xy/yx 或归一化规则。
  4. 检查模型原始 bbox 是否紧贴目标元素。
  5. 检查 shrunkShotToLogicalRatio 是否符合 截图宽度 / 逻辑宽度
  6. 检查 size()、物理尺寸和 adjustCoordinates() 的 X/Y scale。
  7. 多显示屏设备检查截图和 input 是否使用同一个 displayId
  8. 旋转或切屏场景临时启用 alwaysRefreshScreenInfo 验证是否为缓存问题。

10. 相关测试

仓库已经覆盖这条链路的关键行为:

11. 关键源码索引

模块 文件 职责
Android Agent packages/android/src/agent.ts Android Agent 封装与设备连接
Android Device packages/android/src/device.ts ADB、截图、尺寸、坐标换算和输入动作
scrcpy Adapter packages/android/src/scrcpy-device-adapter.ts scrcpy 生命周期和 ADB 回退边界
scrcpy Manager packages/android/src/scrcpy-manager.ts H.264 流、关键帧缓存和 ffmpeg 解码
UI Context packages/core/src/agent/utils.ts 截图尺寸、方向、缩图和坐标比例
AI Planning packages/core/src/ai-model/llm-planning.ts 任务规划和截图输入
AI Locate packages/core/src/ai-model/inspect.ts 独立视觉定位和 deepLocate
Locate Adapter packages/core/src/ai-model/shared/model-locate-result 模型坐标协议统一
Task Builder packages/core/src/agent/task-builder.ts 定位任务、坐标转换和动作执行
Device Actions packages/core/src/device/index.ts Tap/Input/Swipe 等标准动作
Action Space Prompt packages/core/src/ai-model/prompt/llm-planning.ts 把动作描述、参数 schema 和 sample 提供给规划模型
Custom Action 注册 tests/midscene/src/android-layout-actions.mjs 定义 Android Layout 动作、匹配节点并执行 ADB 命令
Custom Action 装配 tests/midscene/src/native-suite.mjs 将 Custom Action 注入 AndroidDevice,并配置 Agent 约束
Custom Action 审计 tests/midscene/src/native-executor.mjstests/midscene/src/midscene-action-audit.mjs 校验模型是否真实使用 Custom Action,并限制视觉回退