安卓无障碍服务入门指南

安卓无障碍服务(Accessibility Service)原本是为视障、听障等残障人士设计的辅助功能,但它强大的 界面感知能力 让它成为自动化操作、辅助工具开发的重要技术。本文将从零开始介绍无障碍服务的核心概念与工程配置。

一、什么是无障碍服务

无障碍服务是 Android 系统提供的一种特殊后台服务,能够接收系统派发的 AccessibilityEvent,并通过 AccessibilityNodeInfo 访问当前界面的控件树。它可以在不 root 的情况下,读取屏幕内容、监听界面变化、模拟用户点击与手势。

核心能力:监听事件 + 读取节点树 + 模拟操作,三者组合即可实现「看得见、摸得着」的自动化。

典型应用场景

  • 辅助工具:屏幕朗读、按键映射、悬浮窗控制
  • 自动化:自动签到、自动抢红包、批量点击
  • 设备管理:应用防卸载、使用时长统计、行为监控
  • 测试辅助:UI 自动化遍历、埋点校验

二、工程配置

无障碍服务的接入需要三步:声明权限 → 注册服务 → 编写配置

1. AndroidManifest.xml 声明

<!-- 无障碍服务必须在 manifest 中声明 -->
<service
    android:name=".service.MyAccessibilityService"
    android:label="@string/accessibility_label"
    android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE"
    android:exported="false">
    <intent-filter>
        <action android:name="android.accessibilityservice.AccessibilityService" />
    </intent-filter>
    <meta-data
        android:name="android.accessibilityservice"
        android:resource="@xml/accessibility_config" />
</service>

关键点:

  • BIND_ACCESSIBILITY_SERVICE 权限是系统级绑定,保证只有系统才能连接服务。
  • intent-filter 的 action 固定,系统据此识别无障碍服务。
  • meta-data 指向服务的配置文件。

2. 创建配置文件 res/xml/accessibility_config.xml

<accessibility-service xmlns:android="http://schemas.android.com/apk/res/android"
    android:description="@string/accessibility_desc"
    android:accessibilityEventTypes="typeAllMask"
    android:accessibilityFeedbackType="feedbackGeneric"
    android:accessibilityFlags="flagDefault|flagRetrieveInteractiveWindows|flagRequestFilterKeyEvents"
    android:canRetrieveWindowContent="true"
    android:canPerformGestures="true"
    android:notificationTimeout="100" />

常用属性说明:

  • accessibilityEventTypes:监听的事件类型,typeAllMask 表示全部。
  • canRetrieveWindowContent:是否允许读取窗口内容(节点树),必须为 true。
  • canPerformGestures:是否允许执行手势,模拟点击必备。
  • flagRetrieveInteractiveWindows:允许获取多窗口(如悬浮窗、通知栏)。

3. 继承 AccessibilityService

class MyAccessibilityService : AccessibilityService() {

    override fun onServiceConnected() {
        super.onServiceConnected()
        // 服务连接成功,可做初始化
    }

    override fun onAccessibilityEvent(event: AccessibilityEvent?) {
        // 接收系统派发的事件
        event ?: return
        Log.d("A11y", "事件类型: ${event.eventType}")
    }

    override fun onInterrupt() {
        // 服务被中断时回调
    }
}

三、服务生命周期

  1. 用户开启:服务必须由用户在「设置 → 无障碍」中手动开启,应用无法直接拉起。
  2. onServiceConnected():服务连接成功,可在此注册全局悬浮窗、初始化缓存。
  3. onAccessibilityEvent():系统派发事件,服务的核心回调。
  4. onUnbind():用户关闭服务时回调,用于释放资源。
注意:无障碍服务常驻后台,被系统杀死后会自动重启。但 Android 9+ 后台限制趋严,需配合前台服务或保活策略。

四、引导用户开启服务

由于系统限制,应用无法直接开启无障碍服务,只能跳转到设置页引导用户操作:

fun openAccessibilitySettings(context: Context) {
    val intent = Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS)
    intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
    context.startActivity(intent)
}

检测服务是否已开启:

fun isServiceEnabled(context: Context): Boolean {
    val service = context.packageName + "/" + MyAccessibilityService::class.java.name
    val enabled = Settings.Secure.getString(
        context.contentResolver,
        Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES
    ) ?: return false
    return enabled.contains(service)
}

小结

本文介绍了无障碍服务的基础概念与工程接入方式。掌握 事件监听节点树查找 后,就能开始构建实际的自动化逻辑,这些内容将在后续文章中展开。

