基於Gradle外掛+Transform+ASM技術實現的全埋點外掛功能

語言: CN / TW / HK

關注“之家技術”,獲取更多技術乾貨

總篇171篇 2022年第46篇

文章簡介

本文詳細介紹瞭如何利用Gradle外掛,Transform API和ASM在Android端實現全埋點相關過程以及原理,希望通過本篇文章能使大家對全埋點外掛有一個較為全面的瞭解。

1.全埋點概念和介紹

1.1

全埋點概念

“資料埋點”是資料採集領域的一個術語,它是針對使用者的某些行為或事件進行捕獲、處理、上報和分析的過程。埋點技術本質上就是在特定的時機去採集使用者的行為資料,同時獲取必要的行為上下文資訊,然後將這些資料資訊上報到指定的服務端,最後通過獲取到的資料進行篩選和分析,最終可以為後續的產品功能迭代,資料運營,營銷價值評判等提供有力、可靠的資料支撐。

全埋點,也叫無埋點、無碼埋點、無痕埋點、自動埋點等。它不需要開發工程師寫程式碼或者只寫少量的程式碼,即可自動收集使用者的所有或者絕大部分的行為資料。比如全量上報一類通用的事件,如某個介面上所有可點選按鈕的點選事件,它不需要開發人員像事件埋點那樣再針對某個按鈕點選寫埋點程式碼才能上報。所有點選事件上報後,可以從中篩選出所需要的行為資料,並最終提供給產品運營成員進行分析。雖然有這麼好的優點,但它的缺點也較明顯,上報的資料量很大,因為它攔截的是一類事件,所以和業務無關的點選也會上報上來。

1.2

埋點分類

業內常見的埋點方式主要包括三種,程式碼埋點,全埋點和視覺化埋點。我們看下其它兩種:

程式碼埋點,是最常見的方式,實現邏輯較清晰和簡單,可以按照業務直接上報定製化的資料,不會有多餘內容,並且能拿到業務的上下文資料,但需要開發人員進行主動呼叫,維護成本稍大一些。  

視覺化埋點,門檻較高,基於全埋點方式來實現。它在一定程度上解決了無法結合具體業務上報資料的問題。但在視覺化操作時,圈選事件有一定難度,並且事件需要結合程式碼和UI介面進行實時更新,一旦介面有UI變化,圈選事件可能會失效。

對於三種方式的實現複雜度, 全埋點 應該介於事件埋點和視覺化埋點之間,但它帶來的優勢是很可觀的。它不僅可以滿足UV、PV、點選量等常見指標統計的需求,還可以節省開發人力成本,適用於以較小的埋點代價收集儘可能多的使用者行為資料的場景。

1.3 

全埋點原理概述

我們結合市面上一些主流的全埋點方案以及開原始碼分析,總結形成了符合我們業務特點的全埋點方案。此方案包括之家的全埋點SDK和外掛(僅Android端需要外掛),包括iOS和Android兩端都已實現。iOS採用Hook系統方法的方式實現全埋點,而Android沒有類似Hook系統方法的機制,所以它的實現要更復雜一些。

iOS和Android兩端都繼承實現了最常見的四種事件:$AppStart(App啟動),$AppEnd(App退出),$AppViewScreen(Activity頁面瀏覽),$AppClick(View點選事件)。對於Android端,其中前三種事件實現原理較為簡單,監聽並處理Activity 生命週期即可實現。而實現 App 瀏覽頁面(Fragment)和View點選事件的全埋點則要複雜的多,雖然這兩個事件要埋點的位置很清楚(例如:Button 的 OnClickListener.onClick 方法觸發就可以視為 Button的點選),但是並沒有一個像 Application.ActivityLifecycleCallbacks一樣全域性可以監聽的介面對所有的點選事件進行監聽。因此,我們需要利用一些技術在原點選事件處理邏輯中“插入”我們想要的埋點程式碼,從而實現自動埋點的效果。全埋點的實現基本上也都是圍繞著如何採集 $AppClick 事件展開的。

因此全埋點的整體解決思路或者實現原理,就變成了找到那個被點選View 的點選處理邏輯,然後利用一定的技術,對原處理邏輯進行“攔截”,或者在原處理邏輯的前面或者後面“插入”自定義的埋點程式碼,從而達到自動埋點的效果。

1.4

實現方案

為達到自動埋點效果,需要熟悉業界的各種方法,找到最優方案。

業界的全埋點方案整體上可以分為“動態方案”和“靜態方案”兩種。

動態方案可以理解成程式執行期間,包括,通過反射動態代理View.OnClickListener;代理Window.Callback;代理View.AccessibilityDelegate;基於增加透明層View利用其onTouchEvent方法這四種。

靜態方案可以理解為在編譯期間實現,包括,AOP方案AspectJ;Transform+ASM插樁;Transform+Javassist;自定義註解器APT+抽象語法樹AST,總共也是四種。

綜合來說,靜態方案明顯優於動態方案,它發生在編譯期間,不僅效率高,更容易擴充套件,而且相容性也比較好。

我們看下這四種靜態方案的處理時機和方法,如圖1-1所示:

圖1-1

可以看到AST是在生成Java檔案時做的操作,利用IDE對專案code進行編譯生成.java 檔案這個時機,通過自定義註解器(APT)來切入我們需要插入程式碼的點,再通過AST的語法來插入埋點程式碼。

而AspectJ是在.java編譯成位元組碼時進行程式碼插入,也實現在編譯期間插入埋點程式碼。

