熱修復原理學習(3)編譯器與語言特性的影響

學習之前,先教沒有反編譯基礎的同學學習如何反編譯處samli文件。
Android Studio中,可以在 setting->plugins 中,安裝 Java2Samli插件,重啓IDE後,選擇對應的類,點擊 Build->compile to smali,該目錄下就會自動生成smali文件了。

本節列舉了幾個在項目實戰中遇到的一些挑戰,這些都是Java語言在編譯實現上的一些特點,雖然這些特點與熱修復沒有直接關係,但深入研究它們對Android以及Java餘元的理解都頗有收益。

1 內部類編譯

有時候我們會發現,在修改外部類某個方法邏輯爲訪問內部類的某個方法時,最後打出來的補丁包竟然提示新增了一個方法,這真的很匪夷所思,所以有必要了解內部類在編譯期間是怎麼工作的,首先我們要知道內部類在編譯期會被編譯爲跟外部類一樣的頂級類。

1.1 靜態內部類/非靜態內部類的區別

這一點大家都很熟悉,非靜態內部類持有外部類的引用,靜態內部類不持有外部類的引用。
所以在Android性能優化中建議Handle的實現儘量使用靜態內部類,防止外部Activity類不能被回收導致可能的OOM。

我們反編譯爲smali比較兩者的不同點:

//靜態內部類
# direct methods
.method constructor <init>()V
   return-void
.end method

//非靜態內部類,編譯器會自動合成this$0域表示的就是外部類的引用
.field final synthetic this$0:Lcom/rikkatheworld/demo/DexFixDemo;
#direct methods
.method constructor <int>(Lcom/rikkatheworld/demo/DexFixDemo;)Via
   .locals 1
   .param p1,"this$0"  #Lcom/rikkatheworld/demo/DexFixDemo;

   iput-object p1, p0, Lcom/rikkatheworld/demo/DexFixDemo$A;->this$0:Lcom/rikkatheworld/demo/DexFixDemo;
   return-void;
.end   

1.2 內部類和外部類互相訪問

既然內部類實際上跟外部類一樣都是頂級類,既然都是頂級類,那是不是意味着對方私有的 method/field是無法被訪問到的,事實上外部類爲了訪問內部類私有的域/方法,編譯期間自動會爲內部類生成 access$數字編號相關方法

public class BaseBug {
    public void test(Context context) {
        InnerClass innerClass = new InnerClass("old apk");
        Toast.makeText(context.getApplicationContext(), innerClass.s, Toast.LENGTH_SHORT).show();
    }

    class InnerClass {
        private String s;

        private InnerClass(String s) {
            this.s = s;
        }
    }
}

此時外部類BaseBug爲了能夠訪問內部類InnerClass的私有域s,編譯器會自動爲 InnerClass這個內部類合成 access$100方法,這個方法的實現簡單返回私有域s的值。同樣的如果此時匿名內部類需要訪問外部類的私有屬性/方法,那麼外部類也會自動生成access$**相關方法提供給內部類使用。

1.3 熱部署解決方案

上面說的東西對熱修復來說,就產生了一種場景:
打補丁前的test方法沒訪問inner.s,打補丁之後的 test方法訪問了inner.s,那麼補丁工具最後檢測到了新增的 access$100方法。
那麼我們只要防止生成 access$**相關方法,就能走熱部署方案,也就是底層替換方式熱修復。所以只要滿足一下條件,就能避免編譯器自動生成 access$**的相關方法:

  • 一個外部類如果有內部類,把所有 methos/field的私有訪問權限改成 protectedpublic或者默認訪問權限
  • 同時把內部類的所有 method/field 的私有訪問權限改成protected或者public或者默認訪問權限

2. 匿名內部類編譯

匿名內部類其實也是內部類,所以自然也有 1.1節所說明情況的影響,但是發現在新增一個匿名類,同時規避了1.1節的情況,但是最後仍然提示了method的新增,所以接下來了解匿名內部類跟非匿名內部類的區別,並且有怎麼樣的特殊性。

2.1 匿名內部類編譯命名規則

匿名內部類顧名思義是沒有名字的。匿名內部類的名稱格式一般是 外部類$數字編號,後面是的數字編號,是編譯器根據該匿名內部類在把外部類中出現的先後關係,依次累加命名的:

public class DexFixDemo {
    public static void test(Context context) {
        /* new DialogInterface.OnClickListener(){

            @Override
            public void onClick(DialogInterface dialog, int which) {
                Log.d("Rikka","OnClickListener");
            }
        }; */

        new Thread("thread-1") {
            @Override
            public void run() {
                Log.d("Rikka", "thread-1 thread");
            }
        }.start();
    }
}

修復後的APK新增 DialogInterface.OnClickListener這麼一個內部類,但是最後補丁工具發現新增了 onClick方法,因爲打補丁前只有一個 Thread匿名內部類,此時該類的名稱是 DexFixDemo$1,然後打補丁後再test方法中新增了 DialogInterface.OnClickListener的匿名內部類。此時 DialogInterface.OnClickListener匿名內部類的名稱是 DexFixDemo$1,Thread匿名內部類名稱是 DexFixDemo$2,所以前後DexFixDemo$1類進行對比差異,這個時候已經完全亂套了。

同樣的道理,減少一個匿名內部類也存在相同的情況。

