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 更新
  • 新增场景只需要服务端调整策略,客户端不用改代码
  • 不需要系统签名,普通应用 + 用户开启无障碍即可实现
相关推荐
华超磊1 小时前
关于手动实现滚动的尝试
前端
宁雨桥1 小时前
前端修行日记之JS 原型与 AI基础常识
前端·javascript·原型模式
程序员陆通1 小时前
月烧 400 刀到不到 20 刀:我是怎么把 OpenClaw 的 Token 账单砍掉 95% 的
java·前端·数据库
水云桐程序员2 小时前
前端教程官方文档|HTML、CSS、JavaScript教程官方文档
前端·javascript·css·html·学习方法
SsunmdayKT2 小时前
前后端项目部署与运行机制全流程详解
前端·后端
本末倒置1832 小时前
Vue 3 开发者转型 React 指南:保姆级教程
前端·javascript·vue.js
Reart2 小时前
从0解构tinyWeb项目--(Day:10)
前端·后端·架构
牛蛙点点申请出战3 小时前
IconFontViewer -- 一个可以在 Android Studio 中实时预览 IconFont 的插件
android·前端·intellij idea