在生成Android APK包時,.class最終會通過dx工具轉換為.dex 檔案(DEX是Dalvik Executable的縮寫,它是Android程式的Java程式碼在編譯成.class之後再經過轉化成Dalvik虛擬機器能夠識別執行的檔案,這種檔案就叫DEX),gradle在這個編譯過程中,允許通過gradle plugin來修改.class檔案。這樣就可以利用Javassist來操作.class位元組碼檔案,從而實現插入埋點程式碼。

ASM和Javassist原理基本相同,只是在操作.class檔案時用的是ASM。

對於上述這些靜態方案,它們都有自身的一些優缺點,比如,AspectJ無法織入第三方庫,也無法相容Lambda語法,還有不支援Gradle4.X等。AST無法掃描其它Moudle,也不支援Lambda語法,並且知識點晦澀,很難維護和使用。Javassist和ASM兩者原理基本相同,但是Javassist用到反射,效能上有所犧牲,在操作位元組碼時ASM 要比 Javassist效能更好。所以ASM框架是一個相對完美的選擇,暫時沒有發現什麼缺點,目前是靜態方案,乃至全埋點方案裡面最優秀的一種。

我們最終選擇的全埋點方案中也是基於Transform +ASM實現的,而通過Transform和ASM實現埋點插樁,得有個實現的“載體”,這個載體就是Gradle外掛。

2.Gradle外掛

2.1 

外掛是前提條件

由全埋點的實現方案介紹可知,我們的全埋點採用的是Transform + ASM的靜態方案,需要在.class檔案轉成.dex檔案之前找到合適的位置插入我們的自定義埋點程式碼。而我們針對的是Android的工程程式碼,包括各種庫,程式碼和資原始檔,那它是怎麼由這些檔案編譯生成.dex檔案的?我們可以從Android Apk的構建流程中找到答案。以下是Google官方提供的Android Apk構建流程圖, 如圖 2-1 所示:

圖2-1  Android Apk構建流程圖

從上圖紅色箭頭可以看到,在生成.class檔案之後,使用通過dx工具將所有位元組碼檔案轉換併合成一個或多個DEX檔案,因此需要在此處對位元組碼檔案做處理。通過遍歷所有的位元組碼檔案,找到我們需要處理邏輯的地方,比如,某個類實現了View$OnClickListener介面的onClick方法處,然後在此方法內插入自定義的埋點程式碼。

這個過程再細化一下可分為兩個步驟:

1.在轉化成dex檔案前獲取到全量、可處理的位元組碼檔案流;

2.識別出位元組碼檔案中的特定邏輯並插入自定義的埋點程式碼。

對於第2步操作,熟悉Spring框架原理的同學可能會眼前一亮,這不是AOP嗎?嗯,是的,其實就是利用了面向切面程式設計的思想。我們把插入程式碼的地方抽象為切入點,然後在切入點處新增埋點程式碼。

而實現這樣的功能,得需要用到這些關鍵技術:Gradle 外掛,Transform API和ASM。

首先得藉助Gradle外掛這個載體才能實現上面描述的功能,它是前提條件。我們看一下Gradle 外掛是什麼?

Gradle是一個非常優秀的自動化專案構建工具,它使用一種基於Groovy的特定領域語言(DSL)來宣告專案配置,拋棄了基於XML的各種繁瑣配置。它主要面向Java應用,當前支援的語言包括有Java、Groovy、Kotlin和Scala這幾種,官方介紹未來會支援更多的語言。

Gradle構建的大部分功能可以通過外掛的方式來實現,並且支援使用Groovy語言來自定義Gradle外掛。Gradle外掛可以應用到各種專案中,通過這樣的外掛能夠擴充套件專案功能。它既可以做成獨立的外掛程式,也可以跟隨系統的配置檔案一起執行。從事Android Studio開發的同學會比較熟悉,它能幫助我們在專案的構建過程中做很多事情,如多渠道打包,自定義輸出APK檔名,設定Debug或Release使用特定簽名檔案,隱藏簽名信息,控制日誌開關,環境分離等等,以及接下來的配合Transform獲取位元組碼檔案。

下面介紹下全埋點外掛程式是如何建立,釋出和使用,以及外掛整體執行流程。

2.2

外掛如何生成,釋出和使用

我們先看看Gradle 外掛是怎麼生成的。

2.2.1 新建moudle並修改

新建module,假設名稱為plugin,刪除不需要的檔案,只保留build.gradle檔案,src目錄,如下圖所示:

2.2.2 新增依賴

開啟plubin模組下的build.gradle檔案將原來的內容全部刪除,並新增如下程式碼:

裡面的內容就是用groovy語言寫的一個gradle指令碼,至於groovy,你可以把它理解為Java的一種方言,包括語法,資料型別,類,介面,方法等等都跟Java非常的相似。

2.2.3編寫外掛

接下來我們利用groovy來編寫外掛,在plugin模組的src目錄下建立如下路徑main/groovy/,然後新建包com/autohome/analytics/android/plugin,最後新建AHAnalyticsPlugin.groovy檔案

檔案結構如下:

AHAnalyticsPlugin.groovy檔案中的大致程式碼如下所示:

在程式碼中可以看到,我們通過project.extensions的create方法建立了一個名稱為autohomeAnalytics,型別為AHAnalyticsExtension的extension,通過這個extension,我們就可以在上層應用的gradle檔案中進行各種配置了,配置表的名稱為autohomeAnalytics。這樣就可以簡單打通上層應用和外掛,應用通過修改配置來影響或控制外掛,外掛需要按照這些配置值來實現各自的功能。

