Android 无障碍服务实现美团/微信自动化:客户端开发实践
本文基于实际项目经验,介绍如何利用 Android AccessibilityService 实现外卖点餐、微信消息等场景的自动化操作。重点讲解客户端侧的核心能力:UI 树获取、截图、手势注入、文本输入,以及如何配合服务端下发的指令完成完整的自动化闭环。
本方案基于主屏自动化,不需要系统签名,普通应用即可实现。
一、整体架构
客户端在整个自动化链路中的定位:
markdown
服务端(AI 决策)
↓ 下发指令(点击/滑动/输入/截图...)
客户端(执行层)
├── 获取当前页面 UI 树 → 上报给服务端
├── 截取当前屏幕 → 上报给服务端
├── 执行点击/滑动/输入等操作
└── 反馈执行结果
↓
服务端根据 UI 树 + 截图分析下一步
↓
循环,直到任务完成
客户端不做决策,只做三件事:
- 感知:获取 UI 树、截图
- 执行:点击、滑动、输入、返回
- 反馈:上报执行结果和当前状态
二、权限与配置
2.1 无障碍服务声明
xml
<!-- AndroidManifest.xml -->
<service
android:name=".a11y.AutomationA11yService"
android:exported="true"
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/a11y_config" />
</service>
2.2 无障碍配置文件
xml
<!-- res/xml/a11y_config.xml -->
<accessibility-service
xmlns:android="http://schemas.android.com/apk/res/android"
android:accessibilityEventTypes="typeAllMask"
android:accessibilityFeedbackType="feedbackGeneric"
android:notificationTimeout="100"
android:canRetrieveWindowContent="true"
android:canPerformGestures="true"
android:canTakeScreenshot="true"
android:accessibilityFlags="flagReportViewIds
|flagRetrieveInteractiveWindows
|flagIncludeNotImportantViews
|flagRequestFilterKeyEvents" />
各 flag 的作用:
| Flag | 作用 |
|---|---|
canRetrieveWindowContent |
允许读取 UI 树节点信息 |
canPerformGestures |
允许注入点击/滑动手势 |
canTakeScreenshot |
允许截图(Android 11+) |
flagReportViewIds |
节点信息中包含 resource-id |
flagRetrieveInteractiveWindows |
可获取多窗口/多 Display 的节点 |
flagIncludeNotImportantViews |
包含装饰性节点,UI 树更完整 |
flagRequestFilterKeyEvents |
可拦截物理按键(如遥控器返回键) |
2.3 权限要求
无障碍自动化不需要系统签名,普通应用即可实现。只需要用户在系统设置中手动开启无障碍服务。
| 权限 | 用途 | 获取方式 |
|---|---|---|
BIND_ACCESSIBILITY_SERVICE |
无障碍服务绑定 | 用户在设置中手动开启 |
canRetrieveWindowContent |
读取 UI 树 | 无障碍配置声明 |
canPerformGestures |
注入点击/滑动 | 无障碍配置声明 |
canTakeScreenshot |
截图(Android 11+) | 无障碍配置声明 |
Android 11 以下截图需要额外申请 MediaProjection 权限(弹窗授权一次即可)。
三、UI 树获取
UI 树是服务端做决策的核心依据。客户端负责把当前页面的所有可交互元素结构化上报。
3.1 获取根节点
kotlin
fun getRootNode(): AccessibilityNodeInfo? {
return rootInActiveWindow
}
rootInActiveWindow 返回当前前台 App 的根节点,从这里开始递归遍历整棵 UI 树。
3.2 遍历并结构化
kotlin
fun captureUiTree(): UiTree? {
val root = rootInActiveWindow ?: return null
val nodes = mutableListOf<UiNode>()
val nodeIdMap = mutableMapOf<Int, AccessibilityNodeInfo>()
var nodeId = 0
fun traverse(node: AccessibilityNodeInfo, depth: Int) {
if (nodes.size >= MAX_NODES) return // 上限 800 个节点
// 过滤无意义节点
if (shouldSkip(node)) return
val bounds = Rect()
node.getBoundsInScreen(bounds)
val uiNode = UiNode(
id = nodeId,
className = node.className?.toString(),
text = node.text?.toString()?.take(100),
contentDescription = node.contentDescription?.toString()?.take(100),
resourceId = node.viewIdResourceName,
bounds = bounds,
clickable = node.isClickable,
scrollable = node.isScrollable,
editable = node.isEditable,
enabled = node.isEnabled,
checked = node.isChecked
)
nodeIdMap[nodeId] = node // 保存引用,后续执行指令时用
nodes.add(uiNode)
nodeId++
// 递归子节点
for (i in 0 until node.childCount) {
node.getChild(i)?.let { traverse(it, depth + 1) }
}
}
traverse(root, 0)
return UiTree(
timestamp = System.currentTimeMillis(),
activePackage = root.packageName?.toString(),
screenWidth = displayWidth,
screenHeight = displayHeight,
nodeCount = nodes.size,
nodes = nodes
)
}
3.3 过滤策略
不是所有节点都有用,合理过滤能大幅减少上报数据量:
kotlin
private fun shouldSkip(node: AccessibilityNodeInfo): Boolean {
// 跳过零尺寸节点
val bounds = Rect()
node.getBoundsInScreen(bounds)
if (bounds.width() <= 0 || bounds.height() <= 0) return true
// 跳过纯装饰性 ImageView(无文字、无描述、不可点击)
if (node.className?.toString() == "android.widget.ImageView"
&& node.text.isNullOrEmpty()
&& node.contentDescription.isNullOrEmpty()
&& !node.isClickable) {
return true
}
return false
}
四、截图获取
截图配合 UI 树一起上报,服务端可以用视觉模型辅助理解页面。
4.1 Android 11+ 方案:AccessibilityService.takeScreenshot
kotlin
suspend fun captureScreenshot(): ByteArray? {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
return captureViaMediaProjection()
}
return suspendCancellableCoroutine { cont ->
takeScreenshot(
Display.DEFAULT_DISPLAY,
mainExecutor,
object : TakeScreenshotCallback {
override fun onSuccess(screenshot: ScreenshotResult) {
val bitmap = Bitmap.wrapHardwareBuffer(
screenshot.hardwareBuffer,
screenshot.colorSpace
) ?: run {
cont.resume(null)
return
}
// HardwareBitmap 不能直接压缩,需要 copy 到软件 Bitmap
val softBitmap = bitmap.copy(Bitmap.Config.ARGB_8888, false)
bitmap.recycle()
screenshot.hardwareBuffer.close()
val stream = ByteArrayOutputStream()
softBitmap.compress(Bitmap.CompressFormat.JPEG, 50, stream)
softBitmap.recycle()
cont.resume(stream.toByteArray())
}
override fun onFailure(errorCode: Int) {
cont.resume(null)
}
}
)
}
}
4.2 Android 11 以下方案:MediaProjection
kotlin
// 需要用户授权一次 MediaProjection
object ScreenCaptureHelper {
private var imageReader: ImageReader? = null
private var virtualDisplay: VirtualDisplay? = null
fun init(context: Context, resultCode: Int, data: Intent) {
val projection = MediaProjectionManager.getMediaProjection(resultCode, data)
imageReader = ImageReader.newInstance(width, height, PixelFormat.RGBA_8888, 2)
virtualDisplay = projection.createVirtualDisplay(
"screenshot", width, height, dpi,
DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
imageReader!!.surface, null, null
)
}
fun capture(): ByteArray? {
val image = imageReader?.acquireLatestImage() ?: return null
// 转 Bitmap → JPEG
// ...
image.close()
return jpegBytes
}
}
五、指令执行
服务端分析完 UI 树和截图后,下发具体操作指令。客户端按指令类型分发执行。
5.1 指令数据结构
kotlin
data class A11yInstruction(
val id: String, // 指令唯一 ID
val action: String, // 操作类型
val params: Map<String, Any>? = null, // 参数
val description: String? = null // 可读描述(调试用)
)
data class A11yInstructionResult(
val instructionId: String,
val status: String, // "success" | "failed" | "skipped"
val message: String? = null
)
5.2 指令分发
kotlin
fun executeAutoInstruction(instr: A11yInstruction): A11yInstructionResult {
return when (instr.action) {
"click" -> execClick(instr)
"long_click" -> execLongClick(instr)
"tap" -> execTap(instr)
"swipe" -> execSwipe(instr)
"scroll" -> execScroll(instr)
"input_text" -> execInputText(instr)
"clear_text" -> execClearText(instr)
"paste_text" -> execPasteText(instr)
"launch_app" -> execLaunchApp(instr)
"press_back" -> execPressBack()
"press_home" -> execPressHome()
"assert" -> execAssert(instr)
else -> A11yInstructionResult(instr.id, "failed", "未知指令: ${instr.action}")
}
}
5.3 点击操作
点击有三层 fallback 策略:
kotlin
private fun execClick(instr: A11yInstruction): A11yInstructionResult {
val nodeId = instr.params?.get("nodeId") as? Int
val node = nodeIdMap[nodeId] ?: return failed("节点不存在")
// 策略 1:直接 performAction
if (node.performAction(AccessibilityNodeInfo.ACTION_CLICK)) {
return success()
}
// 策略 2:向上查找可点击的父节点(最多 8 层)
var parent = node.parent
var depth = 0
while (parent != null && depth < 8) {
if (parent.isClickable && parent.performAction(ACTION_CLICK)) {
return success()
}
parent = parent.parent
depth++
}
// 策略 3:坐标点击(取节点中心点)
val bounds = Rect()
node.getBoundsInScreen(bounds)
val x = bounds.centerX().toFloat()
val y = bounds.centerY().toFloat()
return if (dispatchTap(x, y)) success() else failed("点击失败")
}
5.4 坐标点击(Tap)
kotlin
private fun dispatchTap(x: Float, y: Float): Boolean {
val path = Path().apply { moveTo(x, y) }
val gesture = GestureDescription.Builder()
.addStroke(GestureDescription.StrokeDescription(path, 0, 45))
.build()
return dispatchGesture(gesture, null, null)
}
dispatchGesture 是 AccessibilityService 提供的手势注入 API,45ms 的 duration 模拟一次快速点击。
5.5 滑动操作
kotlin
private fun dispatchSwipe(
x1: Float, y1: Float,
x2: Float, y2: Float,
durationMs: Long
): Boolean {
val path = Path().apply {
moveTo(x1, y1)
lineTo(x2, y2)
}
val gesture = GestureDescription.Builder()
.addStroke(GestureDescription.StrokeDescription(path, 0, durationMs))
.build()
return dispatchGesture(gesture, null, null)
}
durationMs 控制滑动速度,一般 300-500ms 模拟正常滑动,100ms 以下模拟快速滑动。
5.6 文本输入
文本输入是自动化中最复杂的部分,需要多层 fallback:
kotlin
private fun execInputText(instr: A11yInstruction): A11yInstructionResult {
val text = instr.params?.get("text")?.toString() ?: return failed("缺少 text")
val clearFirst = instr.params?.get("clearFirst") as? Boolean ?: false
val nodeId = instr.params?.get("nodeId") as? Int
// 找到目标输入框
val node = if (nodeId != null) {
nodeIdMap[nodeId]
} else {
findTopEditableNode() // 自动查找当前页面第一个可编辑节点
} ?: return failed("找不到输入框")
// 聚焦
node.performAction(ACTION_FOCUS)
// 清空(如果需要)
if (clearFirst) {
val clearArgs = Bundle().apply {
putCharSequence(ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE, "")
}
node.performAction(ACTION_SET_TEXT, clearArgs)
}
// 策略 1:ACTION_SET_TEXT(最直接)
val args = Bundle().apply {
putCharSequence(ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE, text)
}
if (node.performAction(ACTION_SET_TEXT, args)) {
return success()
}
// 策略 2:通过自定义输入法 AIDL 注入
if (ElinkImeClient.isReady()) {
val result = ElinkImeClient.commitText(text)
if (result == 0) return success()
}
// 策略 3:剪贴板粘贴
val clipboard = getSystemService(CLIPBOARD_SERVICE) as ClipboardManager
clipboard.setPrimaryClip(ClipData.newPlainText("auto_input", text))
val focusNode = rootInActiveWindow?.findFocus(FOCUS_INPUT)
focusNode?.performAction(ACTION_PASTE)
return success()
}
5.7 启动应用
kotlin
private fun execLaunchApp(instr: A11yInstruction): A11yInstructionResult {
val packageName = instr.params?.get("package")?.toString() ?: return failed("缺少 package")
val intent = packageManager.getLaunchIntentForPackage(packageName)
?: return failed("应用未安装: $packageName")
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
startActivity(intent)
return success()
}
六、自动化循环
客户端的核心循环逻辑:
kotlin
private suspend fun runStepLoop(task: AutoTask, initialInstructions: List<A11yInstruction>) {
var instructions = initialInstructions
var stepIndex = 0
while (stepIndex < MAX_STEPS && isRunning) {
// 1. 执行本轮指令
val results = instructions.map { instr ->
delay(MIN_ACTION_INTERVAL_MS) // 操作间隔,避免太快
executeAutoInstruction(instr)
}
// 2. 等待页面稳定
delay(800)
// 3. 获取当前状态(UI 树 + 截图)
val uiTree = captureUiTree()
val screenshot = captureScreenshot()
// 4. 上报给服务端,获取下一步指令
val response = apiService.a11yStep(
taskId = task.taskId,
stepIndex = stepIndex,
instructionResults = results,
uiTree = uiTree,
screenshot = screenshot
)
// 5. 判断任务状态
when (response.status) {
"done" -> {
notifyProgress("任务完成")
return
}
"error" -> {
notifyProgress("任务失败: ${response.message}")
return
}
"waiting_for_user" -> {
// 需要用户语音输入(如选择商品)
val userInput = waitForUserInput(timeout = 120_000)
// 带着用户输入重新请求服务端
instructions = retryWithUserInput(userInput)
}
"continue" -> {
instructions = response.nextInstructions
}
}
stepIndex++
}
}
关键设计点
| 设计点 | 说明 |
|---|---|
| 操作间隔 | 每个指令之间至少 450ms,避免页面还没渲染完就执行下一步 |
| 页面稳定等待 | 执行完指令后等 800ms 再截图,确保页面已更新 |
| 最大步数 | 100 步上限,防止死循环 |
| 总超时 | 5 分钟,超时自动终止 |
| App 就绪检测 | 启动 App 后轮询检测目标包名是否到前台,最多等 8 秒 |
七、按键拦截
自动化任务运行期间,需要防止用户误操作(如按返回键导致 App 退出)。
kotlin
override fun onServiceConnected() {
super.onServiceConnected()
// 运行时确保开启按键过滤
serviceInfo = serviceInfo.apply {
flags = flags or FLAG_REQUEST_FILTER_KEY_EVENTS
}
}
override fun onKeyEvent(event: KeyEvent): Boolean {
if (event.keyCode == KEYCODE_BACK) {
if (isAutomationRunning) {
// 任务运行中:吞掉返回键,不让目标 App 处理
return true
}
if (assistantWindow?.isShowing == true && event.action == ACTION_UP) {
// 助手显示中:关闭助手
assistantWindow.hide()
return true
}
}
return super.onKeyEvent(event)
}
八、实际踩坑经验
8.1 UI 树节点数量爆炸
美团首页一个页面可能有 2000+ 节点。全部上报会导致:
- 序列化耗时长
- 网络传输慢
- 服务端 token 消耗大
解决方案:设置 800 节点上限 + 过滤无意义节点(零尺寸、纯装饰 ImageView、分隔符等)。
8.2 ACTION_SET_TEXT 不生效
部分 App 的输入框(如微信搜索框)不响应 ACTION_SET_TEXT。
解决方案:三层 fallback:
ACTION_SET_TEXT- 自定义输入法 AIDL 注入(
commitText) - 剪贴板粘贴(
ACTION_PASTE)
8.3 截图 HardwareBuffer 不能直接压缩
Android 11+ 的 takeScreenshot 返回的是 HardwareBuffer,不能直接 compress()。必须先 copy() 到软件 Bitmap:
kotlin
val softBitmap = hardwareBitmap.copy(Bitmap.Config.ARGB_8888, false)
softBitmap.compress(JPEG, 50, stream)
8.4 页面跳转后 UI 树为空
App 内跳页时,新页面可能还没渲染完,此时取 UI 树会拿到空或旧数据。
解决方案:执行完指令后等待 800ms,并在启动 App 后轮询检测目标包名是否到前台(最多 8 秒)。
8.5 节点引用失效
AccessibilityNodeInfo 是快照引用,页面变化后会失效。
解决方案 :每轮循环重新 captureUiTree(),不跨轮次复用节点引用。
九、总结
Android 无障碍自动化的客户端核心能力:
| 能力 | 实现方式 | 关键 API |
|---|---|---|
| UI 树获取 | 遍历 AccessibilityNodeInfo | rootInActiveWindow |
| 截图 | AccessibilityService API / MediaProjection | takeScreenshot() |
| 点击 | performAction / dispatchGesture | ACTION_CLICK / GestureDescription |
| 滑动 | dispatchGesture | GestureDescription |
| 文本输入 | SET_TEXT / IME AIDL / 剪贴板 | ACTION_SET_TEXT / ACTION_PASTE |
| 启动应用 | startActivity | getLaunchIntentForPackage() |
| 按键拦截 | onKeyEvent | FLAG_REQUEST_FILTER_KEY_EVENTS |
整个自动化的核心思路是:客户端只做感知和执行,决策交给服务端 AI。客户端把 UI 树和截图上报,服务端分析后下发下一步指令,客户端执行后再上报,如此循环直到任务完成。
这套架构的优势在于:
- 客户端逻辑简单,不需要针对每个 App 写死流程
- 服务端可以用大模型理解页面,适应 App 更新
- 新增场景只需要服务端调整策略,客户端不用改代码
- 不需要系统签名,普通应用 + 用户开启无障碍即可实现