事件监听与节点树查找

无障碍服务的核心是 事件驱动 + 节点树遍历。系统通过事件告知你「发生了什么」,你通过节点树查找「在哪里发生」,二者配合才能精准定位并操作目标控件。本文深入讲解事件类型与 AccessibilityNodeInfo 的使用。

一、onAccessibilityEvent 回调

所有界面变化都会触发 onAccessibilityEvent,事件类型由 event.eventType 区分:

override fun onAccessibilityEvent(event: AccessibilityEvent?) {
    event ?: return
    when (event.eventType) {
        AccessibilityEvent.TYPE_VIEW_CLICKED -> {
            Log.d("A11y", "点击了: ${event.className}")
        }
        AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED -> {
            // 新窗口出现(Activity/Dialog 切换)
            val pkg = event.packageName?.toString()
            Log.d("A11y", "窗口变化: $pkg")
        }
        AccessibilityEvent.TYPE_VIEW_TEXT_CHANGED -> {
            // 输入框文本变化
            Log.d("A11y", "文本: ${event.text}")
        }
    }
}

常用事件类型

  • TYPE_VIEW_CLICKED / TYPE_VIEW_LONG_CLICKED:视图被点击 / 长按
  • TYPE_VIEW_SELECTED:视图被选中(如 Tab 切换)
  • TYPE_VIEW_TEXT_CHANGED:输入框文本变化
  • TYPE_WINDOW_STATE_CHANGED:窗口状态变化(Activity/Dialog 切换)
  • TYPE_WINDOW_CONTENT_CHANGED:窗口内容变化(列表刷新等)
  • TYPE_NOTIFICATION_STATE_CHANGED:通知出现
  • TYPE_VIEW_SCROLLED:视图滚动
关键区分:TYPE_WINDOW_STATE_CHANGED 是「新窗口出现」,频率低;TYPE_WINDOW_CONTENT_CHANGED 是「窗口内内容更新」,频率高。做自动化时前者用于页面切换检测,后者用于列表刷新。

二、获取根节点

事件本身携带的是事件源节点,要操作完整界面需获取 根节点

override fun onAccessibilityEvent(event: AccessibilityEvent?) {
    val root = rootInActiveWindow ?: return
    // 遍历整棵节点树
    traverse(root)
}

fun traverse(node: AccessibilityNodeInfo, depth: Int = 0) {
    repeat(depth) { print("  ") }
    println("${node.className} - ${node.viewIdResourceName}")
    for (i in 0 until node.childCount) {
        node.getChild(i)?.let { traverse(it, depth + 1) }
    }
}

三、节点查找方法

1. 按 ID 查找(推荐)

控件若设置了 android:id,可通过资源 ID 精准定位。ID 格式为 包名:id/资源名

fun findById(root: AccessibilityNodeInfo, id: String): AccessibilityNodeInfo? {
    return root.findAccessibilityNodeInfosByViewId(id).firstOrNull()
}

// 示例:查找微信"发送"按钮
val sendBtn = findById(root, "com.tencent.mm:id/b4m")

2. 按文本查找

当控件无 ID 或 ID 混淆时,按显示文本查找更可靠:

fun findByText(root: AccessibilityNodeInfo, text: String): AccessibilityNodeInfo? {
    return root.findAccessibilityNodeInfosByText(text).firstOrNull()
}

// 示例:查找文本为"登录"的按钮
val loginBtn = findByText(root, "登录")

3. 按描述查找

控件的 contentDescription 也可用于定位:

fun findByDesc(node: AccessibilityNodeInfo, desc: String): AccessibilityNodeInfo? {
    if (node.contentDescription?.toString()?.contains(desc) == true) {
        return node
    }
    for (i in 0 until node.childCount) {
        node.getChild(i)?.let { child ->
            findByDesc(child, desc)?.let { return it }
        }
    }
    return null
}

四、节点信息读取

拿到节点后,可读取丰富的属性用于判断:

val node: AccessibilityNodeInfo = // ...

node.className            // 控件类名,如 android.widget.Button
node.text                 // 显示文本
node.contentDescription   // 内容描述
node.viewIdResourceName   // 资源 ID,如 com.xx:id/btn_login
node.isEnabled            // 是否可用
node.isChecked            // 是否选中
node.isClickable          // 是否可点击
node.isVisibleToUser      // 是否对用户可见
node.boundsInScreen       // 屏幕坐标 Rect
node.parent               // 父节点
node.childCount           // 子节点数量

