深入理解 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 只能在这里做
相关推荐
summerkissyou19872 小时前
Android-SurfaceView-打开车机SurfaceFlinger和HWC的日志
android
Fate_I_C2 小时前
Android函数式编程代码规范文档
android·代码规范
安卓蓝牙Vincent3 小时前
Android BLE SDK 设计手册(一):一次参数改动,让我重新设计了整套架构
android·架构
angerdream3 小时前
Android手把手编写儿童手机远程监控App之广播开机自启动
android·android studio
su_ym81103 小时前
Android SELinux
android·selinux
阿巴斯甜3 小时前
Android中项目架构:
android
程序员陆业聪5 小时前
线上监控与防劣化:让启动优化成果不再回退 | Android启动优化系列(五·完结)
android
程序员陆业聪5 小时前
首帧渲染优化:从白屏到内容可见的最后一公里
android
AI玫瑰助手5 小时前
Python基础:字符串的常用内置方法(查找替换分割)
android·开发语言·python