Midscene.js 原理拆解:它不是“自然语言点按钮”,而是一套会看屏幕的 UI 自动化运行时

摘要(先看结论)

  • Midscene.js 最值得理解的,不是它能不能帮你点按钮,而是它把 UI 自动化拆成了四层:看图理解设备执行结果校验报告留痕
  • 如果只看一句 await agent.aiTap('搜索按钮'),你很容易误以为它只是把 click() 换成了自然语言。真正的情况是:它会先截图、再让模型判断目标在哪、再换算成设备坐标、最后才执行点击。
  • 它最适合的场景,不是所有页面都替换成 AI 自动化,而是 selector 不稳自绘界面多弹窗变化快跨端心智不统一 的那部分问题。
  • 它也不是银弹。它更慢、更贵、判断有时会摇摆,但换来的是一套更贴近“用户眼里页面长什么样”的自动化能力。

为什么 Midscene 值得单独讲

传统 UI 自动化的主流写法,大致都长这样:

先知道元素在哪
  -> 再去 click / input / assert

问题在于,“先知道元素在哪” 这件事,在很多真实页面里并不稳定。

常见痛点有四类:

  • Web 里 selector 经常随着组件重构、文案调整、层级变化而失效。
  • Android 和 iOS 的控件树、自动化工具链、调试方式都不一样,团队很难形成统一心智。
  • Canvas、自绘组件、图片按钮、复杂弹窗,本来就不适合强依赖结构树来描述。
  • 对业务同学来说,“点第一个搜索入口”和“点 //*[@id='xxx']/div[3]/button”根本不是同一种语言。

Midscene 做的事,可以粗暴概括成一句话:

把“怎么找元素”这件事,
从手写 selector,
前移成“先看懂当前屏幕”。

这听起来像一句大话,所以更需要拆开讲。

先看一个最小示意:一次 Android 搜索链路到底怎么写

下面这段代码不是某个私有仓库的原样拷贝,而是把一个 Android 冒烟场景提炼成了“读者单独看也能懂”的最小示意。

目标很简单:

  • 连接一台 Android 真机
  • 启动目标 App
  • 找到搜索入口
  • 输入关键词
  • 点击搜索
  • 判断结果页是否真的出现

如果你读到这里,已经想自己跑一个 demo,准备工作其实也不复杂,主要分成四块。

跑 demo 前要准备什么

1. 本地环境

最小要求通常是:

  • 安装 Node.js 18 或更高版本
  • 安装 npm
  • 安装 Android Platform Tools,并确保终端里能执行 adb
  • 准备一台开启 USB 调试的 Android 真机

最基本的自检命令通常是:

node -v
npm -v
adb version
adb devices

如果 adb devices 里看不到状态为 device 的真机,先别急着怪 Midscene,先解决数据线、USB 调试授权、厂商驱动这些基础问题。

2. 新建一个最小工程并安装依赖

如果只是想跑通一个最小 demo,通常一个普通的 Node 工程就够了:

mkdir midscene-android-demo
cd midscene-android-demo
npm init -y
npm install @midscene/android dotenv
npm install -D typescript tsx @types/node

如果你懒得配 TypeScript,也可以直接用 JavaScript。但从可读性和提示信息来看,TypeScript 更适合做这种示意工程。

3. 准备模型参数

Midscene 不是本地规则引擎,它需要一个能看图的模型。

最少你要准备这几个参数:

  • MIDSCENE_MODEL_NAME
  • MIDSCENE_BASE_URL
  • MIDSCENE_API_KEY
  • MIDSCENE_MODEL_FAMILY

可以把它们理解成:

  • MODEL_NAME:这次到底调用哪个模型或接入点
  • BASE_URL:请求发到哪家兼容 OpenAI 协议的网关
  • API_KEY:鉴权凭证
  • MODEL_FAMILY:告诉 Midscene 这是哪一类模型,以及它能不能看图

例如你走火山方舟上的 Doubao Seed 接入点,.env 可以长这样:

ANDROID_SERIAL=你的设备序列号
APP_PACKAGE_NAME=com.example.app
SEARCH_KEYWORD=新能源