2.2 熱部署解決方案

新增或減少匿名內部類,實際上對於熱部署來說都是無解的,因爲補丁工具拿到的是已經編譯後的 .class文件,所以根本沒法去區分是DexFixDemo$1或者是 DexFixDemo$2類。所以在這種情況下,如果有補丁熱部署的需求,應該極力避免插入一個新的匿名內部類。當然如果 匿名內部類是插入到外部類的末尾,那麼是允許的。

3 有趣的域編譯

3.1 靜態field,非靜態field編譯

實際上在熱部署中除了不支持 method/field的新增,同時也不支持 <clinit>的修復,這個方法會在 DVM中類加載的時候進行類初始化時調用。
在Java源碼中本身並沒有 clinit這個方法,這個方法是Android編譯器自動合成的。通過測試發現,靜態field的初始化和靜態代碼塊實際上就會被編譯器編譯在<clinit>這個方法中,所以我們有必要去了解一下 field/代碼塊 是到底時怎麼編譯的。

來看個簡單的實例。

public class DexFixDemo {
    {
        i = 2;
    }

    private int i = 1;

    private static int j = 1;

    static {
        j = 2;
    }
}

反編譯爲smali看下:

.method static constructor <clinit>()V //類初始化方法
   const/4 v0, 0x1
   sput v0, Lcom/rikkatheworld/hotfix;->j:I  //也就是j=1
   const/4 v0, 0x2
   sput v0, Lcom/rikkatheworld/hotfix;->j:I  //也就是j=2
   return-void
.end method

.method public constructor <init>()V //構造方法
   invoke-direct {p0}, Ljava/lang/Object;-><init>()V  //首先調用父類的默認構造函數
   const/4 v0, 0x2
   iput v0, p0, Locom/rikkatheworld/hotfix;->i:I  //就是i=2
   const/4 v0, 0x1
   iput v0, p0, Lcom/rikkatheworld/hotfix;->i:I   //就是i=1
   return-void
.end method

3.2 靜態field初始化,靜態代碼塊

上面的示例中,能夠很明顯的看到靜態field初始化和靜態代碼塊被編譯器翻譯在了 <clinit>中。
靜態代碼塊和靜態域初始化在clinit()中的先後關係就是兩者出現在源碼中的先後關係。

所以上述例子中,最後的j的值爲2。前面說過,類加載進行類初始化的時候,會去調用 clinit(),一個類僅加載一次。以下三種情況都會嘗試去加載一個類:

  • 創建一個類的對象(new-instance指令)
  • 調用類的靜態方法(invoke-static指令)
  • 獲取類的靜態域的值(sget指令)

首先判斷這個類有沒有被加載過,如果沒有被加載過,執行 dvmResolveClass->dvmLinkClass->dvmInitClass的流程,類的初始化時在dvmInitClass中。dvmInitClass這個函數首先會嘗試會對父類進行初始化,然後調用本類的 clinit方法,所以此時 靜態field得到初始化並且靜態代碼塊得到執行。

3.3 非靜態field初始化,非靜態代碼塊

上面的示例中,能夠明顯的看到非靜態 field初始化和非靜態代碼塊被編譯翻譯在 <init>默認無參構造函數中。非靜態field和非靜態代碼塊在init方法中的先後順序也跟兩者在源碼中出現的順序一致,所以上述示例中最後 i == 1。

實際上如果存在有參構造函數,那麼每個有參構造函數都會執行一個非靜態域的初始化和非靜態代碼塊。

構造函數都會被Android編譯器自動翻譯成<init>方法

前面介紹過 clinit方法在類加載初始化的時候被調用,那麼<init>構造函數方法肯定是對類對象進行初始化時候被調用的,簡單來說創建一個對象就會對這個對象進行初始化,並調用這個對象相應的構造函數,看下這行代碼 String s = new String("test")編譯之後的樣子。

new-instance v0, Ljava/lang/String;
invoke-direct {v0}, Ljava/lang/String;-><init>()V

首先執行 new-instance指令,主要爲對象分配堆內存空間,同時如果類之前沒有被加載過,嘗試加載類。然後執行 invoke-direct指令調用類的 init構造函數方法執行對象的初始化。

3.4 熱部署解決方案

由於不支持 <clinit>方法的熱部署,所以任何靜態field初始化和靜態代碼塊的變更都會被編譯到clinit方法中,導致最後熱部署失敗,只能冷啓動生效。如上所見,非靜態field和非靜態代碼塊的變更被編譯到 <init>構造函數中,熱部署模式下只是視爲一個普通方法的變更,此時對熱部署是沒有影響的。

4 final static域編譯

final static域首先是一個靜態域,所以我們自然會認爲其會編譯到 clinit方法中,所以在自然熱部署下也是不能變更,但是測試發現,final static修飾的基本類型或者 String常量類型,匪夷所思的竟然沒有被編譯到 clinit方法中去,見以下分析。

4.1 final static域編譯規則

final static 即 靜態常量域,看下 final static域被編譯後的樣子:

public class DexFixDemo {
    static Temp t1 = new Temp();
    final static Temp t2 = new Temp();

    final static String s1 = new String("heihei");
    final static String s2 = "haha";

    static int i1 = 1;
    final static int i2 = 2;
}