五、点击的父节点回溯

实际开发中常遇到「目标节点不可点击,但其父节点可点击」的情况。这时需要向上回溯找到可点击的祖先节点:

fun AccessibilityNodeInfo.findClickableParent(): AccessibilityNodeInfo? {
    var n: AccessibilityNodeInfo? = this
    while (n != null) {
        if (n.isClickable) return n
        n = n.parent
    }
    return null
}

// 使用:找到 TextView 后,点击它可点击的父容器
val target = findByText(root, "确定")
target?.findClickableParent()?.performAction(...)

六、节点缓存与回收

重要AccessibilityNodeInfo 是系统对象的引用,持有过久会失效(界面变化后节点失效)。每次操作前应重新获取根节点,不要缓存节点跨事件使用。

Android 33+ 推荐使用 close()recycle()(已废弃但仍可用)释放资源:

node.use {  // AccessibilityNodeInfo 实现了 Closeable (API 33+)
    // 在此作用域内使用 node
}

小结

事件监听告诉我们「何时触发」,节点查找告诉我们「操作哪里」。掌握 findAccessibilityNodeInfosByViewIdfindAccessibilityNodeInfosByText 两种查找方式,配合父节点回溯,已能覆盖 90% 的定位场景。下一篇将讲解如何对节点执行点击、手势等操作。

手势模拟与自动操作实战

前两篇解决了「看」的问题——监听事件、查找节点。本篇解决「做」的问题——如何通过无障碍服务模拟点击、长按、滑动等用户操作。无障碍服务提供了两类操作方式:节点操作手势分发

一、节点操作 performAction

当目标节点可见且可交互时,performAction 是最直接的点击方式:

val node = root.findAccessibilityNodeInfosByText("登录").firstOrNull()

// 方式一:直接点击节点
node?.performAction(AccessibilityNodeInfo.ACTION_CLICK)

// 方式二:节点不可点击时,点击其父节点
node?.parent?.performAction(AccessibilityNodeInfo.ACTION_CLICK)

常用 Action

  • ACTION_CLICK:单击
  • ACTION_LONG_CLICK:长按
  • ACTION_SCROLL_FORWARD / ACTION_SCROLL_BACKWARD:向前/向后滚动
  • ACTION_SELECT:选中
  • ACTION_SET_TEXT:设置输入框文本(常用于自动填表)
  • ACTION_FOCUS / ACTION_CLEAR_FOCUS:获取/清除焦点

自动填写文本示例

fun fillText(node: AccessibilityNodeInfo?, text: String) {
    node ?: return
    val args = Bundle().apply {
        putCharSequence(
            AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE,
            text
        )
    }
    node.performAction(AccessibilityNodeInfo.ACTION_SET_TEXT, args)
}

// 使用:清空并填入账号
val accountInput = root.findAccessibilityNodeInfosByViewId("com.xx:id/et_account").firstOrNull()
fillText(accountInput, "user@example.com")
优势:ACTION_SET_TEXT 可直接设置文本,比模拟键盘逐字输入更稳定,且不依赖输入法。

二、手势分发 dispatchGesture

performAction 依赖节点存在且可见。当目标是无 ID 的自定义控件、H5 页面、或需要模拟滑动时,就要用 dispatchGesture 在屏幕坐标上模拟真实手势。

1. 模拟点击(单点触摸)

fun click(x: Float, y: Float) {
    val path = Path().apply { moveTo(x, y) }
    val gesture = GestureDescription.Builder()
        .addStroke(GestureDescription.StrokeDescription(path, 0, 50))
        .build()
    dispatchGesture(gesture, null, null)
}

// 使用:点击屏幕坐标 (540, 1200)
click(540f, 1200f)

参数说明:

  • Path:手势路径,点击是单点。
  • StrokeDescription(path, startTime, duration):起止时间(ms)。点击 duration 通常 50~100ms。
  • dispatchGesture(gesture, callback, handler):第二个参数是执行回调。

2. 模拟长按

fun longClick(x: Float, y: Float) {
    val path = Path().apply { moveTo(x, y) }
    val gesture = GestureDescription.Builder()
        .addStroke(GestureDescription.StrokeDescription(path, 0, 800))
        .build()
    dispatchGesture(gesture, null, null)
}
长按只需将 duration 延长到 500ms 以上即可,系统会识别为长按手势。

