Robust 2.0:支援Android R8的升級版熱修復框架

美團技術團隊發表於2023-05-20
2016年,我們對美團Android熱更新方案Robust的技術原理做了詳細介紹。近幾年,Google 推出了新的程式碼最佳化混淆工具R8,Android 熱修復補丁製作依賴二次構建包和線上包對比,需要對Proguard切換到R8提前進行適配和改造,本文分享 Robust 在適配 R8 以及最佳化改進中的一些思路和經驗,希望能對大家有所幫助或者啟發。

1. 背景

美團 Robust 是基於方法插樁的實時熱修復框架,主要優勢是實時生效、零 Hook 相容所有 Android 版本。2016 年,我們在《Android 熱更新方案 Robust》一文中對技術原理做了詳細介紹,主要透過給每個方法插入 IF 分支來動態控制程式碼邏輯,進而實現熱修復。其核心主要有兩部分:一個是程式碼插樁,一個是自動補丁。

  • 程式碼插樁這部分隨著 Javassist、ASM 工具的廣泛使用,整體方案比較成熟了,迭代改進主要是針對插樁程式碼體積和效能的最佳化;
  • 自動補丁這部分在實際使用過程中一直在迭代,跟業界主流熱修復方案一樣,自動化補丁工具作製作時機是在 Proguard 混淆之後,由於 Proguard 會對程式碼進行程式碼最佳化和混淆處理,在 Proguard 後製作補丁能夠降低補丁生成的複雜性。

近年來, Google 推出了新的程式碼最佳化混淆工具 R8,用於取代第三方的程式碼最佳化混淆工具 Proguard,經過多年功能迭代和缺陷改進,R8 在功能上基本可以替代 Proguard,在結果上更為出色(最佳化生成的 Android 位元組碼體積更小)。Google 已經在新版本的構建工具中強制使用 R8 ,國內外已有多個知名 App 完成了 R8 適配並上線,比如微信 Android 在今年正式從 Proguard 切換到了 R8(透過升級 Android 構建工具鏈)。Android 熱修復補丁製作依賴二次構建包和線上包對比,需要對 Proguard 切換到 R8 提前進行適配和改造,本文分享了美團平臺技術部 Robust 在適配 R8 以及最佳化改進中的一些思路和經驗。

2. 主要挑戰

Android 熱修復補丁的大致製作流程:首先基於線上程式碼進行邏輯修復並二次打包,然後補丁生成工具自動比較修復包和線上包的差異,最後製作出輕量的補丁包。因此在補丁製作的過程中,需要解決兩個主要問題:

  • 對於沒有變動的程式碼,如何在二次打包時保證和線上包一致;
  • 對於本次修復的程式碼,如何在經過編譯、最佳化、混淆之後準確識別出來並生成補丁程式碼。

要解決這兩個問題,需要對 Android 編譯和構建過程有一定了解,弄清楚問題產生的原因。下圖 1 是一個 Android 專案從原始碼到 APK(Android 應用安裝包)的構建過程(橢圓形對應構建工具鏈):

圖1 從原始碼到 APK 的構建過程

上圖有些工具已被新出現的工具所取代,但是整體的流程並沒有太大變化。對照這個圖,我們分析一下其中對補丁製作/二次打包有影響的幾個環節:

  1. 資源編譯器(aapt/aapt2):資源編譯環節會生成一個 R.java 檔案(記錄著資源 id,便於程式碼中引用),一般為了解決 R field 過多以及減少包大小,大型 Android 專案會在構建過程中會將資源 id 直接內聯到呼叫處(發生在 javac 和 proguard 之間)。如果前後兩次打包出現資源 id 不一致,會影響 diff 識別的結果。
  2. 程式碼編譯器(javac):Java 程式碼經過 javac 編譯成位元組碼之後,除了有一些簡單的最佳化(如常量表示式摺疊、條件編譯),還有一些基礎的脫糖(Java 8 之前的語法特性)操作會生成一些新的類/方法/指令,如匿名內部類會被編譯成一個名為 OuterClass$1.class 的新類,以及命名為 access$200 之類的橋方法。如果改動涉及內部類、泛型,二次打包$後面的數字編號可能和線上包出現亂序。
  3. 程式碼最佳化器(ProGuard/R8):目前主要使用第三方開源工具 ProGuard (Google 推出 R8 計劃取代 Proguard),透過 30+ 可選最佳化項,對前面生成的 Java 位元組碼進一步壓縮、最佳化、混淆,可以使得 Android 安裝包更小、更加安全、執行效率更高:

    • 壓縮:透過靜態分析並刪除未被使用的 class/field/method,即原始碼中存在的 class/field/method,線上包中不一定存在。
    • 最佳化:透過一系列最佳化演算法或者模版,對位元組碼進行最佳化,使得構建產物更小、執行更高效/安全,最佳化手段有合併類/介面、內聯短方法、裁剪方法引數、刪除不可達分支、外聯程式碼(R8 新增)、刪除無副作用程式碼(如 Log.d())、修改方法/變數可見性等等。最佳化後的位元組碼相比原始碼,可能出現 class/field/method 數量減少、field/method 訪問修飾符發生變化、method 簽名發生變化、code 指令變少,另外二次構建最佳化結果可能和線上包不一致。
    • 混淆:透過將 class/field/method 的名稱重新命名為一個無意義的短字元,增加逆向難度,減少包大小。二次打包需要保證和線上包的混淆保持一致,不然補丁載入後因呼叫異常而發生崩潰。
  4. 脫糖工具(圖中未標出,舊版本使用三方外掛 Lambda/Desugar,新版本中使用自帶的 R8):由於低版本 Android 裝置不支援 Java 8+ 語法特性,這一步需要將 Lambda 表示式、方法引用、預設和靜態介面方法等高版本的語法特性轉為低版本實現。其中 Lambda 表示式會被編譯成一個內部類,會有類似(2)中的問題。