比如,在app的build.gradle中,進行如下設定,

此處含義,debug表示是否除錯模式,disablePlug表示是否禁止外掛起作用,預設是起作用的,所以置為false表示不禁止。

外掛通過如下程式碼

boolean disableAutohomeAnalyticsPlugin = Boolean.parseBoolean(properties.getOrDefault("autohomeAnalytics.disablePlugin", "false"))

可以讀取到disablePlugin對應的值,獲得app端外掛是否可用。

如果disableAutohomeAnalyticsPlugin條件成立,就會退出外掛,不會執行後續的流程了。

相反,我們會通過如下程式碼,

註冊一個Transform。

2.2.4定義外掛名稱

在plugin模組的src.main資料夾下,再新建resources資料夾,然後新建META-INF資料夾,接著在META-INF資料夾下新建gradle-plugins資料夾(注意,不要一次性建個resources.META-INF.gradle-plugins這樣的目錄,必須按上面的順序一步步的來新建目錄,否則將不是這樣一個目錄層次,這是官方的規則要求)。然後新建一個名稱為com.autohome.analytics.android.properties的檔案,com.autohome.analytics.android將表示外掛的名稱,檔案內容為

裡面的類正是我們之前建立的外掛類--AHAnalyticsPlugin。

新建完成後工程結構如下圖所示:

2.2.5生成外掛併發布

接下來就是釋出到遠端倉庫,在build.gradle中定義如下指令碼,

repository為遠端倉庫的地址,如果沒有遠端倉庫,可以傳到本地目錄,如下所示:

外掛相關檔案將傳送到本地專案根目錄下的repo資料夾中。

在Android Studio開發環境中,我們在右側的gradle欄中依次選擇:AHAnalyticsPluginAndroid=> plugin => Tasks => upload => uploadArchives,然後執行uploadArchives任務,如下所示,

任務執行完畢後可以在遠端倉庫看到定義的外掛,如下所示:

可以看到,它的內容和遠端庫基本沒什麼區別。

►2.2.6使用外掛

首先在上層應用工程中新增外掛依賴,如下所示,

接下來在app模組下的build.gradle中直接應用外掛:

可以看到,這裡應用的外掛名稱為com.autohome.analytics.android,也就是在外掛工程中,resources/META-INF/build-plugins資料夾下定義的com.autohome.analytics.android.properties檔案的檔名(此副檔名為.properties,前面部分為檔名)。

到此,Gradle外掛如何建立和使用介紹完畢。

2.3 外掛執行流程

為了方便使用者控制外掛,之家全埋點外掛提供了一些可配置項,如上文2.2.3 提到的autohomeAnalytics,可以由應用app在gradle指令碼中進行功能配置。外掛會在開始時讀取這些配置以決定具體的執行模式。然後結合Transform API和ASM一起實現插入埋點程式碼的功能,外掛的整體執行流程如下圖所示:

外掛讀取配置資訊,當設定為非禁用時,將註冊一個HAnalyticsTransform類,這個類是Transform的子類,使用其實現的transform方法來遍歷當前應用的所有jar檔案和目錄中的Java位元組碼檔案。我們接下來看下Transform是什麼。

3.Gradle Transform

3.1

Transform概述

Google 從 Android Gradle1.5.0版本開始,提供了一組封裝好的類Transform API。通過這些API允許第三方外掛在Android App打包成.dex檔案之前的編譯過程中去操作位元組碼檔案。我們只要實現一套自定義的Transform,然後遍歷位元組碼檔案的所有方法,對需要的方法進行修改,最後將修改後的檔案替換原檔案就可達到插入程式碼的目的。此類比較經典的應用是位元組碼插樁、程式碼注入。

一個專案的構建過程中可能有多個Transform,每個Transform其實都是一個gradle task,在編譯時,編譯器中的TaskManager會將每個Transform串連起來,如圖3-1所示:

圖 3-1

第一個Transform接收來自javac編譯的結果,以及已經拉取到在本地的第三方依賴庫(如jar、aar),還有resource資源編譯後的結果(各種R.class,資源編譯後生成的.class檔案)。這些編譯的中間產物,在Transform組成的鏈條上流動,每個Transform節點可以對class檔案進行處理,然後再傳遞給下一個Transform。我們常見的如混淆,multi-dex,Instant-Run,jarMerge等這些邏輯,它們如今的實現都封裝成一個個的Transform,而我們自定義的Transform,會插入到這個Transform鏈條的最前面。

3.2

Transform功能介紹

我們通過了解Transform的類定義,來對其功能進行逐個介紹,它的定義如下:

它是一個抽象類,有四個重要的抽象方法需要子類來實現。還有最重要的transform()方法,它是Transform類最核心的部分,在過載的方法內部需要對輸入的.class檔案進行處理,然後放到輸出目錄中。

3.2.1 設定Transform的名稱

getName()用來設定Transform的名稱,這是個抽象方法,必須實現。這個方法並不是返回整個Transform任務的名稱,而是其中關鍵的一部分,如下所示,假設我們的子類定義如下,

那麼最終編譯為Release版本的Transform名稱將為:

這個最終名稱是怎麼來的呢?

在gradle包中有一個叫TransformManager的類,這個類用來管理所有的Transform的子類,裡面有一個方法叫getTaskNamePrefix,在這個方法中根據各項設定值來拼接最終Transform的名稱,如下所示:

可以看到,首先以“transform”開頭,之後拼接transform類getInputTypes()方法返回的 ContentType,這個ContentType代表著這個Transform的輸入檔案的型別,型別主要有兩種,一種是Classes,另一種是Resources,如果有多種型別,各種型別之間使用“And”連線,拼接完成後加上“With”,之後緊跟的就是這個Transform的Name,並且首字母轉換成大寫,如我們的名稱為“autohomeAnalyticsAutoTrack”這裡將返回“AutohomeAnalyticsAutoTrack”,接著拼接上“For”,這裡最終返回的是task的字首,所以如果我們編譯應用選擇的是Release版本,那麼最終得到就是“transformClassesWithAutohomeAnalyticsAutoTrackForRelease”這樣的task名稱。

3.2.2 Transform處理的資料型別

getInputTypes()方法用來指定Transform處理的資料型別。此方法返回兩種型別的Set集合,分別為,

•   CLASSES

表示處理jar包或者資料夾中的.class檔案,將返回TransformManager.CONTENT_CLASS集合。

•   RESOURCES

表示要處理的是標準的Java原始檔,將返回TransformManager.CONTENT_RESOURCES集合。

我們自定義的AHAnalyticsTransform類中此方法實現如下所示,

表示僅處理輸入型別為.class的檔案。

3.2.3 Transform處理的物件範圍

getScopes()方法指定Transform要操作內容的範圍,它返回的也是一個Set集合。

官方文件Scope定義有7種類型:

1、PROJECT:只處理當前的專案

2、SUB_PROJECTS:只處理子專案

3、EXTERNAL_LIBRARIES:只處理外部的依賴庫

4、TESTED_CODE:只處理測試程式碼

5、PROVIDED_ONLY:只處理provided-only的依賴庫

6、PROJECT_LOCAL_DEPS:只處理當前專案的本地依賴,例如jar, aar(已過期,被EXTERNAL_LIBRARIES替代)

7、SUB_PROJECTS_LOCAL_DEPS:只處理子專案的本地依賴,例如jar, aar(已過期,被EXTERNAL_LIBRARIES替代)

我們過載的方法如下所示,

我們的範圍為SCOPE_FULL_PROJECT,再看它的定義為,

可以看到,我們要處理的範圍包括當前專案,子專案和外部依賴庫三個部分,包含了除測試程式碼外的幾乎所有型別。

3.2.4是否增量操作

isIncremental()方法表示增量編譯開關是否開啟。

當我們開啟增量編譯的時候,相當於input包含了changed/removed/added/notchanged四種狀態。針對每種狀態需要不同的操作,如下所示:

NOTCHANGED: 當前檔案不需處理,甚至複製操作都不用;

ADDED、CHANGED: 正常處理,輸出給下一個任務;

REMOVED: 移除outputProvider獲取路徑對應的檔案。

我們過載的方法返回disableAutohomeAnalyticsIncremental,此標誌由SDK對外提供API的方法,由應用進行設定。如下所示,

3.2.5 核心方法-轉換transform()

transform()之前已經提到,它是最重要的一個方法,在它裡面實現對所有.class檔案的遍歷,然後交給ASM進行插樁,生成新檔案,然後放到輸出目錄中。

我們先看transform兩個重要的概念:

·TransformInput

·TransformOutputProvider

TransformInput是指輸入檔案的一個抽象,包括兩種型別的集合:

1.DirectoryInput集合

是指以原始碼的方式參與專案編譯的所有目錄結構及其目錄下的原始碼檔案。

2.JarInput集合

是指以jar包方式參與專案編譯的所有本地jar包和遠端jar包(此處的jar是泛指,包括jar和aar)。

TransformOutputProvider表示Transform的輸出,通過它可以獲取到輸出路徑等資訊。

這兩個資訊都可以通過TransformInvocation類獲取到,而transform()方法的引數就是TransformInvocation型別的,所以我們有必要對其詳細說下。

TransformInvocation類定義如下,

TransformInvocation包含了輸入、輸出相關資訊。通過getInputs()方法可以得到輸入的檔案。TransformOutputProvider的getContentLocation()方法可以獲取檔案的輸出目錄,如果目錄存在的話直接返回,如果不存在就會重新建立一個。

這裡要注意,輸出路徑必須用getContentLocation()方法傳入特定的引數獲取,而不能自己隨意指定,否則下一個任務就無法獲取你這次的輸出檔案,將導致編譯失敗。

我們看下transform()方法的定義,

它只有一個TransformInvocation型別的引數,輸入或輸出都由它來提供。

3.3

Transform的註冊

我們需要實現Transform類的一個子類,並把該Transform子類建立的物件注入到打包過程中,即可完成Transform的註冊。

注入Transform並不複雜,先獲取到com.android.build.gradle.AppExtension物件,然後藉助上一節介紹的外掛程式,呼叫registerTransform()方法即可註冊成功。

註冊方法實際上是屬於BaseExtension的方法,而AppExtension繼承自BaseExtension,它們都屬於Gradle包中的類。

在外掛中註冊Transform物件,如下所示,

3.4

Transform如何操作位元組碼檔案

Transform物件註冊完畢,我們就可以通過transform()方法操作位元組碼了。

我們實現的Transform子類,類名為AHAnalyticsTransform,之前已經多次提到過,它的具體實現如下所示,

transform()方法中呼叫beforeTransform(),transformClass()和afterTransform()三個方法進行處理。

beforeTransform()和afterTransform()方法用來進行一些配置資訊的設定,和資源釋放。我們重點看下transformClass()方法。