MIDSCENE_MODEL_NAME=ep-xxxxxxxxxxxxxxxx
MIDSCENE_BASE_URL=https://ark.cn-beijing.volces.com/api/v3
MIDSCENE_API_KEY=你的 API Key
MIDSCENE_MODEL_FAMILY=doubao-seed

如果你走别的兼容 OpenAI 协议的平台,核心思路也一样:

  • MIDSCENE_MODEL_NAME 填具体模型 ID 或接入点 ID
  • MIDSCENE_BASE_URL 填平台网关地址
  • MIDSCENE_API_KEY 填平台密钥
  • MIDSCENE_MODEL_FAMILY 填 Midscene 能识别的模型族

这里最关键的一点不是“它能不能聊天”,而是“它能不能看图”。因为 Midscene 做的是视觉驱动自动化,不是纯文本问答。

4. 先跑前置检查,再跑主链路

真正写脚本前,建议先做一轮最小检查:

adb devices
adb shell pm list packages | grep 你的包名关键词

确认设备在线、目标 App 已安装之后,再运行你自己的脚本。

更稳的做法是单独写一个 preflight

  • 检查 adb 是否可用
  • 检查真机是否在线
  • 检查目标 App 是否安装
  • 检查模型参数是否填全

这样后面一旦失败,你至少知道问题是出在设备、App、还是模型配置。

import 'dotenv/config';
import {
  AndroidAgent,
  AndroidDevice,
  getConnectedDevices,
} from '@midscene/android';

type AppConfig = {
  androidSerial?: string;
  packageName: string;
  searchKeyword: string;
  modelName: string;
  baseUrl: string;
  apiKey: string;
  modelFamily: string;
};

function readConfig(env: NodeJS.ProcessEnv): AppConfig {
  return {
    androidSerial: env.ANDROID_SERIAL,
    packageName: env.APP_PACKAGE_NAME || 'com.example.app',
    searchKeyword: env.SEARCH_KEYWORD || '新能源',
    modelName: env.MIDSCENE_MODEL_NAME || '',
    baseUrl: env.MIDSCENE_BASE_URL || '',
    apiKey: env.MIDSCENE_API_KEY || '',
    modelFamily: env.MIDSCENE_MODEL_FAMILY || '',
  };
}

async function main() {
  const config = readConfig(process.env);

  // 1. 找到当前通过 ADB 连接的 Android 设备
  const devices = await getConnectedDevices();
  const target = devices.find((item) =>
    config.androidSerial ? item.udid === config.androidSerial : true,
  );

  if (!target) {
    throw new Error('No Android device found');
  }

  // 2. 把这台真机包装成一个可执行动作的设备对象
  const device = new AndroidDevice(target.udid, {
    keyboardDismissStrategy: 'back-first',
  });
  await device.connect();

  // 3. 基于设备对象创建 AndroidAgent
  //    它会把“看图理解 + 设备执行 + 报告记录”绑在一起
  const agent = new AndroidAgent(device, {
    generateReport: true,
    modelConfig: {
      MIDSCENE_MODEL_NAME: config.modelName,
      MIDSCENE_MODEL_BASE_URL: config.baseUrl,
      MIDSCENE_MODEL_API_KEY: config.apiKey,
      MIDSCENE_MODEL_FAMILY: config.modelFamily,
    },
    aiActionContext:
      'If permission dialogs, user agreements, or login popups appear, close or accept them so the search flow can continue.',
  });

  // 4. 拉起目标 App
  await agent.launch(config.packageName);

  // 5. 先确认首页或搜索入口已经出现,再进入后续动作
  await agent.aiWaitFor('首页或搜索入口已经可见');

  // 6. 让模型在当前屏幕里找“搜索入口”,然后点击
  await agent.aiTap('页面上的搜索入口');

  // 7. 找到搜索输入框并输入关键词
  await agent.aiInput('搜索输入框', {
    value: config.searchKeyword,
    autoDismissKeyboard: true,
  });

  // 8. 找到搜索按钮并点击
  await agent.aiTap('搜索按钮');

  // 9. 等待结果页核心内容出现
  await agent.aiWaitFor('页面出现搜索结果列表或结果页核心内容');

  // 10. 最后再问一次模型:现在最能证明已进入结果页的内容是什么
  const summary = await agent.aiQuery<string>(
    '用一句话描述当前页面最能证明已经进入搜索结果页的文字或内容',
  );

  console.log(summary);
}