看下反編譯得到的smali文件:

# static fields
.field static i1:I = 0x0
.field static final i2:I = 0x2
.field static final s1:Ljava/lang/String;
.field static final s2:Ljava/lang/String; = "haha"
.field static t1:Lcom/rikkatheworld/hotfix/Temp;
.field static final t2:Lcom/rikkatheworld/hotfix/Temp;

# direct methods
.method static constructor <clinit>()V
    .registers 2
    .prologue
    .line 8
    new-instance v0, Lcom/rikkatheworld/hotfix/Temp;
    invoke-direct {v0}, Lcom/rikkatheworld/hotfix/Temp;-><init>()V     //調用t1的構造方法
    sput-object v0, Lcom/rikkatheworld/hotfix/DexFixDemo;->t1:Lcom/rikkatheworld/hotfix/Temp;
    .line 9
    new-instance v0, Lcom/rikkatheworld/hotfix/Temp;
    invoke-direct {v0}, Lcom/rikkatheworld/hotfix/Temp;-><init>()V     //調用t2的構造方法
    sput-object v0, Lcom/rikkatheworld/hotfix/DexFixDemo;->t2:Lcom/rikkatheworld/hotfix/Temp;
    .line 11
    new-instance v0, Ljava/lang/String;
    const-string v1, "heihei"                              
    invoke-direct {v0, v1}, Ljava/lang/String;-><init>(Ljava/lang/String;)V  //調用s1構造 “heihei”
    sput-object v0, Lcom/rikkatheworld/hotfix/DexFixDemo;->s1:Ljava/lang/String;
    .line 14
    const/4 v0, 0x1
    sput v0, Lcom/rikkatheworld/hotfix/DexFixDemo;->i1:I             //初始化 i1 = 1
    return-void
.end method
...

我們發現 在 clinit中final static int i2 = 2final static String s2 = "haha"這兩個靜態域竟然沒有被初始化,而其他的非 final靜態域均在clinit函數中得到初始化。

這裏注意下 “haha”new String("heihei")的區別,前者是字符串常量,後者是引用類型。那這兩個final static域(i2和s2)究竟在何處會初始化?
事實上,類加載初始化 dvmInitClass在執行clinit方法之前,首先會執行 <initSFields>,這個方法的作用主要就是給 static域賦予默認值。

如果是引用類型,那麼默認初始值爲NULL。上述代碼示例中,那塊區域有4個默認初始值,分別是 t1==NULL,t2==NULL,s1==NULL,s2=="haha",i1==0,i2==2,即這裏:
在這裏插入圖片描述
t1、t2、s2、i1均在 這裏完成初始化,然後在 clinit中賦值。而i2、s2在 initSFields得到默認值就是程序中設置的值了。

現在我們知道了 static和 final static修飾field的區別了,簡單來說:

  • final static修飾的原始類型和String類型域(非引用類型),並不會編譯在 clinit方法中,而是在類初始化執行 initSFiedls()方法時得到了初始化賦值
  • final static修飾的引用類型,初始化仍然在clinit方法中。

4.2 final static域優化原理

另外一方面,我們經常會看到Android性能優化相關文檔中介紹過,如果一個 field是常亮,那麼推薦儘量使用 static final作爲修飾符。很明顯這句話不太對,得到優化的僅僅是final static原始類型和 String類型域(非引用類型),如果是引用類型,實際上不會得到任何優化的

還是接着上面的示例,Temp直接引用 DexFixDemo的static變量:

class Temp {
    public static void test(){
        int i1 = DexFixDemo.i1;
        int i2 = DexFixDemo.i2;
        
        Temp t1 = DexFixDemo.t1;
        Temp t2 = DexFixDemo.t2;
        
        String s1 = DexFixDemo.s1;
        String s2 = DexFixDemo.s2;
    }
}

看下反編譯後的smali文件:

.method public static test()V
    ...
    sget v0, Lcom/rikkatheworld/hotfix/DexFixDemo;->i1:I  // 通過sget獲取到DexFixDemo中的i1並賦值給 v0
    .local v0, "i1":I    //將v0賦值給 i1
    
    const/4 v1, 0x2     //使用 const/4指令,將 0x2賦值給v1
    .local v1, "i2":I     //將v1 賦值給 i2
    
    sget-object v4, Lcom/rikkatheworld/hotfix/DexFixDemo;->t1:Lcom/rikkatheworld/hotfix/Temp;
    .local v4, "t1":Lcom/rikkatheworld/hotfix/Temp;
    
    sget-object v5, Lcom/rikkatheworld/hotfix/DexFixDemo;->t2:Lcom/rikkatheworld/hotfix/Temp;
    .local v5, "t2":Lcom/rikkatheworld/hotfix/Temp;
    
    sget-object v2, Lcom/rikkatheworld/hotfix/DexFixDemo;->s1:Ljava/lang/String;
    .local v2, "s1":Ljava/lang/String;
    
    const-string v3, "haha"   //使用 const-string指令獲取 final static String類型,速度要比sget好一些
    .local v3, "s2":Ljava/lang/String;
    return-void
.end method

首先看下 Temp怎麼獲取 DexFixDemo.i2(final static域),直接通過 const/4 指令:

const/4 vA, #+b   //前一個字節是opcode,後一個字節前4位是寄存器v1,後4位就是立即數的值 "0x02"