1.首先,由上圖可以看到針對TransformInput型別的輸入資料分兩種情況進行處理,如果是jar檔案就用forEachJar處理;如果是目錄,就用forEachDirectory處理。

2.其次,以forEachJar為例,看下它的實現邏輯:

先獲取輸出檔案,然後構造新的輸出檔名稱。接著判斷是否支援增量編譯。如果不支援,直接呼叫transformJar方法。注意在transformClass方法開始處有判斷,如果不支援增量編譯,已經把所有的輸出刪除掉了。如果支援,複用之前的輸出,那麼就需要判斷當前檔案的狀態了。如果是NOTCHANGED,當前InputFile不用任何操作。如果是ADDED、CHANGED,新增或者修改,呼叫transformJar方法處理。如果是REMOVED,就把之前複用的輸出檔案強制移除。

3.接著,transformJar()方法會呼叫modifyJarFile方法, 然後它再呼叫modifyJar方法,在此方法內會遍歷每一個class檔案。

看下方法modifyJar()方法的大體實現:

經過對jar包中的檔案進行遍歷,如果是class檔案,不是目錄型別,並且允許修改此類,那麼將呼叫modifyClass()方法針對每個class檔案進行處理。我們看下modifyClass()方法的實現。

可以看到這裡將藉助ASM的ClassWriter,ClassVisitor,ClassReader來對位元組碼檔案進行操作了。後面將詳細介紹下這些類含義和使用。

4.最後,forEachDirectory方法和forEachJar方法的實現是類似的,只不過從處理jar包換成了處理目錄,這裡不再贅述。

3.5 

Transform操作的整體流程

有了上面的介紹,我們可以得到自定義的Transform的整體流程圖了,如下所示:

圖中的方法名和原始碼是一致的,整體流程先是從AHAnalyticsTransform重寫的Transform類的transform()方法開始,接著呼叫beforeTransform(),此方法主要用來通過反射讀取SDK中的一些版本和配置資訊,然後呼叫transfromClass開始遍歷所有檔案,通過判斷每個檔案型別,區分是Jar檔案還是目錄,分別開始對對應的JarInpu集合和DirectoryInpu集合進行處理,最終在modifyClass()方法中將遍歷所得的位元組碼檔案使用ASM框架方法進行處理。在呼叫modifyClass()方法後還有一個afterTransform()方法, 這個方法主要用來釋放之前申請的一些資源,比較簡單。

Transform API支援多執行緒編譯以和增量編譯,我們的全埋點外掛也實現了這一部分的功能,因此可以看到在遍歷JarInput集合和DirectoryInput集合的時候有多層的巢狀處理。ASM框架處理完位元組碼檔案後輸出到 TransformOutputProvider,再供下一個 Gradle Task使用。

3.6

Transform輸出內容

集成了註冊有Transform,並使用ASM對位元組碼檔案進行處理的外掛,針對這樣的應用,我們看下執行編譯命令./gradlew assembleRelease後的輸出結果。

如上圖所示,在專案的build資料夾中的intermediates中有一個transforms資料夾,即目錄/app/build/intermediates/transforms,這裡包含所有transform任務構建生成的檔案儲存的資料夾。其中有名為autohomeAnalyticsAutoTrack的目錄,這個名稱就是由整合自Transform的AHAnalyticsTransform類的getName()方法返回的字串得來的。最裡面就是以index命名生成的檔案,這裡的0-55就是檔案的index。

可以看到應用引用到的所有的三方jar檔案都被輸出成以數字,0,1,3…這樣命名的jar檔案。而專案中目錄下的原始檔則放到了以56,57命名的目錄中。

autohomeAnalyticsAutoTrack目錄下還會有一個名為__content__的.json檔案。該檔案中展示了autohomeAnalyticsAutoTrack中檔案目錄下的內容,以及每個檔案的型別,來源等。如下圖所示:

以上就是Transform處理後生成的結果檔案。

綜上,這一節首先對Transform進行了概述,並對Transform類中要實現的功能逐個進行了介紹,然後介紹瞭如何註冊Transform,詳細講述了是如何操作位元組碼檔案的,並對其操作結果進行了展示。接下我們看下本文的重點ASM。

4.ASM位元組碼插樁

如果把打包編譯流程比喻成一條水管運輸水的過程,那增加外掛,註冊transform以及使用ASM的過程就是相當於給水管在特定位置增加了一個過濾水的功能。

1.  .class 檔案轉成 .dex檔案的時機就是這個特定的位置;

2.自定義外掛就相當於一套工具,用來切開這個水管,接入一段自己的水管;

3.註冊的transform就是接入的那段水管,所有的水需要先流經這段水管,然後再沿著原來的水管繼續流淌;

4.ASM就相當於一套過濾裝置,它安裝在自己這段水管內部,怎麼過濾就看ASM的操作了。

可見ASM是整個環節的重中之重。

4.1 

ASM是什麼?

►4.1.1 概念

ASM,是一個通用的Java位元組碼操作類庫或框架,它被用來動態生成類或者增強既有類的功能。我們知道,一個.java檔案經過Java編譯器(javac)編譯之後會生成一個.class檔案,而.class檔案儲存的是就位元組碼(ByteCode)資料, ASM所操作物件就是位元組碼或者.class檔案。它提供了一系列工具類集合,可以對位元組碼進行解析和操作class檔案流。它實現的效果類似於將.java檔案編譯生成.class檔案,但它的效率要高於這種方式,因為它的原理是通過直接手動操作jvm的指令集,生成或修改class檔案流。

