用 Flutter 做一个能控制手机的 AI Agent
用 Flutter 做一个能控制手机的 AI Agent
周日晚上 10 点,老板群里冒出一句"周报发了吗"。
我打开飞书,找上周文档,复制要点,贴到对话框,加几个 emoji,发出去。8 分钟。
这种事我每周做几次。机械、重复、不动脑、又必须我自己动手——因为它是从我手机里发出去的。
写代码 10 年,对这种事有反射动作:有规律的事就该自动化。但手机上的自动化是个死结:iOS 沙盒、Android 厂商魔改、各 App 反爬反自动,传统的录脚本路子早跑不通了。
直到大模型有了视觉能力,思路才变了——让 AI 直接看屏幕,像人一样操作。
我用 Flutter 做了一个。装在手机上,不连电脑,跟我说一句话,它自己处理。
不是又一个录屏脚本
先把这个跟传统手机自动化区分清楚。
| 方案 | 工作方式 | 脆弱性 |
|---|---|---|
| 按键精灵 / Auto.js | 录制坐标 + 固定流程 | UI 一变就废,没语义理解 |
| Appium / Espresso | 测试框架,依赖元素 ID | 只在测试 App 里有效,跨 App 失败 |
| Mobile-Agent / AppAgent | LLM + ADB | 必须电脑连着手机 |
| AutoDroid | 模拟器 + 学习历史轨迹 | 实机部署受限 |
| 这个项目 | 手机本体跑 LLM 决策 + 无障碍执行 | 视觉理解 → UI 变了也认得 |
核心区别一句话:前几种是脚本,这个是 Agent。脚本按指令走,Agent 看着屏幕想下一步该做什么。
核心循环
四步,重复执行直到完成:
- 观察 — 截屏 + 读取 UI 树
- 思考 — 把屏幕状态发给 LLM:"为了完成任务,下一步该做什么?"
- 执行 — 点击 / 滑动 / 输入 / 长按 / 返回
- 反思 — 检查执行结果,更新记忆,决定继续还是收尾
经典的 ReAct 循环。区别在于它跑在真实手机上,不是浏览器里,也不是模拟器里。
为什么用 Flutter
跑在手机本体上。 不需要电脑,不需要 USB 线,是一个独立 App。市面上大部分手机 Agent 项目(Mobile-Agent、AppAgent)都需要一台电脑通过 ADB 连着手机。这个不需要——出门带个手机就够。
跨平台。 同一套代码 iOS 和 Android 都能用。Dart 写一遍,两边编译。后面要支持 iOS 时省一大半功夫。
原生性能。 Flutter 编译成原生代码,UI 自动化层走 Platform Channel 直接调系统服务,没有 RPC 跨进程的开销。从看见屏幕到点下去,端到端延迟压在 100ms 以内(不算 LLM 推理时间)。
前端写得也快。 Agent 需要一个壳:任务输入框、进度日志、截图回放、设置面板。Flutter 写这些 UI 比 native 快得多。
观察:屏幕状态怎么喂给 LLM
最容易忽视、但决定 Agent 智商上限的环节。
双轨数据
Snapshot 有两部分,都要给 LLM:
- 截图 — 像素级真实,但 LLM 视觉模型对小字、复杂列表、layered UI 容易看走眼
- UI 树 — 来自 Android Accessibility Service,每个节点带类型、文本、
bounds、resource-id、content-desc、clickable,结构化但有盲区
abstract class UIService {
Future<UISnapshot> getSnapshot(); // 截图 + UI 树
Future<void> tap(UIElement element);
Future<void> tapAt(Offset position); // 兜底:纯坐标
Future<void> input(UIElement element, String text);
Future<void> scroll(Offset delta);
Future<void> back();
Future<void> longPress(UIElement element);
}
为什么 UI 树不够用
我一开始以为光给 LLM UI 树就够了——结构化、token 也省。后来发现一堆场景挂掉:
- Jetpack Compose / Flutter App — UI 树拿到的常常是一个大 Canvas,里面什么文字都没有
- WebView 内嵌网页 — 默认拿不到 DOM,一片空白
- 游戏 / 自绘界面 — 同上
- 图片含义 — 一个图标按钮,UI 树里只有
content-desc="button",看不懂干啥的
只有截图能 cover 这些。所以最终方案是截图为主 + UI 树为辅:截图让模型理解语义,UI 树让模型精准定位元素。
Prompt 怎么拼
[SYSTEM] You are a phone agent. Decide next action toward the goal.
[GOAL] 在飞书里找上周周报文档,把要点复制出来
[SCREEN] <截图 base64>
[UI_TREE]
- App: com.lark.feishu
- TextView "搜索" id=feishu:id/search [400, 80, 680, 140] clickable
- ListView id=feishu:id/doc_list [40, 200, 1040, 1800]
- Item "周报-第24周" [60, 220, 1020, 380] clickable
- Item "周报-第23周" [60, 400, 1020, 560] clickable
...
[HISTORY]
Step 1: Opened 飞书 → 看到首页
Step 2: 点击搜索 → 看到搜索框
(this is step 3)
[OUTPUT FORMAT] JSON: {"thought": "...", "action": {...}}
UI 树要做剪枝——一屏完整树常上千节点,全塞进去 token 爆炸。我的策略:
- 只保留
clickable=true或有可见文本的节点 - 同质 ListView Item 截断,最多保留 8 条 + "... 还有 N 条"
- 屏外节点(bounds 不在视口)全部砍掉
剪完一般 1-2k token。
思考:Tool Use 比让 LLM 写 JSON 稳
我对 Action 的设计走了两个版本。
v1:让 LLM 输出自由 JSON。{"action": "tap", "target": {...}}。坏处:模型会乱编字段、漏字段、写错坐标格式。每次都要写一堆 fallback 解析。
v2:用 Function Calling / Tool Use。每个动作是一个 Tool,schema 强约束。模型选哪个 Tool、传什么参数,都是平台层校验过的。
final tools = [
Tool(
name: 'tap_element',
description: '点击 UI 树里的某个元素',
parameters: {
'element_id': 'string, 来自 UI_TREE 的节点 id',
},
),
Tool(
name: 'tap_at',
description: '当 UI 树里没找到合适元素时,按坐标点击(兜底)',
parameters: {
'x': 'int', 'y': 'int',
},
),
Tool(name: 'input_text', parameters: {'element_id': 'string', 'text': 'string'}),
Tool(name: 'scroll', parameters: {'direction': 'up|down|left|right', 'distance': 'int'}),
Tool(name: 'back'),
Tool(name: 'wait', parameters: {'seconds': 'int'}),
Tool(name: 'finish', parameters: {'summary': 'string'}),
];
finish 也是个 Tool 很关键——LLM 自己宣布任务完成,不需要外部判断。
ReAct 还是 Plan-then-Execute?
两个范式我都试过:
- ReAct:每一步都重新观察、重新决策。鲁棒,但慢且贵——每步一次 LLM 调用
- Plan-then-Execute:先让 LLM 出一个完整计划("先点搜索 → 输入关键词 → 点第一条结果 → 复制内容"),再按计划执行
最后选 ReAct。手机界面不可预测——弹窗、加载动画、网络抖动随时打断。Plan 一旦中途出错,整个计划都要废掉。ReAct 每步重新看一眼屏幕,对意外的容错强得多。
代价是每次任务调 6-12 次 LLM。所以才有了下面的多模型策略。
执行:稳定性细节
元素 ID 优先,坐标兜底
tap_element 通过 findAccessibilityNodeInfosByViewId 重新拿元素,然后调用 performAction(ACTION_CLICK)——这个最稳,不受位置变化影响。
tap_at 走 dispatchGesture 模拟手势点击。只在 UI 树没有合适元素时用(比如 Canvas 上的图标)。
Accessibility Service 的坑
- 用户必须在系统设置里手动开启无障碍权限,不能代填
- Android 12+ 对滥用无障碍的 App 限制变严,必须申明清楚用途
- 部分国产 App(金融、社交头部)会反检测——开启无障碍直接闪退或拒绝服务。这部分得 case by case 处理
- 系统更新后偶尔会自动关闭无障碍,需要 App 启动时检测并提示重开
输入文本
ACTION_SET_TEXT 是优先方案,瞬间填好。但有的输入框不接受这个 action,需要先 focus 再走 IME 模拟输入。
中文输入更麻烦——直接 set 是 OK 的,但模拟敲键盘走拼音 IME 路径就要解决候选词选择,目前避开了。
滚动定位
LLM 看到的总是当前一屏。要找一个屏外的元素得先滚。我加了一个 scroll_until 复合动作:滚动方向 + 期望元素特征 + 最大滚动次数。
这个动作不交给 LLM 自己实现(它会无脑滚到天荒地老),平台层封装好。
反思:记忆怎么管
短期记忆:步骤栈
每一步存:当时的截图缩略图、当时的 thought、执行的 action、执行结果。
但 token 受不住——一次任务 10 步,每步带个截图就爆了。压缩策略:
- 历史步骤的截图全部丢弃,只留 thought + action + 一句结果摘要
- 当前步骤之前一步的 UI 树也丢,只留当前
- 历史 thought 长的话用小模型摘要成一句话
最终一次任务的上下文稳定在 3-5k token。
长期记忆:用户偏好
有些事跨任务有用:
- "用户惯用的支付方式是支付宝"
- "用户提到'老板'指的是 xxx"
- "周报模板存在飞书的工作台 / 项目周报 文件夹"
这些不放在每次任务里。任务结束后,让 LLM 复盘:"这次任务有什么值得记住的偏好?" 写进一个独立的 KV 存储,下次任务前注入到 system prompt。
简单粗暴,但它让 Agent 第二次做同样的事变快——不用再到处搜文档了。
多模型策略
做这个项目最大的一课:别什么都用同一个模型。
任务复杂度 推荐模型 单步成本
─────────────────────────────────────────────
简单操作 Haiku 级小模型 ~$0.001
视觉观察 GPT-4o / Claude 3.5 ~$0.01
复杂规划 Opus / GPT-5 级 ~$0.05
实际策略:
- 视觉观察必须用支持 vision 的模型,没得选
- 简单决策("看到登录按钮,点它")用便宜模型
- 遇到第二次做同样的事失败,自动升级到大模型重试一次
- 复杂任务(多 App 跨流程)首步规划用大模型,后续步骤降级
ModelRouter 自动根据任务标签选。一次"发周报"任务平均成本 <$0.10,主要消耗在每一步的视觉观察。
Agent 核心
Stream<AgentEvent> execute(String instruction) async* {
yield AgentEvent.started(instruction);
final memory = AgentMemory(longTermStore: longTerm);
await memory.loadRelevant(instruction);
for (var step = 0; step < maxSteps; step++) {
yield AgentEvent.observing();
final snapshot = await ui.getSnapshot();
yield AgentEvent.thinking();
final decision = await provider.decide(
goal: instruction,
snapshot: snapshot,
history: memory.recentSteps,
preferences: memory.preferences,
tools: actionTools,
);
yield AgentEvent.acting(decision.action);
final result = await executor.execute(decision.action);
memory.addStep(step, decision, result);
if (decision.action is FinishAction) {
await memory.distillPreferences(); // 长期记忆抽取
yield AgentEvent.completed((decision.action as FinishAction).summary);
return;
}
if (result.failed && step > 3 && lastTwoFailed(memory)) {
provider.upgradeModel(); // 连续失败升级模型
}
}
yield AgentEvent.timeout(memory);
}
用 Stream 返回事件,UI 实时显示进度:"正在观察屏幕..."、"思考中..."、"点击 #登录按钮..."、"完成 ✓"。
每一步都可以暂停 / 终止 / 单步。debug 起来友好。
真实跑过的任务
几个跑通的,时间是 2026 年 2 月在我自己手机上的实测:
| 任务 | 步数 | 耗时 | 成本 |
|---|---|---|---|
| 飞书找上周周报,复制要点 | 7 | 38s | $0.04 |
| 美团点一份惯常的午餐 | 11 | 64s | $0.08 |
| 微信给某联系人发"我下楼了" | 5 | 22s | $0.02 |
| 京东比价同款商品三个店 | 14 | 92s | $0.12 |
| 设置里关闭某 App 后台耗电 | 8 | 41s | $0.05 |
没跑通的:抖音直播间互动(直播流帧太快 LLM 跟不上)、银行 App(反 Accessibility)、复杂手势游戏。
踩过的坑
LLM 视觉对中文小字不行。 截图给 GPT-4o,识别中文文字的准确率比英文低一截。补救:UI 树里的 text 字段都拼到 prompt 里,让模型对照看。
坐标精度。 不同分辨率屏幕坐标不通用。所有坐标在 prompt 里转成相对比例(0-1),执行前再转回像素。
任务漂移。 长任务跑 10+ 步后,模型会忘记最初目标。每一步的 prompt 顶部都重复贴一遍 GOAL,便宜有效。
循环卡死。 模型偶尔会反复点同一个按钮。检测连续 3 步同样 action 没进展就强制中断 + 切换模型重试。
截图体积。 1080P 全屏截图 base64 化 200KB+,喂给 LLM 慢且贵。降到 720P + JPEG 80% 画质,肉眼看不出差别,token 省 60%。
电池。 跑一个任务约 1-2% 电量。挂在后台跑定时任务时要管理唤醒锁,不然系统会杀。
隐私边界。 Agent 看屏幕等于它能看到所有内容——包括银行余额、私聊、密码。坚决在本地处理截图,发给 LLM 前先做敏感区域打码(识别密码框、身份证号、银行卡号自动模糊)。这部分还在做。
接下来做什么
- 任务模板 — 常用操作的预设流程:发社交媒体、点外卖、给某人发消息。模板里固化好高频步骤,模型只填变化部分,又快又便宜
- 定时任务 — "每天早上 9 点检查邮件,把重要的发到我手机通知"
- 任务链 — "截取数据统计 → 写报告 → 发邮件",跨 App 的多步骤工作流
- iOS 支持 — Android 先跑起来,iOS 接下来做。无障碍能力受限会更难,但小范围内能做的事也不少
- 本地小模型 — 简单决策迁到端侧 7B 模型,成本归零、隐私更好。这块跟我日常做的推理优化是同一套问题
最后这点其实有意思:手机端 Agent 是边缘推理的一个典型场景。我每天工作做的是数据中心 GPU 集群上的 LLM Serving,但手机端、笔记本端的轻量推理才是 AI 普及的另一半。两边的优化思路有不少能互通——量化、KV cache、推理调度、多模型路由。
它跑起来的那一刻你会有种**"对,AI 应该是这样用"**的感觉——不是你打开一个聊天框跟它说话,而是它替你把那些重复的、机械的、占用你周末的事处理掉。

Written by
Zoe
AI Infra Engineer · LLM Serving · GPU/RDMA · 造工具的偏执狂