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 架构是一个思路。每一轮循环包含四个步骤截屏或读取节点树获取当前屏幕的状态。这里有两种方式一种是直接调用无障碍服务拿节点树另一种是用 MediaProjection API 截屏后做 OCR。节点树的优势是信息结构化直接能拿到文本和坐标劣势是有些应用不暴露节点。截屏 OCR 的优势是通用性强什么都能识别劣势是速度慢、耗资源、准确率受图像质量影响。这个项目默认走节点树路线OCR 作为兜底方案。把屏幕状态喂给模型将当前屏幕的控件信息整理成一段结构化的文本比如“屏幕上有三个可点击元素按钮A在坐标(100,200)输入框B在坐标(300,400)文本C显示‘欢迎回来’”。然后加上任务指令比如“请点击登录按钮”。模型输出动作指令模型根据当前状态和任务目标输出一个 JSON 格式的动作比如{action: click, target: 登录按钮}或者{action: input, target: 输入框B, text: hello}。执行动作并等待无障碍服务解析这个 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:exportedtrue android:labelAI Agent Service android:permissionandroid.permission.BIND_ACCESSIBILITY_SERVICE intent-filter action android:nameandroid.accessibilityservice.AccessibilityService / /intent-filter meta-data android:nameandroid.accessibilityservice android:resourcexml/accessibility_service_config / /service第二步配置accessibility_service_config.xmlaccessibility-service android:accessibilityEventTypestypeWindowStateChanged|typeWindowContentChanged android:accessibilityFeedbackTypefeedbackGeneric android:accessibilityFlagsflagDefault|flagReportViewIds|flagRetrieveInteractiveWindows|flagIncludeNotImportantViews android:canRetrieveWindowContenttrue android:notificationTimeout100 android:descriptionstring/accessibility_description /第三步在AgentAccessibilityService.kt里实现核心逻辑。这个类需要重写onAccessibilityEvent和onInterrupt并在onServiceConnected里初始化各个模块。4.2 节点树读取的具体实现节点树读取是整个系统里最需要打磨的部分。我一开始写的递归遍历在复杂页面上直接卡死。后来改成了广度优先遍历 深度限制 节点过滤性能好了很多。核心代码逻辑是这样的fun readScreenNodes(root: AccessibilityNodeInfo): ListNodeInfo { val result mutableListOfNodeInfo() val queue ArrayDequePairAccessibilityNodeInfo, 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是不是 trueaccessibilityFlags里有没有加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 有用得多。所以如果你要动手做这个项目建议先把基础的动作执行和异常处理做扎实再去追求更复杂的任务规划能力。
阅读完成 · 觉得有帮助?