ASM處理位元組碼有兩種表現,一種是完全構造生成一個全新的.class檔案;另一種就是改造.class檔案,首先將.class檔案拆分成多個部分,然後對其中某一個部分的資訊進行修改,最後再將多個部分重新組織成一個新的.class檔案。我們的全埋點技術實現就是基於後者來做的,這個也正是ASM的Transformation的能力。

它的名字有什麼含義呢?一般的,大寫字母的組合,可能是某個特定短語中個別單詞的首字母,例如,SDK表示“Software Development Kit”軟體開發工具包,而ASM並不是多個單詞的首字母縮寫形式,也沒有什麼具體意義,僅僅是引用了C語言中的一個叫“__asm__”的關鍵字,這個關鍵字在C語言中表示函式可以內嵌彙編程式來實現。

►4.1.2 ASM的能力

ASM操作的是位元組碼,它能對這個位元組碼檔案對應的類進行各種更改,

包括:

1. 修改當前類的父類,可以讓它繼承新的父類;

2. 能夠修改當前類實現的介面,可以增加實現的介面,也可以刪除已實現的介面;

3.可以給當前類添加註解,也可以取消已有的註解;

4. 能對類的變數進行修改,包括增加和刪除;

5. 同樣可以對類中的方法進行增刪改,包括給方法添加註解。

6. 只要符合類的定義,ASM基本都能實現我們改造類的需求。

根據官方的說明,它的整體能力有三部分,如下圖所示:

1.Analysis(分析):它能夠實現從簡單的語法分析到完整的語義分析,可以用來發現應用程式中潛在的bug,檢測未使用的程式碼,逆向工程程式碼等等。只對已有.class檔案進行分析,不會產生新的.class檔案。

2.Generation(生產):這個能力可以用在編譯器編譯過程中,包括傳統的編譯器,分散式編譯器,即時編譯器等等。能力使它可以從無到有產生新的.class檔案。

3.Transformation(變換):可以用來優化或混淆程式,將除錯或效能監控程式碼插入到應用程式等等。對已有的.class檔案進行變換,產生新的.class檔案。

►4.1.3歷史版本

ASM是一個開源庫,它屬於OW2組織,OW2是一個獨立的,全球性的,開源軟體社群。

Java語言在不斷髮展,ASM也伴隨其不斷更新版本。所以在選擇ASM版本的時候,要注意對應的Java版本來確保相容性。如下是最新的一個版本對應關係:

可見使用的Java版本不同,就要用相對應的ASM版本。當然,如果不確定Java的版本號,我們可以儘量使用較高的ASM版本,一般都會向下相容。

►4.1.4使用場景

所謂的“位元組碼插樁”,其實就是修改已經編譯好的.class檔案,往裡面新增自己的位元組碼,然後打包的時候打包的是修改後的class檔案, ASM這樣的工具就是為此類功能而生的。

我們看看它都用在哪些場景中,

1.一些開源框架中。很多開源框架基於ASM實現,比如,CGLib (Code Generation Library)框架就是基於ASM實現的,而被廣泛應用的Hibernate(標準的ORM框架),Spring框架都是基於Cglib實現了AOP技術,所以Spring AOP是間接的使用了ASM;

2.JDK當中的Lambda表示式。通過跟蹤Lambda表示式的原始碼,可以檢視JDK中內建了ASM的程式碼,並且使用了ClassWriter類來對Lambda表示式進行了包裝生成新類。所以在現階段的Java 8版本中,Lambda表示式的呼叫就是通過ASM來實現的;

3.還用在Groovy編譯器和Kotlin編譯器中;

4.用在Gradle中,可以在執行時生成一些類。

等等。

4.2

為什麼選擇ASM

ASM可以用來操作位元組碼,但它並不是唯一的,還有許多其它的操作位元組碼的類庫。比如如下這些,

1.Javassist:Javassist是Java programming assistant的縮寫。是一個開源的分析、編輯和建立 Java 位元組碼的類庫。Javassit相對於ASM要簡單點,它提供了更高階的API。但是執行效率上比ASM要差一些,因為ASM直接操作位元組碼,而Javassit用到了反射。

2.Apache Commons BCEL:Apache位元組碼工程庫。其中BCEL為Byte Code Engineering Library首字母的縮寫。

3.Byte Buddy:在ASM基礎上實現的一個類庫。它可以看作是ASM的一個功能擴充套件。

ASM框架相對使用者遮蔽了整個類位元組碼的偏移量和長度,能夠使使用者非常靈活和方便得實現對位元組碼的解析和操作。與其它的操作Java位元組碼的類庫相比,在實現相同的功能前提下,使用ASM,執行速度更快,佔用的記憶體空間也更小。

4.3 

引入ASM依賴

使用ASM很簡單,在專案的build.gradle中新增如下引用即可。注意,需要把gradle中預置的ASM使用exclude語句排除掉,否則會報多重定義的問題。

4.4 

常用物件介紹

從整體結構來看,ASM主要提供了兩組API,Core Api 及Tree Api。

Core Api可以基於訪問者模式來操作類,主要包括asm.jar、asm-util.jar和asm-commons.jar三個部分。

Tree Api則是基於樹節點的模式來操作類,主要包括asm-tree.jar和asm-analysis.jar。

對比兩者來看,有如下區別;

1.先有Core Api,後有Tree Api,後者是基於前者基礎上實現的;

