深入理解 Android 无障碍服务

文章目录

  • [深入理解 Android 无障碍服务](#深入理解 Android 无障碍服务)
    • 一、先从一个例子说起
    • 二、整体架构:三个进程的故事
      • [2.1 无障碍服务涉及哪些进程?](#2.1 无障碍服务涉及哪些进程?)
      • [2.2 这三个进程分别负责什么?](#2.2 这三个进程分别负责什么?)
      • [2.3 组件之间的调用关系](#2.3 组件之间的调用关系)
    • 三、服务生命周期:容易忽略的起点
      • [3.1 必须在 AndroidManifest.xml 中声明](#3.1 必须在 AndroidManifest.xml 中声明)
      • [3.2 onServiceConnected:真正的初始化位置](#3.2 onServiceConnected:真正的初始化位置)
    • [四、事件流转:完整的 9 步链路](#四、事件流转:完整的 9 步链路)
      • [4.1 完整流程](#4.1 完整流程)
      • [4.2 关键代码路径(对应 AOSP)](#4.2 关键代码路径(对应 AOSP))
      • [4.3 为什么有时候收不到事件?](#4.3 为什么有时候收不到事件?)
    • [五、核心 API 详解](#五、核心 API 详解)
      • [5.1 AccessibilityEvent 的核心属性](#5.1 AccessibilityEvent 的核心属性)
      • [5.2 常用事件类型](#5.2 常用事件类型)
      • [5.3 AccessibilityNodeInfo:查找与操作节点](#5.3 AccessibilityNodeInfo:查找与操作节点)
      • [5.4 常用节点操作](#5.4 常用节点操作)
    • 六、手势注入原理
      • [6.1 dispatchGesture 的工作方式](#6.1 dispatchGesture 的工作方式)
      • [6.2 手势在系统中的流转](#6.2 手势在系统中的流转)
      • [6.3 手势的限制与陷阱](#6.3 手势的限制与陷阱)
    • 七、配置文件详解
      • [7.1 核心配置项](#7.1 核心配置项)
    • 八、内存管理与性能
      • [8.1 节点的本质:Binder 对象,必须回收](#8.1 节点的本质:Binder 对象,必须回收)
      • [8.2 减少 rootInActiveWindow 调用](#8.2 减少 rootInActiveWindow 调用)
    • 九、常见问题与解决方案
      • [Q1: 收不到事件?](#Q1: 收不到事件?)
      • [Q2: rootInActiveWindow 返回 null?](#Q2: rootInActiveWindow 返回 null?)
      • [Q3: 手势不生效?](#Q3: 手势不生效?)
      • [Q4: performAction 返回 true 但没效果?](#Q4: performAction 返回 true 但没效果?)
    • 十、总结

深入理解 Android 无障碍服务

从高级开发者视角,深度解析 AccessibilityService 的内部工作机制


一、先从一个例子说起

当你点击手机上的一个按钮,TalkBack 是如何知道的?

这个看似简单的问题,隐藏着 Android 最复杂的系统机制之一。从手指触碰屏幕到你的 AccessibilityService 收到事件,数据经历了:

  • View → ViewRootImpl → AccessibilityManager → AccessibilityManagerService → AccessibilityServiceConnection → 你的 Service
  • 至少 2 次 Binder IPC 调用(事件传递链路本身;节点查询时额外产生第 3 次)
  • 跨 2 个进程边界
  • 涉及 5+ 个系统组件

理解这个链路,才能真正知道"为什么收不到事件"、"为什么操作没生效"。本文将逐层拆解每个环节。


二、整体架构:三个进程的故事

2.1 无障碍服务涉及哪些进程?

Android 无障碍服务横跨 3 个进程,这是很多开发者没有意识到的:

关键点:你的 Service 根本不知道被监控的应用在哪里,它只通过 Binder 的 AIDL 接口与 system_server 通信。被监控的应用也不知道有人在"监听"它,这一切都由系统透明代理。

2.2 这三个进程分别负责什么?

进程 负责内容 关键类
你的应用进程 运行你写的 AccessibilityService,处理事件、执行操作 AccessibilityService(你继承)
system_server 管理所有无障碍服务的注册/注销,聚合并分发事件 AccessibilityManagerService
被监控的应用进程 产生 UI 事件,响应节点查询,构建 View 树快照 ViewRootImpl、View

2.3 组件之间的调用关系

一句话理解:system_server 是中间人,它既是被监控应用的"事件收件箱",也是你的 Service 的"事件邮差"。你和被监控的应用永远不会直接对话。


三、服务生命周期:容易忽略的起点

很多文章直接讲事件监听,跳过了服务的启动流程。但 onServiceConnected() 才是真正的入口。

3.1 必须在 AndroidManifest.xml 中声明

没有这个声明,系统根本不会启动你的服务:

xml 复制代码
<service
    android:name=".MyAccessibilityService"
    android:exported="true"
    android:label="@string/service_label"
    android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE">

    <intent-filter>
        <action android:name="android.accessibilityservice.AccessibilityService" />
    </intent-filter>

    <!-- 指向 res/xml/accessibility_service_config.xml -->
    <meta-data
        android:name="android.accessibilityservice"
        android:resource="@xml/accessibility_service_config" />
</service>

BIND_ACCESSIBILITY_SERVICE 权限是关键------它确保只有系统(system_server)才能绑定你的服务,防止第三方应用伪装成系统来控制你的 Service。

3.2 onServiceConnected:真正的初始化位置

kotlin 复制代码
class MyAccessibilityService : AccessibilityService() {

    override fun onServiceConnected() {
        // 这里是服务成功连接到系统后的回调
        // 也是动态修改服务配置的唯一时机
        
        // 如果你需要在运行时调整监听的事件类型或包名,在这里做:
        serviceInfo = serviceInfo.apply {
            // 动态添加额外监听的包名(配置文件里的是静态的)
            packageNames = arrayOf("com.target.app")
            
            // 确保手势注入权限开启
            flags = flags or AccessibilityServiceInfo.FLAG_REQUEST_TOUCH_EXPLORATION_MODE
        }
        
        Log.d("A11y", "服务已连接,配置生效")
    }

    override fun onAccessibilityEvent(event: AccessibilityEvent) {
        // 事件处理(见第四节)
    }

    override fun onInterrupt() {
        // 系统中断服务时回调(如用户在设置中关闭服务)
        // 应在此停止所有正在进行的操作
    }
}

为什么不在构造函数或 onCreate 里初始化? 因为 serviceInfoonServiceConnected() 之前为 null,此时修改没有意义,系统还没完成绑定。


四、事件流转:完整的 9 步链路

4.1 完整流程

当用户点击一个按钮,事件这样传递:

为什么是异步的? 步骤 3 中 ViewRootImpl 通过 Handler 异步投递,目的是不阻塞 UI 线程。这意味着从用户点击到你的回调,有轻微延迟(通常 < 5ms),但在高负载时可能更长。

4.2 关键代码路径(对应 AOSP)

步骤 2:View 触发事件

java 复制代码
// frameworks/base/core/java/android/view/View.java
public boolean performClick() {
    notifyAutofillManagerOnClick();
    final boolean result;
    final ListenerInfo li = mListenerInfo;
    if (li != null && li.mOnClickListener != null) {
        playSoundEffect(SoundEffectConstants.CLICK);
        li.mOnClickListener.onClick(this);
        result = true;
    } else {
        result = false;
    }
    sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_CLICKED);
    notifyEnterOrExitForAutoFillIfNeeded(true);
    return result;
}

注意:sendAccessibilityEvent 是在 onClick 回调之后才调用的,所以你的业务逻辑先执行,无障碍事件后发出。

步骤 3:ViewRootImpl 的异步投递

java 复制代码
// frameworks/base/core/java/android/view/ViewRootImpl.java
// AccessibilityInteractionController 负责异步处理
// 通过 mHandler 投递,不阻塞调用方
void scheduleAccessibilityEvent(View view, int eventType) {
    // 实际代码路径经过 AccessibilityDelegate
    // 最终调用 AccessibilityManager.sendAccessibilityEvent
}

步骤 5:系统服务的事件聚合

java 复制代码
// frameworks/base/services/accessibility/AccessibilityManagerService.java
private void sendAccessibilityEventLocked(AccessibilityEvent event, int userId) {
    // 关键:按事件类型独立节流
    // 每个 (serviceConnection, eventType) 组合维护独立的上次发送时间
    // 而不是所有事件类型共用一个 notificationTimeout 计时器
    for (AccessibilityServiceConnection service : mBoundServices) {
        service.notifyAccessibilityEventLocked(event);
    }
}

4.3 为什么有时候收不到事件?

事件经过 4 层过滤,任意一层不通过就会被丢弃:

五、核心 API 详解

5.1 AccessibilityEvent 的核心属性

kotlin 复制代码
override fun onAccessibilityEvent(event: AccessibilityEvent) {
    // 事件类型:决定这是什么操作
    val eventType = event.eventType
    
    // 包名:哪个应用触发了这个事件
    val packageName = event.packageName
    
    // 类名:触发事件的 View 的类型(如 android.widget.Button)
    val className = event.className
    
    // 文本列表:View 上的文字(可能是列表,如 ListView item)
    val text = event.text.joinToString()
    
    // contentDescription:View 的无障碍描述(图标按钮常用此字段)
    val desc = event.contentDescription
    
    // source:触发事件的 View 对应的节点(用完必须 recycle)
    val source = event.source
    
    // windowId:所在窗口的 ID(多窗口场景有用)
    val windowId = event.windowId
    
    // 注意:event 本身不需要 recycle,但 event.source 需要
    source?.recycle()
}

5.2 常用事件类型

kotlin 复制代码
// UI 交互
AccessibilityEvent.TYPE_VIEW_CLICKED              // 点击
AccessibilityEvent.TYPE_VIEW_LONG_CLICKED         // 长按
AccessibilityEvent.TYPE_VIEW_FOCUSED              // 获得输入焦点
AccessibilityEvent.TYPE_VIEW_TEXT_CHANGED         // 输入框文字变化
AccessibilityEvent.TYPE_VIEW_SCROLLED             // 滚动

// 窗口变化(最常用的两个,注意区别)
AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED      // 新 Activity/Dialog 出现
AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED    // 当前窗口内容局部更新

// 其他
AccessibilityEvent.TYPE_NOTIFICATION_STATE_CHANGED  // 通知栏变化
AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED   // 无障碍焦点移动

TYPE_WINDOW_STATE_CHANGED vs TYPE_WINDOW_CONTENT_CHANGED 的区别

前者是页面级变化(跳转了新页面),后者是内容级变化(同一页面的列表刷新、弹窗出现)。

判断"是否进入了某个页面"用前者;监听动态内容更新用后者,但要注意后者触发频率极高。

5.3 AccessibilityNodeInfo:查找与操作节点

kotlin 复制代码
// 获取根节点(当前前台窗口的顶层 View)
// 每次调用都有 Binder IPC 开销,不要在循环里调用
val rootNode = rootInActiveWindow ?: return

// 方式1: 按文本模糊查找(contains 语义)
val nodes = rootNode.findAccessibilityNodeInfosByText("确认")

// 方式2: 按 View ID 精确查找(推荐,稳定性高于文本)
val buttons = rootNode.findAccessibilityNodeInfosByViewId("com.app:id/btn_confirm")

// 方式3: 查找当前输入焦点(做自动填写时有用)
val focused = rootNode.findFocus(AccessibilityNodeInfo.FOCUS_INPUT)

// 方式4: 递归遍历(最灵活,可自定义匹配条件)
fun traverseNode(node: AccessibilityNodeInfo, depth: Int = 0) {
    println("${" ".repeat(depth)}[${node.className}] text=${node.text} clickable=${node.isClickable}")
    for (i in 0 until node.childCount) {
        node.getChild(i)?.let { child ->
            traverseNode(child, depth + 1)
            child.recycle()
        }
    }
}
traverseNode(rootNode)
rootNode.recycle()

为什么按 ViewId 查找比文本更稳定? 文本会随多语言、A/B 测试变化;ViewId 通常只随重构变化,且语义明确,更不容易误匹配。

5.4 常用节点操作

kotlin 复制代码
// 点击(优先找可点击的节点,必要时向上找父节点)
fun clickNode(node: AccessibilityNodeInfo): Boolean {
    if (node.isClickable) {
        return node.performAction(AccessibilityNodeInfo.ACTION_CLICK)
    }
    // 有些文字放在不可点击的 TextView 里,但父容器可点击
    var parent = node.parent
    while (parent != null) {
        if (parent.isClickable) {
            val result = parent.performAction(AccessibilityNodeInfo.ACTION_CLICK)
            parent.recycle()
            return result
        }
        val next = parent.parent
        parent.recycle()
        parent = next
    }
    return false
}

// 输入文字(直接写入,无需节点先获得焦点)
fun inputText(node: AccessibilityNodeInfo, text: String): Boolean {
    val args = Bundle().apply {
        putCharSequence(AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE, text)
    }
    return node.performAction(AccessibilityNodeInfo.ACTION_SET_TEXT, args)
}

// 向下滚动
fun scrollDown(node: AccessibilityNodeInfo): Boolean {
    return node.performAction(AccessibilityNodeInfo.ACTION_SCROLL_FORWARD)
}

// 全局操作(不依赖特定节点)
fun pressBack() = performGlobalAction(GLOBAL_ACTION_BACK)
fun goHome() = performGlobalAction(GLOBAL_ACTION_HOME)
fun openRecents() = performGlobalAction(GLOBAL_ACTION_RECENTS)

六、手势注入原理

6.1 dispatchGesture 的工作方式

手势注入和节点操作的本质区别:节点操作是"语义层"的(告诉系统"点击这个按钮"),手势注入是"输入层"的(模拟真实手指触摸坐标)。有些应用会检测节点操作(performAction 的来源),手势注入因为走 InputDispatcher,更难被检测。

kotlin 复制代码
// 模拟从屏幕中间向上滑动(上划手势)
fun swipeUp() {
    val displayMetrics = resources.displayMetrics
    val centerX = displayMetrics.widthPixels / 2f
    val startY = displayMetrics.heightPixels * 0.8f
    val endY = displayMetrics.heightPixels * 0.2f

    val path = Path().apply {
        moveTo(centerX, startY)
        lineTo(centerX, endY)
    }

    val gesture = GestureDescription.Builder()
        .addStroke(
            GestureDescription.StrokeDescription(
                path,
                0L,        // 延迟开始(ms)
                500L       // 持续时间(ms),影响滑动速度
            )
        )
        .build()

    dispatchGesture(gesture, object : GestureResultCallback() {
        override fun onCompleted(gestureDescription: GestureDescription?) {
            // 手势成功注入
        }
        override fun onCancelled(gestureDescription: GestureDescription?) {
            // 注入被取消(如系统资源不足)
        }
    }, null)
}

6.2 手势在系统中的流转

6.3 手势的限制与陷阱

  1. 坐标是屏幕绝对坐标 ,不是 View 内部坐标。获取节点位置用 node.getBoundsInScreen(rect)
  2. flagRequestTouchExplorationMode 必须开启 ,否则 dispatchGesture 会静默失败
  3. StrokeDescription 的持续时间影响速度:太短会被识别为点击而非滑动;滑动一般 300~800ms 比较自然
  4. 悬浮窗可能拦截:如果目标坐标被其他窗口覆盖,事件会发给覆盖的窗口
kotlin 复制代码
// 正确获取节点屏幕坐标再注入手势
fun clickNodeByGesture(node: AccessibilityNodeInfo) {
    val rect = Rect()
    node.getBoundsInScreen(rect)
    val centerX = rect.centerX().toFloat()
    val centerY = rect.centerY().toFloat()

    val path = Path().apply { moveTo(centerX, centerY) }
    val gesture = GestureDescription.Builder()
        .addStroke(GestureDescription.StrokeDescription(path, 0, 50)) // 50ms = 点击
        .build()

    dispatchGesture(gesture, null, null)
}

七、配置文件详解

7.1 核心配置项

xml 复制代码
<!-- res/xml/accessibility_service_config.xml -->
<accessibility-service
    xmlns:android="http://schemas.android.com/apk/res/android"

    <!-- 监听哪些事件类型(多个用 | 连接,typeAllMask 监听全部) -->
    android:accessibilityEventTypes="typeWindowStateChanged|typeWindowContentChanged|typeViewClicked"

    <!-- 反馈方式(辅助功能对用户的输出方式) -->
    android:accessibilityFeedbackType="feedbackGeneric"

    <!-- 额外能力标志 -->
    android:accessibilityFlags="flagDefault|flagRequestTouchExplorationMode|flagReportViewIds"

    <!-- 允许读取窗口内容(必须为 true 才能用 rootInActiveWindow) -->
    android:canRetrieveWindowContent="true"

    <!-- 允许执行手势(API 24+) -->
    android:canPerformGestures="true"

    <!-- 事件聚合间隔(ms):同类型事件在此时间内只投递一次 -->
    android:notificationTimeout="100"

    <!-- 用户在设置中看到的服务说明 -->
    android:description="@string/service_description"

    <!-- 点击"设置"时跳转的页面(可选) -->
    android:settingsActivity="com.example.SettingsActivity" />

flagReportViewIds 的作用 :默认情况下 AccessibilityNodeInfo.getViewIdResourceName() 返回 null,开启此 flag 后才能获取到 View 的 ID(如 com.app:id/btn_ok),按 ViewId 查找节点时必须开启。


八、内存管理与性能

8.1 节点的本质:Binder 对象,必须回收

AccessibilityNodeInfo 不是普通 Java 对象,它的底层是 Binder 代理(指向 system_server 里的实际节点)。系统为了避免 Binder 句柄耗尽,强制要求你手动回收。不回收不会立刻崩溃,但会缓慢泄漏系统资源,最终导致 AccessibilityNodeInfo 返回 null 或行为异常。

kotlin 复制代码
// ❌ 泄漏:拿到节点但忘记 recycle
val nodes = rootNode.findAccessibilityNodeInfosByText("确认")
val first = nodes.firstOrNull()
// first 用完了,但没有 recycle

// ✅ 方式1: 手动回收所有节点
val nodes = rootNode.findAccessibilityNodeInfosByText("确认")
try {
    nodes.firstOrNull()?.let { node ->
        node.performAction(AccessibilityNodeInfo.ACTION_CLICK)
        node.recycle()
    }
} finally {
    // 其余未使用的节点也要回收
    nodes.drop(1).forEach { it.recycle() }
}

// ✅ 方式2: Kotlin 扩展(兼容全版本,命名避开标准库的 use)
inline fun <T> AccessibilityNodeInfo.useNode(block: (AccessibilityNodeInfo) -> T): T =
    try { block(this) } finally { recycle() }

// 使用方式:
rootNode.findAccessibilityNodeInfosByText("确认")
    .forEach { node -> node.useNode { it.performAction(AccessibilityNodeInfo.ACTION_CLICK) } }

8.2 减少 rootInActiveWindow 调用

每次调用 rootInActiveWindow 都会触发 Binder IPC,让 system_server 重新为你序列化当前窗口的 View 树快照。在 onAccessibilityEvent 里高频调用会明显拖慢性能。

kotlin 复制代码
// ❌ 每次事件都重新获取整棵树
override fun onAccessibilityEvent(event: AccessibilityEvent) {
    val root = rootInActiveWindow ?: return  // 每次都有 IPC 开销
    val node = root.findAccessibilityNodeInfosByViewId("com.app:id/btn_ok")
    // ...
    root.recycle()
}

// ✅ 缓存根节点,仅在窗口切换时刷新
private var cachedRoot: AccessibilityNodeInfo? = null

override fun onAccessibilityEvent(event: AccessibilityEvent) {
    // 只有窗口级别切换(新 Activity/Dialog)才刷新缓存
    // ⚠️ 不在 TYPE_WINDOW_CONTENT_CHANGED 时刷新:该事件触发极频繁,
    //    每次刷新都会触发一次 Binder IPC,与减少调用的目的相悖
    if (event.eventType == TYPE_WINDOW_STATE_CHANGED) {
        cachedRoot?.recycle()
        cachedRoot = rootInActiveWindow
    }
    val root = cachedRoot ?: return
    val nodes = root.findAccessibilityNodeInfosByViewId("com.app:id/btn_ok")
    // ... 用完回收 nodes
}

// ⚠️ 缓存的风险:TYPE_WINDOW_CONTENT_CHANGED 触发时,部分子树已变化
// 如果发现节点信息不是最新的,在那次事件里刷新缓存即可

九、常见问题与解决方案

Q1: 收不到事件?

按顺序检查:

  1. 系统设置 → 无障碍 → 你的服务,是否已开启?
  2. accessibilityEventTypes 是否包含你期望的事件类型?
  3. packageNames 是否漏掉了目标应用的包名?(null = 监听全部)
  4. notificationTimeout 是否太长导致事件被丢弃?
  5. 目标应用是否是系统应用或关闭了无障碍支持(importantForAccessibility="no")?

Q2: rootInActiveWindow 返回 null?

最常见原因:窗口刚切换,View 树还没渲染完成。

kotlin 复制代码
override fun onAccessibilityEvent(event: AccessibilityEvent) {
    if (event.eventType == TYPE_WINDOW_STATE_CHANGED) {
        // 延迟 300ms 等待页面渲染完成再获取节点
        // 注意:这个 Handler 在主线程,不会有线程安全问题
        Handler(Looper.getMainLooper()).postDelayed({
            val root = rootInActiveWindow ?: return@postDelayed
            // 此时树已稳定
            root.recycle()
        }, 300)
    }
}

其他原因:

  • 目标是系统 UI(如锁屏),默认不对无障碍服务开放
  • canRetrieveWindowContent 没有设为 true

Q3: 手势不生效?

按顺序检查:

  1. 配置文件里有 android:canPerformGestures="true" 吗?
  2. onServiceConnected 里有设置 FLAG_REQUEST_TOUCH_EXPLORATION_MODE 吗?
  3. 坐标是否用 getBoundsInScreen() 获取的屏幕绝对坐标?
  4. StrokeDescription 的持续时间是否合理(点击 50ms,滑动 300ms+)?

Q4: performAction 返回 true 但没效果?

返回 true 只代表系统接收了请求 ,不代表目标应用执行了操作。原因可能是:

  • 节点已不在屏幕上(stale 引用)
  • 目标应用自定义了 AccessibilityDelegate 并拦截了操作
  • 在错误的线程调用(确保在主线程)

十、总结

核心要点

  1. 三进程模型:你的进程 ↔ system_server ↔ 被监控进程,你和目标应用永不直接通信
  2. 事件完整链路:View.performClick → ViewRootImpl 异步打包 → AccessibilityManager → system_server 聚合过滤 → 你的 Service
  3. 四层过滤:事件类型、notificationTimeout(按类型独立计时)、包名、反馈类型,任意一层不过就收不到
  4. 节点是 Binder 对象:必须 recycle,否则缓慢泄漏系统资源
  5. onServiceConnected 是真正的起点:动态配置 serviceInfo 只能在这里做
相关推荐
赏金术士3 小时前
Kotlin ViewModel
android·kotlin
vistaup5 小时前
kotlin 二维码实现高斯模糊
android·kotlin
愈努力俞幸运5 小时前
function calling与mcp
android·数据库·redis
阿巴斯甜6 小时前
LeakCanary
android
阿巴斯甜6 小时前
compose
android
阿巴斯甜7 小时前
Glide
android
-SOLO-7 小时前
使用Perfetto debug trace查看超时slice
android
阿巴斯甜7 小时前
Retrofit
android
阿巴斯甜7 小时前
OkHttp
android
阿巴斯甜8 小时前
Flow
android