輔助模式最終考驗的是想象力,先來看看怎麼用!| Accessibility

承香墨影發表於2018-06-20

輔助模式最終考驗的是想象力,先來看看怎麼用!| Accessibility

一、序

Hi,大家好,我是承香墨影!

Android 的輔助模式(Accessibility)功能非常的強大。基本上被獲取到授權之後,可以監聽手機上的任何事件,例如:螢幕點選、視窗的變化、以及模擬點選、模擬系統按鍵等等。

比較常見的實際使用例子,就是一般應用市場,會推薦開啟輔助模式,以便在安裝 Apk 的時候,自動幫你點選“下一步”和“安裝”按鈕。還有個例子就是微信搶紅包外掛,也是基於它來實現的。

Accessibility 的許可權非常的高,基本上你授權開啟某個別人提供的 AccessibilityService 之後,他就可以幹很多事情而不讓你知道,而這些是不需要 Root 許可權的。所以一般小體量的產品,可能支援它並沒有什麼用,因為信任度太低了,大部分使用者根本不會開啟。比較常見的就是一些工具類的 App,幫使用者節省一些點選的時間。

雖然很多時候,Accessibility 不會被用在商業產品上,但是這並不妨礙我們使用 Accessibility 來做一些有意思的功能。

二、輔助模式的使用步驟

輔助模式是可以支援第三方開發,也就是我們可以按照文件對其進行支援,只要使用者授權開啟此服務,我們就可以利用 Accessibility 提供的一些標準 Api 實現很多有意思的功能。

如果你想要使用輔助模式,你還需要如下步驟:

  1. 實現一個繼承自 AccessibilityService 的服務類。
  2. 設定配置資訊,以便系統知道該輔助模式的一些基本資訊,例如監聽那些事件。
  3. 在清單檔案(AndroidManifest.xml)中,註冊此服務。
  4. 在系統設定中,找到“無障礙”,並開啟此服務。

接下來我們一步一步講解這裡的步驟和細節。

2.1 繼承 AccessibilityService

輔助模式,本質上還是一個服務,我們如果想要支援它,首先需要繼承 AccessibilityService 這個類。

AccessibilityService 類提供了很多需要重寫的方法,其中有兩個是強制重寫的:

public abstract void onAccessibilityEvent(AccessibilityEvent event);
public abstract void onInterrupt();
複製程式碼

當開啟了某個 AccessibilityService 服務之後,系統會在該服務監聽的事件發生的時候,回撥它的 onAccessibilityEvent() 方法,並將該事件的資訊當引數傳遞過去,如果你監聽的事件足夠多,它就會被頻繁呼叫。

onInterrupt() 方法會在系統事件被打斷的時候回撥,也是會被頻繁呼叫,一般我們不需要做額外處理。

通常我們只需要在 onAccessibilityEvent() 方法中,編寫核心邏輯即可,其他的方法,只是輔助使用。

2.2 配置輔助模式

當建立一個 AccessibilityService 之後,我們還需要對其進行一些基本的配置,否則在系統設定的“無障礙”中,是看不到我們編寫的服務的。

配置 AccessibilityService 有兩種方式,

  • 通過 xml 配置檔案
  • 通過 Java 程式碼中動態配置。

但是其實有一些屬性是隻能通過 XML 配置檔案進行配置的,Java 程式碼只是讓某一些配置項更靈活了而已,後面會細說。

1、xml 配置檔案

想要使用 XML 配置檔案,首先需要建立一個 res/xml 的目錄,並在其內建立一個 xml 檔案,檔名隨意無要求,內部定義一個 accessibility-service 標籤,在其中設定 AccessibilityService 的各項配置。例如我這裡建立一個 accessibility_config.xml 的檔案,後面會用到這個檔案。

XML 配置 AccessibilityService 是我們一個比較常用的配置方法,非常清晰且方便。

<accessibility-service
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:accessibilityEventTypes="typeAllMask"
    android:accessibilityFeedbackType="feedbackAllMask"
    android:accessibilityFlags="flagReportViewIds"
    android:canRetrieveWindowContent="true"
    android:packageNames="com.forwarding.wechat"
    android:description="@string/accessbility_desc"
    android:notificationTimeout="100" />
複製程式碼

例如上面就是一個常見的配置,如果沒有特殊要求的話,直接複製過去,修改一些個別引數就可以使用。

