☰
安卓本地AI Agent实战:无障碍服务+大模型实现手机自动化
2026/10/26 12:18:25 网站建设 项目流程

1. 这个项目到底在折腾什么

手机自己操作 App,这句话第一次听起来像是科幻片里的桥段。但如果你最近关注过移动端自动化这个方向,就会发现它已经悄悄从“脚本小子”的玩具,变成了一个正经的技术赛道。我手上这台备用机,现在每天定时自己打开某个应用签到、自己滑动列表抓取信息、自己填写表单提交数据,全程不需要我碰屏幕一下。这套东西的核心,就是一个跑在安卓本地的 AI Agent。

说白了,这个开源项目的本质是:把大语言模型的决策能力和安卓系统的无障碍服务(Accessibility Service)打通,让模型看着屏幕上的内容,自己决定下一步点哪里、输入什么、滑动多少距离。它解决的是一个非常具体的痛点——那些重复、机械、但又必须有人操作的手机任务,终于可以交给机器自己完成了。

适合谁来参考这篇文章?三类人。第一类是移动端开发者,想了解无障碍服务和 AI 结合的技术路径;第二类是自动化爱好者,手头有闲置手机想折腾点实用工具;第三类是产品经理或技术决策者,在评估移动端 Agent 的可行性和落地成本。不管你之前有没有接触过安卓开发,只要对“让手机自己动起来”这件事感兴趣,下面的内容都能让你拿到一套可复现的方案。

我先把结论放在前面:这套方案的技术门槛没有想象中高,但坑不少。核心难点不在 AI 模型本身,而在于如何稳定地获取屏幕信息、如何把模型输出转化成可靠的点击动作、以及如何处理各种弹窗和异常状态。接下来我会从设计思路、核心细节、实操过程、问题排查四个维度,把这套东西拆干净。

2. 整体设计思路与方案选型

2.1 为什么选无障碍服务而不是 ADB 或模拟点击

安卓上做自动化,常见的技术路线有三条:ADB 命令、Root 权限下的输入注入、以及无障碍服务。这个项目选了无障碍服务,背后有很实际的考量。

ADB 方案的问题在于它依赖电脑。你得用数据线连着电脑,或者在同一局域网内开调试端口,手机才能接收指令。这意味着手机没法独立运行,出门在外就废了。而且 ADB 的输入事件是系统级的,很多应用会检测调试状态,一旦发现 USB 调试开启就直接拒绝运行。

Root 方案更直接,用input tap或者sendevent直接注入触摸事件。但 Root 本身就是一道高门槛,而且现在主流机型解锁 Bootloader 越来越麻烦,普通用户根本走不通。更重要的是,Root 后的输入注入绕过了应用层的权限检查,容易被风控系统标记为异常行为。

无障碍服务的优势就很明显了。它是安卓官方提供的辅助功能框架,本来是为了帮助视障用户操作手机而设计的。开启之后,应用可以读取屏幕上的节点树(Node Tree),获取每个控件的文本、坐标、可点击状态等信息,也可以执行点击、滑动、输入文本等动作。最关键的是,它不需要 Root,不需要连电脑,完全在手机本地运行。模型跑在手机上或者通过 API 调用,决策完直接通过无障碍服务执行,整个闭环都在设备端完成。

当然,无障碍服务也有它的限制。比如它只能操作那些暴露了无障碍节点的应用,某些用了自绘引擎或者做了反无障碍处理的应用,节点树可能是空的或者混乱的。但这个问题后面会讲怎么绕过。

2.2 AI Agent 的决策循环是怎么跑起来的