至此,我們對本章開頭提到的2個問題的產生原因有了一定認識,經過 Android 構建過程生成的位元組碼相比原始碼在 class/field/method/code 維度上有了“結構性”的變化,比如修復程式碼中呼叫的 class/field/method 線上上包中不存在(被 shrink、被 merge、被 inline),或者原始碼中可以訪問、但在補丁中無法訪問的 field/method(修飾符被標記為 private)、method parameter 列表匹配不上(之前沒有被用到的 parameter 被裁剪了)等等。

Proguard 提供的這些最佳化項是可選的,一般情況下大型 Android 專案中會結合實際收益、穩定性以及構建耗時等多方因素綜合考量後,會禁用一部分最佳化項,但並不是完全禁用。因此,二次打包時和線上包會產生一些差異,補丁製作準確性會受此影響。過去 Robust 補丁製作過程經常遇到此類問題,透過特殊字元檢測、白名單等方式能夠提升識別的準確性,但實現方案不夠自動化。Robust 補丁製作流程如下:

圖2 Robust 補丁製作流程

如果將 Android 專案的構建工具鏈(Android Gradle Plugin)升級到官方較新版本,上圖中的 Proguard(Java位元組碼最佳化和) + Dex(Android 位元組碼生成) 兩個環節將被合併成一個,並被替換成 R8:

圖3 兩種構建流

上述構建工具鏈的升級變化,給 Robust 補丁製作帶來 2 個新的問題:

  1. 沒有合適時機制作補丁。如果將基於 JAR 的改動識別方案,改成基於 DEX 或者 Smali,等同於更換補丁製作方案,前者需要基於 DEX 檔案格式和指令,後者需要處理大量暫存器,更容易出錯,相容性和穩定性不夠好。
  2. Proguard 可以禁用一部分最佳化選項,但是 R8 官方文件明確表示不支援禁用部分最佳化,相比之前會產生更多的差異,對改動識別造成干擾。

3 解決思路

3.1 整體方案介紹

基於 R8 構建的補丁製作思路是將改動識別提到最佳化混淆之前,對比 Java 位元組碼,同時結合對線上 APK 結構化解析(class/field/method),校正補丁程式碼對線上程式碼的呼叫,得到 patch.jar,最後藉助 R8 對 patch.jar 進行混淆(applymapping)、脫糖、生成 Dex,打包得到 patch.apk,完整流程如下圖所示:

圖4 完整流程

3.2 問題和解決方法

3.2.1 R8 與 Proguard 最佳化對比

部分 ProGuard 的配置項在切換到 R8 後失效,R8 官方文件對此做出的解釋是:隨著 R8 的不斷改進,維護標準的最佳化行為有助於 Android Studio 團隊輕鬆排查並解決您可能遇到的任何問題。

圖5 R8 官方解釋

截至目前,仍能在網上搜到不少因 R8 最佳化帶來的問題,沒有公開文件介紹最佳化規則的使用和禁用說明。只能透過閱讀 ProGuard 官方文件和 R8 原始碼,對比分析兩者最佳化規則的相似和差異。透過 R8 原始碼發現可以透過隱藏的構建引數、反射或者直接修改 R8 原始碼實現一部分規則禁用,雖然 R8 的最佳化規則並不是和 Proguard 一一對應,但也基本可以實現和之前使用 Proguard 時相同的最佳化效果。