3. 模拟滑动

fun swipe(startX: Float, startY: Float, endX: Float, endY: Float, duration: Long = 300) {
    val path = Path.apply {
        moveTo(startX, startY)
        lineTo(endX, endY)
    }
    val gesture = GestureDescription.Builder()
        .addStroke(GestureDescription.StrokeDescription(path, 0, duration))
        .build()
    dispatchGesture(gesture, null, null)
}

// 向上滑动列表
swipe(540f, 1500f, 540f, 500f, 400)

4. 手势执行回调

需要知道手势是否执行完成时,传入回调:

dispatchGesture(gesture, object : AccessibilityService.GestureResultCallback() {
    override fun onCompleted(gesture: GestureDescription?) {
        Log.d("A11y", "手势完成")
    }
    override fun onCancelled(gesture: GestureDescription?) {
        Log.d("A11y", "手势被取消")
    }
}, Handler(Looper.getMainLooper()))

三、实战:自动点击登录按钮

综合应用——监听登录页出现后,自动填写账号密码并点击登录:

override fun onAccessibilityEvent(event: AccessibilityEvent?) {
    if (event?.eventType != AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED) return
    if (event.packageName?.toString() != "com.xx.app") return

    val root = rootInActiveWindow ?: return

    // 1. 填写账号
    root.findAccessibilityNodeInfosByViewId("com.xx.app:id/et_account")
        .firstOrNull()?.let {
            val args = Bundle().apply {
                putCharSequence(
                    AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE,
                    "user@example.com"
                )
            }
            it.performAction(AccessibilityNodeInfo.ACTION_SET_TEXT, args)
        }

    // 2. 填写密码
    root.findAccessibilityNodeInfosByViewId("com.xx.app:id/et_password")
        .firstOrNull()?.let {
            val args = Bundle().apply {
                putCharSequence(
                    AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE,
                    "password123"
                )
            }
            it.performAction(AccessibilityNodeInfo.ACTION_SET_TEXT, args)
        }

    // 3. 延迟 500ms 后点击登录
    Handler(Looper.getMainLooper()).postDelayed({
        rootInActiveWindow?.findAccessibilityNodeInfosByText("登录")
            ?.firstOrNull()
            ?.findClickableParent()
            ?.performAction(AccessibilityNodeInfo.ACTION_CLICK)
    }, 500)
}

四、注意事项

1. 时序与延迟

无障碍操作是异步的,连续操作需要适当延迟,否则节点未刷新会导致失败:

// 错误:连续操作,第二个节点可能尚未渲染
btn1.performAction(ACTION_CLICK)
root.findAccessibilityNodeInfosByText("下一步")  // 可能查不到

// 正确:延迟等待页面刷新
btn1.performAction(ACTION_CLICK)
Handler(Looper.getMainLooper()).postDelayed({
    rootInActiveWindow?.findAccessibilityNodeInfosByText("下一步")?.firstOrNull()?.performAction(ACTION_CLICK)
}, 800)

2. 防止重复触发

事件回调可能高频触发,需加节流:

private var lastClickTime = 0L

fun AccessibilityNodeInfo.clickOnce(): Boolean {
    val now = System.currentTimeMillis()
    if (now - lastClickTime < 1000) return false
    lastClickTime = now
    return performAction(AccessibilityNodeInfo.ACTION_CLICK)
}

3. 权限与合规

  • 无障碍服务权限敏感,上架 Google Play 需声明合理用途,纯自动化工具可能被拒。
  • 国内应用商店审核较宽松,但仍需在隐私政策中告知用户。
  • 切勿读取并上传用户敏感信息(密码、验证码等),存在严重合规风险。

4. 兼容性

  • dispatchGesture 需要 Android 7.0(API 24)+,且配置 canPerformGestures="true"
  • Android 10+ 对后台无障碍服务有后台启动限制。
  • MIUI、EMUI 等定制系统可能额外限制后台无障碍服务,需引导用户加入电池白名单。

小结

节点操作 performAction 精准但依赖节点,手势分发 dispatchGesture 灵活但依赖坐标。实际开发中两者配合:能用节点就用节点,节点不可用时回退到坐标手势。掌握时序控制与防重复触发,是构建稳定自动化脚本的关键。

至此,安卓无障碍开发系列三篇完结。从基础配置到事件监听、节点查找,再到手势模拟实战,已具备构建完整自动化工具的能力。