这个项目的核心是一个感知-决策-执行的循环,跟机器人领域的 Agent 架构是一个思路。每一轮循环包含四个步骤:

  1. 截屏或读取节点树:获取当前屏幕的状态。这里有两种方式,一种是直接调用无障碍服务拿节点树,另一种是用 MediaProjection API 截屏后做 OCR。节点树的优势是信息结构化,直接能拿到文本和坐标;劣势是有些应用不暴露节点。截屏 OCR 的优势是通用性强,什么都能识别;劣势是速度慢、耗资源、准确率受图像质量影响。这个项目默认走节点树路线,OCR 作为兜底方案。

  2. 把屏幕状态喂给模型:将当前屏幕的控件信息整理成一段结构化的文本,比如“屏幕上有三个可点击元素:按钮A在坐标(100,200),输入框B在坐标(300,400),文本C显示‘欢迎回来’”。然后加上任务指令,比如“请点击登录按钮”。

  3. 模型输出动作指令:模型根据当前状态和任务目标,输出一个 JSON 格式的动作,比如{"action": "click", "target": "登录按钮"}或者{"action": "input", "target": "输入框B", "text": "hello"}。

  4. 执行动作并等待:无障碍服务解析这个 JSON,找到对应的控件坐标,执行点击或输入,然后等待一段时间让界面响应,进入下一轮循环。

这个循环的频率大概在 2-5 秒一次,取决于模型推理速度和界面加载时间。如果模型跑在本地(比如量化后的小模型),一次推理可能只要几百毫秒;如果走云端 API,网络延迟加上推理时间,一轮下来可能要三五秒。实际用下来,本地小模型适合简单任务,复杂任务还是得靠云端大模型,这个取舍后面会细说。

2.3 为什么用 JSON 作为动作协议

模型输出到执行的桥梁是一个 JSON 格式的动作协议。这个设计看起来很朴素,但非常关键。原因有三:

第一,JSON 是结构化的,解析起来不会歧义。如果你让模型直接输出“点击登录按钮”这样的自然语言,执行端还得再做一次语义解析,多一层就多一层出错的可能。JSON 直接把动作类型、目标、参数都定死了,解析逻辑可以写得非常健壮。

第二,JSON 方便做校验和重试。比如模型输出了一个不存在的控件 ID,执行端可以直接返回错误,让模型重新决策。如果是一段自然语言,你很难判断它到底想干什么。

第三,JSON 易于扩展。今天支持 click、input、swipe 三个动作,明天要加长按、双击、滚动,直接在协议里加字段就行,不用改整个架构。

这个项目的动作协议大概长这样:

{ "action": "click", "target": { "type": "text", "value": "登录" }, "reasoning": "当前页面有登录按钮,点击进入登录流程" }

其中reasoning字段是让模型解释自己的决策理由,方便调试的时候看模型到底在想什么。这个字段在实际运行中不参与执行,但排查问题的时候非常有用。

3. 核心细节解析与实操要点

3.1 无障碍服务的配置与节点树读取

无障碍服务是整套系统的基石,配置不对后面全白搭。在安卓项目里,你需要先在AndroidManifest.xml里声明一个继承自AccessibilityService的类,然后在res/xml目录下创建一个配置文件,声明这个服务需要哪些能力。

配置文件里最关键的是android:accessibilityFlags和android:canRetrieveWindowContent。前者决定了你能获取多少信息,后者必须设为true,否则读不到节点树。我建议把flagReportViewIds、flagRetrieveInteractiveWindows、flagIncludeNotImportantViews这几个都加上,这样能拿到更完整的控件信息。

节点树的读取逻辑是这样的:从getRootInActiveWindow()拿到根节点,然后递归遍历所有子节点,把每个节点的className、text、contentDescription、boundsInScreen、isClickable、isEditable等属性提取出来,整理成一个列表。这个列表就是喂给模型的“屏幕状态”。

这里有个坑要注意:节点树可能非常深,直接递归遍历容易栈溢出或者耗时过长。实际做法是限制遍历深度,比如只遍历前 5 层,或者只收集那些isClickable或isEditable为 true 的节点。另外,boundsInScreen返回的是一个矩形区域,点击的时候要取中心点坐标,不能直接拿左上角,否则可能点到边缘无效区域。

还有一个细节:有些应用的节点树是懒加载的,你第一次读的时候可能只有部分节点,等界面完全渲染后才全部出来。所以读取节点树之前最好先等个 500 毫秒到 1 秒,或者监听TYPE_WINDOW_CONTENT_CHANGED事件,等界面稳定后再读。