com.android.tools.r8.utils.InternalOptions.enableEnumUnboxing
com.android.tools.r8.utils.InternalOptions.enableVerticalClassMerging
com.android.tools.r8.utils.InternalOptions.enableClassInlining
com.android.tools.r8.utils.InternalOptions.inlinerOptions().enableInlining//方法內聯
com.android.tools.r8.utils.InternalOptions.outline.enabled)//方法外聯
com.android.tools.r8.utils.InternalOptions.testing.disableMarkingMethodsFinal
com.android.tools.r8.utils.InternalOptions.testing.disableMarkingClassesFinal

一些規則可以透過構建引數-Dcom.android.tools.r8.disableMarkingMethodsFinal 來控制關閉/開啟,其他不支援的引數也可以參考如下方式簡單改造一下:

圖6 改造方式

如果某個專案中不希望禁用這些規則呢?在之前的補丁製作流程中,可能會影響改動識別的準確性。而在新的補丁製作流程中,改動識別不受影響,但在識別之後,還需要結合線上 APK 檢查補丁中的外部呼叫是否合法。進一步仔細分析這些最佳化規則,可以分為 class、field、method、code 四類,其中對 Robust 補丁製作影響較大的是方法內聯、引數移除、被標記為 private,後面的小節裡將會介紹相應的處理方法。

3.2.2 “真”“假”改動識別

如果原始碼中有匿名內部類,javac 會編譯生成一個命名為 {外部類名}${數字編號} 的類,後面的數字編號是根據該匿名內部類在外部類中出現的先後順序,依次累加計算出來的。

當修復程式碼中有新增/刪除匿名內部類時,僅透過類名無法比較(所以在一些以類為最小粒度的熱修復框架使用文件裡,會看到類似“不支援新增匿名內部類”、“只支援在外部類的末尾增加匿名內部類”之類的描述),這時候 Robust 會模糊處理後面的數字編號,透過位元組碼對比進一步查詢到真實變化的匿名內部類,識別出哪些是真改,哪些是假改。

此外,如果巢狀類之間涉及私有 field/method 訪問,javac 編譯器會生成命名規則為 access$100access$200 的橋接方法,access$ 後面的數字編號(和出現的先後順序有關)也會影響改動識別(最終 R8 會將修飾符改成 public 並刪除橋接方法),這裡的解決辦法和上面識別真實內部類改動的方式類似。

還有一種情況值得注意,大一點的 Android 專案通常會採用元件化的方式,每個元件以 AAR 形式參與 App 構建打包,在元件二進位制發版(原始碼-> AAR)過程中,可以使用 R8 進行脫糖(For Android)得到 Java 7 位元組碼,典型的例子是 Lambda 表示式,經過脫糖處理生成 {外部類}$$ExternalSyntheticLambda{數字} (甚至有多重數字的情況如$2$1) 之類的 class,以及在外部類中生成命名規則為 lambda${方法名}${數字} 的靜態方法(不同的脫糖器,命名規則不一樣),補丁生成工具處理方法和上面類似。

最終識別出來的程式碼改動,不僅包含原始碼有改動的方法或者新增方法/類(如果有),還包括與之有關的、由 javac 編譯器脫糖生成的位元組碼,以及由元件二進位制發版過程中經 R8 脫糖生成的位元組碼。

3.2.3 內聯識別與處理

透過第二章節的介紹,可以看到線上程式碼在經 javac 編譯之後還會經過位元組碼最佳化、混淆等處理,因此,透過上面位元組碼比對識別出來的程式碼變更(class/method 維度),如果涉及對線上程式碼的呼叫,還需要確保這些 Field/Method 的呼叫是“合法”的,避免執行時崩潰。

在眾多最佳化項當中,主要需要關注的是 class/field/method 是否存在、是否可訪問。如果線上包中不存在(上次構建過程中被移除或者被內聯),補丁生成階段需要當做新增類/方法加進來;如果線上包中不可以被外部訪問(上次構建過程中 public 被改為 private),補丁生成階段需要將直接呼叫改成反射呼叫;如果線上包中方法簽名發生變化(上次構建過程中引數被裁剪),需要修改呼叫或者當做新方法加進來。

由於 Dex 檔案與標準的 class 檔案在結構設計上有著本質的區別(Dex 工具將所有的 class 檔案整合到一個或幾個 Dex 檔案,目的是其中各個類能夠共享資料,使得檔案結構更加經湊),兩者無法直接對比。具體檢測方法是先透過 ASM 分析補丁 class 中的外部引用,然後藉助 dexlib2 庫解析 APK 中的 Dex,提取出 class/field/method 結構化資訊(還需反混淆處理),最後再相容性分析和處理。

