安卓的無障礙模式可以很好的進行自動化操作以幫助視障人士自動化完成一些任務。
無障礙可以做到,監聽屏幕變化,朗讀文本,定位以及操作控件等。
以下從配置到代碼依次進行無障礙設置與教程。
一、配置 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)}}}
}