2.如果之前沒有接觸過ASM,那麼Tree Api提供的類和操作方式會更容易上手。在實現複雜功能時,Tree API比Core API也更容易實現。並且一些複雜場景如果Core API實現不了的, 而Tree API可能能實現。這是Tree API的優勢。

3.在實現相同功能時,Core API比Tree API執行的效率要高,花費的時間更少,並且佔用的記憶體也比Tree API少。這個是Core API的優勢。

所以,兩者各有優勢。

我們的全埋點主要是基於Core API來實現,雖然上手比Tree API難一些,但它實現的效率會更高,記憶體佔用更小。

4.5

重要的類介紹

►4.5.1三個重要的類

上一節提到,asm.jar是Core Api一個類庫,裡面有ASM非常重要的三個類,即ClassReader、ClassVisitor和ClassWriter類,它們也是實現全埋點涉及的三個核心類。

1.ClassReader類,負責讀取原始的.class位元組碼檔案裡的內容,然後拆分成各個不同的部分。

2.ClassVisitor類,負責對.class檔案中某一部分裡的資訊進行修改。

3.ClassWriter類,負責將各個不同的部分重新組合成一個完整的.class檔案。

如果是從無到有生成一個新類的話,不需要ClassReader類的參與,只需要ClassVisitor和ClassWriter。但是要修改一個現有類的話,得需要它們三個類都參與。

修改現有類大體的一個步驟是這樣的,首先ClassReader類通過讀取位元組陣列或者.class檔案間接的獲得位元組碼資料,接著呼叫accept()方法,接受一個實現了抽象類ClassVisitor的物件例項作為引數,然後依次呼叫ClassVisitor中的各個方法。在這個過程中,位元組碼空間上的偏移被轉成各種visitXXX方法,使用者只需要在對應的方法上進行需求操作即可,無需考慮位元組偏移。整個過程中ClassReader可以看作是一個事件生產者,它將class解析成byte陣列,然後再通過accept方法去按順序呼叫繫結物件(繼承了ClassVisitor的例項)的方法。而ClassWriter繼承自ClassVisitor抽象類,最終負責將物件化的class檔案內容重構成一個二進位制格式的class位元組碼檔案,因此ClassWriter可以看作是一個事件的消費者。

示例程式碼如下所示:

可見ClassVisitor是這實現類訪問和修改的核心。

►4.5.2 類訪問器ClassVisitor

上一節講述了ClassReader、ClassVisitor和ClassWriter這三個類的作用,它們的關係,可以描述為下圖:

可以看到,ClassVisitor在修改類內容時,是關鍵的一環。

它主要負責訪問類的成員資訊,包括標記在類上的註解、屬性、構造方法、普通方法、靜態方法,靜態程式碼塊等等。在這裡我們可以對需要插樁的類進行過濾處理,通過Transform API拿到位元組碼檔案流,判斷是否是我們關心的類,然後對其進行處理。例如,在全埋點中,我們想採集可點選的Button控制元件的點選事件,那麼就只需要對繼承了View.OnClickListener介面的類進行處理即可。

ClassVisitor對類中的各個部分進行處理,在工作時,由於其屬於Core Api,ASM在內部採用訪問者模式會將.class類檔案的內容從頭到尾掃描一遍,每次掃描到類檔案相應的內容時,都會呼叫ClassVisitor內部相應的方法,並生成一個個對應的操作物件。

比如,

掃描到類檔案開始時,會回撥ClassVisitor的visit()方法;

掃描到類註解時,會回撥ClassVisitor的visitAnnotation()方法;

掃描到類成員時,會回撥ClassVisitor的visitField()方法;

掃描到類方法時,會回撥ClassVisitor的visitMethod()方法;

···

以此類推。

掃描到相應結構內容時,會回撥相應方法,該方法將返回一個對應的位元組碼操作物件(比如,visitMethod()返回MethodVisitor例項),通過修改這個物件,就可以修改class檔案相應結構的內容了。

結構內容和對應的方法如下;

ClassVisitor 的呼叫遵循下面的呼叫順序:

visit

[visitSource][visitModule][visitNestHost][visitPermittedSubclass][visitOuterClass]

(

visitAnnotation |

visitTypeAnnotation |

visitAttribute

)*

(

visitNestMember |

visitInnerClass |

visitRecordComponent |

visitField |

visitMethod

)*

visitEnd

其中,涉及到一些符號,它們的含義如下:

[]: 表示最多呼叫一次,可以不呼叫,但最多呼叫一次;

()和|: 表示在多個方法之間,可以選擇任意一個,並且多個方法之間不分前後順序;

*: 表示方法可以呼叫0次或多次。

全埋點不會涉及複雜操作,所以我們關注如下方法,

visit

(

visitField |

visitMethod

)*

visitEnd

首先呼叫visit訪問類,然後呼叫0次或多次visitField和visitMethod,最後呼叫visitEnd結束。

其中visitMethod方法會返回一個MethodVisitor物件,而MethodVisitor是我們在的方法中注入程式碼的核心。

►4.5.3 方法訪問器MethodVisitor

MethodVisitor主要負責訪問方法的資訊,用來進行具體的位元組碼操作。在方法中“插入”程式碼的過程便是通過的MethodVisitor類的方法來完成。

在MethodVisitor類中,定義了許多的visitXxx()方法,而ClassVisitor類也有visitXxx()這樣的方法,兩者是不一樣的,要注意區分。

這些方法的呼叫,和ClassVisitor類中的方法一樣,也要遵循一定的順序。

如下所示,

(visitParameter)*

[visitAnnotationDefault]