R8 外聯最佳化是一種高階最佳化技術,生效條件非常苛刻,需要在合適的環境下合理使用,R8的外聯最佳化會將多個方法中的相同程式碼提取到新方法中,以降低程式碼體積,但是會增加一次方法呼叫開銷。如果恰好想修復的程式碼是被外聯出去的方法,直接將外聯方法當成新增方法來修復即可。

3.2.4 混淆問題與最佳化

不同於前面對在二次打包過程中對整個專案進行 ApplyMapping,這裡只需要對少數發生變更的類進行 ApplyMapping,出現混淆不一致的機率會小很多。Robust 補丁製作過程中,僅將改動的類傳遞給 Proguard 進行二次混淆,這個過程中會自動應用線上包的 mapping 檔案:

-applymapping {線上包的 mapping.txt}

但在某些特殊情形下,比如刪了一箇舊方法、同時又增加了一個新方法,或者是 ApplyMapping 的缺陷,還是會出現補丁中的混淆和線上混淆實際並不一致的情況,因此在生成補丁之後,還需要根據線上 APK 進行對比校驗,如果發現錯誤混淆,進一步反編譯成 Smali 之後進行字元替換。

3.2.5 其他方面的最佳化

(1)super 指令

在 Android 開發中,invoke-super 指令經常被用來重寫某個系統方法,同時保留父類方法中的一些邏輯。以 Activity 類的 onCreate 方法為例:

public class MyActivity extends Activity {
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState); // 呼叫父類的 onCreate 方法
    }
}

其中 super.onCreate(savedInstanceState) 就是一種典型的 super 呼叫,經過 Dex 編譯後,在 Smali 語法層面看到的就是 invoke-super 指令。但在 patch 類中,無法編寫類似 myActivity.super.onCreate(savedInstanceState),因為 super 只能在原類使用;即使採用位元組碼技術強行編寫了,在執行時會提示 java.lang.NoSuchMethodError,因為 patch 不是目標方法的子類。

為了模擬實現 JVM 的 invoke-super 指令,需要為每個 patch 類生成一個繼承了被修復類父類的輔助類(解決 super 呼叫只能在目標子類使用的問題),並且在輔助類裡面將 patch.onCreate 轉換為原始類的呼叫 origin.super.onCreate。Robust 早期是在 Smali 層面進行處理的,需要將 Dex 轉換為 Smali,處理完以後,再把 Smali 轉換為 Dex。用 ASM 位元組碼直接對 Class 位元組碼進行處理更方便,不需要再轉換為 Smali,針對該輔助類的ASM位元組碼轉換關鍵程式碼如下:

public class SuperMethodVisitor extends MethodVisitor {
    ...
    @Override
    public void visitMethodInsn(int opcode, String owner, String name, String desc, boolean itf) {
        if (opcode == Opcodes.INVOKEVIRTUAL) {
            // 將 INVOKEVIRTUAL 指令替換成 INVOKESPECIAL
            super.visitMethodInsn(Opcodes.INVOKESPECIAL, owner, name, desc, itf);
        } else {
            super.visitMethodInsn(opcode, owner, name, desc, itf);
        }
    }

    @Override
    public void visitVarInsn(int opcode, int var) {
        if (opcode == Opcodes.ALOAD && var == 0) {
            //保證super呼叫在原始類
            mv.visitVarInsn(opcode, 1);
            return;
        }
        mv.visitVarInsn(opcode, var);
    }
    ...
}

上述方式是採用了一個輔助類來實現的,下面介紹另一種改進的方法。

在 JNI 層,常見的 CallObjectMethod 函式適用於呼叫虛方法,即呼叫方法時依賴於物件的類層次結構,類似於 Java 的 invoke-virtual;與之對應的是 CallNonvirtualObjectMethod 函式,它適用於非虛方法呼叫,即呼叫的物件為指定的類的物件,無論這個類有沒有被繼承或覆蓋,也就是說可以透過 CallNonvirtualObjectMethod 呼叫父類 super 方法。

Java 語言中的 invoke-super 指令可以透過 CallNonvirtualObjectMethod、GetMethodID 組合來實現,關鍵程式碼如下:

jmethodID methodID = env->GetMethodID(parentClass, "superMethodName", "()V");
jvalue args[] = {};
jobject result = env->CallNonvirtualObjectMethod(parentObj, parentClass, methodID, args);

(2)\<init> 函式的插樁與修復

