简介这份资源面向Android开发者尤其是希望快速上手无障碍服务AccessibilityService并实现自动化操作业务的中高级工程师。它提供了一套可复用的库与示例工程核心价值在于“一行代码启用”的便捷封装帮助开发者跳过繁琐的初始化配置直接聚焦于业务逻辑。压缩包共67个文件约223KB包含17个Java源码文件、16个XML配置、20张PNG截图以及Gradle构建脚本等覆盖服务声明、事件监听、节点操作等关键模块结构清晰便于二次开发。目前已有4162人学习下载说明其在自动化测试、辅助功能等场景中具有较高参考价值。通过阅读源码与配置读者可以掌握AccessibilityService的注册流程、onAccessibilityEvent回调处理、AccessibilityNodeInfo节点操作以及权限请求与异常处理等要点从而高效构建稳定的自动操作应用。1. 从一行代码说起这个无障碍服务库到底能省掉多少事做过 Android 自动化操作类需求的人大概都有体会AccessibilityService 这套东西官方文档写得四平八稳真上手写起来却处处是坑。你要在 XML 里声明服务、在代码里处理onAccessibilityEvent回调、判断AccessibilityEvent类型、遍历AccessibilityNodeInfo树、处理节点回收、还要兼容不同厂商对无障碍事件的裁剪差异。一个简单的「自动点击某个按钮」功能光是把服务跑起来、把节点找到可能就要折腾大半天。这个库要解决的就是这件事。它把 AccessibilityService 的注册、事件分发、节点查找、手势模拟、文本输入这些高频操作封装成了一套链式调用接口官方示例里那种动辄几百行的样板代码用它可以压缩到一行。比如查找并点击一个文本为「确定」的按钮传统写法要拿到根节点、递归遍历、匹配文本、调用performAction而这个库直接提供类似findByText(确定).click()的写法。它适合谁一是做自动化测试工具、辅助操作类 App、批量任务处理的开发者二是需要快速验证某个自动化流程可行性、不想在基础设施上耗时间的团队。如果你正在写一个需要模拟点击、滑动、读取界面文本的功能又不想把 AccessibilityService 的每个回调都手写一遍这个库值得花时间拆一拆。2. 核心机制拆解事件分发、节点查找与手势模拟怎么串起来2.1 AccessibilityService 的生命周期与事件回调要理解这个库封装了什么得先看清楚原生 AccessibilityService 的工作方式。系统在无障碍服务启用后会在界面发生变化时回调onAccessibilityEvent(AccessibilityEvent event)事件类型包括TYPE_WINDOW_STATE_CHANGED、TYPE_VIEW_CLICKED、TYPE_WINDOW_CONTENT_CHANGED等。每次回调里你能拿到一个AccessibilityNodeInfo作为事件源但真正要操作的目标节点往往不在事件源上需要从getRootInActiveWindow()拿根节点再往下找。原生写法里节点查找是个体力活。你得写递归函数遍历子节点判断getText()、getContentDescription()、getViewIdResourceName()还要在遍历完后调用recycle()释放节点。Android API 33 之后recycle()虽然废弃了但低版本上不回收就是内存泄漏。这个库的做法是把查找逻辑抽成Finder类内部维护节点缓存和回收策略对外只暴露findByText、findById、findByDesc这些方法。事件分发这块库通常会提供一个AccessibilityServiceDelegate或者类似的中间层把原始事件按类型路由到注册的监听器上。你不需要在onAccessibilityEvent里写一长串when或switch而是注册一个只关心TYPE_WINDOW_STATE_CHANGED的监听器剩下的交给库去过滤。2.2 节点查找的匹配策略与性能取舍节点查找看起来简单实际是这类库最考验设计的地方。文本匹配要不要忽略大小写要不要支持正则getText()为空时要不要回退到getContentDescription()这些策略直接决定查找成功率和耗时。常见做法是提供多级匹配先按viewIdResourceName精确匹配这是最快的因为可以直接用findAccessibilityNodeInfosByViewId如果 ID 不稳定很多 App 用动态 ID再按文本匹配用findAccessibilityNodeInfosByText做粗筛然后遍历结果做精确比对最后才用全树遍历兜底。全树遍历在复杂界面上可能耗时几百毫秒所以库一般会加超时和深度限制。下面是一个典型的查找封装逻辑用 Kotlin 写// Finder.kt 核心查找逻辑示意 class Finder(private val root: AccessibilityNodeInfo?) { // 按文本查找支持精确/包含两种模式 fun findByText(text: String, exact: Boolean false): ListAccessibilityNodeInfo { val result mutableListOfAccessibilityNodeInfo() root ?: return result // 先用系统 API 做粗筛减少遍历量 val candidates root.findAccessibilityNodeInfosByText(text) ?: return result for (node in candidates) { val nodeText node.text?.toString() ?: val matched if (exact) nodeText text else nodeText.contains(text) if (matched) result.add(node) } return result } // 按资源 ID 查找速度最快但依赖 ID 稳定性 fun findById(viewId: String): ListAccessibilityNodeInfo { return root?.findAccessibilityNodeInfosByViewId(viewId) ?: emptyList() } }这段代码的关键点在于findAccessibilityNodeInfosByText是系统提供的 API它内部已经做了一层过滤比你自己从根节点递归遍历要快。但它返回的结果是「包含」匹配所以如果要做精确匹配还得再遍历一次结果集。参数exact控制是否精确匹配默认false是为了提高容错——很多按钮的文本前后带空格或换行精确匹配反而找不到。2.3 手势模拟dispatchGesture 的参数与兼容性点击和滑动最终都要落到dispatchGesture上。这个 API 从 API 24 开始提供低版本只能用performAction(ACTION_CLICK)做节点点击但节点点击对某些自定义 View 无效而且没法模拟滑动。dispatchGesture接收一个GestureDescription里面可以放一个或多个StrokeDescription。每个 Stroke 由路径、起始时间、持续时间组成。点击本质上是一个极短路径的 Stroke// 模拟点击坐标 (x, y) fun click(x: Float, y: Float) { val path Path().apply { moveTo(x, y) } val stroke GestureDescription.StrokeDescription(path, 0, 50) // 50ms 完成 val gesture GestureDescription.Builder().addStroke(stroke).build() dispatchGesture(gesture, null, null) }参数说明moveTo(x, y)把路径起点设到目标坐标StrokeDescription的第二个参数是起始延迟毫秒第三个是持续时间。持续时间太短比如 1ms在某些设备上会被系统忽略太长又会让操作显得迟钝50ms 是个比较稳的经验值。滑动则是moveTo起点后lineTo终点持续时间根据滑动距离调整一般 200 到 500ms。需要注意的是dispatchGesture是异步的调用后不会立即生效。如果你要连续做多个手势得等上一个完成再发下一个否则可能被系统丢弃。库一般会提供一个队列或者回调来处理这个时序问题。3. 接入实战从零配置到跑通第一个自动操作3.1 依赖引入与无障碍服务声明假设你拿到的是一个 AAR 包或者源码模块第一步是把它引入工程。如果是 AAR放到libs目录后在build.gradle里加依赖// app/build.gradle dependencies { implementation files(libs/accessibility-lib.aar) // 如果库依赖了 kotlin 标准库按需补充 implementation org.jetbrains.kotlin:kotlin-stdlib:1.9.0 }接下来是声明服务。在AndroidManifest.xml里注册一个继承自库提供的基类的服务service android:name.MyAccessibilityService android:exportedtrue android:permissionandroid.permission.BIND_ACCESSIBILITY_SERVICE intent-filter action android:nameandroid.accessibilityservice.AccessibilityService / /intent-filter meta-data android:nameandroid.accessibilityservice android:resourcexml/accessibility_config / /serviceandroid:permission这一行不能少否则系统不会把服务当作无障碍服务。accessibility_config.xml放在res/xml下内容大致如下!-- res/xml/accessibility_config.xml -- accessibility-service xmlns:androidhttp://schemas.android.com/apk/res/android android:accessibilityEventTypestypeWindowStateChanged|typeWindowContentChanged android:accessibilityFeedbackTypefeedbackGeneric android:accessibilityFlagsflagDefault|flagRetrieveInteractiveWindows android:canRetrieveWindowContenttrue android:canPerformGesturestrue android:notificationTimeout100 /参数说明accessibilityEventTypes决定你关心哪些事件只写你需要的写多了会拖慢系统canPerformGestures必须为true否则dispatchGesture无效notificationTimeout是事件合并间隔100ms 是个折中值太小会频繁回调太大会漏掉快速变化。3.2 服务初始化与事件监听注册服务类本身通常只需要继承库的基类在onServiceConnected里做初始化class MyAccessibilityService : BaseAccessibilityService() { override fun onServiceConnected() { super.onServiceConnected() // 注册一个只关心窗口变化的监听器 registerListener(object : SimpleEventListener() { override fun onWindowChanged(event: AccessibilityEvent) { // 在这里触发你的业务逻辑 handleWindowChange(event) } }) } private fun handleWindowChange(event: AccessibilityEvent) { val finder Finder(rootInActiveWindow) val button finder.findByText(确定, exact true).firstOrNull() button?.let { // 优先用节点点击失败再用手势兜底 if (!it.performAction(AccessibilityNodeInfo.ACTION_CLICK)) { val rect Rect() it.getBoundsInScreen(rect) click(rect.centerX().toFloat(), rect.centerY().toFloat()) } } } }这段代码的逻辑是窗口变化时用Finder找文本为「确定」的节点找到后先尝试performAction(ACTION_CLICK)这是最直接的方式如果返回false说明该节点不可点击或点击无效再取节点在屏幕上的坐标用dispatchGesture模拟点击。这种「节点优先、手势兜底」的策略在实际项目里成功率最高。3.3 一个完整的自动操作流程示例把上面的片段串起来一个「打开设置、进入关于手机、读取版本号」的流程大概是这样fun runFlow() { // 步骤1打开设置 startActivity(Intent(Settings.ACTION_SETTINGS).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) }) // 步骤2等待界面稳定后查找「关于手机」 waitForText(关于手机, timeout 5000)?.let { node - node.performAction(AccessibilityNodeInfo.ACTION_CLICK) } // 步骤3读取版本号文本 waitForText(版本号, timeout 3000)?.let { node - val version node.text?.toString() Log.d(Flow, 版本号: $version) } } // 轮询等待某个文本出现避免写死 sleep fun waitForText(text: String, timeout: Long): AccessibilityNodeInfo? { val start System.currentTimeMillis() while (System.currentTimeMillis() - start timeout) { val node Finder(rootInActiveWindow).findByText(text, exact true).firstOrNull() if (node ! null) return node Thread.sleep(200) // 轮询间隔 } return null }waitForText这个轮询封装很实用。原生开发里很多人直接Thread.sleep(2000)等界面加载结果要么等太久浪费时间要么等太短找不到节点。轮询加超时的写法兼顾了速度和稳定性200ms 的间隔在大多数设备上够用超时时间根据界面复杂度设 3000 到 5000ms。4. 避坑与排查那些让自动化流程翻车的细节4.1 节点找不到现象、原因与解决现象明明屏幕上能看到「确定」按钮findByText却返回空列表。原因通常有三种一是该按钮的文本不在getText()里而是画在 Canvas 上或者用了contentDescription二是节点在 WebView 或 Compose 里无障碍树的结构和传统 View 不同三是事件回调触发时界面还没渲染完根节点是空的。解决先用findByDesc试contentDescription如果是 WebView需要在accessibility_config.xml里加flagRequestEnhancedWebAccessibility如果是时序问题用上面waitForText的轮询方式别在onAccessibilityEvent里直接查。4.2 点击无效坐标对了但没反应现象dispatchGesture返回true但按钮没被点中。原因一是坐标算错了getBoundsInScreen拿到的是屏幕坐标但有些设备有虚拟导航栏偏移二是点击持续时间太短系统判定为无效手势三是目标控件在可滚动容器里实际位置和getBoundsInScreen返回的不一致。解决把持续时间从 50ms 调到 100ms 试试用getBoundsInScreen后打印坐标和开发者选项里的「指针位置」对照如果是滚动容器先调用performAction(ACTION_SCROLL_FORWARD)确保目标可见再取坐标。4.3 服务被系统杀掉后台存活问题现象自动化跑了一段时间后突然不动了检查发现无障碍服务被关闭。原因部分厂商系统对无障碍服务有额外的保活限制长时间无操作或内存紧张时会强制关闭。解决在onUnbind里返回true并重新请求绑定引导用户在系统设置里把 App 加入电池优化白名单避免在服务里做耗时操作减少被系统判定为异常的概率。这个坑没有根治办法只能尽量降低触发频率。4.4 事件风暴回调太频繁导致卡顿现象界面快速变化时onAccessibilityEvent被疯狂调用主线程卡死。原因accessibilityEventTypes配置了太多类型或者notificationTimeout设得太小。解决只保留业务需要的事件类型把notificationTimeout调到 100 到 200ms在回调里做去重相同事件在短时间内只处理一次。5. 进阶技巧用条件等待和重试机制把成功率拉满自动化流程最怕的不是找不到节点而是「这次能找到、下次找不到」的玄学问题。我的习惯是给每个关键步骤都加上条件等待和重试而不是依赖固定的sleep。一个实用的重试封装// 带重试的操作执行器 fun T retry(times: Int 3, delay: Long 500, block: () - T?): T? { repeat(times) { index - val result block() if (result ! null) return result Log.w(Retry, 第 ${index 1} 次尝试失败${delay}ms 后重试) Thread.sleep(delay) } return null } // 使用示例查找并点击最多重试3次 retry(times 3) { Finder(rootInActiveWindow) .findByText(下一步, exact true) .firstOrNull() ?.takeIf { it.isEnabled } ?.also { it.performAction(AccessibilityNodeInfo.ACTION_CLICK) } }retry的参数times控制重试次数delay是每次重试的间隔。block返回null表示这次没成功返回非空表示成功。注意takeIf { it.isEnabled }这行——有些按钮虽然存在但处于禁用状态直接点击会失败先判断isEnabled能过滤掉这种情况。另一个技巧是给查找加「可见性判断」。AccessibilityNodeInfo有isVisibleToUser属性但它在某些设备上不准。更可靠的做法是用getBoundsInScreen拿到矩形判断它是否在屏幕范围内且面积大于 0fun AccessibilityNodeInfo.isReallyVisible(): Boolean { val rect Rect() getBoundsInScreen(rect) return isVisibleToUser !rect.isEmpty rect.width() 0 rect.height() 0 }把这个判断加到查找结果过滤里能过滤掉那些「存在于树中但实际不可见」的节点减少误点。最后说一个验证方法在开发阶段把每次查找的候选节点文本和坐标都打到日志里跑几轮之后回看日志就能发现哪些步骤的查找不稳定、哪些坐标有偏移。我一般会在关键流程里加一个debugDump(rootInActiveWindow)方法把整棵树的文本和 ID 打印出来排查问题时比盲猜快得多。从那以后我每次接入新的自动化流程都会先把debugDump跑一遍确认目标节点的文本、ID、坐标都符合预期再写业务逻辑。这个习惯帮我省掉了大量「明明代码没问题但就是点不中」的排查时间。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?