当前位置: 首页 > news >正文

kotlin Android AccessibilityService 无障碍入门

安卓的无障碍模式可以很好的进行自动化操作以帮助视障人士自动化完成一些任务。

无障碍可以做到,监听屏幕变化,朗读文本,定位以及操作控件等。

以下从配置到代码依次进行无障碍设置与教程。

一、配置 AndroidManifest.xml

无障碍是个服务,因此需要再 AndroidManifest.xml 进行声明等配置。包括申请权限,声明服务等

    <uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" /><uses-permission android:name="android.permission.ACCESSIBILITY_SERVICE" /><application><serviceandroid:name="io.github.zimoyin.asdk.accessibility.AutoSdkAccessibilityService"android:exported="true"android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE"><intent-filter><action android:name="android.accessibilityservice.AccessibilityService" /></intent-filter><meta-dataandroid:name="android.accessibilityservice"android:resource="@xml/accessibility_service_config" /></service></application>
  • android:name:无障碍服务类路径
  • meta-data/android:resource:无障碍配置,需要创建 res/xml/accessibility_service_config.xml 文件

二、无障碍配置

需要创建 res/xml/accessibility_service_config.xml 文件

<accessibility-servicexmlns:android="http://schemas.android.com/apk/res/android"android:accessibilityEventTypes="typeWindowStateChanged|typeWindowContentChanged"android:accessibilityFeedbackType="feedbackSpoken"android:notificationTimeout="100"android:canPerformGestures="true"android:canRetrieveWindowContent="true"android:canRequestTouchExplorationMode="false"android:settingsActivity="true"android:accessibilityFlags="flagDefault|flagIncludeNotImportantViews|flagReportViewIds"android:description="@string/accessibility_service_description" />
  • accessibilityEventTypes: 监听事件类型,比如窗口滑动,弹窗,窗体变化,点击等事件监听,typeAllMask 则说监听全部事件,尽可能合理的配置事件以减少电量消耗,减少服务频繁唤醒
  • accessibilityFeedbackType: 回显给用户的方式(例如:配置TTS引擎,实现发音)
  • accessibilityFlags: 决定无障碍服务如何响应用户操作、事件监听范围以及对界面元素的访问权限
  • canPerformGestures:用于允许服务模拟用户的复杂手势操作 (如滑动、点击、长按等)(API 24新增)
  • description: 无障碍描述,这里需要在 res/value/string.xml下配置
  • notificationTimeout:响应事件间隔,单位 ms
  • canRetrieveWindowContent: 是否能读取窗口内容
  • settingsActivity: 允许用户在系统设置中通过点击你的无障碍服务名称,跳转到自定义的配置界面

三、无障碍描述配置

res/values/strings.xml

<resources><string name="accessibility_service_description">This is a accessibility service for AutoSDK.</string>
</resources>

四、代码

1. 打开系统配置页

        /*** 打开系统设置页面,跳转到辅助功能页面* @param context 上下文,可传入 Activity 或 Application*/fun openAccessibilitySettings(context: Context) {val intent = Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS)intent.flags = Intent.FLAG_ACTIVITY_NEW_TASKcontext.startActivity(intent)}

2. 是否打开了无障碍配置

        /*** 检查当前辅助功能服务是否已启用*/fun isAccessibilityServiceEnabled(context: Context): Boolean {return getAccessibilityManager(context)?.isEnabled == true}/*** 获取 AccessibilityManager*/fun getAccessibilityManager(context: Context): AccessibilityManager? {return context.getSystemService(ACCESSIBILITY_SERVICE) as? AccessibilityManager}

3. 继承 AccessibilityService 并暴露实例对象

签名代码可以没有但是,AccessibilityService 是一定要继承的,并且类位置要与 AndroidManifest.xml 中声明的位置一致


