R8疑難雜症分析實戰 - 類反射篇|得物技術

架構師修行手冊發表於2024-01-18


來源:得物技術

目錄

一、背景

二、問題分析

    1. 查文件

    2. 嘗試除錯

    3. 啃原始碼

        3.1 R8 最佳化

        3.2 R8 混淆

            3.2.1 混淆當前類自身

            3.2.2 混淆當前類的引用

三、總結

背景

基於 Java 類載入的特性,我們通常會將一些期望只執行一次且不需要上下文的程式碼(例如 SDK 初始化)放到類的靜態程式碼塊中,透過觸發類載入來執行這些程式碼,這樣就不需要考慮執行緒安全問題以及重複執行問題。

在啟動最佳化中就頻繁採用了這種方案來將一些主執行緒耗時邏輯轉移至非同步執行緒並提前執行,為了避免不必要的耦合,我們通常是透過 Class.forName("com.aaa.bbb") 的方式來觸發類載入,但是這種寫法要求對應的類必須 keep 住,避免被混淆導致找不到類。

有一處程式碼恰恰就是粗心忘了加 keep 註解,但是最終線上並沒有丟擲 ClassNotFoundException,反編譯生產包之後發現位元組碼中的類名字串竟然被神奇的替換成了混淆之後對應的類名。

R8疑難雜症分析實戰 - 類反射篇|得物技術

R8疑難雜症分析實戰 - 類反射篇|得物技術

問題分析

Google 雖說會在編譯期做很多最佳化,但為了穩定性應該不會做侵入性這麼強的編輯,所以最終呈現的結果應該是異常情況,帶著這個疑問我們開始分析問題。

查文件

這個問題顯而易見是和混淆有關,但是常規的混淆不會直接修改硬編碼的字串常量,於是翻閱了下 Google 官閘道器於 R8 的一些介紹,得知在混淆之外 R8 還做了一系列的最佳化操作來減少包體積&提高指令執行速度,和我們這個問題最接近的就是這篇類反射的最佳化:

R8疑難雜症分析實戰 - 類反射篇|得物技術

大致總結下,就是針對不存在子類的類,呼叫它的 getClass() 方法的地方都會被替換成 xx.class,位元組碼角度來看就是指令從 Invoke-Virtual 替換成了 Const-Class,訪問常量池的引用和執行方法無疑效能會略有提升,但是當我們在類中定義了 TAG 成員變數用於列印日誌時會頻繁訪問這個變數,這將使得效能提升更顯著。

嘗試除錯

這個最佳化似乎和我們的問題關聯不是很大,因此想到走捷徑直接去 Debug,斷點除錯一下就能知道這個字串是如何變更的。但是 R8 早就被 Google 內建到了 AGP 中一併打包,並且 R8 自身的程式碼就是混淆過的,而且很多關鍵節點的類都被壓縮成了一行,因此 Debug 行不通。

R8 的官方 Git 倉庫 Readme 中有提到如何用本地構建的 R8.Jar 去替換 AGP 中的 R8,但是我實測沒有生效,感興趣的可以自行嘗試。

啃原始碼


R8 最佳化

事已至此,再想分析問題就只能看 R8 原始碼分析問題,我們先找到反射最佳化相關的類 ReflectionOptimizer:

R8疑難雜症分析實戰 - 類反射篇|得物技術

參考註釋,我們得知 getClass() 和 forName() 兩種寫法其實都會被替換成對應的 Class 常量,但是它後續提到了這個類必須是 resolvable,accessible and already initialized,因此我們的類沒有被最佳化應該就是因為這裡,反射最佳化的邏輯比較長,在正式開始替換之前有很多的判斷,關鍵的判斷就是下圖中的 !baseClass.isResolvable(appView) 判斷,如果這裡返回了 False,則直接 Return,即該類不會參與最佳化。

R8疑難雜症分析實戰 - 類反射篇|得物技術

這裡是用了一個遞迴的方法來檢查當前這個類,以它的父類,它實現的所有介面,是否符合要求,有任何一個不符合就返回 False。具體邏輯看下方程式碼中註釋:

R8疑難雜症分析實戰 - 類反射篇|得物技術

R8疑難雜症分析實戰 - 類反射篇|得物技術

這個集合的定義:

R8疑難雜症分析實戰 - 類反射篇|得物技術