HANDLE_OPCODE(OP_CONST_4 /*vA, #+B*/) {
    s4 tmpl;
    vdst = INST_A(inst);
    tmp = (s4) (INST_B(inst) << 28) >>28;
    SET_REGISTER(vdst, tmp);
}
FINISH(1);
OP_END;

const/4 指令的執行過程很簡單,操作數在 dex文件中的位置就是在 opcode後一個字節。

怎麼獲取 DexFixDemo.i1(非final域),就是通過sget指令。

sget vAA, field@BBBB /* 前一個字節是opcode,後一個字節是寄存器v0,後兩個字節是DexFixDemo.i1 這個field在dex文件結構中field在dex文件結構中 field區的索引值 */

HANDLE_OPCODE(OP_CONST_4 /*vAA, #field@BBBB*/) {
    StaticField* sfield;
    vdst = INST_AA(inst);
    ref = FETCH(1);
    sfield = (StaticField*)dvmDexGetResolvedField(methodClassDex, ref);  // 1
    if(sfield == NULL) {  // 2
        EXPORT_PC(); // 3
        sfield = dvmResolveStaticFeild(curMethod->clazz, ref);  // 4
        if(sfield == NULL)
            GOTO_exceptionThrown();
        if(dvmDexGetResolvedField(methodClassDex, ref) == NULL) {
            JIT_STUB_HACK(dvmJitEndTraceSelect(self, pc));
        }
    }
    SET_REGISTER##_regisze(vdst, dvmGetStaticField##_ftype(sfield));  // 5
}
FINISH(2);

註釋1: 調用 dvmDexGetResolvedField()方法得到指定的區域,在上述例子中,這個區域Lcom/rikkatheworld/hotfix/DexFixDemo;->i1:I

註釋2:判斷註釋1中的區域有沒有被解析過
註釋3:如果沒有被解析過,則調用EXPORT_PC,它會調用 dvmResolveClass()解析類

註釋4:通過 dvmResolveStaticFeild()拿到靜態域。
註釋5:返回靜態域。

可見此時 sget指令比 const/4指令的解析過程要複雜,所以final static基本類型可以得到優化。

final static String類型引用 const-string指令的解析執行速度要比sget快一些。
final static String類型的變量,在編譯期間引用會被優化成 const-string指令,因爲 const/4 獲取的值是 立即數,但是 const-string指令獲取的只是 字符串常量在dex文件結構中字符串常量區的索引ID,所以需要額外的一次字符串查找。
dex文件中有一塊區域存儲這程序所有的字符串常量,最終這塊區域會被虛擬機完整加載到內存中,這塊區域也就是通常說的 “字符串常量區”內存。

final static引用類型沒有得到優化,是因爲不管是不是final,最後都是通過 sget-object指令去獲取該值,所以此時實際上從虛擬機運行性能方面來說得不到任何優化,此時final的作用,僅僅是讓編譯器能在編譯期間檢測到該final域有沒有被修改。final域修改過在編譯期就會直接報錯。

