1. 程式人生 > >原始碼探索系列0---微信搶紅包外掛原理解析

原始碼探索系列0---微信搶紅包外掛原理解析

好程式設計師,序號都是從0開始的。哈哈

微信搶紅包外掛

不知道你們還記不記得小米釋出會的時候,把不服跑個分改成搶紅包了,同時演示了一遍。
現在這個是基於他的修改版本,地址在這裡

,如果你想看下小米搶紅包的原始碼,就點選這裡

接下來講解其中一種實現 ,但這個是dev版,不是百分百,要百分百好用的,點這裡

下面的講解僅針對該夥伴分享的dev分支

預期特性

  1. 可以搶螢幕上顯示的所有紅包,同類外掛往往只能獲取最新的一個紅包。
  2. 智慧跳過已經戳過的紅包,避免頻繁點選影響正常使用。
  3. 紅包日誌 (預設未開啟),方便檢視搶過的紅包內容。
  4. 效能優化,感受不到外掛的存在,可一直後臺開啟,不影響日常聊天。
  5. 由於這是一份教學程式碼,專案的文件和註釋都比較完整,程式碼適合閱讀。

實現原理

1. 螢幕內容檢測和自動化點選的實現

和其他外掛一樣,這裡使用的是Android API提供的AccessibilityService
這個類位於android.accessibilityservice包內,該包中的類用於開發無障礙服務,提供代替或增強的使用者反饋。

那麼用這個包怎麼做到搶紅包呢?
那麼用這個包怎麼做到搶紅包呢?
那麼用這個包怎麼做到搶紅包呢?

標題就寫了,螢幕內容檢測和自動化點選的實現。

AccessibilityService 服務會在後臺執行,
我們要做的就是靜靜的

等待系統在發生 AccessibilityEvent 事件時回撥,
然後根據對調的內容,判斷是不是紅包,然後搶搶搶!!!

這些事件指的就是使用者介面上發生的狀態變化,
比如焦點變更(如有新的紅包出現了)、按鈕按下等等。
當然也可以請求“查詢當前視窗中內容”的能力。

1.1 配置AccessibilityService

我們需要監聽的事件
當紅包來或者滑動螢幕時引起的螢幕內容變化,
和點開紅包時窗體狀態的變化,
因此我們需要在配置XML的accessibility-service標籤中加入一條
android:accessibilityEventTypes="typeWindowStateChanged|typeWindowContentChanged"


或在onAccessibilityEvent回撥函式中對事件進行一次型別判斷

final int eventType = event.getEventType();
if (eventType == AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED
     || eventType == AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED) {
         // ...
}

除此之外,由於我們只監聽微信,還需要指定微信的包名

android:packageNames="com.tencent.mm"

為了獲取視窗內容,我們還需要指定

android:canRetrieveWindowContent="true"

其他配置請看程式碼。

1.2 獲取紅包所在的節點 - - 找到紅包

配置好後,首先,我們要獲取當前螢幕的 根節點,然後判斷下是不是 紅包
下面兩種方式效果是相同的:

AccessibilityNodeInfo nodeInfo = event.getSource();

AccessibilityNodeInfo nodeInfo = getRootInActiveWindow();

這裡返回的AccessibilityNodeInfo是窗體內容的節點,包含節點在螢幕上的位置、文字描述、子節點id、能否點選等資訊。從AccessibilityService的角度來看,窗體上的內容表示為輔助節點樹,雖然和檢視的結構不一定一一對應。換句話說,自定義的檢視可以自己描述上面的輔助節點資訊。當輔助節點傳遞給AccessibilityService之後就不可更改了,如果強行呼叫引起狀態變化的方法會報錯。

在聊天頁面,每個紅包上面都有“領取紅包”這幾個字,我們把它作為識別紅包的依據。
在聊天頁面,每個紅包上面都有“領取紅包”這幾個字,我們把它作為識別紅包的依據。
在聊天頁面,每個紅包上面都有“領取紅包”這幾個字,我們把它作為識別紅包的依據。

如果你收到了這四個字的文字訊息,可能其他的外掛會做出誤判。
因為我們加入了階段的概念,不會出現這個問題。

AccessibilityNodeInfo的API中有一個findAccessibilityNodeInfosByText方法,
允許我們通過文字來搜尋介面中的節點。
匹配是大小寫敏感的,它會從遍歷樹的根節點開始查詢。
API文件中特別指出,為了防止建立大量例項,節點回收是呼叫者的責任,
這一點會在接下來的部分中講到。

