安卓的无障碍模式可以很好的进行自动化操作以帮助视障人士自动化完成一些任务。
无障碍可以做到,监听屏幕变化,朗读文本,定位以及操作控件等。
以下从配置到代码依次进行无障碍设置与教程。
一、配置 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)
}
}
}
}