Android全埋點解決方案之Javassist
Javassist
Java 位元組碼以二進位制的形式儲存在 .class 檔案中,每一個 .class 檔案包含一個 Java 類或介面。Javaassist 就是一個用來 處理 Java 位元組碼的類庫。它可以在一個已經編譯好的類中新增新的方法,或者是修改已有的方法,並且不需要對位元組碼方面有深入的瞭解。
Javassist 可以繞過編譯,直接操作位元組碼,從而實現程式碼注入。所以使用 Javassist 的時機就是在構建工具 Gradle 將源 檔案編譯成 .class 檔案之後,在將 .class 打包成 .dex 檔案之前。
Javassist 基礎
• 讀寫位元組碼
在 Javassist 中,.class 檔案是用類 Javassist.CtClass 表示。一個 CtClass 物件可以處理一個 .class 檔案。
在上面這個示例中,先獲取一個 ClassPool 物件 。ClassPool 是 CtClass 物件的容器。它按需讀取類檔案來建立 CtClass 物件,並且儲存 CtClass 物件以便以後會被使用到。
為了修改類的定義,首先需要使用 ClassPool.get() 方法從 ClassPool 中獲得一個 CtClass 物件。使用 getDefault() 方法獲 取的 ClassPool 物件使用的是預設系統的類搜尋路徑。
ClassPool 是一個儲存 CtClass 的 Hash 表,類的名稱作為 Hash 表的 key。ClassPool 的 get() 函式會從 Hash 表查詢 key 對應的 CtClass 物件。如果沒有找到,get() 函式會建立並返回一個新的 CtClass 物件,這個物件會儲存在 Hash 表中。
從 ClassPool 中獲取的 CtClass 物件是可以被修改的。在上面的例子中,com.sensorsdata.analytics.android.sdk.Sen- sorsDataAutoTrackHelper 的父類被設定為 java.lang.Object。呼叫 writeFile() 後,這項修改會被寫入原始類檔案中。
writeFile() 會將 CtClass 物件轉換成類檔案並寫到本地磁碟。同時,也可以使用 toBytecode() 函式來獲取修改過的位元組碼 :
byte[] b = aClass.toBytecode();
也可以使用 toClass() 函式直接將 CtClass 轉換成 Class 物件:
Class clazz = aClass.toClass();
toClass() 請求當前執行緒的 ClassLoader 載入 CtClass 所代表的類檔案,它返回此類檔案的 java.lang.Class 物件。
• 凍結類
如果一個 CtClass 物件通過 writeFile()、toClass()、toBytecode() 等方法被轉換成一個類檔案,此 CtClass 物件就會被凍 結起來,不允許再被修改,這是因為一個類只能被 JVM 載入一次。
其實,一個凍結的 CtClass 物件也可以被解凍,比如:
此處呼叫 defrost() 方法之後,這個 CtClass 物件就又可以被修改了。
• 類搜尋路徑
通過 ClassPool.getDefault() 獲取的 ClassPool 使用 JVM 的類搜尋路徑。如果程式執行在 JBoss 或者 Tomcat 等 Web 服 務器上,ClassPool 可能無法找到使用者的類,因為 Web 伺服器使用多個類載入器作為系統類載入器。在這種情況下, ClassPool 必須新增額外的類搜尋路徑。
上面的程式碼示例,將 this 指向的類新增到 ClassPool 的類載入路徑中。你可以使用任意 Class 物件來代替 this.getClass(),
從而將 Class 物件新增到類載入路徑中。同時,也可以註冊一個目錄作為搜尋路徑。比如:
上面的例子是將“/usr/local/Library/”目錄新增到類搜尋路徑中。
• ClassPool
ClassPool 是 CtClass 物件的容器。因為編譯器在編譯引用 CtClass 代表的 Java 類的原始碼時,可能會引用 CtClass 物件, 所以一旦一個 CtClass 被建立,它就會被儲存在 CtClass 中。
• 避免記憶體溢位
如果 CtClass 物件的數量變得非常多,ClassPool 有可能會導致巨大的記憶體消耗。為了避免這個問題,我們可以從 ClassPool 中顯式刪除不必要的 CtClass 物件。如果對 CtClass 物件呼叫 detach() 方法,那麼該 CtClass 物件將會被從 ClassPool 中刪除。比如:
在呼叫 detach() 方法之後,就不能再呼叫這個 CtClass 物件的任何有關方法了。如果呼叫 ClassPool 的 get() 方法, ClassPool 會再次讀取這個類檔案,並建立一個新的 CtClass 物件。
• 在方法體中插入程式碼
CtMethod 和 CtConstructor 均提供了 insertBefore()、insertAfter() 及 addCatch() 等方法。它們可以把用 Java 編寫的代 碼片段插入到現有的方法體中。Javassist 包括一個用於處理原始碼的小型編譯器,它接收用 Java 編寫的原始碼,然後將 其編譯成 Java 位元組碼,並內聯到方法體中。
也可以按行號來插入程式碼段(如果行號表包含在類檔案中)。向 CtMethod 和 CtConstructor 中的 insertAt() 方法提供源代 碼和原始類定義中的原始檔的行號,就可以將編譯後的程式碼插入到指定行號位置。
insertBefore() 、insertAfter()、addCatch() 和 insertAt() 等方法都能接收一個表示語句或語句塊的 String 物件。一個語 句是一個單一的控制結構,比如 if 和 while 或者以分號結尾的表示式。語句塊是一組用大括號 {} 包圍的語句。
語句和語句塊可以引用欄位和方法。但不允許訪問在方法中宣告的區域性變數,儘管在塊中宣告一個新的區域性變數是允許的。
傳遞給方法 insertBefore() 、insertAfter() 、addCatch() 和 insertAt() 的 String 物件是由 Javassist 的編譯器編譯的。由 於編譯器支援語言擴充套件,所以以 $ 開頭的幾個識別符號都有特殊的含義:
$0, $1, $2, ...
傳遞給目標方法的引數使用 $1,$2,... 來訪問,而不是原始的引數名稱。$1 表示第一個引數,$2 表示第二個引數,以此類推。 這些變數的型別與引數型別相同。$0 等價於 this 指標。如果方法是靜態的,則 $0 不可用。
$args
變數 $args 表示所有引數的陣列。該變數的型別是 Object 型別的陣列。如果引數型別是原始型別(如 int、boolean 等), 則該引數值將被轉換為包裝器物件(如 java.lang.Integer)以儲存在 $args 中。 因此,如果第一個引數的型別不是原始型別, 那麼 $args[0] 等於 $1。注意 $args[0] 不等於 $0,因為 $0 表示 this。
$
變數 $$ 是所有引數列表的縮寫,用逗號分隔。
$_
CtMethod 中的 insertAfter() 是在方法的末尾插入編譯的程式碼。傳遞給 insertAfter() 的語句中,不但可以使用特殊符號如 $0,$1。也可以使用 $_ 來表示方法的結果值。
該變數的型別是方法的返回結果型別(返回型別)。如果返回結果型別為 void,那麼 $_ 的型別為 Object,$_ 的值為 null。
雖然由 insertAfter() 插入的編譯程式碼通常在方法返回之前執行,但是當方法丟擲異常時,它也可以執行。要在丟擲異常時 執行它,insertAfter() 的第二個引數 asFinally 必須為 true。
如果丟擲異常,由 insertAfter() 插入的編譯程式碼將作為 finally 子句執行。$_ 的值 0 或 null。在編譯程式碼的執行終止後, 最初丟擲的異常被重新丟擲給呼叫者。注意,$_ 的值不會被拋給呼叫者,它將被丟棄。
• addCatch
addCatch() 插入方法體丟擲異常時執行的程式碼,控制權會返回給呼叫者。在插入的原始碼中,異常用 $e 表示。