List<AccessibilityNodeInfo> node1 = nodeInfo.findAccessibilityNodeInfosByText("領取紅包");

1.3 對節點進行操作 - - 開包

AccessibilityNodeInfo同樣暴露了一個API——performAction來對節點進行點選或者其他操作。
出於安全性考慮,只有這個操作來自AccessibilityService時才會被執行。

nodeInfo.performAction(AccessibilityNodeInfo.ACTION_CLICK);

不過,我們在除錯時發現”領取紅包”的mClickable屬性為false,說明點選的監聽加在它父輩的節點上。通過getParent獲取父節點,這個節點是可以點選的。

我們還需要全域性的返回操作,最方便的辦法就是performGlobalAction,不過注意這個方法是API 16後才有的。

performGlobalAction(GLOBAL_ACTION_BACK);

2. 獲取螢幕上的所有紅包

和其他外掛最大的區別是,這個外掛的邏輯是獲取螢幕上所有的紅包節點,去掉已經獲取過的之後,將待搶紅包加入佇列,再將佇列中的紅包一個個開啟。

2.1 判斷紅包節點是否已被搶過

實現這一點是編寫時最大的障礙。對於一般的Java物件例項來說,除非被GC回收,例項的Id都不會變化。我最初的想法是通過正則表示式匹配下面的十六進位制物件id來表示一個紅包。

android.view.accessibility.AccessibilityNodeInfo@2a5a7c; .......

但在測試中,佇列中的部分紅包沒有被戳開。
進一步觀察發現,新的紅包節點和舊的紅包節點id出現了重複,且出現概率較大。
由於GC日誌正常,我推測AccessibilityNode可能有一個例項池的設計。
獲取當前窗體節點樹的時候,從一個可重用的例項池中獲取一個輔助節點資訊 (AccessibilityNodeInfo)例項。在接下來的獲取時,仍然從例項池中獲取節點例項,這時可能會重用之前的例項。
這樣的設計是有好處的,可以防止每次返回都建立大量的例項,影響效能。AccessibilityNodeProvider的源碼錶明瞭這樣的設計。

也就是說,為了標識一個唯一的紅包,只用例項id是不充分的。這個外掛採用的是紅包內容+節點例項id的hash來標記。因為同一屏下,同一個節點樹下的節點id是一定不會重複的,滑動屏幕後新紅包的內容和節點id同時重複的概率已經大大減小。

更改標識策略後,實測中幾乎沒有出現誤判

2.2 將新出現的紅包加入待搶佇列

我們維護了兩個列表,分別記錄待搶紅包和搶過的紅包標識。

private List<AccessibilityNodeInfo> nodesToFetch = new ArrayList<>();

private List<String> fetchedIdentifiers = new ArrayList<>();

在每次讀取聊天螢幕的時候,會檢查這個紅包是否在fetchedIdentifiers佇列中,如果沒有,則加入nodesToFetch佇列。

for (AccessibilityNodeInfo cellNode : fetchNodes) {
    String id = getHongbaoHash(cellNode);
    /* 如果節點沒有被回收且該紅包沒有搶過 */
    if (id != null && !fetchedIdentifiers.contains(id)) {
        nodesToFetch.add(cellNode);
    }
}

3. 開啟佇列中的紅包

通過紅包開啟後顯示的文字判斷這個紅包是否可以搶,進行接下來的操作。

3.1 判斷紅包節點是否被重用

這也是實現時的一個坑。前面提到了例項池的設計,當我們把紅包們加入待搶佇列,戳完一個紅包再回來時,佇列中的其他紅包節點可能已被回收重用,如果再去點選這個節點,顯然沒有什麼卵用。

為了解決這個問題,我們只能退而求其次,在點開前做一次檢查。如果發現被重用了,就捨棄這個節點,在下一輪fetch的階段重新加入待搶佇列。確認沒有重用立即開啟,並把節點hash加入fetchedIdentifiers佇列。這裡如果node失效getHongbaoHash會返回null。

AccessibilityNodeInfo node = nodesToFetch.remove(nodesToFetch.size() - 1);
if (node.getParent() != null) {
    String id = getHongbaoHash(node);
    if (id == null) return;
    fetchedIdentifiers.add(id);
    Stage.getInstance().entering(Stage.OPENING_STAGE);
    node.getParent().performAction(AccessibilityNodeInfo.ACTION_CLICK);
}

