Android 无障碍服务实现美团/微信自动化:客户端开发实践

Android 无障碍服务实现美团/微信自动化:客户端开发实践

本文基于实际项目经验,介绍如何利用 Android AccessibilityService 实现外卖点餐、微信消息等场景的自动化操作。重点讲解客户端侧的核心能力:UI 树获取、截图、手势注入、文本输入,以及如何配合服务端下发的指令完成完整的自动化闭环。

本方案基于主屏自动化,不需要系统签名,普通应用即可实现。


一、整体架构

客户端在整个自动化链路中的定位:

markdown 复制代码
服务端(AI 决策)
    ↓ 下发指令(点击/滑动/输入/截图...)
客户端(执行层)
    ├── 获取当前页面 UI 树 → 上报给服务端
    ├── 截取当前屏幕 → 上报给服务端
    ├── 执行点击/滑动/输入等操作
    └── 反馈执行结果
    ↓
服务端根据 UI 树 + 截图分析下一步
    ↓
循环,直到任务完成

客户端不做决策,只做三件事:

  1. 感知:获取 UI 树、截图
  2. 执行:点击、滑动、输入、返回
  3. 反馈:上报执行结果和当前状态

二、权限与配置

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:

  1. ACTION_SET_TEXT
  2. 自定义输入法 AIDL 注入(commitText
  3. 剪贴板粘贴(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 更新
  • 新增场景只需要服务端调整策略,客户端不用改代码
  • 不需要系统签名,普通应用 + 用户开启无障碍即可实现
相关推荐
Pedantic8 小时前
SwiftUI 手势层级(Gesture Hierarchy)详解
前端
飘尘9 小时前
前端转型全栈(Java后端)的快速上手指引
前端·后端·全栈
一颗烂土豆9 小时前
Meshopt 压缩深度解析,为什么它比 Draco 更快
前端·javascript·webgl
浏览器工程师10 小时前
AI Agent 接浏览器任务,先别让它一路点到底
前端·后端
雨季mo浅忆10 小时前
VSCode自动格式化三要素
前端
爱勇宝11 小时前
深扒 Anthropic 1680 位工程师简历:应届生几乎没机会,AI 公司最缺的不是博士
前端·后端·程序员
kyriewen11 小时前
同事每天催我 Code Review,我写了个脚本让 AI 替我 review PR——现在他反过来催 AI 了
前端·javascript·ai编程
user205855615181313 小时前
Windows 项目安装时报 `node-sass` 错误,如何快速处理
前端
LiaCode13 小时前
Redis 在生产项目的使用
前端·后端