3.2 模型选型:本地小模型还是云端大模型

这是整个项目里最需要权衡的一个决策。我两种方案都试过,各有各的适用场景。

本地小模型的优势是隐私好、延迟低、不依赖网络。你可以用量化后的 7B 级别模型,跑在手机的 NPU 或者 GPU 上。实测下来,一轮推理大概 300-800 毫秒,对于简单的点击任务完全够用。但问题是,小模型对复杂界面的理解能力有限。比如一个页面有十几个按钮,小模型经常选错目标,或者输出的 JSON 格式不对。

云端大模型的优势是理解能力强、输出稳定。你给它一个复杂的界面描述,它能准确判断该点哪个按钮,甚至能处理多步骤的任务规划。但劣势也很明显:每次请求都要把屏幕信息传到云端,隐私是个问题;网络延迟让整个循环变慢;API 调用有成本。

我的建议是混合方案:简单任务(比如“点击屏幕上的确认按钮”)用本地小模型,复杂任务(比如“帮我填写这个表单并提交”)走云端大模型。项目里可以做一个路由层,根据任务复杂度自动选择。如果实在只能选一个,我倾向于先用云端大模型把流程跑通,再逐步把简单场景迁移到本地模型。

模型输出的格式约束也很重要。你需要在 prompt 里明确告诉模型:“只输出 JSON,不要输出任何其他文字。” 有些模型会自作主张加一句“好的,我来帮你点击登录按钮”,这就会导致 JSON 解析失败。更稳妥的做法是用JSON Schema 或者 Function Calling来约束输出格式,如果模型支持的话。

3.3 动作执行的稳定性处理

模型输出一个点击指令,执行端找到对应控件,调用performAction(ACTION_CLICK),看起来很简单。但实际跑起来,你会发现各种意外情况。

第一种情况是控件找不到。模型说要点“登录按钮”,但当前屏幕上根本没有这个文本的控件。这时候不能直接报错退出,而是要把“找不到目标”这个信息反馈给模型,让它重新决策。项目里可以做一个重试机制,最多重试 3 次,每次把错误信息附加到 prompt 里。

第二种情况是控件找到了但点不动。有些控件虽然isClickable为 true,但实际点击没反应,因为它可能被一个透明的遮罩层挡住了。这时候需要往上找父节点,看看有没有可点击的祖先节点。无障碍服务提供了getParent()方法,可以逐层往上找,直到找到一个真正可点击的节点。

第三种情况是点击后界面没变化。这可能是因为点击太快了,界面还没反应过来。解决办法是在每次动作执行后加一个等待时间,或者监听TYPE_WINDOW_STATE_CHANGED事件,等界面真正切换后再进入下一轮。我一般会设一个 1.5 秒的固定等待,再加上事件监听的动态等待,两者结合比较稳。

第四种情况是弹窗干扰。你正在执行任务,突然弹出一个权限请求或者广告弹窗,整个流程就断了。处理办法是在每轮循环开始前,先检查屏幕上有没有已知的弹窗特征(比如“允许”、“拒绝”、“关闭”这些按钮),如果有就先处理弹窗,再继续原任务。这个逻辑可以写成一个独立的“弹窗处理器”,在 Agent 主循环之前调用。

3.4 任务指令的设计技巧

给模型的任务指令写得好不好,直接决定了 Agent 能不能完成任务。我踩过的坑是:指令太模糊,模型不知道从哪下手;指令太死板,模型遇到意外情况就不会变通。

好的任务指令应该包含三个部分:目标、约束、兜底策略。举个例子:

目标:在当前应用中找到“设置”页面,把“自动更新”开关关闭。 约束:不要点击任何广告或推荐内容;如果找不到“自动更新”选项,尝试在搜索框中搜索。 兜底:如果连续三次操作都没有进展,停止并报告当前屏幕状态。

这样的指令给了模型明确的方向,同时也留了变通空间。实测下来,这种结构的指令比单纯说“帮我关掉自动更新”的成功率高很多。

