什麼是AccessibilityService?
在開始之前我們先了解一下 AccessibilityService是一個什麼東西;AccessibilityService 是一種提供用戶界面增強功能的應用程序,可以幫助殘障用戶或者暫時無法與設備進行完全交互的用戶提供界面反饋,幫助用戶更好的處理和響應事件。通俗一點來說就是可以幫助我們監聽頁面的變化,比如按鈕的點擊,頁面的切換,頁面內容的變化,通知的接收等,然後我們接收到這些事件之後可以進行我們想做的操作;並且接收到事件之後我們可以獲取到事件觸發的 View 以及其子 View 的一些信息和當前窗口根節點 View 及其子 View 的一些信息。
Android 從 1.6 就已經引入了 AccessibilityService ,並且在 Android 4.0 對其進行了改進,而在 Android 8.0 開始已經可以執行手勢操作了。 所以對我們現在開發 AccessibilityService 已經是非常方便了,我們可以根據用戶的操作來進行對應的反饋,並且我們可以自定義一個腳本流程,當用戶觸發時,我們就可以進行一系列操作,比如自動打開某個App的頁面等;微信自動搶紅包功能就是基於 AccessibilityService 來實現的。
創建我們的AccessibilityService
1.我們首先要創建一個Service繼承自AccessibilityService
2.在 AndroidManifest.xml 中配置我們的 Service (需要指定一些特別屬性來表明這是一個 AccessibilityService )
3.在 xml 中配置我們的 AccessibilityService 所監聽的 App 包名、接收的事件類型等
我們來一一實現這些步驟,第一步很簡單,直接創建一個類繼承即可:
class MyAccessibilityService : AccessibilityService() {
override fun onInterrupt() {
}
override fun onAccessibilityEvent(event: AccessibilityEvent?) {
}
override fun onServiceConnected() {
super.onServiceConnected()
}
}
我們實現了三個方法,onServiceConnected() 爲服務可用時調用的方法,我們在 xml 裏面的配置也可以寫到這裏;onInterrupt() 爲 連接斷開時調用的方法;onAccessibilityEvent(event: AccessibilityEvent?) 爲接收到監聽事件的方法,我們主要的處理邏輯也會寫在這裏面。
和普通的 Service 一樣,這個也需要在 AndroidManifest.xml 中進行聲明配置,我們需要添加<intent-filter> 來表示爲 AccessibilityService,從 Android 4.0 開始,我們可以添加 <meta-data> 來指定對應的 xml 配置文件,在 xml 裏面做一些我們這個 AccessibilityService 的配置。從 4.1 開始,我們還必添加 BIND_ACCESSIBILITY_SERVICE 權限來保護服務,確保只有系統才能夠綁定到它。所以 Service 的配置如下:
<service
android:name="com.android.service.MyAccessibilityService"
android:enabled="true"
android:exported="true"
android:label="服務名字"
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>
這裏面 <meta-data> 所指定的 resource 則爲 我們的配置文件名字,這個配置也是可以通過 setServiceInfo(AccessibilityServiceInfo info) 在代碼中配置的,我們這裏只在xml中進行配置舉例來說明,accessibility_service_config.xml代碼如下:
<accessibility-service xmlns:android="http://schemas.android.com/apk/res/android"
android:accessibilityEventTypes="typeNotificationStateChanged|typeWindowStateChanged|typeWindowContentChanged|typeViewScrolled|typeViewClicked|typeViewFocused"
android:accessibilityFeedbackType="feedbackGeneric"
android:accessibilityFlags="flagDefault|flagIncludeNotImportantViews|flagRetrieveInteractiveWindows|flagReportViewIds"
android:canRetrieveWindowContent="true"
android:notificationTimeout="100"
android:description="這個是這個service的用途介紹"
android:packageNames="com.tencent.mm" />
我們來一一分析這些屬性的作用:
accessibilityEventTypes 指定接收事件類型,typeAllMask代表全部都接收,其他的可以我們可以通過上述的名字來知曉其用途,我們上述監聽的分別是 通知狀態改變,頁面切換,頁面內容改變,View 滑動,View 點擊,View 獲取焦點時的事件監聽。
accessibilityFeedbackType 操作相應按鈕以後輔助功能給用戶的反饋類型,包括聲音(feedbackAudible),震動(feedbackHaptic)等。
accessibilityFlags 輔助功能查找截點方式,一般配置爲flagDefault默認方式 flagIncludeNotImportantViews獲取完整的view層級列表。
canRetrieveWindowContent 是否能獲取活動窗口內容。
notificationTimeout 響應事件的間隔,單位爲毫秒。
description 對服務的描述;就是用戶在開啓這個服務的時候提示的用語
packageNames 指定監聽的應用程序的包名,包名可以監聽多個,用 “,” 逗號分隔。我們這裏監聽的包名爲 WeChat 的包名。
到此我們的 AccessibilityService 基本就已經配置完成了。下面我們只要運行我們的 App 然後到 無障礙裏面打開 名字爲 “服務名字” 也就是我們上面 AndroidManifest.xml 裏面 配置的那個名字就可以接收到 WeChat 的事件了,將在我們 Service 的 onAccessibilityEvent 中 觸發對應的 事件。
下面我們將使用通過監聽 onAccessibilityEvent 來實現 WeChat 的好友信息複製功能,流程爲:
1.跳轉到 WeChat主頁面
2.點擊通訊錄按鈕
3.從第一個聯繫人點擊進入詳情頁面
4.存儲詳情頁面的信息
5.返回滑動至下一條,點擊進入,如此往復
6.執行到最後一個 item 爲 xxx位聯繫人結束。
首先我們要先跳轉到 WeChat 的主頁面:
private fun goWechat() {
//修改爲狀態爲開始
MyAccessibilityService.scanContactsStatus = WeChatContactsController.WX_CONTACTS_SCAN_START
try {
val intent = Intent(Intent.ACTION_MAIN)
val cmp = ComponentName("com.tencent.mm", "com.tencent.mm.ui.LauncherUI")
intent.addCategory(Intent.CATEGORY_LAUNCHER)
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
intent.component = cmp
startActivity(intent)
} catch (e: ActivityNotFoundException) {
// TODO: handle exception
toast("檢查到您手機沒有安裝 WeChat,請安裝後使用該功能")
}
}
然後我們會在onAccessibilityEvent 中接收到 AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED 的事件,我們在此判斷當前的頁面爲哪個頁面,如果是 WeChat主頁面,則執行 查找到 通訊錄 按鈕點擊過去 :
if(scanContactsStatus == WeChatContactsController.WX_CONTACTS_SCAN_START){
//查找 通訊錄 進行點擊操作
parseSource(source)
scanContactsStatus = WeChatContactsController.WX_CONTACTS_SCANNING
}
parseSource() 方法 是使用遞歸查找 text 爲 通訊錄 的 node 對其parent 進行點擊操作(通過使用 uiautomatorviewer 我們可以得到微信的點擊事件放在了 text 的 parent 上)。
我們接收到的 event 的 View 信息都爲 AccessibilityNodeInfo ,我們可以通過node.text 獲取當前 View 的文本,如果沒有文本,將會返回 null,最新 WeChat 版本的 聊天記錄頁面聊天信息已經全部換爲自定義 View 實現,我們是獲取不到文本內容的,parseSource 代碼如下:
private fun parseSource(source: AccessibilityNodeInfo?){
if(source == null) return
val sCount = source.childCount
for (i in 0 until sCount){
val info = source.getChild(i)
if (info != null) {
parseSourceInfo(info)
}
}
}
private fun parseSourceInfo(info : AccessibilityNodeInfo, tag : String? = "") {
val count = info.childCount
if (count == 0) {
if(info.className == "android.widget.TextView" && info.text != null && info.parent.className == "android.widget.LinearLayout"){
val name = info.text.toString()
if(name == "通訊錄" && scanContactsStatus == WeChatContactsController.WX_CONTACTS_SCAN_START){
//獲取點擊事件的
Handler().postDelayed({
info.parent.parent.performAction(AccessibilityNodeInfo.ACTION_CLICK)
},100)
}
}
return
}
for (i in 0 until count){
val v = info.getChild(i)
if(v != null) parseSourceInfo(v,tag)
}
}
然後點擊之後我們會在 AccessibilityEvent.TYPE_VIEW_CLICKED 收到響應,我們需要在此時響應中分析是否是通訊錄的點擊事件,如果是的話我們會將獲取當前 rootInActiveWindow 的 AccessibilityNodeInfo,並且得到當前通訊錄頁面的 ListView Node :
AccessibilityEvent.TYPE_VIEW_CLICKED -> {
val source = event.source ?: return
//判斷是否是 通訊錄 點擊過來的
val contactClick = analysisClickTextClass(source)
if(contactClick){
//通過 uiautomatorviewer 分析 viewpager 的包名 找到 viewpager 第二頁 爲通訊錄頁面
val vxViewpager = searchWxViewPager(rootInActiveWindow) ?: return
//將 list 賦爲 當前的 通訊錄 listView
parseContact(vxViewpager.getChild(1))
//記錄當前滑動的下標
lastClick = list?.childCount?:0
//滑動到可以滑動的位置
scrollListView(list,lastClick)
//找到 ListView 的 item 進行點擊操作
Handler().postDelayed({
getListInfo(list)
}, clickDelay)
}
}
private fun analysisClickTextClass(source: AccessibilityNodeInfo?,
analysisText : String? = "通訊錄"): Boolean{
if(source == null) return false
val sCount = source.childCount
for (i in 0 until sCount){
val info = source.getChild(i)
if (info != null) {
if(analysisClickTextClass(info)){
return true
}
}
}
val text = source.text
if(text == analysisText){
//知道是 analysisText 點擊的 返回 true
return true
}
return false
}
private fun searchWxViewPager(source: AccessibilityNodeInfo) : AccessibilityNodeInfo? {
//查找 view pager 返回
return if(source.className == "com.tencent.mm.ui.mogic.WxViewPager") {
source
}else {
val sCount = source.childCount
for (i in 0 until sCount){
val info = source.getChild(i)
if (info != null) {
return searchWxViewPager(info)
}
}
null
}
}
private fun parseContact(source: AccessibilityNodeInfo?) {
if(source == null) return
val sCount = source.childCount
//沒有子view
if (sCount == 0) {
return
}else if(source.className == "android.widget.ListView"){
//此 list 爲 通訊錄頁面的 list
list = source
}
for (i in 0 until sCount){
val info = source.getChild(i)
if (info != null) {
parseContact(info)
}
}
}
private fun scrollListView(source : AccessibilityNodeInfo?,position : Int) : Boolean{
if(source == null) return false
if(source.className != "android.widget.ListView") return false
//執行滑動事件,滑動到對應的item
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
val bundle = Bundle()
bundle.putInt(AccessibilityNodeInfo.ACTION_ARGUMENT_ROW_INT,position)
val b = source.performAction(AccessibilityNodeInfo.AccessibilityAction.ACTION_SCROLL_TO_POSITION.id,bundle)
return b
}
return false
}
然後就是重複找到 ListView 對應的 item 進行點擊 然後在滑動即可,查找的邏輯如下:
private fun getListInfo(list : AccessibilityNodeInfo?){
if(list == null) return
val count = list.childCount
var clickItem = lastClick % count
//如果到最底部了這裏需要加加,滑動的時候只需要每次點擊第一個 item 即可
if(lastClick > count){
clickItem = endPosition
}
if(endPosition == count){
//結束
scanContactsStatus = WeChatContactsController.WX_CONTACTS_SCAN_END
return
}
searchClickItem(list,clickItem,count)
}
private fun searchClickItem(list : AccessibilityNodeInfo, position : Int, count : Int){
var item = list.getChild(position)
if(item != null){
//解析list 判斷是否到結尾了
val lCount = list.childCount
val endItem = list.getChild(lCount - 1)
if(endItem?.className == "android.widget.FrameLayout"
&& endItem.getChild(0)?.className == "android.widget.FrameLayout"){
endPosition++
}
}
if(item != null){
counter = 0
val cCount = item.childCount
val text = try {
item.getChild(cCount - 1)?.getChild(0)?.getChild(0)?.getChild(1)?.text
}catch (e : Exception){
""
}
//防止點擊到的是頭部item,過濾特定的 item
if(item.getChild(item.childCount - 1)?.className == "android.widget.RelativeLayout"
|| text == "微信團隊" || text == "文件傳輸助手"){
searchClickItem(list,(position + 1) % count,count)
}else if(item.isClickable){
Handler().postDelayed({
ccName = try {
item.getChild(cCount - 1)?.getChild(0)?.getChild(0)?.getChild(1)?.text?.toString()?:""
}catch (e : Exception){
""
}
val b = item.performAction(AccessibilityNodeInfo.ACTION_CLICK)
if(!b){
scrollListView(list,lastClick--)
getListInfo(list)
}
},clickDelay)
}
} else {
counter++
if(counter > count){
//沒有找到可以點擊的按鈕
return
}
//查找下一個可點擊的view
searchClickItem(list,(position + 1) % count,count)
}
}
點擊後,我們將收到 AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED 事件,此時頁面切換到了聯繫人詳情頁面,我們只要解析此頁面的 nodeInfo,即可得到聯繫人的信息:
if(event.className == "com.tencent.mm.plugin.profile.ui.ContactInfoUI"){
if(KeyboardService.scanContactsStatus != WeChatContactsController.WX_CONTACTS_SCANNING){
return
}
//聯繫人詳情頁面
val model = WxContactEntity(id = lastClick, remarkName = ccName)
parseContactInfo(source,model)
}
private fun parseContactInfo(source: AccessibilityNodeInfo,model : WxContactEntity){
val count = source.childCount
if(count == 0){
//找一下back鍵
val text = source.text?.split(": ")
when(text?.get(0)){
"暱稱" ->{
val nickname = text[1]
model.nickname = nickname
}
"微信號" -> {
val wxId = text[1]
model.wxId = wxId
}
}
if(source.contentDescription == "返回"){
val back = source.parent
//找到返回按鈕 返回上一個頁面
Handler().postDelayed({
back.performAction(AccessibilityNodeInfo.ACTION_CLICK)
},clickDelay)
}
return
}
for (i in 0 until count){
val item = source.getChild(i)
if(item != null){
parseContactInfo(item,model)
}
}
}
存儲完成後我們只需要找到返回按鈕 返回上一個頁面即可。返回後又會觸發 AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED 所以我們需要在此重複我們的操作即可:
if(event.className == "com.tencent.mm.ui.LauncherUI"){
if(scanContactsStatus == WeChatContactsController.WX_CONTACTS_SCAN_START){
//查找 通訊錄 進行點擊操作
parseSource(source)
scanContactsStatus = WeChatContactsController.WX_CONTACTS_SCANNING
}
if(lastEventName != "com.tencent.mm.ui.LauncherUI"){
Handler().postDelayed({
val b = scrollListView(list,lastClick++)
if(!b){
scrollListView(list,lastClick++)
}
},300)
Handler().postDelayed({
getListInfo(list)
}, clickDelay)
}
lastEventName = event.className?.toString()
}
由於 ListView 的緩存原理,所以這裏我們得到的 list 只是屏幕上的幾個 View ,所以我們還需要在滑動的時候將 新的 ListView 的item 賦值給我們的 list 變量,這樣我們每次遍歷我們的 list 的時候 就可以得到當前頁面的 View 的 list 了。滑動的時候我們會接收到 AccessibilityEvent.TYPE_VIEW_SCROLLED 事件:
AccessibilityEvent.TYPE_VIEW_SCROLLED -> {
if(event.className != "android.widget.ListView"
|| scanContactsStatus != WeChatContactsController.WX_CONTACTS_SCANNING) return
//滑動時候的監聽
val source = event.source ?: return
list = source
}
自此,一個完整的流程就已經結束了。通過監聽 AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED、AccessibilityEvent.TYPE_VIEW_CLICKED、AccessibilityEvent.TYPE_VIEW_SCROLLED 完成了我們所有的操作。
此方式僅供學習交流使用,在此只是爲了學習具用 WeChat 的例子。