R8疑難雜症分析實戰 - 類反射篇|得物技術

綜上,我們得知一個類如果有父類或者實現了介面,那麼它們(父類和介面)都必須是這個集合中的一員,否則這個類就不會參與最佳化,回到我們一開始的問題,這個類確實實現了介面,而且不在這個集合中。

R8疑難雜症分析實戰 - 類反射篇|得物技術

帶這個這個結論我們寫 demo 驗證下,確實只有繼承了 Activity 的類成功的被最佳化成了 Class 常量。

R8疑難雜症分析實戰 - 類反射篇|得物技術R8疑難雜症分析實戰 - 類反射篇|得物技術

R8疑難雜症分析實戰 - 類反射篇|得物技術R8疑難雜症分析實戰 - 類反射篇|得物技術

除此之外我還分別在 AGP 4.1.3,AGP 7.1.2, AGP 8.2.0,發現 AGP 4 和 AGP 7 表現一致,但是在 AGP 8 環境下,即使繼承的類不在這個集合中也能被替換成.Class。透過查詢 Git 記錄找到了這個改動對應的 Commit:

R8疑難雜症分析實戰 - 類反射篇|得物技術

可以看到這裡用了開關控制,並新增了另一種判斷方式,即計算這些類對應的最低安卓版本,當這個版本大於我們工程中配置的 minSdkVersion 時,就不會對這個類進行替換,否則在低版本裝置上執行時會因為找不到這個 Class 物件而崩潰。

R8疑難雜症分析實戰 - 類反射篇|得物技術

R8 混淆

至此,我們已經解開了這個問題的前半部分,知道了為什麼這個 forName 方法為什麼沒有被正常替換成 Class 常量,接下來再分析為什麼類名字串會被替換成混淆後的類名。

我們知道混淆會將除了被 keep 的類方法,成員變數給替換成無意義的簡短字元,例如a,b,c,d。在實際執行的過程中其實是分成兩步:

  • 將要混淆的目標類及其所有的成員變數,方法的名稱都替換成混淆字元,這一步其實主要是在修改常量池中的 Class 物件中的內容。

  • 將所有引用了這個類/成員變數/方法的地方,都替換成混淆後的名稱。

混淆當前類自身:

這裡我們針對類名做分析,第一步對應的程式碼在 ClassRenamer 中,這個類主要負責前面的第一步工作,因此定義了一系列重新命名的方法,這裡我們主要關注給 Class 常量修改的方法:

R8疑難雜症分析實戰 - 類反射篇|得物技術

簡單來說就是拿到當前類對應的混淆後類名,並用它向常量池插入一個新的字串常量,並將其常量池索引賦值給當前類的 Class 常量,實現類名修改。

混淆當前類的引用:

第二步的實現主要在 ClassReferenceFixer 中,參考註釋可知這個類負責對所有引用了混淆過的常量池常量、成員變數、方法、類的地方進行同步替換。

R8疑難雜症分析實戰 - 類反射篇|得物技術

我們主要關注字串常量相關的修改,因此分析 visitStringConstant 這個方法即可:

R8疑難雜症分析實戰 - 類反射篇|得物技術

R8疑難雜症分析實戰 - 類反射篇|得物技術

如果一個字串常量是作為引數存在於 Class.forname(),Class.getDeclaredFied() 這類方法中,那麼他對應的 stringConstant 物件則會持有一個對應的類或者成員變數的引用。

這裡就是透過對比當前字串常量的值和持有的引用指向的 Class 常量中的類名字串,如果不一致則說明類名已經被混淆,此時會將這個字串常量的值修改成混淆後的類名。


總結

綜上,這個問題的根因就是 AGP8 以下版本中的 R8 對類反射最佳化的判斷方式過於嚴格,導致我們的類沒能被最佳化成 Class 變數,隨後又因為混淆流程的設計,導致了該字串常量被替換成混淆類名。

雖然這些問題疊加在一起,最終執行的結果依舊是符合我們預期的,但也只能說 R8 的開發團隊考慮的足夠全面,這類取巧利用 Java 或者安卓特性寫的程式碼在實際開發中務必要在最終的 Release 包上充分測試方才穩妥。


來自 “ ITPUB部落格 ” ,連結:https://blog.itpub.net/70027824/viewspace-3004259/,如需轉載,請註明出處,否則將追究法律責任。

相關文章