各項屬性的含義:

  • accessibilityEventTypes:監聽的事件型別,例如:typeAllMask 表示全部事件,而 typeViewClicked 表示只監聽點選事件。
  • accessibilityFeedbackType:監聽事件的反饋模式。
  • canRetrieveWindowContent:是否允許獲取檢視層級的訪問權,如果它被設定為 false,node.getSource() 方法會呼叫失敗。
  • accessibilityFlags:指定 Flag,一般用於指定根據 Node 獲取 View ID 的許可權。
  • packageNames:開啟監聽的應用包名,可以指定多個包名,通過逗號“,”分割,不設定此屬性標識全域性監聽。
  • description:輔助功能的描述,它會顯示在系統設定的“無障礙”中的描述資訊中。
  • notificationTimeout:響應的毫秒數。

這些可配置的引數,系統都提供了可選的配置引數,正常不需要額外定製的時候,使用上面預設的配置即可,如果有定製需要,還是查閱官方文件獲得最全的介紹。

AccessibilityService:

https://developer.android.com/reference/android/accessibilityservice/AccessibilityService

2、Java 程式碼中動態配置

除了 XML 檔案配置的方式,我們還可以通過重寫 AccessibilityService 的 onServiceConnected() 方法,我們首先需要構建一個 AccessibilityServiceInfo 物件,通過它的標準 Api 進行配置,再使用 setServiceInfo() 方法將它設定給輔助模式。

onServiceConnected() 會在應用成功連線到此輔助服務的時候系統呼叫,一般在其中做一些初始化的操作即可。

override fun onServiceConnected() {
	super.onServiceConnected()
    var serviceInfo = AccessibilityServiceInfo()
    serviceInfo.eventTypes = AccessibilityEvent.TYPES_ALL_MASK
    serviceInfo.feedbackType = AccessibilityServiceInfo.FEEDBACK_ALL_MASK
    serviceInfo.notificationTimeout = 100
    serviceInfo.packageNames = arrayOf("com.forwarding.wechat")
    serviceInfo.flags =  AccessibilityServiceInfo.FLAG_REPORT_VIEW_IDS
    setServiceInfo(serviceInfo)
}
複製程式碼

這裡提供的例子,其實和前面使用 XML 配置的效果一直。推薦使用 XML 的配置方式,會更清晰且靈活,而且像 description 這種屬性,在 AccessibilityServiceInfo 中,並沒有提供有效的類似 setDescription() 方法,這一點也確實是設計如此,畢竟服務沒有執行,就不存在描述資訊,在系統設定的“無障礙”頁面,就讀取不到。

也就是說即便是使用 setServiceInfo() 方法動態設定,也逃不脫使用 XML 配置檔案的方式,我還是強烈建議都使用 XML 配置檔案的方式配置輔助服務,主要是為了省事。

2.3 清單檔案中註冊服務

本質上 AccessibilityService 還是一個 Service,使用它我們還需要在清單檔案中配置它。

<service android:label="承香墨影的輔助工具"
    android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE"
    android:name=".WeForwardServer">
    <intent-filter>
        <action android:name="android.accessibilityservice.AccessibilityService"/>
    </intent-filter>
    <meta-data
        android:name="android.accessibilityservice"
        android:resource="@xml/accessibility_config"/>
</service>
複製程式碼

這就是一個標準的 Service,其中 label 會被解析在系統設定的“輔助模式中顯示”,而 intent-filtermeta-data 按照格式寫就好了,沒什麼原因。

meta-data 中,通過 android:resource 屬性指定的就是我們在第二步編輯的配置檔案路徑,指定它就好了。

2.4 開啟輔助模式

以上步驟都完成之後,你就可以在系統的“無障礙”設定裡,看到你編寫的輔助模式的開關了。

預設為關閉狀態,開啟它的時候,你會收到一個警告彈窗,說明當前你正在開啟一個無障礙的服務,它有哪些許可權,這個對話方塊,我們是控制不了的。

open-accessibility

注意這裡的 Title 就是清單檔案裡配置的 android:label ,而描述就是 XML 配置檔案裡的 android:description 資訊。

當你在系統設定裡,能看到此開關的時候,就說明你的輔助模式的服務,配置的沒問題了,接下來就要思考如何使用它。

三、編寫邏輯程式碼

前面提到,在 AccessibilityService 裡,我們最需要關注的就是 onAccessibilityEvent() 方法,它會在我們監聽的事件發生的時候,被系統回撥,並傳遞過來該事件相關的資訊。

接下來我們看看如何在 onAccessibilityEvent() 回撥方法裡,編寫具體的邏輯。

接下來 "程式設計師思維" 要上線了,把大象關冰箱,需要幾步。我們接下來來拆分輔助模式的步驟。

  1. 判斷事件,onAccessibilityEvent() 會被回撥多次,而我們只需要處理我們關心的事件,其他的忽略過濾掉即可。
  2. 找到需要控制的關鍵節點(Node),以便之後進行控制。
  3. 對關鍵節點,傳送對於的操作事件,以便完成我們的步驟。
  4. 回收資源,防止資源洩露。