轉換成對應的 java 程式碼如下:
請注意,插入的程式碼片段必須以 throw 或 return 語句結束。
• 註解(Annotations)
CtClass、CtMethod、CtField 和 CtConstructor 均提供了 getAnnotations() 方法,用於讀取註解。它返回一個註解型別 的物件陣列。
我們目前只介紹當前全埋點方案會用到的關於 Javassist 的相關基礎知識,關於 Javassist 更詳細的用法,可以參考: ofollow,noindex" target="_blank">https://github.com/jboss-javassist/javassist/wiki/Tutorial-1
原理概述
在自定義的 plugin 裡,註冊一個自定義的 Transform,然後可以分別對目錄和 jar 包進行遍歷。在遍歷的過程中,利用 Javassist 的 API 來對滿足特定條件的方法進行修改,插入相關埋點程式碼。原理與 ASM 類似,只是把操作 .class 檔案的庫 由 ASM 換成 Javassist。
實現步驟
完整的專案原始碼後續會 release 給大家。
缺點
• 暫時沒有什麼發現缺點
知識點
• 彙編相關知識
參考資料
[1] https://www.jianshu.com/p/43424242846b
[2]https://blog.csdn.net/Deemons/article/details/78473874
[3]https://blog.csdn.net/yulong0809/article/details/77752098
[4] https://juejin.im/post/58fea36bda2f60005dd1b7c5
[5] https://www.jianshu.com/p/417589a561da
[7] https://github.com/jboss-javassist/javassist
[8]https://github.com/jbossjavassist/javassist/wiki/Tutorial-1
[9]https://github.com/jbossjavassist/javassist/wiki/Tutorial-2
[10]https://github.com/jbossjavassist/javassist/wiki/Tutorial-3
注:該內容來自神策資料使用者行為洞察研究院出品的《Android 全埋點解決方案》白皮書,檢視完整白皮書可點選 360775e118954518de2c?utm_source=WeChat&utm_medium=free&utm_term=%e9%98%85%e8%af%bb%e5%8e%9f%e6%96%87&utm_content=%e7%99%bd%e7%9a%ae%e4%b9%a6-Android%e5%85%a8%e5%9f%8b%e7%82%b9&utm_campaign=sensorsdata2" rel="nofollow,noindex" target="_blank">《Android 全埋點解決方案》
更多白皮書、報告、乾貨和案例,可以關注“神策資料”和“使用者行為洞察研究院”公眾號瞭解~