(visitAnnotation | visitAnnotableParameterCount | visitParameterAnnotation | visitTypeAnnotation | visitAttribute)*

[

visitCode

(

visitFrame |

visitXxxInsn |

visitLabel |

visitInsnAnnotation |

visitTryCatchBlock |

visitTryCatchAnnotation |

visitLocalVariable |

visitLocalVariableAnnotation |

visitLineNumber

)*

visitMaxs

]

visitEnd

其中[],(),|,*符號的含義和上一節ClassVisitor類中提到的符號是一致。

visitCode()表示方法體的開始,我們以它為分界線,這個方法之前的方法負責parameter、annotation和attributes等內容;而visitCode()方法之後,到visitMaxs()之前,則是負責當前方法的“方法體”內的opcode內容。visitCode()是方法體的開始,visitMaxs()是方法體的結束;visitMaxs()之後只有一個方法--visitEnd()方法,它是最後一個進行呼叫的方法。

一般開發不是涉及這麼多方法,精簡之後如下所示,

[

visitCode

(

visitFrame |

visitXxxInsn |

visitLabel |

visitTryCatchBlock

)*

visitMaxs

]

visitEnd

精簡後呼叫visitCode()方法一次,呼叫visitXxxInsn()方法多次,用來構造方法的“方法體”,最後呼叫一次visitMaxs()方法,一次visitEnd()方法。

4.6

舉個例子

下面例子演示瞭如何利用自定義的ClassVisitor和MethodVisitor,在實現了View.OnClickListener()介面的匿名類中,怎樣在重寫了public void onClick(View v)方法中,在其返回前插入一行日誌的,

先看下AsmDemoClassVisitor的定義,

在這個類中,除了構造方法外,我們只重寫了一個visitMethod方法,因為要針對方法插入程式碼,所以只關注visitMethod。如果針對類中的變數或者註解部分進行修改,可以新增visitField和visitAnnotation方法,具體可以參考上兩節介紹的相關方法,更詳細的可以檢視ASM官網。在visitMethod方法中,我們重寫了visitMethod方法,並返回一個自定義的MethodVisitor--AsmDemoMethodVisitor。

注意,我們同時重寫了visit方法,只是為了將類實現的所有介面資訊儲存下來,這些介面資訊儲存在interfaces字串陣列中,此變數會傳遞給AsmDemoMethodVisitor。

然後我們看下AsmDemoMethodVisitor的定義,

在AsmDemoClassVisitor類中的visitMethod方法返回的是一個MethodVisitor抽象類的子類AsmDemoMethodVisitor,而這個子類繼承的是AdviceAdapter,AdviceAdapter是MethodVisitor的子類。之所以選擇繼承它是為了要使用onMethodExit()方法,這個方法的作用是,在訪問方法退出前執行需要插入的語句,這樣插入程式碼很方便。當然如果我們直接繼承MethodVisitor然後重寫visitCode方法,效果是一樣的。ASM包中預置了很多MethodVisitor的子類,這些子類提供了很多特殊場景的切入口可以供使用者選擇。

在onMethodExit方法中,我們通過methodName判斷方法名是不是onClick,通過methodDes是否等於(Landroid/view/View;)V,來判斷方法的描述是不是void onClick(View v)這種結構的,即引數為View,返回值為空,再利用interfaces是否包含android/view/View$OnClickListener來判斷當前解析的類是否實現了View 類中的OnClickListener介面。如果以上條件都成立,則說明當前方法符合條件。那就在當前位置插入我們自定義的程式碼。怎樣插入的程式碼可以參考上面程式碼中的註釋。

我們看下測試程式碼,執行如下java檔案,

我們最終得到的.class檔案如下所示,

和原java檔案對比下,可以發現在onClick方法最後會從插入一行

Log.e("AsmDemo", var1.toString());

這樣的log。

var1物件就是onClick方法的View型別的入參物件。

綜上,我們利用自定義的ClassVisitor和MethodVisitor實現了在位元組碼檔案中插入一行Log的功能。當然,要實現類似這樣的功能得需要藉助ClassReader和ClassWriter,更得需要藉助外掛和Transform API,這些在之前已經詳細介紹,這裡不再贅述。

本文首先介紹了全埋點的基本概念,以及埋點的分類和全埋點的概要原理和採用的實現方案。接著重點講述了Android全埋點Gradle外掛如何生成,釋出和使用。然後對Transform 進行了概述,介紹了Transform類的各種關鍵方法,以及如何藉助Transform API實現對位元組碼的攔截和處理位元組碼,Transform操作位元組碼的整體流程和輸出結果。最後,介紹了ASM的概念和能力,指出為什麼選擇ASM進行位元組碼插樁,如何使用ASM等,並對參與全埋點功能的幾個重要的類進行了介紹,給出了ASM埋點插樁的一個示例。

通過以上各方面的介紹,我們可以瞭解到是如何利用Gradle外掛,Transform API和ASM在Android端來實現全埋點的。希望通過本篇文章能使大家對全埋點外掛有一個較為全面的瞭解,也希望大家能學到其中涉及的一些知識點,拓展自己的知識面。

參考文獻

https://asm.ow2.io/

https://developer.android.com/studio/build/index.html?hl=zh-cn#build-process

作者簡介

肖劍鋒

  2018年加入汽車之家,具有多年的移動應用開發經驗,目前負責公司採集SDK和汽車人App  Android端的開發與維護。

閱讀更多:

▼ 關注「 之家技術 」,獲取更多技術乾貨