kotlin Android AccessibilityService 无障碍入门

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

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

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

一、配置 AndroidManifest.xml

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

xml 复制代码
    <uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
    <uses-permission android:name="android.permission.ACCESSIBILITY_SERVICE" />

    <application>
        <service
            android: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-data
                android: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 文件

xml 复制代码
<accessibility-service
    xmlns: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

xml 复制代码
<resources>
    <string name="accessibility_service_description">This is a accessibility service for AutoSDK.</string>
</resources>

四、代码

1. 打开系统配置页

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

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

kotlin 复制代码
        /**
         * 检查当前辅助功能服务是否已启用
         */
        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 中声明的位置一致

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

    init {
        instance = this
    }

    companion object {

        val LAST_ID: String = AutoSdkAccessibilityService::class.java.name

        /**
         * 当前辅助功能服务实例
         */
        var instance: AutoSdkAccessibilityService? = null
            private 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-data
                    val metaData = serviceInfo.serviceInfo.metaData
                    return !(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 null
            val 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_TASK
            context.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)
    }
}

获取根节点

kotlin 复制代码
AutoSdkAccessibilityService.instance?.rootInActiveWindow

遍历节点

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

节点查找 filter

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

点击节点

kotlin 复制代码
/**
 * 点击节点范围内的任意空间
 */
fun AccessibilityNodeInfo?.click(
    service: AccessibilityService = requireNotNull(instance),
    minDuration: Long = 1L,
    maxDuration: Long = 200L
): Boolean {
    if (this == null) return false
    val bounds = Rect().apply { [email protected](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 false
    return 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 false
    return if (isClickable) {
        performAction(AccessibilityNodeInfo.ACTION_LONG_CLICK)
    } else {
        parent.longClickMatchNode()
    }
}

节点选择器

kotlin 复制代码
/**
 * 包装一个 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)

事件分发

kotlin 复制代码
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)
            }
        }
    }
}
相关推荐
AA-代码批发V哥19 分钟前
Java-List集合类全面解析
java·开发语言·list
羚羊角uou24 分钟前
【C++】map和multimap的常用接口详解
开发语言·c++
Q_Q196328847530 分钟前
python动漫论坛管理系统
开发语言·spring boot·python·django·flask·node.js·php
举一个梨子zz32 分钟前
Java—— IO流 第一期
java·开发语言
Toby_0091 小时前
go 数据类型转换
开发语言·golang
zwjapple1 小时前
python创建flask项目
开发语言·python·flask
Elastic 中国社区官方博客1 小时前
JavaScript 中使用 Elasticsearch 的正确方式,第一部分
大数据·开发语言·javascript·数据库·elasticsearch·搜索引擎·全文检索
__ocean2 小时前
编译Qt5.15.16并启用pdf模块
开发语言·qt·pdf
万物得其道者成2 小时前
从零开始创建一个 Next.js 项目并实现一个 TodoList 示例
开发语言·javascript·ecmascript
77tian2 小时前
设计模式的原理及深入解析
java·开发语言·单例模式·设计模式·代理模式·享元模式·原型模式