很簡單對不對,接下來我們細細的說下,這些步驟相關的方法和屬性。

3.1 判斷事件

onAccessibilityEvent() 被系統回撥的時候,同時也會傳遞過來一個 AccessibilityEvent 物件,它其中包含了很多與當前事件相關的資訊,有興趣可以看看原始碼,我們這裡只關注最需要的幾個屬性。

1、eventType 判斷事件型別

通過 eventType 來判斷事件的型別,我們可以利用 getEventType() 方法獲取到它。

輔助模式最終考驗的是想象力,先來看看怎麼用!| Accessibility

這些事件都很好辨認,例如:TYPE_NOTIFICATION_STATE_CHANGED 是一個視窗 View 發生了變化,TYPE_VIEW_CLICKED 是某個 View 發生了一次點選事件等等。

2、packageName 判斷事件發生的 App

通過 getPackageName() 方法,判斷出事件發生在那個 App 裡的。

3、className 判斷當前發生事件的是那個類

通過 getClassName() 判斷當前發生事件的是那個類,例如 頁面的顯示,className 可能指向一個 Activity,一個按鈕的點選,className 可能指向的是一個 Button,這些都是根據實際場景區分的。

4、text 判斷當前事件觸發源上的 Text

通過 getText() 獲取當前事件源的 text 屬性,可能是 TextView 的 Text,也可能是 Activity 的 Label 屬性,依然是根據實際情況區分。

一般我們可以通過以上幾種方式,猜測是否是我們需要監聽的事件,下一步就是我們找到我們要操作的源。

3.2 找到待控制的關鍵節點(Node)

通常我們是使用輔助模式去操作頁面上的某個元素,那這一步,就是為了找到它。

在輔助模式下,頁面上的每個元素,其實都是一個個 AccessibilityNodeInfo 節點,它是一個類似樹形的結構,其內和我們真實 App 內的佈局層級是一致的,但是並不能將它單純的理解成一個 ViewTree。

既然是樹形結構,我們首先要獲取到根節點的 NodeInfo,可以通過以下兩個方式獲取:

  • event.getSource()
  • getRootInActiveWindow()

這兩個方法都會返回一個 AccessibilityNodeInfo 物件。getSource() 是AccessibilityEvent 的方法,它可用的前提是前面配置 android:canRetrieveWindowContent 的時候,被設定為 True。所以我推薦使用 getRootInActiveWindow() 方法來獲取。這兩個方法還是略微有些差異,有興趣可以打斷點看看資訊,但是大多數情況下,對我們使用者來說是一致的。

獲得根節點的 AccessibilityNodeInfo 之後,就可以通過它找到我們想操作的關鍵節點,在 AccessibilityNodeInfo 中,提供了以下兩個方法來找到關鍵節點。

  • findAccessibilityNodeInfosByViewId(String viewId)
  • findAccessibilityNodeInfosByText(String text)

一個是依賴 ViewId,另外一個是依賴 Text 資訊。

使用 ViewId 查詢關鍵節點是穩妥的方案,而使用 Text 去查詢,可能會找不到。

無論通過哪種方式查詢 關鍵節點 ,都是存在能找到多個 NodeInfo 的可能的,所以這兩個方法乾脆的都返回了一個 List<AccessibilityNodeInfo> ,所以需要我們通過其他條件再過濾一遍,通常就是通過 Text 資訊過濾。

var mNodeInfo = rootInActiveWindow
var listItem = mNodeInfo.findAccessibilityNodeInfosByViewId("com.tencent.mm:id/lp")
for (item in listItem) {
    if (item.text.toString().equals("承香墨影")){
		nodeClick(item)
	}
}
複製程式碼

如果是使用 findXxxByText() 的方法的話,還需要注意它實際上不是通過類似 == 或者 equals() 的方法來查詢子節點的,而是通過類似 contain() 的方式,所以只要節點的 text 屬性包含查詢的內容,都會被找到,這個我們額外還需要增加判斷條件。

如果這些方法都試過,還是找不到關鍵節點,可以通過遍歷的方式查詢。

AccessibilityNodeInfo 既然是一個樹狀結構,也提供了我們遍歷樹的方法。

  • getParent():查詢父節點。
  • getChild():返回子節點。
  • getChildCount():當前節點的子節點個數。

通過 getChild()getChildCount() 兩個方法,我們是可以對整個 ViewNodeTree 進行遍歷,來找到我們關注的關鍵節點,這是一個最後的方案,並不推薦使用。