所以這裏引出一個冷知識
final字段只在編譯期間起到作用----它可以在編譯期阻止任何 final類型的修改,但是到了運行期,final就冇用了,這就說明,運行時使用反射是可以更改 final字段的…(網上一搜,果然有人試驗過:利用反射修改final數據域
在這裏插入圖片描述

4.3 熱部署解決方案

  • 修改final static基本類型或者String類型域(非引用類型域),由於在編譯期間引用到基本類型的地方被立即數替換,引用到String類型的類型 的地方被常量池索引ID替換,所以在熱部署模式下,最終所有引用到該 final static域的方法都會被替換。實際上此時仍然可以執行熱部署方案
  • 修改final static引用類型域,是不允許的,因爲這個field的初始化會被編譯到clinit方法中,所以此時沒法走熱部署。

5 有趣的方法編譯

5.1 應用混淆方法編譯

除了以上內部類和匿名內部類可能會造成method新增之後,我們發現項目如果應用了混淆方法編譯,可能導致方法的內聯和裁剪,那麼最後也可能導致 method的新增或減少,以下介紹在哪些場景中會造成方法的內聯或裁剪。

5.2 方法內聯

實際上有好幾種情況可能導致方法被內聯掉。

  • 方法沒有被其他任何地方引用,毫無疑問,該方法會被內聯掉。
  • 方法足夠簡單,比如一個方法的實現就只有一行代碼,該方法會被內聯掉,那麼任何調用該方法的地方都會被該方法的實現替換掉。
  • 方法只被一個地方引用,這個地方會被方法的實現替換掉。

5.3 方法裁剪

public class BaseBug {
    public static void test(Context context) {
        Log.d("BaseBug", "test");
    }
}

查看下生成的 mApping.txt文件:

com.rikkatheworld.hotfix.BaseBug -> com.rikkatheworld.hotfix.a:
    void test$faab20d() -> a  //在括號中沒有context參數

此時test方法context參數沒有被使用,所以test方法的context參數被裁剪。
混淆任務首先生成 test$faab20d()裁剪過後的無參方法,然後再混淆。

所以如果我們想要fix test方法時,裏面用到context的參數,那麼test方法的context參數不會被裁剪,補丁工具檢測到新增了(test(context))方法。那麼補丁只能走冷啓動方案。

怎麼讓該參數不被裁剪呢?我們只要讓編譯器在優化的時候認爲引用了一個無用的參數就好了,可以採取的方法很多,這裏介紹一種最有用的方法:

public static void test(Context context) {
   if(Boolean.FALSE.booleanValue()) {
       context.getApplicationContext();
   }
   Log.d("BaseBug", "test");
}

注意,這裏不能使用基本類型false,必須使用包裝類Boolean,因爲如過使用基本類型if語句很可能會被優化掉的。

5.4 熱部署解決方案

實際上只要混淆配置文件加上 -dontoptimize選項就不會去做方法的裁減和內聯。
在一般情況下,項目的混淆配置都會使用到 Android SDK默認的混淆配置文件 proguard-android-optimize.txt或者 proguard-android.txt,兩者的區別就是後者應用了 -dontoptimize這一項配置而前者沒有用。

preverification step :針對 .class文件的預校驗,在 .class文件中加上 StackMa/StackMapTable信息,這樣 Hotspot VM在類加載時執行類校驗階段會省去一些步驟,因此類加載會更快。

我們知道Android虛擬機執行的是 dex文件格式,編譯期間dx工具會把所有的 .class文件優化成 .dex文件,所以混淆庫的域編譯在Android中是沒有任何意義的,反而會降低打包速度,Android虛擬機中有自己的一套代碼校驗邏輯(dvmVerifyClass)。所以Android中混淆配置一般都需要加上 -dontpreverify這一項。

6 switch case語句編譯

6.1 關於switch case語句的編譯差異

由於在實現資源修復方案熱部署的過程中(後面章節會講到),要做新舊資源的ID替換,我們竟然發現存在switch case語句中的ID不會被替換掉的情況,所以有必要來探索下 switch case語句編譯的特殊性。

public void testContinue() {
        int temp = 2;
        int result = 0;
        switch (temp) {
            case 1:
                result = 1;
                break;
            case 3:
                result = 3;
                break;
            case 5:
                result = 5;
                break;
        }
    }

    public void testNotContinue() {
        int temp = 2;
        int result = 0;
        switch (temp) {
            case 1:
                result = 1;
                break;
            case 3:
                result = 3;
                break;
            case 10:
                result = 10;
        }
    }

看看上面兩個方法編譯出來有什麼不同:

.method public testContinue()V
    ...
    const/4 v1, 0x2
    .local v1, "temp":I
    const/4 v0, 0x0
    .local v0, "result":I
    packed-switch v1, :pswitch_data_c
    
    :pswitch_5
    return-void
    :pswitch_6
    const/4 v0, 0x1
    :pswitch_8
    const/4 v0, 0x3
    :pswitch_a
    const/4 v0, 0x5

    :pswitch_data_c
    .packed-switch 0x1
        :pswitch_6
        :pswitch_5
        :pswitch_8
        :pswitch_5
        :pswitch_a
    .end packed-switch
.end method

.method public testNotContinue()V
    ...
    const/4 v1, 0x2
    .local v1, "temp":I
    const/4 v0, 0x0
    .local v0, "result":I
    sparse-switch v1, :sswitch_data_e

    :sswitch_6
    const/4 v0, 0x1
    :sswitch_8
    const/4 v0, 0x3
    :sswitch_a
    const/16 v0, 0xa

    :sswitch_data_e
    .sparse-switch
        0x1 -> :sswitch_6
        0x3 -> :sswitch_8
        0xa -> :sswitch_a
    .end sparse-switch
.end method

testContinue() 的switch case語句被編譯成 packed-switch指令
testNotContinue() 的switch case語句被編譯成 sparse-switch指令。
比較兩者的差異:
①testContinue的switch語句的case項是連續的幾個比較相近的值1、3、5,。所以被編譯爲 packed-switch指令,可以看到幾個連續的數中間的差值用: pswitch_5 補齊, :pswitch_5標籤處直接return-void、
②testNotContinue的switch語句的case分別是1、3、10,很明顯不夠連續,所以被編譯爲 sparse-switch指令。編譯器會決定怎樣的值纔算是連續的case。

6.2 熱部署解決方案

一個資源ID肯定是 const final static變量,此時恰好switch case語句被編譯 packed-switch指令,所以這個時候如果不做任何處理就會存在資源ID替換不完全的情況。
解決這種情況方案其實很簡單,修改smali反編譯流程,碰到 packed-switch指令強制轉爲 sparse-switch指令, :pswitch_N等相關標籤指令也需要強轉爲 :sswitch_N指令。然後做資源ID暴力替換,然後再回編譯 smali爲dex。再做類方法變更的檢測,所以就需要經過反編譯->資源ID替換->回編譯,這也會使打補丁變得稍慢一些。

7 泛型編譯

泛型是從Java5才引入的,我們發現泛型的使用,也可能導致method的新增,所以是時候瞭解一下泛型的編譯過程了。

7.1 爲什麼需要泛型

  • Java語言中泛型基本上完全在編譯器中實現,由編譯器執行類型檢查和類型推斷,然後生成普通的非泛型字節碼,就是虛擬機完全無感知泛型的存在。這種技術稱爲泛型擦除。編譯器使用泛型類型信息保證類型安全,然後在生成字節碼之前將其清除。
  • Java5才引入泛型,所以擴展虛擬機指令集來支持泛型被認爲是無法接受的,因爲這會爲Java廠商升級其JVM造成難以逾越的障礙。因此採用了可以完全在編譯器中實現的擦除方法。

我們知道了以上的兩點,其中最重要的是類型擦除的理解,先來通過一個例子說明Java爲什麼需要泛型。Java5之前,要實現類似“泛型”的功能,由於Java類都是以Object爲最上層的父類別,所以可以用Object來實現似“泛型”的功能。

public class ObjectFoo {
    private Object foo;

    public Object getFoo() {
        return foo;
    }

    public void setFoo(Object foo) {
        this.foo = foo;
    }
}

// 代碼調用示例
        ObjectFoo  foo = new ObjectFoo();
        foo.setFoo(new Boolean(true));
        Boolean b = (Boolean) foo.getFoo();  //正確
        String s = (String) foo.getFoo();   //運行時,類型轉換失敗, ClassCastException異常

由於語法上並沒有錯誤,所以編譯器檢查不出上面程序有誤,真正的錯誤要在執行器纔會發生。

所以可以看到使用Obejct來實現“泛型”存在一些問題,因爲必須要強制類型轉換,但很多人可能忘記強制類型轉換,或者是強轉用錯類型,然而由於語法上可以的,所以編譯器檢查不出錯誤。Java5之後,提出了針對泛型設計的解決方案。該方案在編譯器進行類型安全監測,允許程序員在編譯器就能監測到非法的類型,泛型解決方案如下:

    public class GenericFoo<T> {
        private T foo;

        public T getFoo() {
            return foo;
        }

        public void setFoo(T foo) {
            this.foo = foo;
        }
    }

// 代碼調用示例
        GenericFoo<Boolean>  foo = new GenericFoo();
        foo.setFoo(new Boolean(true));
        Boolean b = foo.getFoo();  //正確
        String s = (String) foo.getFoo();   //編譯不通過

很明顯此時使用泛型的優勢就體現出來了,可以在編譯期就檢查到了可能的異常。

7.2泛型類型擦除

我們來反編譯一下上述 GenericFoo<T>的字節碼:

.method public getFoo()Ljava/lang/Object;
.method public setFoo(Ljava/lang/Object;)V

可以看到它是被編譯成了Object,如果此時再定義一個 setFoo<Object foo>方法是行不通的,編譯期會報重複方法定義。
如果這樣的 <T extends Numble>,那麼是這樣子的:

.method public setFoo(Ljava/lang/Number;)V
.method public getFoo()Ljava/lang/Number;

所以我們知道 new T()這樣使用泛型是編譯不過的,因爲類型擦除會導致實際上是 new Object(),所以是錯誤的

7.3 類型擦除與多態的衝突和解決

    class A<T> {
        private T t;

        public T get() {
            return t;
        }

        public void set(T t) {
            this.t = t;
        }
    }
    
    class B extends A<Number> {
        private Number n;

        @Override  //跟父類返回值不一樣,爲什麼重寫父類get方法
        public Number get() {
            return n;
        }

        @Override   //跟父類方法參數類型不一樣,爲什麼重寫父類set方法
        public void set(Number number) {
            this.n = number;
        }
    }
    
    class C extends A {
        private Number n;

        @Override //跟父類返回值不一樣,爲什麼重寫父類get方法
        public Number get() {
            return n;
        }

        //@Override  重寫父類get方法,因爲方法參數類型不一樣,這裏沒問題
        public void set(Number o) {
            this.n = o;
        }
    }

按照前面類型擦除的分析,爲什麼類B的 set和get方法可以用 @Override而不報錯。@Override表明這個方法是重寫,我們知道重寫的意思是子類的方法與父類中的某一方法具有相同的方法名,返回類型和參數表。
但是根據前面的分析,基類A由於類型擦除的影響,set(T t)在字節碼中實際上是 set(Object t),那麼類B的方法 set(Number n)方法參數不一樣,此時類B的set方法理論上來說應該重載而不是重寫基類的set方法。但是我們的本意是重寫,實現多態,可是類型擦除後,只能變爲重載,這樣,類型擦除就和多態有了衝突。

實際上JVM採用了一個特殊的方法,來完成這項重寫功能,那就是橋接。看下類B的字節碼錶示:

.method public get()Ljava/lang/Number;
.method public bridge synthetic get()Ljava/lang/Object;
    invoke-virtual {p0}, Lcom/rikkatheworld/hotfix/Main$B;->get()Ljava/lang/Number;
    move-result-object v0
    return-object v0
.end method

.method public set(Ljava/lang/Number;)V
.method public bridge synthetic set(Ljava/lang/Object;)V
    check-cast p1, Ljava/lang/Number;
    invoke-virtual {p0, p1}, Lcom/rikkatheworld/hotfix/Main$B;->set(Ljava/lang/Number;)V
    return-void
.end method

我們可以發現編譯器自動合成 set(Ljava/lang/Object)get()Ljava/lang/Object這兩個橋接方法來重寫父類,同時這兩個橋接方法上實際上調用 B.print(Ljava/lang/Number)B.get()Ljava/lang/Number這兩個重載方法。那麼可以得到最終的結論:

  • 子類中真正重寫基類方法的是編譯器自動合成的橋接方法。而類B定義的get和set方法上面的@Override只不過是假象,橋接方法的內部實現是去調用自己重寫的print方法而已。所以,虛擬機巧妙使用了橋接方法,來解決了類型擦除和多態的衝突。

1.4 熱部署解決方案

前面類型擦除中說過,如果 B extends A 變成了 B extend A<Number>,那麼就可能會新增對應的橋接方法。此時新增了方法,只能走冷部署。

這這種情況下,如果要走熱部署,應該避免類似上面那種修復。

另一方面,實際上泛型方法內部會生成一個 dalvik/annotation/Signature這個系統註解:

    class A {
        public <A extends Number> void getA(A t) {
            System.out.println("t:" + t);
        }
    }
    
//getA方法內註解
    .annotation system Ldalvik/annotation/Signature;
        value = {
            "<A:",
            "Ljava/lang/Number;",
            ">(TA;)V"
        }
    .end annotation

如果現在把方法的簽名換成 <B extends Number> void getA(B t),前面說過,泛型類型擦除方法的邏輯實際上沒有發生任何變化,但是這個方法的註解卻發生了變化:

    .annotation system Ldalvik/annotation/Signature;
        value = {
            "<B:",
            "Ljava/lang/Number;",
            ">(TB;)V"
        }
    .end annotation

補丁工具發現這個方法發生了變化(不會排除註解的變化),然後對這個方法進行打包,此時打包時多餘且沒有任何意義的,所以熱部署下,需要避免這種會導致浪費性能的修復。

8 Lambda表達式編譯

Lambda表達式是Java7才引入的一種表達式,類似於匿名內部類,實際上又和匿名內部類有很大的差別,我們發現Lambda表達式的使用也可能導致方法的新增或減少,導致最後走不了熱部署模式。

8.1 Lambda表達式編譯規則

首先簡單介紹下Lambda表達式,Lambda爲Java添加了缺失的函數式編程特點,Java現在提供的最接近閉包的概念便是Lambda表達式。
gradle就是基於groovy存在的大量閉包。

函數式接口具有兩個主要特徵:它是一個接口,這個接口具有唯一的抽象方法;我們將同時滿足這兩個特性的接口稱爲函數式接口。

比如Java標準庫中的 java.lang.Runnablejava.util.Comparator都是典型的函數式接口。函數式接口跟匿名內部類的區別如下:

  • 關鍵字this,匿名類的this關鍵字指向匿名類,而Lambda表達式的this關鍵字指向包圍Lambda表達式的類
  • 編譯方式,Java編譯器將Lambda表達式編譯成類的私有方法,使用Java7的invokedynamic字節碼指令來動態綁定這個方法。Java編譯器將匿名內部類編譯成 外部類$數字編號的新類。

通過一個代碼示例來講解 Lambda表達式,匿名內部類前面已經詳細介紹過。

public class Main {
    private static int temp = 2;

    public static void main(String[] args) {
        new Thread(() -> System.out.println("java 8 lambda...")).start();

        test(s -> temp + 1);
    }

    public static void test(TInterface tInterface) {
        System.out.println("java8 TInterface lambda..." + tInterface.test("1"));
    }

    public interface TInterface {  //自定義函數式接口
        int test(String s);
    }
}

先通過javac轉換成.class字節碼文件,再用 javap -p -v Main.class反編譯 .class文件,截取關鍵內容

public class com.rikkatheworld.hotfix.Main
Constant pool:
   ...

  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=3, locals=1, args_size=1
         0: new           #2                  // class java/lang/Thread
         3: dup
         4: invokedynamic #3,  0              // InvokeDynamic #0:run:()Ljava/lang/Runnable;
         9: invokespecial #4                  // Method java/lang/Thread."<init>":(Ljava/lang/Runnable;)V
        12: invokevirtual #5                  // Method java/lang/Thread.start:()V
        15: invokedynamic #6,  0              // InvokeDynamic #1:test:()Lcom/rikkatheworld/hotfix/Main$TInterface;
        20: invokestatic  #7                  // Method test:(Lcom/rikkatheworld/hotfix/Main$TInterface;)V
        23: return-

  private static int lambda$main$1(java.lang.String);
  private static void lambda$main$0();
}

