1. 程式人生 > >Android後臺模擬點選探索(附原始碼)

Android後臺模擬點選探索(附原始碼)

本篇文章已授權微信公眾號 guolin_blog (郭霖)獨家釋出

工作中我們需要自制一套工具,其中遇到需要模擬點選事件的需求,類似按鍵精靈的功能,支援後臺持續執行,滿足觸發條件時完成點選。

經過一番探索,一共整理出兩種不同的方案:AccessibilityServiceadb shell命令,讀者可自行選擇合適的場景。

AccessibilityService

無障礙模式是我首先想到的方案,對於不知道Android無障礙模式的,可自行百度。這裡簡單說明一下,AccessibilityService是Android為殘障人士提供的貼心功能,比如可以報出當前頁面有哪些按鈕balabala。使用官方提供的一些列API,我們還可以完成一些自動執行的“黑科技”操作,比如早些年的紅包外掛、微信自動回覆外掛、自動點贊外掛等。

本方案原理比較簡單:掃描當前頁面的View樹,找到目標控制元件,模擬點選操作,下面詳細闡述。

新增配置檔案

首先需要在res目錄下建立配置檔案:accessible_service_config.xml ,名字隨意取。

<?xml version="1.0" encoding="utf-8"?>
<accessibility-service
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:accessibilityEventTypes="typeAllMask"
    android:accessibilityFeedbackType
="feedbackGeneric" android:accessibilityFlags="flagReportViewIds" android:canRetrieveWindowContent="true" android:notificationTimeout="100" android:description="@string/description" android:packageNames="目標包名"/>

accessibilityEventTypes:設定響應事件的型別,這裡設定typeAllMask,就是響應全部型別的事件。

accessibilityFeedbackType

:設定回饋給使用者的方式,有語音播出和振動,這裡使用通用型別。

notificationTimeout:設定響應時間。

packageNames:目標包名,比如紅包外掛就要設定微信包名,關於包名如何獲取,下文會提到。

繼承AccessibilityService編碼

接著我們繼承AccessibilityService新建AutoClickAccessibilityService,重寫onAccessibilityEvent(AccessibilityEvent event)


public class AutoClickAccessibilityService extends AccessibilityService {
    private static final String TAG = "GK";