3.3 觸發事件

輔助模式一般都是幫助我們響應一些事件,而這些事件大體上,可以分為兩類。

  • 全域性系統事件。
  • View 事件。

對於全域性系統事件,其實我們並不需要第二步找到的關鍵節點。AccessibilityService 提供了一個 performGlobalAction() 方法,我們可以通過該方法,操作一些全域性的系統事件,例如:模擬返回鍵點選、模擬 HOME 鍵點選、鎖屏等等。

// 返回鍵
performGlobalAction(AccessibilityService.GLOBAL_ACTION_BACK);
// HOME鍵
performGlobalAction(AccessibilityService.GLOBAL_ACTION_HOME);
複製程式碼

這些事件被封裝在 AccessibilityService 中,以 GLOBAL_ 為字首,看看屬性說明就懂了。

除了全域性系統事件之外,通常我們就是想操作第二步拿到的關鍵節點。

在 AccessibilityNodeInfo 中,提供了一個 performAction() 的方法,可以通過該方法,對關鍵節點傳遞一個我們需要的事件。

這些事件都被定義在 AccessibilityNodeInfo 中,以 ACTION_ 為字首定義。例如:ACTION_CLICK 是一個點選事件,ACTION_SET_TEXT 設定一個輸入。

這裡僅介紹一些比較常見的操作,更多的操作也是類似的使用方式。

1. View 的點選

找到關鍵節點之後,就可以傳送 AccessibilityNodeInfo.ACTION_CLICK 模擬對這個 View 的點選操作。

但是有時候它是不生效的,主要原因是因為你找到的這個關鍵節點,它的 isClickable() 為 false。

輔助模式最終考驗的是想象力,先來看看怎麼用!| Accessibility

例如微信的這個公眾號分享彈窗,如果我們想要查詢“傳送給朋友”,其實最好的辦法是找到這個 TextView 控制元件所代表的關鍵節點(NodeInfo),然後對它進行點選。而實際上這個 TextView 是不具有點選效果的,它的 isClickable() 為 false。

這個時候可以想一個折中的方案,去找關鍵節點(NodeInfo)的父節點,再去判斷它是否可點選,可點選則點選它,否則繼續向上找。

private fun nodeClick(node : AccessibilityNodeInfo?){
    var clickNode = node;
    while (clickNode!=null){
        if(clickNode.isClickable){
            clickNode.performAction(AccessibilityNodeInfo.ACTION_CLICK)
            break;
        }
        clickNode = node?.parent
    }
}
複製程式碼

雖然 AccessibilityNodeInfo 其實也開放了 setClickable() 方法,但是我不建議操作它,有些時候會丟擲一個異常,不太穩定。

2. EditText 輸入文字

對 EditText 輸入文字,最少需要兩個引數,關鍵節點和輸入的文字。這就需要用到 performAction() 的另外一個過載方法,它允許額外在傳遞一個 Bundle 來指定引數。

private fun nodeSetText(node : AccessibilityNodeInfo?,text:String){
    var argument = Bundle()
    argument.putString(AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE,text)
    node?.performAction(AccessibilityNodeInfo.ACTION_SET_TEXT,argument)
}
複製程式碼

所有支援定義的額外引數,都被定義在 AccessibilityNodeInfo 中,並以 ACTION_ARGUMENT_ 為字首定義。

3. ListView 的滾動

AccessibilityNodeInfo 其實只能操作當前螢幕下可見的 節點,所以碰上 ListView 或者 RecycleView 這種列表,就需要對它進行滾動。

滾動的事件有兩種:

  • ACTION_SCROLL_FORWARD
  • ACTION_SCROLL_BACKWARD
private fun nodeScrollList(node : AccessibilityNodeInfo?){
    node?.performAction(AccessibilityNodeInfo.ACTION_SCROLL_FORWARD)
}
複製程式碼

一個前進一個後退,足夠使用了。

3.4 回收資源

在使用完 AccessibilityNodeInfo 之後,別忘了還需要呼叫 recycle() 方法,釋放資源。

nodeInfo.recycle();
複製程式碼

四、小結

輔助模式如何使用,到現在已經講解的非常清楚了,後面基本上就是靠自己的想象力來做小功能了。

利用輔助模式,發揮想象力,你也可以做出很多有意思的功能。


公眾號後臺回覆成長『成長』,將會得到我準備的學習資料,也能回覆『加群』,一起學習進步;你還能回覆『提問』,向我發起提問。

推薦閱讀:

輔助模式最終考驗的是想象力,先來看看怎麼用!| Accessibility

相關文章