class AutoSdkAccessibilityService : AccessibilityService() {init {instance = this}companion object {val LAST_ID: String = AutoSdkAccessibilityService::class.java.name/*** 当前辅助功能服务实例*/var instance: AutoSdkAccessibilityService? = nullprivate set/*** 检查 AndroidManifest.xml 是否存在 android.permission.SYSTEM_ALERT_WINDOW 权限*/fun hasSystemAlertWindowPermission(context: Context): Boolean {return isPermissionDeclared(context, Manifest.permission.SYSTEM_ALERT_WINDOW)}/*** 检查 AndroidManifest.xml 是否存在 android.permission.ACCESSIBILITY_SERVICE 权限*/fun hasAccessibilityPermission(context: Context): Boolean {return isPermissionDeclared(context, "android.permission.ACCESSIBILITY_SERVICE")}/*** 检查 AndroidManifest.xml 是否存在 android.permission.ACCESSIBILITY_SERVICE 权限*/@SuppressLint("QueryPermissionsNeeded")fun isAccessibilityServiceDeclared(context: Context): Boolean {val services = context.packageManager.queryIntentServices(Intent("android.accessibilityservice.AccessibilityService"),PackageManager.GET_META_DATA)for (serviceInfo in services) {if (serviceInfo.serviceInfo.packageName == context.packageName) {// 检查是否声明了 BIND_ACCESSIBILITY_SERVICE 权限if (serviceInfo.serviceInfo.permission != "android.permission.BIND_ACCESSIBILITY_SERVICE") {return false}// 检查是否声明了 meta-dataval metaData = serviceInfo.serviceInfo.metaDatareturn !(metaData == null || !metaData.containsKey("android.accessibilityservice"))}}return false}/*** 检查是否声明了指定权限*/private fun isPermissionDeclared(context: Context, permission: String): Boolean {return try {val packageInfo = context.packageManager.getPackageInfo(context.packageName,PackageManager.GET_PERMISSIONS)packageInfo.requestedPermissions?.contains(permission) == true} catch (e: Exception) {false}}/*** 检查当前辅助功能服务是否已启用*/fun isAccessibilityServiceEnabled(context: Context): Boolean {return getAccessibilityManager(context)?.isEnabled == true}/*** 获取 AccessibilityManager*/fun getAccessibilityManager(context: Context): AccessibilityManager? {return context.getSystemService(ACCESSIBILITY_SERVICE) as? AccessibilityManager}/*** 获取 AccessibilityServiceInfo*/fun getAccessibilityServiceInfo(context: Context): AccessibilityServiceInfo? {val accessibilityManager = getAccessibilityManager(context) ?: return nullval serviceInfo = accessibilityManager.installedAccessibilityServiceList.firstOrNull {it.id.endsWith(LAST_ID)}return serviceInfo}/*** 打开系统设置页面,跳转到辅助功能页面* @param context 上下文,可传入 Activity 或 Application*/fun openAccessibilitySettings(context: Context) {val intent = Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS)intent.flags = Intent.FLAG_ACTIVITY_NEW_TASKcontext.startActivity(intent)}}/*** 当系统检测到 UI 变化(如窗口更新、控件点击)时才会触发*/override fun onAccessibilityEvent(event: AccessibilityEvent) {AccessibilityListener.send(event)}override fun onInterrupt() {// TODO}/*** 模拟点击* @param x x 坐标* @param y y 坐标*/fun clickAt(x: Float, y: Float) {val path = Path().apply {moveTo(x, y)lineTo(x, y)}val gestureDescription = GestureDescription.Builder().addStroke(GestureDescription.StrokeDescription(path, 0L, 100L)).build()dispatchGesture(gestureDescription, null, null)}/*** 模拟点击* @param path 模拟点击的路径* @param start 模拟点击的开始时间* @param end 模拟点击的结束时间*/fun clickAt(path:Path, start: Long, end: Long) {val gestureDescription = GestureDescription.Builder().addStroke(GestureDescription.StrokeDescription(path, start, end)).build()dispatchGesture(gestureDescription, null, null)}
}

获取根节点

AutoSdkAccessibilityService.instance?.rootInActiveWindow

遍历节点

fun AccessibilityNodeInfo.forEach(callback: (AccessibilityNodeInfo) -> Unit) {for (i in 0 until childCount) {val node = getChild(i)callback(node)node.forEach(callback)}
}

节点查找 filter

fun AccessibilityNodeInfo.filter(callback: (AccessibilityNodeInfo) -> Boolean): MutableList<AccessibilityNodeInfo> {val list = mutableListOf<AccessibilityNodeInfo>()forEach {if (callback(it)) {list.add(it)}}return list
}

点击节点


/*** 点击节点范围内的任意空间*/
fun AccessibilityNodeInfo?.click(service: AccessibilityService = requireNotNull(instance),minDuration: Long = 1L,maxDuration: Long = 200L
): Boolean {if (this == null) return falseval bounds = Rect().apply { this@click.getBoundsInScreen(this) }if (bounds.isEmpty) return false// 在控件边界内生成随机坐标val randomX = Random.nextInt(bounds.left, bounds.right)val randomY = Random.nextInt(bounds.top, bounds.bottom)val path = Path().apply {moveTo(randomX.toFloat(), randomY.toFloat())lineTo(randomX.toFloat(), randomY.toFloat())}val gesture = GestureDescription.Builder().addStroke(GestureDescription.StrokeDescription(path,0L,Random.nextLong(minDuration, maxDuration + 1))).build()return service.dispatchGesture(gesture,object : AccessibilityService.GestureResultCallback() {override fun onCancelled(gestureDescription: GestureDescription) {super.onCancelled(gestureDescription)}override fun onCompleted(gestureDescription: GestureDescription) {super.onCompleted(gestureDescription)}},null)
}/*** 点击节点* 注意:点击节点时,如果节点不可点击,则会返回false。* 一般情况下在控件上面都会有图标或者文本,如果匹配到了文本或者图标,非特殊情况下是不能被点击的,因此需要获取父或者子节点进行点击.* 推荐使用 [clickMatchNode]* @return 是否点击成功*/
fun AccessibilityNodeInfo?.clickNode(): Boolean {return this?.performAction(AccessibilityNodeInfo.ACTION_CLICK) == true
}/*** 点击节点* 注意:点击节点时,如果节点不可点击,则会查找父节点*/
fun AccessibilityNodeInfo?.clickMatchNode(): Boolean {if (this == null) return falsereturn if (isClickable) {performAction(AccessibilityNodeInfo.ACTION_CLICK)} else {parent.clickMatchNode()}
}/*** 长按节点* 注意:长按节点时,如果节点不可点击,则会返回false。* 一般情况下在控件上面都会有图标或者文本,如果匹配到了文本或者图标,非特殊情况下是不能被点击的,因此需要获取父或者子节点进行点击* 推荐使用 [longClickMatchNode]*/
fun AccessibilityNodeInfo?.longClickNode(): Boolean {return this?.performAction(AccessibilityNodeInfo.ACTION_LONG_CLICK) == true
}/*** 长按节点* 注意:长按节点时,如果节点不可点击,则会查找父节点*/
fun AccessibilityNodeInfo?.longClickMatchNode(): Boolean {if (this == null) return falsereturn if (isClickable) {performAction(AccessibilityNodeInfo.ACTION_LONG_CLICK)} else {parent.longClickMatchNode()}
}

节点选择器


/*** 包装一个 AccessibilityNodeInfo 集合,提供链式条件过滤能力,仿照 Auto.js 的节点选择器风格。** @property node 待过滤的节点列表*/
class AccessibilityNodeInfoWrapper(val node: AccessibilityNodeInfo) {private val conditions = mutableListOf<(AccessibilityNodeInfo) -> Boolean>()/*** 筛选文本等于 [text] 的节点。*/fun text(text: String): AccessibilityNodeInfoWrapper {conditions += { it.text?.toString() == text }return this}/*** 筛选文本去除空格后等于 [text] 的节点。*/fun textTrim(): AccessibilityNodeInfoWrapper {conditions += { it.text?.toString()?.trim() == it.text?.toString() }return this}/*** 筛选文本包含 [substr] 的节点。*/fun textContains(substr: String): AccessibilityNodeInfoWrapper {conditions += { it.text?.toString()?.contains(substr) == true }return this}/*** 筛选文本匹配正则 [regex] 的节点。*/fun textMatches(regex: Regex): AccessibilityNodeInfoWrapper {conditions += { it.text?.toString()?.matches(regex) == true }return this}/*** 筛选类名等于 [className] 的节点。*/fun className(className: String): AccessibilityNodeInfoWrapper {conditions += { it.className.toString() == className }return this}/*** 筛选资源 ID 等于 [id] 的节点。*/fun id(id: String): AccessibilityNodeInfoWrapper {conditions += { it.viewIdResourceName == id }return this}/*** 筛选包名等于 [packageName] 的节点。*/fun pkg(packageName: String): AccessibilityNodeInfoWrapper {conditions += { it.packageName == packageName }return this}/*** 筛选 contentDescription 等于 [desc] 的节点。*/fun description(desc: String): AccessibilityNodeInfoWrapper {conditions += { it.contentDescription?.toString() == desc }return this}/*** 筛选节点可点击的。*/fun clickable(boolean: Boolean = true): AccessibilityNodeInfoWrapper {conditions += { it.isClickable == boolean}return this}/*** 筛选节点可见的(isVisibleToUser 为 true)。*/fun visible(): AccessibilityNodeInfoWrapper {conditions += { it.isVisibleToUser }return this}private fun conditionResult(info: AccessibilityNodeInfo): Boolean {for (condition in conditions) {if (!condition(info)) {return false}}return true}/*** 执行所有累积的条件过滤,返回符合条件的节点列表。* 调用完成后会清空已设置的条件,以便下一次重用。** @return 符合所有条件的节点列表*/fun done(): List<AccessibilityNodeInfo> = node.filter { info ->conditionResult(info)}.also {conditions.clear()}fun textStartsWith(string: String): AccessibilityNodeInfoWrapper {conditions += { it.text?.toString()?.startsWith(string) == true }return this}
}fun AccessibilityNodeInfo.selector(callback: AccessibilityNodeInfoWrapper.() -> Unit): List<AccessibilityNodeInfo> {return AccessibilityNodeInfoWrapper(this).apply { callback() }.done()
}fun AccessibilityNodeInfo.selector(): AccessibilityNodeInfoWrapper =AccessibilityNodeInfoWrapper(this)

事件分发

object AccessibilityListener {private val accessibilityListener = ConcurrentHashMap<UUID, (AccessibilityEvent) -> Unit>()fun onAccessibilityEvent(callback: (AccessibilityEvent) -> Unit) {val id = UUID.randomUUID()accessibilityListener[id] = callback}fun removeAccessibilityEvent(id: UUID) {accessibilityListener.remove(id)}fun send(event: AccessibilityEvent) {accessibilityListener.forEach {runCatching { it.value.invoke(event) }.onFailure {logger.error(it)}}}
}

相关文章:

  • UE RPG游戏开发练手 第二十八课 重攻技能1
  • k8s节点维护的细节
  • 带你搞懂@Valid和@Validated的区别
  • 线代第三章向量第一节:n维向量及其运算
  • Electron + Vite + Vue 项目中的 IPC 通信三层封装实践
  • 解决RAGFlow部署中镜像源拉取的问题
  • vi实时查看日志
  • 专题讨论3:基于图的基本原理实现走迷宫问题
  • WPF中资源(Resource)与嵌入的资源(Embedded Resource)的区别及使用场景详解
  • 2025.05.01【Barplot】柱状图的多样性绘制
  • TinyEngine 2.5版本正式发布:多选交互优化升级,页面预览支持热更新,性能持续跃升!
  • 1.1 结构体与类对象在List中使用区别
  • iOS:重新定义移动交互,引领智能生活新潮流
  • vue3与springboot交互-前后分离【验证element-ui输入的内容】
  • Axure设计数字乡村可视化大屏:从布局到交互的实战经验分享
  • 解决leetcode第3539题.魔法序列的数组乘积之和
  • 通过子接口(Sub-Interface)实现三层接口与二层 VLAN 接口的通信
  • PKDV5351高压差分探头在充电桩安全测试中的应用
  • GraphQL 接口设计
  • Linux架构篇、第五章_06Jenkins 触发器全面解析与实战指南
  • 文化破冰,土耳其亚美尼亚合拍摄影大师阿拉·古勒传记片
  • 文化厚度与市场温度兼具,七猫文学为现实题材作品提供土壤
  • 巴基斯坦外长访华是否与印巴局势有关?外交部:此访体现巴方高度重视中巴关系
  • 推开“房间”的门:一部“生命存在的舞台” 史
  • 新时代,新方志:2025上海地方志论坛暨理论研讨会举办
  • 杨建全已任天津市委副秘书长、市委市政府信访办主任