    @Override
    public void onAccessibilityEvent(AccessibilityEvent event) {
        ztLog("===start===");
        try {
            //拿到根節點
            AccessibilityNodeInfo rootInfo = getRootInActiveWindow();
            if (rootInfo == null) {
                return;
            //開始遍歷,這裡拎出來細講,直接往下看正文
            if (rootInfo.getChildCount() != 0) {
                ……
            }
        } catch (Exception e) {
        ztLog("Exception:" + e.getMessage(), true);
    }
}

拿到根節點以後,我們有兩種方式開始尋找目標節點:

  • 根據View id:findAccessibilityNodeInfosByViewId

  • 根據控制元件文案:findAccessibilityNodeInfosByText

這裡我們拿魅族手機自帶的音樂App做例子,假如我們需要自動點選下圖的 專欄

這裡寫圖片描述

使用findAccessibilityNodeInfosByViewId尋找目標

我們可以使用findAccessibilityNodeInfosByViewId(),通過id找到目標節點,關於View id,可以使用DDMS中的Dump View Hierarchy for UI Automator,就是點選下圖按鈕(不知道如何開啟eclipse或者AS的DDMS的同學可以自行百度):

這裡寫圖片描述

稍等片刻,生成螢幕快照,並解析出View樹,從右下的屬性框就可以找到id,同時仔細看,包名也可以獲取到啦~

這裡寫圖片描述

這裡很有可能因為目標apk混淆嚴重而讀不到id,比如是個,那麼可以嘗試第二個方法。

使用findAccessibilityNodeInfosByText尋找目標

使用findAccessibilityNodeInfosByText("最熱MV"),顧名思義,就是根據文案找控制元件。

找到控制元件以後,就可以執行點選操作了,但是且慢,這裡有個坑。

因為注意看這裡的view樹:

這裡寫圖片描述

無論我們根據id還是文案,找到的可能只是一個TextView或者Button,但是根據我們日常經驗,我們肯定是給其父佈局設定的點選事件,也就是這裡的LinearLayout或者FrameLayout

所以我的方案是根據View樹的結構,自行遍歷。比如這裡的View樹結構如下:
這裡寫圖片描述

我先做深度優先遍歷找到GridView,然後遍歷它所有孩子直至找到專欄這個TextView,為什麼我不直接DFS找到專欄呢?因為我要記錄它的父節點甚至爺爺節點,方便接下來執行點選操作。

如果有同學使用這種方案,建議根據實際View樹的結構,自行遍歷尋找,我的程式碼如下:

/**
 * 深度優先遍歷尋找目標節點
 */
private void DFS(AccessibilityNodeInfo rootInfo) {
    if (rootInfo == null || TextUtils.isEmpty(rootInfo.getClassName())) {
        return;
    }
    if (!"android.widget.GridView".equals(rootInfo.getClassName())) {
        ztLog(rootInfo.getClassName().toString());
        for (int i = 0; i < rootInfo.getChildCount(); i++) {
            DFS(rootInfo.getChild(i));
        }
    } else {
        ztLog("==find gridView==");
        final AccessibilityNodeInfo GridViewInfo = rootInfo;
        for (int i = 0; i < GridViewInfo.getChildCount(); i++) {
            final AccessibilityNodeInfo frameLayoutInfo = GridViewInfo.getChild(i);
            //細心的同學會發現,我程式碼裡的遍歷的邏輯跟View樹裡顯示的結構不一樣,
            //快照顯示的FrameLayout下明明該是LinearLayout,我這裡卻是TextView,
            //這個我也不知道,實際調試出來的就是這樣……所以大家實操過程中也要注意了
            final AccessibilityNodeInfo childInfo = frameLayoutInfo.getChild(0);
            String text = childInfo.getText().toString();
            if (text.equals("專欄")) {
                performClick(frameLayoutInfo);
            } else {
                ztLog(text);
            }
        }
    }
}

private void performClick(AccessibilityNodeInfo targetInfo) {
    targetInfo.performAction(AccessibilityNodeInfo.ACTION_CLICK);
}

AndroidManifest檔案新增Service配置

AccessibilityService也是一個Servcie,所以要在AndroidManifest配置一下。

<service
   android:name=".AutoClickService"
   android:exported="false"
   <!-- label就是在手機設定中的無障礙裡,顯示的標籤 -->
   android:label="自動點選Demo"
   <!-- 注意這裡的android:permission是在service結構裡面的!! -->
   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/accessible_service_config" />
</service>

至此無障礙模式方案就講完了,執行之後,需要在手機設定中的無障礙裡開啟對應的開關:

這裡寫圖片描述

這裡寫圖片描述

開啟以後,自動點選功能可以自動後臺運行了,不想用時可以在上圖開關那裡關閉即可。

以後需要先執行App,再開啟開關,開啟功能。

無障礙模式雖然用著挺舒服,但是在很多廠商的系統裡,已經開啟的無障礙模式隔一段時間經常會被自動關閉,比如MIUI系統裡就要給App加開機執行的許可權。

而廠商自帶的無障礙就沒事,猜測系統裡內建了處理,這也是無障礙模式的一個坑吧。

小結

最後總結一下,AccessibilityService是一個很有趣的功能,發揮想象力可以做很多事,但是要小心踩坑:

  • 通過findAccessibilityNodeInfosByViewId或者findAccessibilityNodeInfosByText找到的目標控制元件不一定是你想要的點選控制元件
  • 各家廠商系統可能對無障礙模式內建了遮蔽處理

adb shell命令

adb可以方便我們直接高效的操作真機,比如安裝apk,批量安裝apk,複製檔案等,而模擬點選事件也是可以通過adb命令完成的。

我是突然想到,前陣子看過網上流傳的一個“微信跳一跳”的輔助,使用python + adb完成。

原理就是adb負責截圖,python負責影象識別畫素計算距離,最後再由adb模擬點選。

如果我們需要點選的目標,座標相對確定,那我們直接在程式碼裡執行adb命令模擬點選即可。

真機實驗

我們先用USB連線真機,在cmd命令列工具裡:

adb shell
shell@PRO6:/ $ input tap 125 521
shell@PRO6:/ $ 

這裡的意思就是點選螢幕上 (x, y) = (125, 521)的地方。果然手機響應了,缺點就是響應時間略長,感覺有1秒左右。

同理其他手勢操作也可以完成,這裡不作詳解,感興趣的可以自行搜尋。

下面我們需要做的就是在程式碼裡完成上述操作,並且可以持續在後臺執行。這裡我也是踩坑無數,聽我慢慢吐槽。

尋找後臺執行adb命令的方案

ProcessBuilder — OUT

沒什麼好說的,直接看程式碼:

    int x = 0, y = 0;
    String[] order = { "input", "tap", " ", x + "", y + "" };
    try {
        new ProcessBuilder(order).start();
    } catch (IOException e) {
        Log.i("GK", e.getMessage());
        e.printStackTrace();
    }

這種版本,在Activity中可行,但是切後臺不行……這肯定無法滿足需求,再找!

Instrumentation — OUT

try {
    Instrumentation inst = new Instrumentation();
    inst.sendPointerSync(MotionEvent.obtain(SystemClock.uptimeMillis(), SystemClock.uptimeMillis(), MotionEvent.ACTION_DOWN, x, y, 0));
    inst.sendPointerSync(MotionEvent.obtain(SystemClock.uptimeMillis(), SystemClock.uptimeMillis(), MotionEvent.ACTION_UP, x, y, 0));
    Log.i("GK", "模擬點選" + x + ", " + y);
} catch (Exception e) {
    Log.e("Exception when sendPointerSync", e.toString());
}

這種版本如果想後臺,必須獲得系統簽名,需要自行編譯Android系統!成本太高!

救世主Runtime登場

private OutputStream os;

/**
 * 執行ADB命令: input tap 125 340
 */
private final void exec(String cmd) {
    try {
        if (os == null) {
            os = Runtime.getRuntime().exec("su").getOutputStream();
        }
        os.write(cmd.getBytes());
        os.flush();
    } catch (Exception e) {
        e.printStackTrace();
        Log.e("GK", e.getMessage());
    }
}

後臺問題迎刃而解!但是需要Root許可權!!所以只能自己玩玩。

新增合適的時機

目前我們把核心功能做完了,最後需要做的就是找到合適的時機,執行操作。

首先我們的容器肯定是一個Service,然後後臺不斷的判斷當前app是否是目標app,如果是的話,再執行自動點選操作。

所以我們需要判斷當前前臺app的包名或者Activity的名字是否是我們的目標。

/**
 * 如果前臺APP是目標apk
 */
private boolean isCurrentAppIsTarget() {
    String name = getForegroundAppPackageName();
    if (!TextUtils.isEmpty(name) && PACKAGE_NAME.equalsIgnoreCase(name)) {
        return true;
    }
    return false;
}

/**
 * 獲取前臺程式包名,該方法僅在android L之前有效
 */
public String getForegroundAppPackageName() {
    ActivityManager am = (ActivityManager) getSystemService(Context.ACTIVITY_SERVICE);
    List<RunningAppProcessInfo> lr = am.getRunningAppProcesses();
    if (lr == null) {
        return null;
    }

    for (RunningAppProcessInfo ra : lr) {
        if (ra.importance == RunningAppProcessInfo.IMPORTANCE_VISIBLE || ra.importance == RunningAppProcessInfo.IMPORTANCE_FOREGROUND) {
            Log.i("GK", ra.processName);
            return ra.processName;
        }
    }
    return "";
}

以上就是adb shell方案,這種方案缺陷也比較明顯,就是要求 自動點選的位置不能改變和Root許可權,而且獲取前臺程式包名的許可權也比較敏感。

對於如何獲取點選位置的座標,可以開啟開發者選項中的指標位置:

這裡寫圖片描述

直接檢視座標。

總結

模擬點選這種需求,我們一般都不會用到,也有點歪門邪道的意思。但是無論什麼需求,中間的探索過程才最珍貴。技術也是人,不是每次都會有說幹就幹的決心和勇氣,保持一顆好奇心,珍惜每次探索的機會,學有所得,小有收穫,也未嘗不是一種自我認可。

歡迎交流和star~