main().catch((error) => {
  console.error(error);
  process.exit(1);
});

这段代码最值得注意的点,不是写法有多复杂,而是它非常短。越短,越说明真正的复杂度被压进了 SDK。

先建立一个总心智模型

如果把这段代码背后的运行过程画出来,大致是这样:

.env / 终端参数
   |
   v
读取配置
   |
   v
发现设备 -> 连接设备
   |
   v
创建 AndroidAgent
   |
   v
截图 -> 模型理解 -> 执行动作 -> 再截图
   |
   v
结果校验 + HTML report

再换一种说法,Midscene 在 Android 上真正做的不是:

把自然语言直接翻译成 adb 命令

而是:

先把真机抽象成统一屏幕,
再让模型围绕截图做判断和定位,
最后把动作稳定落到设备执行层。

这就是为什么它更像“运行时”,而不是“自然语言 click 封装”。

先把最基础的问题讲透

为什么很多 Midscene Android 项目都会有一个 preflight

因为这类工程最怕一开始就把所有问题混在一起。

preflight 的价值不是“跑个前置脚本显得专业”,而是先把下面这些问题分离掉:

  • adb 有没有安装
  • 真机有没有连上
  • USB 调试有没有授权
  • 目标 App 包名对不对

常见的第一步就是:

adb version
adb devices
adb shell pm list packages

这三条命令背后回答的是三个完全不同的问题:

  • adb version:系统能不能找到 adb
  • adb devices:手机有没有在线
  • pm list packages:目标 App 在不在这台设备上

如果这三步没过,就不要急着怪 Midscene。

逐段看这段代码到底在做什么

下面开始顺着刚才的最小示意,一段一段拆。

第一段:发现设备

const devices = await getConnectedDevices();
const target = devices.find((item) =>
  config.androidSerial ? item.udid === config.androidSerial : true,
);

这里最容易冒出来的问题是:getConnectedDevices() 到底是什么?

你可以把它理解成:

去问当前 ADB server:
现在有哪些 Android 设备在线?

它本质上是设备发现入口。

这一步不是 AI,也不是视觉理解,而是纯设备层能力。

第二段:包装成 AndroidDevice

const device = new AndroidDevice(target.udid, {
  keyboardDismissStrategy: 'back-first',
});
await device.connect();

AndroidDevice 更像“手脚”。

它负责的不是判断页面里有什么,而是把一台真机变成一个“可以被执行动作的对象”。

它通常会处理这些事情:

  • 连接 ADB
  • 截图
  • 点击
  • 输入
  • 滑动
  • 收起键盘
  • 启动 App
对应的 ADB 命令速查
  • 连接 ADB:adb devices,例如 adb devices
  • 截图:adb exec-out screencap -p > home.png
  • 点击:adb shell input tap 980 180
  • 输入:adb shell input text "tesla",其中 tesla 是具体输入内容
  • 滑动:adb shell input swipe 500 1600 500 500 300
  • 收起键盘:adb shell input keyevent 4
  • 启动 App:adb shell am start -n com.example.demo/.MainActivity

如果没有这层,模型就算知道“搜索按钮大概在右上角”,也没有人真正去点。

第三段:创建 AndroidAgent

const agent = new AndroidAgent(device, {
  generateReport: true,
  modelConfig: {
    MIDSCENE_MODEL_NAME: config.modelName,
    MIDSCENE_MODEL_BASE_URL: config.baseUrl,
    MIDSCENE_MODEL_API_KEY: config.apiKey,
    MIDSCENE_MODEL_FAMILY: config.modelFamily,
  },
  aiActionContext:
    'If permission dialogs, user agreements, or login popups appear, close or accept them so the search flow can continue.',
});

这里是全文的关键点。

AndroidAgent 不等于“更高级的设备对象”,它更像一个把三件事捆起来的指挥官:

  • 看图理解
  • 调设备执行
  • 写 report 留痕

可以记成:

AndroidDevice = 手脚
AndroidAgent  = 大脑 + 指挥官

其中 modelConfig 决定它到底找哪个模型来做视觉理解。

MIDSCENE_MODEL_FAMILY 这种字段看起来有点抽象,但它的真实作用很朴素:

告诉 Midscene:
你现在接的是哪一类模型,
它能不能看图,
应该按什么策略去调用。

第四段:真正的动作链路

await agent.launch(config.packageName);
await agent.aiWaitFor('首页或搜索入口已经可见');
await agent.aiTap('页面上的搜索入口');
await agent.aiInput('搜索输入框', {
  value: config.searchKeyword,
  autoDismissKeyboard: true,
});
await agent.aiTap('搜索按钮');
await agent.aiWaitFor('页面出现搜索结果列表或结果页核心内容');

这一段最容易让人误解成:

就是几条“智能 click”

其实不是。

它真正更像一串 感知 -> 决策 -> 执行 的循环。

为什么 aiTapaiInputaiWaitFor 看起来像在和模型多轮对话

因为本质上就是。

以这句为例:

await agent.aiTap('页面上的搜索入口');

它背后大致做的是:

1. 先截图
2. 把截图和“页面上的搜索入口”一起交给模型
3. 模型返回一个大致区域
4. 系统把区域换算成设备坐标
5. 再调用设备层去点击
6. 把这一整步写进 report

同样地,其他几个 API 也能这样理解。

aiWaitFor

作用不是动作,而是状态判断。

await agent.aiWaitFor('首页或搜索入口已经可见');

等价于:

请你看这张截图,
判断这个条件是否已经成立。

它的价值是:先确认页面状态到位,再决定要不要继续。

aiTap

作用不是固定坐标点击,而是:

先定位,再点击

所以它更接近“点屏幕上那个搜索入口”,而不是“点 x=500, y=300”。

aiInput

作用不是把文本硬塞给系统,而是:

先找到输入框
  -> 聚焦
  -> 清空旧内容
  -> 输入文字
  -> 必要时收起键盘

在 Android 上这一步尤其麻烦,因为中文输入、键盘弹起、焦点变化都可能出问题。

aiQuery

作用不是执行动作,而是把当前页面翻译成人能读懂的一句话或结构化结果。

例如:

const summary = await agent.aiQuery<string>(
  '用一句话描述当前页面最能证明已经进入搜索结果页的文字或内容',
);

它本质上是在问:

你现在看到的页面,
最能证明业务目标已经成立的信号是什么?

这类问题特别适合做业务验收。

这不是“纯 AI”,而是“AI + 设备控制 + 业务规则”

如果只看 aiTap() 这类 API,很容易误以为 Midscene 是“全靠模型自动化”。

更准确的结构应该是:

AI 负责理解
设备层负责执行
业务代码负责兜底判断

拆开看:

  • AI
    • 判断首页是否可见
    • 找搜索入口、输入框、搜索按钮
    • 总结页面内容
  • AndroidDevice / ADB
    • 启动 App
    • 点击
    • 输入
    • 截图
  • 业务代码
    • 决定流程顺序
    • 决定什么才算“搜索成功”

这也是为什么一份靠谱的 Midscene 脚本,最后一般不会停在“没报错就算成功”。

为什么最后还要做结果校验

很多人第一次写这种脚本,会默认:

已经点了搜索按钮
=
已经进入搜索结果页

这是错的。

真实世界里可能发生很多事:

  • 搜索按钮点到了,但页面卡住了
  • 页面跳了,但结果区还是空白
  • 结果页出来了,但其实是异常兜底页
  • 键盘没收起,真正的搜索动作并没有触发

所以更稳的做法是:

const summary = await agent.aiQuery<string>(
  '用一句话描述当前页面最能证明已经进入搜索结果页的文字或内容',
);

const hints = ['搜索', '结果', '车型', '商品', '列表'];
const matched = hints.some((item) => summary.includes(item));

if (!matched) {
  throw new Error(`Unexpected result page: ${summary}`);
}

这段代码的意义不是“字符串匹配很高级”,而是把“视觉理解”再收敛成业务能接受的通过条件。

Report 为什么不是附件,而是这类工程的核心设施

Midscene 这类工程,如果没有 report,调试成本会非常高。

因为传统自动化的失败通常很直白:

  • selector 没找到
  • 元素不可点击
  • 超时