這裏我們可以發現幾點:

  • 編譯期間自動生成私有靜態的 lambda$main$**(*)方法,這個方法的實現就是lambda表達式裏面的邏輯
  • invokedynamic指令執行Lambda表達式
  • 比較匿名內部類的區別,發現並沒有在磁盤上生成 外部類$數字編號的新類。

那麼我們應該思考的是 invokedynamic指令的執行,最後爲什麼就調用到了 Main的私有靜態 lambda$main$**(*)方法。我們首先看下JVM中關於 invokedynamic指令的介紹:在Java7 JVM中增加了一個新的指令 invokedynamic,用於支持動態語言,即允許方法調用可以在運行時指定類和方法,不必再編譯的時候確定。
字節碼中每條 invokedynamic指令出現的位置稱爲一個動態調用點, invokedynamic指令後面跟一個指向常量池的調用點限定符(#3, #0),這個限定符會被解析成一個動態調用點。

從上面的反編譯後的內容中可以發現, invokedynamic指令執行時實際上會去調用 java/lang/invoke/LambdaMetafactorymatafactory靜態方法。**這個靜態方法實際上會在運行時生成一個實現函數式接口的具體類。**然後具體類會調用 Main的私有靜態 lambda$main$**(*)方法。
這就是 Sun/Oracle Hotspot VM對Lambda表達式的解釋。

那Android虛擬機是如何解釋Lambda表達式的呢?
我們知道Android虛擬機首先通過javac把源代碼編譯成.class,再通過 dx工具優化成 dex字節碼文件。
但是Android中如果要使用新的Java8語言特性,還需要使用新的 Jack工具鏈來替換掉舊的工具鏈來編譯。
新的Android工具鏈將Java源語言直接編譯成Android可以讀取的 Dalvik可執行文件字節碼,且有自己的 .jack庫格式。
它試圖取代 javac/dx/proguard/jarjar/multidex庫等工具。以下是構建Android Dalvik可執行文件可用的兩種工具鍵的對比:

  • 舊版javac工具鍵
    javac(.java -> .class) -> dx(.class -> .dex)
  • 新版 Jack工具鏈
    Jack(.java -> .java -> .dex)

這裏省略使用 Jack工具鏈編譯代碼的示例。
.dex字節碼和.class字節碼文件對Lambda表達式處理的異同點:

  • 共同點:編譯期間都會爲外部類合成一個 static輔助方法,該方法內部邏輯實現Lambda表達式
  • 不同點:
    (1).class字節碼中通過 inoke-dynamic指令執行Lambda表達式,而.dex字節碼中執行Lambda表達式跟普通方法調用沒有任何區別
    (2).class字節碼運行時生成新類, .dex字節碼中編譯期間生成新類。

8.2 熱部署解決方案

有了以上知識點做基礎,同時知道打補丁是通過反編譯爲smali然後新APK跟基線APK做差異對比,得到最後的補丁包。

新增一個Lambda表達式,會導致外部類新增一個輔助方法,所以此時不支持走熱部署方案。

那麼如果只修改Lambda表達式內部的邏輯,此時看起來僅僅相當於修改了一個方法,所以此時看起來是允許走熱部署的。事實上並非如此。我們忽略了一種情況,Lambda表達式訪問外部非靜態 field/method的場景。

前面我麼你知道 .dex字節碼中 Lambda表達式在編譯期間會自動生成新的輔助類。注意該輔助類是非靜態的,所以該輔助類如果爲了訪問“外部類”的非靜態 field/method就必須持有“外部類”的引用。如果該輔助類沒有訪問“外部類”的field/method,那麼就不會持有“外部類”的引用。這裏注意這個輔助類和內部類的區別。我們前面說過如果是非靜態內部類的話一定會持有外部類的引用的。

下面的示例很容易說明這個問題。
Lambda表達式沒有訪問“外部類”的非靜態 field/method,可以看到並沒有持有“外部類的引用”:

public synthetic constructor <init>()V

Lambda表達式訪問了“外部類”的非靜態 field/method,可以看到持有了“外部類”的引用。

private synthetic val$Lcom/rikkatheworld/hotfix/Main;  /*合成的變量 val$this, “外部類”的引用 */
public synthetic constructor <init>(.....)

所以此時存在這樣的情況:基線APK中Lambda表達式中沒有訪問非靜態field/method,修復後的APK中的Lambda表達式中訪問非靜態 field/methid.name會導致發現新增field,此時熱部署也會失敗。

最後進行小結:

  • 增加或減少一個Lambda表達式會導致類方法比較錯亂,所以會導致熱部署失敗
  • 修改一個Lambda表達式,可能導致新增field,所以此時也會導致熱部署失敗。

9 訪問權限檢查對熱替換的影響

  • 類加載階段父類/實現接口訪問檢查
    一個類的加載階段包括resolve->link->init三個階段,父類/實現接口 權限檢查在link階段,dvmLinkClass()中依次對父類/實現接口進行dvmCheckClassAcess()權限檢查,如果父類/實現接口是非public然後進行檢查當前類和它是否是相同的ClassLoader。熱修復補丁類是新classLoader加載的,所在會報父類不允許訪問的錯誤。
  • 類校驗階段訪問權限檢查
    如果訪問public類和方法在類加載階段會通過,但是在運行時會爆出crash異常。
    補丁在單個dex文件中,加載dex肯定要進行dexopt,再dexopt過程中會dvmVerifyClass校驗dex每個類。在校驗過程中會檢查補丁類所引用類的訪問權限(提前dvmResolveClass被調用類)。還會校驗調用方法的訪問權限,public修飾直接返回。protected的話,先檢查當前類和被調用方法所屬類是否是父子類關係,不是的話會調用dvmIsSamePackage,這裏會判斷是否是相同的classLoader。

10 <clinit>方法

由於補丁熱部署模式的特殊性----不允許類結構變更以及不允許變更 <clinit>方法,所以補丁工具如果發現了這幾種限制情況,那麼此時只能走冷啓動重啓生效,冷啓動幾乎是無任何限制的,可以做到任何場景的修復。

可能有時候再源碼層上來看並沒有新增或減少 method和field,但是實際上由於要滿足Java各種語法特性的需求,所以編譯器會在編譯期間自動合成一些method和field,最後就有可能觸發了這幾個限制情況。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章