在部分子類 \<init> 函式會顯式呼叫父類的建構函式 super() ,且 super() 必須是子類 \<init> 函式中的第一句語句,否則編譯失敗。因此對於 \<init> 函式,不能在第一行進行 Robust 插樁,需要在父類的建構函式 super() 之後插樁。

那麼 \<init> 函式如何修復呢?原始類 \<init> 函式修改後,在 patch 類也是 \<init> 函式,這裡需要將該 \<init> 函式複製成普通函式,並將原始類的 Robust 插樁關聯到該普通函式。

複製建構函式並將其轉換為方法需要注意:

  • 原始類函式名稱 \<init> 需要改成普通方法名稱,避免與 patch 類的 \<init> 函式衝突。
  • 原始類 \<init> 函式如果有方法引數,則需要保留成一致的。
  • patch 類新方法的 return type 是 void。
  • 原始類 \<init> 函式如果有呼叫 this() 或 super() 建構函式,則需要在 patch 新方法裡刪除它們。

(3)\<clinit> 函式的插樁與修復

\<clinit> 函式是由編譯器生成的一個特殊的靜態構造方法,它被用來初始化類中的靜態變數和複雜的靜態表示式。如果在一個類中定義了靜態變數或程式碼塊,那麼編譯器會為這些靜態變數和程式碼塊生成一個 \<clinit> 函式。\<clinit> 函式只會被執行一次,虛擬機器會保證只有一個執行緒能夠執行 \<clinit> 方法,確保對共享的類級別變數的執行緒安全訪問。

因此,對 \<clinit> 函式進行插樁和修復時,需要特別注意 \<clinit> 方法的執行時機:

  • 在類例項化時,如果該類的 \<clinit> 方法還沒有執行,則會執行該方法,以初始化類的靜態變數和複雜的靜態表示式。
  • 在透過反射獲取該類的某個靜態成員時,如果該類的 \<clinit> 方法還沒有執行,則會執行該方法,以初始化類的靜態變數和複雜的靜態表示式。
  • 如果該類被子類繼承,而子類中也定義了 \<clinit> 方法,則在建立子類例項時,會先執行父類的 \<clinit> 方法,然後再執行子類的 \<clinit> 方法。

根據上述 \<clinit> 函式執行時機分析,插樁時不能訪問類的靜態成員變數(訪問靜態變數時 clinit 函式就已經執行了,無法被有效修復),因此無法藉助於Robust常規插樁方法(給 Class 插入一個靜態介面 Field),需要藉助一個輔助類 ClintPatchProxy 來實現插樁邏輯。

/**
 * 線上 MainActiviy clinit 插樁
 */
public class MainActivity {
    static {
        String classLongName = "com.app.MainActivity";
        if (ClintPatchProxy.isSupport(classLongName)) {
            ClintPatchProxy.accessDispatch(classLongName);
        } else {
            // MainActitiy Clinit origin code
        }
        

clinit 函式修復時,在補丁入口類的靜態程式碼塊裡面設定好 ClintPatchProxy 的跳轉介面實現即可,原 MainActivity 的 clinit 程式碼將不再執行,轉而執行 MainActivityPatch的clinit 程式碼(對應 MainActivity 的新 clinit 程式碼)。

(4)修復新增類/新增成員變數/新增方法

基於方法插樁的方法,天然支援新增類;對於新增 Field 和新增 Method,分兩種情況:靜態的 Field 和 Method 可以用一個新增類來包裹;新增非靜態 Field 可以使用一個輔助類來維持 this 物件與該 Field 的對映關係,補丁裡面原本使用this.newFieldName的程式碼,透過位元組碼工具轉換為 FieldHelper.get(this).getNewFieldName() 即可。

4 總結

回顧 Robust 熱修復補丁製作過程,主要是對構建編譯過程和位元組碼編輯技術的巧妙結合。透過分析 Android 應用打包過程、Java 語言編譯和最佳化過程,補丁製作過程中可能會遇到的各種問題就有了答案,再借助位元組碼工具分析、處理就能夠生成一個熱修復補丁。當然,這其中涉及大量的細節處理,僅透過一篇文章不足以涵蓋各種細節,還需要結合實際專案才能有更全面的瞭解。

5 本文作者

常強,美團平臺-App技術部工程師。

| 本文系美團技術團隊出品,著作權歸屬美團。歡迎出於分享和交流等非商業目的轉載或使用本文內容,敬請註明“內容轉載自美團技術團隊”。本文未經許可,不得進行商業性轉載或者使用。任何商用行為,請傳送郵件至tech@meituan.com申請授權。

相關文章