可以看出這並不是很有效率的解決方案。
在實測中,有時佇列中中紅包失效後被捨棄,但沒有新的AccessibilityEvent發生,接下來的操作都被掛起了。在戳過較多紅包之後,這種情況表現得尤為明顯,必須要顯式地改變窗體內容才能解決。

3.2 根據紅包型別選擇操作

紅包被戳開前會進行查重

戳開後如果介面上出現了“拆紅包”幾個字,說明紅包還沒有被別人搶走,立刻點選“拆紅包”並將stage標記為OPENED_STAGE。

此時,另三種情況表明搶紅包失敗了,直接返回,接下來狀態會被標記為FETCHED_STAGE。

  1. “過期”,說明紅包超過有效時間
  2. “手慢了”,說明紅包發完但沒搶到
  3. “紅包詳情”,說明你已經搶到過

3.3 防止載入紅包時返回

戳開紅包和紅包載入完之間有一個“正在載入”的過渡動畫,會觸發onAccessibilityEvent回撥方法。如果在載入完之前判斷,上述文字還沒出現,會被預設標記為FETCHED_STAGE並觸發返回。因此,我們要在返回前特殊判定這種情形。

我們引入了TTL來記錄嘗試次數,並返回錯誤值-1。如果到達MAX_TTL時紅包還沒有加載出來就捨棄這個紅包。

Stage.getInstance().entering(Stage.OPENING_STAGE);
ttl += 1;
return -1;

上面的就這樣的基本流程說完了,那麼現在是一些附加的介紹

4. 搶紅包流程的邏輯控制

這個外掛通過一個Stage類來記錄當前對應的階段。
Stage類被設計成單例並惰性例項化,因為一個Service不需要也不應該處在不同的階段。
對外暴露階段常量和entering和getCurrentStage兩個方法,分別記錄和獲取當前的階段。

public class Stage {

    private static Stage stageInstance;

    public static final int FETCHING_STAGE = 0, OPENING_STAGE = 1, FETCHED_STAGE = 2, OPENED_STAGE = 3;

    private int currentStage = FETCHED_STAGE;

    private Stage() {}

    public static Stage getInstance() {
        if (stageInstance == null) stageInstance = new Stage();
        return stageInstance;
    }

    public void entering(int _stage) {
        stageInstance.currentStage = _stage;
    }

    public int getCurrentStage() {
        return stageInstance.currentStage;
    }
}

4.1 階段說明

階段 說明
FETCHING_STAGE 正在讀取螢幕上的紅包,此時不應有別的操作
FETCHED_STAGE 已經結束一個FETCH階段,螢幕上的紅包都已加入待搶佇列
OPENING_STAGE 正在拆紅包,此時不應有別的操作
OPENED_STAGE 紅包成功搶到,進入紅包詳情頁面

1.程式以FETCHED_STAGE 開始,將螢幕上的紅包加入待搶佇列:

--> FETCHED_STAGE --> FETCHING_STAGE  --> FETCHED_STAGE -->

2.處理待搶佇列中的紅包:

--> [CLICK] --> OPENING_STAGE --> [CLICK] --> OPENED_STAGE --> [BACK] --> FETCHED_STAGE -->(搶到)

--> [CLICK] --> OPENING_STAGE --> [BACK] --> FETCHED_STAGE -->(沒搶到)

3.不斷重複流程1和2

4.2 根據階段選擇不同的入口

在每次窗體狀態發生變化後,根據當前所在的階段選擇入口。

switch (Stage.getInstance().getCurrentStage()) {
    case Stage.OPENING_STAGE:
        // .......
        Stage.getInstance().entering(Stage.FETCHED_STAGE);
        performGlobalAction(GLOBAL_ACTION_BACK);
        break;
    case Stage.OPENED_STAGE:
        Stage.getInstance().entering(Stage.FETCHED_STAGE);
        performGlobalAction(GLOBAL_ACTION_BACK);
        break;
    case Stage.FETCHED_STAGE:
        if (nodesToFetch.size() > 0) {
            AccessibilityNodeInfo node = nodesToFetch.remove(nodesToFetch.size() - 1);
            if (node.getParent() != null) {
                // .......
                Stage.getInstance().entering(Stage.OPENING_STAGE);
                node.getParent().performAction(AccessibilityNodeInfo.ACTION_CLICK);
            }
            return;
        }
        Stage.getInstance().entering(Stage.FETCHING_STAGE);
        fetchHongbao(nodeInfo);
        Stage.getInstance().entering(Stage.FETCHED_STAGE);
        break;
}