原始碼探索系列0---微信搶紅包外掛原理解析
好程式設計師,序號都是從0開始的。哈哈
微信搶紅包外掛
不知道你們還記不記得小米釋出會的時候,把不服跑個分改成搶紅包了,同時演示了一遍。
現在這個是基於他的修改版本,地址在這裡
,如果你想看下小米搶紅包的原始碼,就點選這裡
接下來講解其中一種實現 ,但這個是dev版,不是百分百,要百分百好用的,點這裡:
下面的講解僅針對該夥伴分享的dev分支。
預期特性
- 可以搶螢幕上顯示的所有紅包,同類外掛往往只能獲取最新的一個紅包。
- 智慧跳過已經戳過的紅包,避免頻繁點選影響正常使用。
- 紅包日誌 (預設未開啟),方便檢視搶過的紅包內容。
- 效能優化,感受不到外掛的存在,可一直後臺開啟,不影響日常聊天。
- 由於這是一份教學程式碼,專案的文件和註釋都比較完整,程式碼適合閱讀。
實現原理
1. 螢幕內容檢測和自動化點選的實現
和其他外掛一樣,這裡使用的是Android API提供的AccessibilityService
。
這個類位於android.accessibilityservice包內,該包中的類用於開發無障礙服務
,提供代替或增強的使用者反饋。
那麼用這個包怎麼做到搶紅包呢?
那麼用這個包怎麼做到搶紅包呢?
那麼用這個包怎麼做到搶紅包呢?
標題就寫了,螢幕內容檢測和自動化點選的實現。
AccessibilityService 服務會在後臺執行,
我們要做的就是靜靜的
然後根據對調的內容,判斷是不是紅包,然後搶搶搶!!!
這些事件指的就是使用者介面上發生的狀態變化,
比如焦點變更(如有新的紅包出現了)、按鈕按下等等。
當然也可以請求“查詢當前視窗中內容”的能力。
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。
- “過期”,說明紅包超過有效時間
- “手慢了”,說明紅包發完但沒搶到
- “紅包詳情”,說明你已經搶到過
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;
}