最近在做一個基于無障礙自動刷短視頻的APP,需要支持用任意藍牙遙控器遠程控制, 把無障礙服務流程大致研究了一下,從下面3個部分做一下小結。
1、需要可調整自動上滑距離和速度以適配不同的屏幕和應用
智能適配99%機型,滑動參數可自由調節。
默認的距離和速度可能在個別手機上無法達到滑屏的要求,表現就是屏幕可見滑動了下,但還是停留回當前界面。所以需要給用戶一種自定義調整的方式,這里以【輔助觸控】APP為例,提供了屏幕配置的實現,可以自行拖動滑動起始點,然后調整滑動參數。
編輯屏幕的時候支持增刪按鍵映射(12個可編程功能鍵 (F1-F12)),并自定義如下參數:
- ? 單擊/雙擊/連擊/長按
- ?? 自定義間隔(0.1-30秒)
- ?? 按壓停留時長設置
支持手勢軌跡定制如下參數:
- 📍 起點/終點坐標設置
- ?? 自定義間隔(3-30秒)和滑動速度
- 📐 四向滑動獨立配置
在定義好按鍵映射后,還可以對其進行組合控制,編寫一組相關動作然后執行。
其中還可以進一步定義文本識別后要執行的動作,比如單擊文本節點、返回、上滑等。
2、監聽TYPE_WINDOW_STATE_CHANGED事件,在圖形驗證碼出現時停止,需要能識別出帶有關鍵文本的視圖元素
比如支付寶看視頻領紅包活動,會一定機率跳出圖形驗證碼,需要用戶手動點選,如果此界面繼續滑屏,很容易被系統識別到正在進行自動化腳本刷屏。需要對界面內容識別,比如文本 “請依次點擊下面的圖案”。
在自動滑屏期間檢測到該事件,說明有窗口焦點切換,一般就是切換到了不同的窗口,比如?Dialog、PopupWindow等。有可能就是這個驗證框,這時候我們需要拿到getRootInActiveWindow(),然后通過無障礙API findAccessibilityNodeInfosByText找出包含上面文本的Node。
//這里是我們要找的可能的文本
val ocrTexts = listOf("請在下圖依次點擊")
for(ocrTry in 0 until 4) {for (text in ocrTexts) {//找包含text的那些節點,這些節點要么是能呈現指定文本(text、hint)的視圖,要么是包含指定內容描述(content description)的視圖var nodes = rootInActiveWindow?.findAccessibilityNodeInfosByText(text)nodes?.forEach { nodeInfo ->//找到了驗證框,停止滑屏,并發出聲音和震動提示用戶,需要手動驗證。autoRepeatIntervalJob?.cancel()playBeepSoundAndVibrate(5000)return}}if (ocrTry < 3) {//延遲一下,有可能文本內容還沒加載Thread.sleep(500)}
}
在循環中每次我們都重新獲取rootInActiveWindow, 否則可能獲取到的不是當前界面,不用擔心性能問題,只要沒有新的TYPE_WINDOW_STATE_CHANGED事件發生,都會使用緩存。所以每次獲取的好處就是即使事件發生了,我下一個循環就能得到新界面。
實測發現,如下圖支付寶這個驗證框使用findAccessibilityNodeInfosByText居然找不到
難道他是圖片?帶著懷疑我用uiautomatorviewer看了下布局,發現只是一個TextView, 文本也是“請在下圖依次點擊”,和我們檢索字符串一樣,只是它的ImportantForAccessibility屬性是false, 我們的AccessibilityService的config里也添加了flagIncludeNotImportantViews(包括不重要的視圖),照理應該能找到并返回。然后我又嘗試了下遍歷的方式:
fun AccessibilityService.findTextByTraversal(text: String, include: Boolean = false): List<AccessibilityNodeInfo> {val result = mutableListOf<AccessibilityNodeInfo>()traverseNodes(result, rootInActiveWindow, text, include)return result
}
private fun traverseNodes(result: MutableList<AccessibilityNodeInfo>,node: AccessibilityNodeInfo?,searchText: String,include: Boolean = false,
) {node?.let {if (node.text != null && node.text.isNotEmpty()) {if (include && node.text.contains(searchText)) {result.add(node)} else if (node.text == searchText) {result.add(node)}if (DebugUtils.DEBUG) DebugUtils.logD(TAG, "traverseNodes find $node")}for (i in 0 until node.childCount) {traverseNodes(result, node.getChild(i), searchText, include)}}
}
竟然能找到這個node,這樣的話先修改一下邏輯,優先使用findAccessibilityNodeInfosByText,找不到再遞歸找。
第3部分我們通過源碼梳理一下AccessibilityService和AccessibilityManagerService之間的通信過程,嘗試分析一下findAccessibilityNodeInfosByText是怎樣進行查找的?
3、AccessibilityService和AccessibilityManagerService之間的通信過程
參考源碼
https://xrefandroid.com/android-11.0.0_r48/
首先我們需要簡單了解一下AccessibilityService啟動流程。
AccessibilityService啟動流程
在SystemServer主進程服務啟動階段,AccessibilityManagerService(AMS)作為系統服務被初始化,負責管理全局無障礙服務生命周期及事件分發?。
// frameworks/base/services/java/com/android/server/SystemServer.java
private static final String ACCESSIBILITY_MANAGER_SERVICE_CLASS = "com.android.server.accessibility.AccessibilityManagerService$Lifecycle";
private void startOtherServices(@NonNull TimingsTraceAndSlog t) {...try {mSystemServiceManager.startService(ACCESSIBILITY_MANAGER_SERVICE_CLASS);} catch (Throwable e) {reportWtf("starting Accessibility Manager", e);}}
實例化AccessibilityManagerService$Lifecycle對象并調用其onStart()
將AMS發布出來,之后就可以通過Context.getSystemService(Context.ACCESSIBILITY_SERVICE)獲取對應的AccessibilityManager來和AMS通信。
AMS init初始化時注冊?PackageMonitor?監聽應用安裝/卸載事件,動態維護已注冊的無障礙服務列表?,注冊ACTION_USER_PRESENT讀取所有已安裝應用的無障礙服務信息查詢所有已安裝應用中的無障礙服務信息:
//frameworks/base/services/accessibility/java/com/android/server/accessibility/AccessibilityManagerService.java
其中只要有somethingChanged = true, 比如下面這個,讀取到已安裝應用發生了變更
則調用onUserStateChangedLocked更新相關信息:
包括綁定App的AccessibilityService, 創建一個AccessibilityServiceConnection來綁定服務和完成兩者之間的通信。
綁定成功后,會回調onServiceConnected(ComponentName componentName, IBinder service)
我們知道Service的綁定過程是,被綁定的服務會啟動,然后在onBind(Intent)返回一個IBinder對象給綁定者, ? 綁定者在onServiceConnected中可以獲取到一個用于和Service進行IPC通信的接口對象IBinder。
比如前面提到的TYPE_WINDOW_STATE_CHANGED事件,傳遞過程為:
AMS sendAccessibilityEvent(AccessibilityEvent event, int userId)? ->? AMS notifyAccessibilityServicesDelayedLocked(AccessibilityEvent event, boolean isDefault) -> AccessibilityServiceConnection.notifyAccessibilityEvent(event) -> mServiceInterface.onAccessibilityEvent(event, serviceWantsEvent) ···IPC···> IAccessibilityServiceClientWrapper.onAccessibilityEvent(event, serviceWantsEvent) -> AccessibilityService.onAccessibilityEvent(event)
?上面是AMS到AccessibilityService的通信,AccessibilityService到AMS則是通過AccessibilityInteractionClient。?
前面已經提到在onBind回調的時候,我們返回了一個IAccessibilityServiceClientWrapper IBinder給AMS, AMS在綁定服務成功后拿到service IBinder,調用了initializeService,將AMS端的AccessibilityServiceConnection回傳給了AccessibilityService,如下代碼所示:
public void onServiceConnected(ComponentName componentName, IBinder service) {...mServiceInterface = IAccessibilityServiceClient.Stub.asInterface(service); //service就是IAccessibilityServiceClientWrapper IBinder...mMainHandler.sendMessage(obtainMessage(AccessibilityServiceConnection::initializeService, this));...
}
private void initializeService() {...serviceInterface.init(this, mId, mOverlayWindowTokens.get(Display.DEFAULT_DISPLAY));...
}//frameworks/base/core/java/android/accessibilityservice/AccessibilityService$IAccessibilityServiceClientWrapper
public void init(IAccessibilityServiceConnection connection, int connectionId, IBinder windowToken) {Message message = mCaller.obtainMessageIOO(DO_INIT, connectionId, connection, windowToken);mCaller.sendMessage(message);
}public void executeMessage(Message message) {...case DO_INIT: {mConnectionId = message.arg1;SomeArgs args = (SomeArgs) message.obj;IAccessibilityServiceConnection connection = (IAccessibilityServiceConnection) args.arg1;IBinder windowToken = (IBinder) args.arg2;args.recycle();if (connection != null) {//關聯 IAccessibilityServiceConnectionAccessibilityInteractionClient.getInstance().addConnection(mConnectionId, connection);mCallback.init(mConnectionId, windowToken);mCallback.onServiceConnected();}...}...
}
IAccessibilityServiceClientWrapper 將AMS傳來的IAccessibilityServiceConnection添加到AccessibilityInteractionClient中緩存起來,后續用來和AMS通信。
到這里我們的AccessibilityService與AMS的通道就建好了:
AccessibilityService -> AccessibilityInteractionClient -> IAccessibilityServiceConnection? ···IPC···> AccessibilityServiceConnection -> AMS?
現在回頭來看findAccessibilityNodeInfosByText, 一般我們需要先getRootInActiveWindow獲取root節點。
getRootInActiveWindow獲取root節點
public AccessibilityNodeInfo getRootInActiveWindow() {return AccessibilityInteractionClient.getInstance().getRootInActiveWindow(mConnectionId);}//AccessibilityInteractionClient
public AccessibilityNodeInfo getRootInActiveWindow(int connectionId) {//這里使用固定的ACTIVE_WINDOW_ID和ROOT_NODE_ID,在AMS那邊會對應到當前可交互窗口的root,return findAccessibilityNodeInfoByAccessibilityId(connectionId,AccessibilityWindowInfo.ACTIVE_WINDOW_ID, AccessibilityNodeInfo.ROOT_NODE_ID, false, AccessibilityNodeInfo.FLAG_PREFETCH_DESCENDANTS, null);
}public @Nullable AccessibilityNodeInfo findAccessibilityNodeInfoByAccessibilityId(int connectionId, @NonNull IBinder leashToken, long accessibilityNodeId,boolean bypassCache, int prefetchFlags, Bundle arguments) {if (leashToken == null) {return null;}int windowId = -1;try {//獲取前面關聯的緩存在線程中的IAccessibilityServiceConnectionIAccessibilityServiceConnection connection = getConnection(connectionId);if (connection != null) {windowId = connection.getWindowIdForLeashToken(leashToken);} else {if (DEBUG) {Log.w(LOG_TAG, "No connection for connection id: " + connectionId);}}} catch (RemoteException re) {Log.e(LOG_TAG, "Error while calling remote getWindowIdForLeashToken", re);}if (windowId == -1) {return null;}return findAccessibilityNodeInfoByAccessibilityId(connectionId, windowId,accessibilityNodeId, bypassCache, prefetchFlags, arguments);
}public AccessibilityNodeInfo findAccessibilityNodeInfoByAccessibilityId(int connectionId,int accessibilityWindowId, long accessibilityNodeId, boolean bypassCache,int prefetchFlags, Bundle arguments) {...//向AMS connection發請求, 傳入AccessibilityInteractionClient自身作為callback,用于接收結果回調packageNames = connection.findAccessibilityNodeInfoByAccessibilityId(accessibilityWindowId, accessibilityNodeId, interactionId, this,prefetchFlags, Thread.currentThread().getId(), arguments);...//等待AMS 返回結果List<AccessibilityNodeInfo> infos = getFindAccessibilityNodeInfosResultAndClear(interactionId);...
}//frameworks/base/services/accessibility/java/com/android/server/accessibility/AbstractAccessibilityServiceConnection.java
public String[] findAccessibilityNodeInfoByAccessibilityId(int accessibilityWindowId, long accessibilityNodeId, int interactionId,IAccessibilityInteractionConnectionCallback callback, int flags,long interrogatingTid, Bundle arguments) throws RemoteException {...// 解析當前的windowIdresolvedWindowId = resolveAccessibilityWindowIdLocked(accessibilityWindowId);...//找到當前窗口和AMS之間的交互連接對象connection = mA11yWindowManager.getConnectionLocked(mSystemSupport.getCurrentUserIdLocked(), resolvedWindowId);...//將請求通過IPC發給連接的遠程端connection.getRemote().findAccessibilityNodeInfoByAccessibilityId(accessibilityNodeId,?partialInteractiveRegion,?interactionId,?callback,mFetchFlags?|?flags,?interrogatingPid,?interrogatingTid,?spec,?arguments);...
}
這里經過查閱源碼,發現connection是應用通過ViewRootImpl創建新窗口(如 Activity、Dialog、PopupWindow 等)時,會通過IPC向AMS進行addAccessibilityInteractionConnection()?調用,從而注冊窗口與AMS之間的交互連接對象,
connection.getRemote()就是這個對象 ,如下源碼所示的AccessibilityInteractionConnection:
//frameworks/base/core/java/android/view/ViewRootImpl.java
public void setView(View view, WindowManager.LayoutParams attrs, View panelParentView,int userId) {...if (mAccessibilityManager.isEnabled()) {mAccessibilityInteractionConnectionManager.ensureConnection();}...
}final class AccessibilityInteractionConnectionManagerimplements AccessibilityStateChangeListener {...public void ensureConnection() {final boolean registered = mAttachInfo.mAccessibilityWindowId!= AccessibilityWindowInfo.UNDEFINED_WINDOW_ID;if (!registered) {mAttachInfo.mAccessibilityWindowId =mAccessibilityManager.addAccessibilityInteractionConnection(mWindow,mLeashToken,mContext.getPackageName(),new AccessibilityInteractionConnection(ViewRootImpl.this));}}...
}static final class AccessibilityInteractionConnection extends IAccessibilityInteractionConnection.Stub {private final WeakReference<ViewRootImpl> mViewRootImpl;AccessibilityInteractionConnection(ViewRootImpl viewRootImpl) {mViewRootImpl = new WeakReference<ViewRootImpl>(viewRootImpl);}...@Overridepublic void findAccessibilityNodeInfoByAccessibilityId(long accessibilityNodeId,Region interactiveRegion, int interactionId,IAccessibilityInteractionConnectionCallback callback, int flags,int interrogatingPid, long interrogatingTid, MagnificationSpec spec, Bundle args) {ViewRootImpl viewRootImpl = mViewRootImpl.get();if (viewRootImpl != null && viewRootImpl.mView != null) {viewRootImpl.getAccessibilityInteractionController().findAccessibilityNodeInfoByAccessibilityIdClientThread(accessibilityNodeId,interactiveRegion, interactionId, callback, flags, interrogatingPid,interrogatingTid, spec, args);} else {// We cannot make the call and notify the caller so it does not wait.try {callback.setFindAccessibilityNodeInfosResult(null, interactionId);} catch (RemoteException re) {/* best effort - ignore */}}}...}
所以,最終AMS會調用到了應用端,同時傳遞了回調callback用于接收結果:
viewRootImpl.getAccessibilityInteractionController().findAccessibilityNodeInfoByAccessibilityIdClientThread(accessibilityNodeId,interactiveRegion,interactionId, callback, flags, interrogatingPid,interrogatingTid,spec,args);
//frameworks/base/core/java/android/view/AccessibilityInteractionController.java
我們之前在AccessibilityService中調用getRootInActiveWindow使用的accessibilityId是AccessibilityNodeInfo.ROOT_NODE_ID,這里得到的就是mViewRootImpl.mView,即窗口的根視圖DecorView。
如果此時root view已經可見,則封裝并返回root的無障礙節點信息:
//frameworks/base/core/java/android/view/AccessibilityInteractionController$AccessibilityNodePrefetcher
public void prefetchAccessibilityNodeInfos(View view, int virtualViewId, int fetchFlags,List<AccessibilityNodeInfo> outInfos, Bundle arguments) {...AccessibilityNodeInfo root = view.createAccessibilityNodeInfo();if (root != null) {...outInfos.add(root);...}...
}
之后將找到的節點通過回調callback.setFindAccessibilityNodeInfosResult(infos, interactionId)傳回給請求方。?
AMS端傳遞的callback對應的是AccessibilityService端的AccessibilityInteractionClient這個Binder, 結果也就傳到了AccessibilityInteractionClient,即IPC調用過程如下:
<發起請求>
AccessibilityService? -> IAccessibilityServiceConnection ···IPC···> AccessibilityServiceConnection -> AMS -> IAccessibilityInteractionConnection ···IPC···> AccessibilityInteractionConnection -> 當前窗口應用的ViewRootImpl
<返回結果>
當前窗口應用的ViewRootImpl? -> callback ···IPC···> AccessibilityService
返回的是一個列表,我們使用第一個作為找到的root節點。
findAccessibilityNodeInfosByText
和前面的IPC調用過程一樣,我們直接去ViewRootImpl去找對應的方法:
@Override
public void findAccessibilityNodeInfosByText(long accessibilityNodeId, String text,Region interactiveRegion, int interactionId,IAccessibilityInteractionConnectionCallback callback, int flags,int interrogatingPid, long interrogatingTid, MagnificationSpec spec) {ViewRootImpl viewRootImpl = mViewRootImpl.get();if (viewRootImpl != null && viewRootImpl.mView != null) {viewRootImpl.getAccessibilityInteractionController().findAccessibilityNodeInfosByTextClientThread(accessibilityNodeId, text,interactiveRegion, interactionId, callback, flags, interrogatingPid,interrogatingTid, spec);} else {// We cannot make the call and notify the caller so it does not wait.try {callback.setFindAccessibilityNodeInfosResult(null, interactionId);} catch (RemoteException re) {/* best effort - ignore */}}
}//AccessibilityInteractionController.java
private void findAccessibilityNodeInfosByTextUiThread(Message message) {...List<AccessibilityNodeInfo> infos = null;final?View?root?=?findViewByAccessibilityId(accessibilityViewId);ArrayList<View> foundViews = mTempArrayList;foundViews.clear();//首先找出包含檢索字符串的viewroot.findViewsWithText(foundViews, text, View.FIND_VIEWS_WITH_TEXT| View.FIND_VIEWS_WITH_CONTENT_DESCRIPTION| View.FIND_VIEWS_WITH_ACCESSIBILITY_NODE_PROVIDERS);if (!foundViews.isEmpty()) {infos = mTempAccessibilityNodeInfoList;infos.clear();final int viewCount = foundViews.size();for (int i = 0; i < viewCount; i++) {//依次遍歷找到的view,滿足條件則生成AccessibilityNodeInfo,添加到結果列表中View foundView = foundViews.get(i);if (isShown(foundView)) {provider = foundView.getAccessibilityNodeProvider();if (provider != null) {//這里最可能是隱藏不重要節點的地方,通過自定義AccessibilityNodeProvider實現,返回null或者空即可List<AccessibilityNodeInfo> infosFromProvider = provider.findAccessibilityNodeInfosByText(text,
AccessibilityNodeProvider.HOST_VIEW_ID);if (infosFromProvider != null) {infos.addAll(infosFromProvider);}} else {infos.add(foundView.createAccessibilityNodeInfo());}}}}...//通知callback結果updateInfosForViewportAndReturnFindNodeResult(infos, callback, interactionId, spec, interactiveRegion);}
主要看root.findViewsWithText
//ViewGroup實現
//View默認實現
默認情況下,View?類的?getAccessibilityNodeProvider()?返回?null。?
?//TextView實現
根據代碼或者注釋,我們知道了匹配規則,系統會遍歷View樹,只要view可見,定義了content description或text,并且包含我們要查找的文本(忽略大小寫),這個view就認為是需要的。所有符合條件的view依次封裝為AccessibilityNodeInfo,添加到結果列表infos中:
infos.add(foundView.createAccessibilityNodeInfo());
之后返回結果給callback。
callback.setFindAccessibilityNodeInfosResult(infos, interactionId);
到目前為止并沒有看到根據view的importantForAccessibility=no來過濾視圖,唯一可能得地方就是foundView自定義了AccessibilityNodeProvider進行了過濾,如源碼所示:
provider = foundView.getAccessibilityNodeProvider();
if (provider != null) {List<AccessibilityNodeInfo> infosFromProvider =provider.findAccessibilityNodeInfosByText(text, AccessibilityNodeProvider.HOST_VIEW_ID);if (infosFromProvider != null) {infos.addAll(infosFromProvider);}
}
只要provider.findAccessibilityNodeInfosByText此時返回null即可。
而遍歷節點樹的方式只要我們的AccessibilityServie申明了包含不重要視圖這個flag, View就能在節點樹里找到。