而视觉自动化的失败会更模糊:

  • 模型把图标认错了
  • 页面刚加载出来时被误判为空白
  • 搜索入口和标题长得太像
  • 键盘挡住了真正的按钮

这时 report 的价值就出来了。它会记录:

  • 每一步动作
  • 每一步截图
  • 模型的判断
  • 定位结果
  • 耗时
  • token 消耗
  • 哪一步失败了

它的意义不是“生成一个好看的 HTML”,而是:

没有 report,
你几乎没法稳定解释
“为什么这次成功” 和 “为什么这次失败”。

Midscene 的一个真实边界:同一页面,模型可能前后判断不一致

这类方案最需要读者有心理预期的一点是:

它会摇摆。

一个非常典型的场景是:

  • 某次 aiWaitFor('页面出现搜索结果列表或结果页核心内容') 返回了 false
  • 过了一步,aiQuery() 又总结说结果页已经出现
  • 最终脚本仍然通过

这听起来像自相矛盾,但它在视觉自动化里并不罕见。

原因通常有三类:

  • 页面正处在骨架屏、白屏、渐进加载的过渡态
  • 两次截图的时机不同
  • 提示词本身过于抽象,比如“核心内容”这类词没有明确视觉锚点

这正好说明 Midscene 的真实定位:

它的优势

  • 不强依赖 selector
  • 更适合跨端和弱结构页面
  • 更贴近业务语言
  • 失败后更容易回看执行过程

它的代价

  • 更慢
  • 更贵
  • 更依赖模型质量
  • 同一页面在不同时间点可能给出不同判断

所以它不是“更稳定的传统自动化替代品”,而是:

一种更灵活、但更依赖感知质量的 UI 自动化范式。

如果页面上没有“搜索按钮”,只有一个放大镜图标,它能识别吗

这个问题很适合拿来理解 Midscene 的边界。

如果你写:

await agent.aiTap('搜索按钮');

而页面上只有一个放大镜图标,没有文字按钮,模型有机会识别,但不能保证每次都稳。

原因是视觉模型会结合:

  • 图标本身的语义
  • 它在页面上的位置
  • 周围是不是搜索框、标题栏、工具栏

所以它不是只看字面词,而是在做语义匹配。

但更稳的写法,通常是把提示词描述得更具体:

页面右上角的搜索按钮或放大镜图标

也就是说,Midscene 不是不要提示词,而是更吃提示词质量。

为什么 Android 是最能看出 Midscene 价值的地方

如果只做 Web,很多团队已经有成熟 selector 体系了,Midscene 的优势未必会立刻显现。

但到了 Android,上述问题会马上变得现实:

  • 自绘控件多
  • 图片按钮多
  • 不同机型分辨率和密度不一致
  • 中文输入、键盘、权限弹窗都会影响执行
  • 控件树不一定像 Web DOM 那样稳定

这时你会发现,Midscene 真正解决的不是“如何更优雅地写点击”,而是:

如何让自动化脚本
先看懂眼前屏幕,
再决定怎么操作。

这就是它和传统自动化思路最根本的差别。

Midscene 适合什么,不适合什么

更适合它的场景

  • 你要同时覆盖 Web、Android、iOS 或桌面
  • 页面结构不稳,但视觉语义稳定
  • 界面里有自绘组件、图片按钮、复杂弹窗
  • 你更关心“用户看到的页面是否符合预期”
  • 你想给更高层 Agent 暴露 UI 能力

暂时不必优先用它的场景

  • 页面 selector 已经非常稳定
  • 你追求极高确定性和极低成本
  • 已有成熟 Appium / Playwright / 原生控件树体系
  • 团队还没有准备好设备权限、模型配置、失败回放这些基础设施

最后给工程师的判断标准

如果你的问题是:

我想把一套确定性脚本写到极致稳定

那传统自动化依然非常重要。

但如果你的问题是:

我希望脚本先看懂当前屏幕,再决定怎么操作
我希望这套心智模型能跨 Web / Android / iOS
我希望上层 Agent 真正拥有 UI 理解和执行能力

那 Midscene 值得认真看。

它不是替代一切 UI 自动化,而是把一件以前经常停留在 demo 层的事,真正拉进了工程体系:

让“视觉理解”成为 UI 自动化的一等能力。
Logo

一站式 AI 云服务平台

更多推荐