另外,任务指令最好用中文写,因为大部分模型对中文的理解能力已经足够好,而且中文描述界面元素更自然。如果你的模型中文能力较弱,用英文也行,但要注意界面上的文本可能是中文,模型需要做跨语言匹配。

4. 完整实操过程与核心环节实现

4.1 环境准备与项目搭建

先说一下我用的环境:一台安卓 11 以上的备用机,Android Studio 最新版,Kotlin 作为开发语言。项目结构大概是这样:

app/ src/main/java/com/example/agent/ MainActivity.kt // 主界面,用于配置任务和启动服务 AgentAccessibilityService.kt // 无障碍服务核心类 ScreenReader.kt // 节点树读取与整理 ActionExecutor.kt // 动作执行与重试 ModelClient.kt // 模型调用封装 TaskManager.kt // 任务循环调度 src/main/res/xml/ accessibility_service_config.xml // 无障碍服务配置

第一步,在AndroidManifest.xml里注册服务:

<service android:name=".AgentAccessibilityService" android:exported="true" android:label="AI Agent Service" android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE"> <intent-filter> <action android:name="android.accessibilityservice.AccessibilityService" /> </intent-filter> <meta-data android:name="android.accessibilityservice" android:resource="@xml/accessibility_service_config" /> </service>

第二步,配置accessibility_service_config.xml:

<accessibility-service android:accessibilityEventTypes="typeWindowStateChanged|typeWindowContentChanged" android:accessibilityFeedbackType="feedbackGeneric" android:accessibilityFlags="flagDefault|flagReportViewIds|flagRetrieveInteractiveWindows|flagIncludeNotImportantViews" android:canRetrieveWindowContent="true" android:notificationTimeout="100" android:description="@string/accessibility_description" />

第三步,在AgentAccessibilityService.kt里实现核心逻辑。这个类需要重写onAccessibilityEvent和onInterrupt,并在onServiceConnected里初始化各个模块。

4.2 节点树读取的具体实现

节点树读取是整个系统里最需要打磨的部分。我一开始写的递归遍历,在复杂页面上直接卡死。后来改成了广度优先遍历 + 深度限制 + 节点过滤,性能好了很多。

核心代码逻辑是这样的:

fun readScreenNodes(root: AccessibilityNodeInfo): List<NodeInfo> { val result = mutableListOf<NodeInfo>() val queue = ArrayDeque<Pair<AccessibilityNodeInfo, Int>>() queue.add(root to 0) while (queue.isNotEmpty()) { val (node, depth) = queue.removeFirst() if (depth > MAX_DEPTH) continue val info = NodeInfo( id = node.viewIdResourceName ?: "", text = node.text?.toString() ?: "", desc = node.contentDescription?.toString() ?: "", className = node.className?.toString() ?: "", bounds = Rect().also { node.getBoundsInScreen(it) }, clickable = node.isClickable, editable = node.isEditable, scrollable = node.isScrollable ) if (info.isMeaningful()) { result.add(info) } for (i in 0 until node.childCount) { node.getChild(i)?.let { queue.add(it to depth + 1) } } } return result }

其中isMeaningful()是一个过滤函数,只保留那些有文本、有描述、或者可点击可编辑的节点。这样能把节点数量从几百个压缩到几十个,大大减少喂给模型的 token 数量。

注意:AccessibilityNodeInfo对象用完要调用recycle()回收,否则在低内存设备上容易 OOM。虽然新版本安卓已经自动回收了,但手动回收是个好习惯。

4.3 模型调用的封装与 Prompt 设计

模型调用这块,我用的是一个统一的接口,支持本地模型和云端 API 两种后端。本地模型走 ONNX Runtime 或者 llama.cpp 的安卓绑定,云端 API 就是标准的 HTTP 请求。

Prompt 的模板大概是这样:

你是一个安卓手机操作助手。当前屏幕上的可操作元素如下: {node_list} 用户的任务是:{task} 请输出一个 JSON 格式的动作指令,格式如下: {"action": "click|input|swipe|wait", "target": "...", "text": "...", "reasoning": "..."} 只输出 JSON,不要输出其他内容。

其中{node_list}是把节点列表格式化成文本,每个节点一行,包含序号、类型、文本、坐标。比如:

1. [按钮] "登录" 坐标(540, 1200) 2. [输入框] "请输入手机号" 坐标(540, 800) 3. [文本] "忘记密码" 坐标(540, 1400)

这样模型能直接看到每个元素的坐标,输出的时候可以直接引用序号或者坐标,减少歧义。

4.4 主循环的调度与异常处理

主循环是整个 Agent 的心脏。它的逻辑是:读屏幕 -> 调模型 -> 执行动作 -> 等待 -> 再读屏幕,直到任务完成或者达到最大轮次。

suspend fun runTask(task: String, maxSteps: Int = 20) { var step = 0 while (step < maxSteps) { val nodes = screenReader.readScreenNodes() val prompt = buildPrompt(nodes, task, step) val action = modelClient.getAction(prompt) if (action.action == "done") { break } val success = actionExecutor.execute(action) if (!success) { // 把失败信息附加到下一轮的 prompt 里 lastError = "上一步操作失败:找不到目标 ${action.target}" } delay(1500) step++ } }

异常处理的关键是把错误信息反馈给模型。比如点击失败后,下一轮的 prompt 里会多一句“上一步操作失败:找不到目标”,模型看到之后就会尝试其他方案。这个反馈循环是 Agent 能自我纠正的核心。

5. 常见问题与排查技巧实录

5.1 节点树读取为空怎么办

这是最常见的问题。你兴冲冲地打开无障碍服务,结果读出来的节点列表是空的。原因通常有三个:

第一,目标应用没有开启无障碍支持。有些应用在AndroidManifest.xml里把importantForAccessibility设为了no,或者用了FLAG_SECURE阻止截屏和节点读取。这种情况无解,只能换应用。

第二,无障碍服务配置不对。检查canRetrieveWindowContent是不是 true,accessibilityFlags里有没有加flagReportViewIds。有时候还需要加flagRetrieveInteractiveWindows才能读到弹窗和对话框的节点。

第三,读取时机不对。界面还在加载的时候读,节点树可能不完整。解决办法是监听TYPE_WINDOW_CONTENT_CHANGED事件,等事件停止触发后再读。

如果节点树实在读不到,可以退而求其次用截屏 + OCR的方案。用 MediaProjection API 截屏,然后用 ML Kit 或者 PaddleOCR 做文字识别,把识别结果和坐标喂给模型。这个方案通用性强,但速度慢,而且需要额外申请截屏权限。

5.2 模型输出格式错误怎么处理

模型输出格式错误是另一个高频问题。你让它输出 JSON,它给你输出一段带 markdown 代码块的文字,或者加了一堆解释。处理办法分三层:

第一层,Prompt 约束。在 prompt 里明确说“只输出 JSON,不要用代码块包裹,不要加任何解释”。大部分模型会遵守。

第二层,解析容错。写一个健壮的 JSON 解析器,能处理常见的格式问题。比如去掉 markdown 代码块标记、提取第一个{到最后一个}之间的内容、修复缺失的引号等。

第三层,重试机制。如果解析失败,把原始输出和错误信息一起发回给模型,让它重新输出。通常重试一次就能拿到正确格式。

我实测下来,用 Function Calling 或者 JSON Schema 约束输出格式是最稳的,比纯 prompt 约束靠谱得多。如果你的模型支持这些功能,强烈建议用上。

5.3 点击无效或点错位置的排查

点击无效的原因很多,我整理了一个排查表:

现象可能原因解决办法
点击后无反应控件被遮挡往上找可点击的父节点
点击后跳转到错误页面坐标计算错误检查 bounds 是否取的中心点
点击后界面卡住点击太快,界面未响应增加等待时间或监听界面变化事件
点击后弹出权限请求应用需要额外权限预先处理权限弹窗
点击后提示“操作频繁”被风控检测降低操作频率,增加随机延迟

其中坐标计算错误是最隐蔽的。boundsInScreen返回的是控件在屏幕上的绝对坐标,但如果你在计算中心点的时候用了相对坐标,就会点偏。正确的做法是:

val centerX = bounds.centerX() val centerY = bounds.centerY()

另外,有些控件的可点击区域比它的 bounds 小,比如一个带内边距的按钮,实际可点击区域可能只有中间一部分。这种情况可以尝试点击 bounds 的多个位置,或者用performAction(ACTION_CLICK)直接触发控件的点击事件,而不是模拟触摸。

5.4 任务执行到一半卡住的恢复策略

Agent 跑着跑着卡住,是最让人头疼的问题。常见的原因有:界面弹出了未知弹窗、网络请求超时、模型陷入了死循环。

我的恢复策略是设置最大轮次 + 超时 + 状态快照。最大轮次设 20 步,超过就停止并报告;每步操作设 10 秒超时,超时就跳过;每轮循环把屏幕状态和动作记录到日志里,卡住的时候可以回看日志定位问题。

还有一个技巧是设置“无进展检测”。如果连续三轮循环,屏幕上的节点列表没有明显变化,说明 Agent 卡住了。这时候可以强制刷新界面(比如按返回键),或者重新开始任务。

实操心得:我习惯在 Agent 运行的时候,在旁边放一个日志窗口,实时打印每一轮的屏幕节点和模型输出。这样一旦出问题,一眼就能看出是哪一步卡住了。这个日志功能在调试阶段比什么都重要。

5.5 性能优化与资源占用控制

Agent 跑久了,手机会发烫、耗电快,这是正常的。因为节点树读取和模型推理都是计算密集型操作。优化方向有几个:

第一,降低循环频率。不是每轮都要读完整节点树,可以只读变化的部分。无障碍服务提供了getWindows()方法,可以只读当前活动窗口的节点。

第二,模型推理批量化。如果本地模型支持批处理,可以把多轮屏幕状态攒在一起推理,减少调用次数。

第三,空闲时休眠。任务完成后,Agent 应该进入休眠状态,停止读取节点树和调用模型。可以设置一个定时器,每隔一段时间检查一次是否有新任务。

第四,用轻量级模型做预处理。比如用一个很小的模型判断当前屏幕是否发生了变化,只有变化了才调用大模型做决策。这样能省下大量无效推理。

我实测下来,优化之后,Agent 连续运行一小时的耗电量从 25% 降到了 8% 左右,手机温度也从烫手降到了温热。这个优化幅度还是很可观的。

6. 这套方案还能怎么扩展

跑通基础流程之后,你会发现这套架构的扩展性其实很强。我目前尝试过的几个方向:

多任务队列。把多个任务排成一个队列,Agent 依次执行。比如先签到,再抓数据,再发消息。任务之间可以设置优先级和依赖关系。

定时触发。结合安卓的AlarmManager或者WorkManager,让 Agent 在特定时间自动启动。比如每天早上 8 点自动签到,每周一自动提交周报。

多设备协同。如果你有多台手机,可以做一个中央调度器,把任务分发到不同设备上执行。每台设备跑一个 Agent 实例,通过局域网通信。

接入更多模型。除了文本模型,还可以接入视觉模型,直接看屏幕截图做决策。这样就不依赖节点树了,通用性更强。不过视觉模型的推理成本更高,需要权衡。

任务录制与回放。让用户手动操作一遍,Agent 记录下每一步的屏幕状态和动作,然后自动生成任务指令。这样非技术用户也能创建自己的自动化任务。

这套东西最吸引我的地方在于,它把 AI 的决策能力和手机的自动化能力结合在了一起,打开了很多以前想都不敢想的场景。比如自动整理相册、自动回复消息、自动填写表单、自动比价下单。当然,这些场景里有些涉及隐私和合规问题,需要谨慎对待。但技术本身是中性的,关键在于怎么用。

我在实际使用中最大的体会是:Agent 的稳定性比智能程度更重要。一个只能完成简单任务但从不出错的 Agent,比一个能完成复杂任务但经常卡死的 Agent 有用得多。所以如果你要动手做这个项目,建议先把基础的动作执行和异常处理做扎实,再去追求更复杂的任务规划能力。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询