Android AndFix熱修復原理詳情

前言

當我們寫瞭一個方法,那麼這個方法是如何被執行的呢?

public int add(){
    int a = 10;
    int b = 20;
    return a + b;
}

其實方法的本質就是arm指令,在Android當中,dalvik或者art虛擬機的執行引擎會執行arm指令 

 add方法是java代碼,java代碼編譯成class文件,還需要一步轉換為dex文件,才能被Android虛擬機執行,dex文件包含瞭app的所有代碼,因此方法也是存在dex文件中,那麼通過dx命令,可以查看方法被編譯成的字節碼指令

1 arm指令集

dx --dex --verbose --dump-to=dex_method.txt --dump-method=Method.add --verbose-dump Method.class 

Android中可以通過dx命令將class文件轉換為dex文件,dx.bat位於Android SDK中的build-tools文件夾下,那麼可以通過dx命令將class文件翻譯成arm指令集 

可以看一下,打印輸出的arm指令集,ART執行某個方法的時候,執行的就是這個指令集,當apk安裝的時候,dex文件會被dex2oat工具翻譯成本地機器碼(arm指令集)保存在oat文件中,當apk運行的時候oat會被加載到內存中,存在虛擬機的方法區中 

 執行的時候,會構建一個棧幀壓入虛擬機棧中,然後每一個方法在ART中都對應一個ArtMethod(這個後邊會說),ArtMethod中的invoke函數會找到當前方法對應的本地機器碼執行,執行完成之後,棧幀出棧

關註點回到指令集上,在每一行指令前有一個數字,代表程序計數器記錄的行號,精簡之後的指令集(隻保留每個行號的最後一個)

Method.add:()I:
regs: 0002; ins: 0001; outs: 0000
 
  0000: const/16 v0, #int 30 // #001e
  0002: return v0
  0003: code-address
  debug info
    line_start: 4
    parameters_size: 0000
    0000: prologue end
    0000: line 4
    0000: line 6
    end sequence
  source file: "Method.java"

另外還有一種方式獲取字節碼,是通過javap獲取,這種跟arm指令有啥區別呢?其實都是字節碼,但是javap獲取的字節碼是JVM執行的字節碼,Android虛擬機是Dalvik或者Art虛擬機,執行的是arm指令集

public int add();
    descriptor: ()I
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=3, args_size=1
         0: bipush        10
         2: istore_1
         3: bipush        20
         5: istore_2
         6: iload_2
         7: iload_1
         8: iadd
         9: ireturn
      LineNumberTable:
        line 4: 0
        line 5: 3
        line 6: 6

這兩者有什麼區別呢?我們看同是執行 10 + 20 ,JVM是先創建一個10變量,然後再創建20 ,最後將兩個相加然後返回;但是從ART機器指令中可以看到是直接計算好瞭,然後創建v0 = 30,直接返回,所以:Android編譯器在編譯的過程中會做優化,提高執行的效率(這個可以自己去試一下,javac並沒有做優化處理)

當一個class類加載進來之後,class類中有方法、成員變量等,這些類的信息加載的時候是放在方法區,當Java層調用某個方法時,ART虛擬機找到該方法對應的本地機器碼指令,在虛擬機棧中,該方法棧幀入棧,CPU去讀取每行指令,程序計數器+1,等到方法執行完畢,棧幀出棧。

2 AndFix熱修復原理

之前我們介紹過阿裡的AndFix或者Sophix是通過hook native層替換已經加載的類的方法,接下來我們著重看一下,AndFix熱修復是怎麼實現的

Method.add:()I:
regs: 0002; ins: 0001; outs: 0000
 
  0000: const/16 v0, #int 30 // #001e
  0002: return v0
  0003: code-address
  debug info
    line_start: 4
    parameters_size: 0000
    0000: prologue end
    0000: line 4
    0000: line 6
    end sequence
  source file: "Method.java"
public class Method {

    public int add(){
        int a = 10;
        int b = 20;
        return a + b;
    }
}
//調用
Method method = new Method();
method.add();

我們看下這個方法,通過Method對象去調用,method是在堆內存中,通過對象可以拿到類信息在方法區中。 

當執行這個方法時,ART執行引擎從方法區中找到方法的本地機器指令,通過CPU執行得到結果,如果add方法中拋出異常導致app崩潰,那麼如何修復?

2.1 ArtMethod

既然要做到方法替換,首先必須要瞭解方法在虛擬機中的形態;其實前面有提到,方法在虛擬機中對應的結構體就是ArtMethod,每個方法在ART中對應一個ArtMethod。

# Android 10.0/art/runtime/art_method.h
protected:
 
  GcRoot<mirror::Class> declaring_class_;
  
  std::atomic<std::uint32_t> access_flags_;

  uint32_t dex_code_item_offset_;

  uint32_t dex_method_index_;
  uint16_t method_index_;

  union {
    uint16_t hotness_count_;
    uint16_t imt_index_;
  };

  // Fake padding field gets inserted here.

  // Must be the last fields in the method.
  struct PtrSizedFields {
    // Depending on the method type, the data is
    //   - native method: pointer to the JNI function registered to this method
    //                    or a function to resolve the JNI function,
    //   - conflict method: ImtConflictTable,
    //   - abstract/interface method: the single-implementation if any,
    //   - proxy method: the original interface method or constructor,
    //   - other methods: the profiling data.
    void* data_;

    // Method dispatch from quick compiled code invokes this pointer which may cause bridging into
    // the interpreter.
    void* entry_point_from_quick_compiled_code_;
  } ptr_sized_fields_;

在ArtMethod中,有一個結構體PtrSizedFields,其中一個成員變量為entry_point_from_quick_compiled_code_,這個指針指向的就是在方法區中該方法本地機器碼的內存地址,也就是說,如果想要實現熱修復,那麼就將entry_point_from_quick_compiled_code_指向正確的方法機器碼指令地址即可。

除此之外,看下其他成員變量的含義:

  • declaring_class_:用來標記當前方法屬於哪個類
  • access_flags_:當前方法的訪問修飾符
  • hotness_count_:記錄當前方法被調用的次數,如果超過某個限制,那麼該方法就被標記為是熱方法,這個與ART的編譯模式相關

對於hotness_count_,這裡需要說一下ART的編譯模式,Dalvik的就先不介紹瞭

2.2 ART編譯模式

在Android 5.0之後,Android編譯器由ART代替瞭Dalvik,采用瞭全新的編譯模式AOT,代替JIT;

什麼是AOT?就是全量編譯,在APK安裝的時候,會將所有的dex文件編譯成本地機器碼,然後在執行方法時會直接拿到相應的機器碼執行,速度非常快,但是這也帶來一些問題:

(1)安裝時間長

因為在安裝的過程中做全量的編譯,耗時非常嚴重;早先的Android手機我們在安裝的時候,進度條一直在轉但就是裝不上,這種是非常差的用戶體驗

(2)存儲空間

因為全量編譯的時候,dex被編譯成機器碼之後,保存在.oat文件中,10M的dex翻譯成的機器碼內存激增4-5倍,大量的文件保存在手機中會占據內存空間

所以在Android N之後,采用瞭混合編譯模式,AOP + 解釋 + JIT

全新的混編模式不再在APK安裝的時候進行全量編譯,而是會解釋字節碼,因此安裝的速度很快;此外新增瞭一個JIT編譯器,會在App運行的時候分析代碼,把結果保存在Profile中,並且在空閑時間分析並編譯這些代碼;

接著上面的hotness_count_,其實用來記錄這個方法被調用的次數,當超過某個閾值之後,這個方法會被標記為熱代碼,這些熱方法在設備空閑的時候做編譯,並保存在名為app_image的base.art文件中,這個art文件會在類加載之前加載到內存中,意味著當調用這個方法的時候,不再需要編譯為機器碼,而是直接執行拿到結果。

2.3 AndFix框架實現

首先創建一個C++的模塊,然後C++版本可選擇個人熟悉的,我對C++ 11的一些特性比較熟悉 

 其實AndFix實現的關鍵,就是找到ArtMethod,在JNI層是能夠實現的,通過JNIEnv的FromReflectedMethod函數

public class AndFixManager {
    //native熱修復方法
    public static native void fix(Method wrong, Method right);
}
//fix對應的JNI接口
extern "C"
JNIEXPORT void JNICALL
Java_com_tal_andfix_AndFixManager_fix(
        JNIEnv *env,
        jclass clazz,
        jobject wrong,
        jobject right) {

    //獲取ArtMethod
    env->FromReflectedMethod(wrong);

}

其實在Java層調用的時候,是需要反射獲取某個方法,也就是說,在Java層反射拿到的方法其實就是ArtMethod,隻不過再底層的我們看不到,那現在就能看到瞭!

try {
    Class<?> clazz = Class.forName("com.tal.demo02.FixDemo");
    Method run = clazz.getDeclaredMethod("run");
    AndFixManager.fix(run,run);
} catch (Exception e) {
    e.printStackTrace();
}

2.3.1 獲取ArtMethod

之前我們看源碼的時候,可以看到ArtMethod.h中存在很多系統的頭文件,全部導入工程中不現實 

 因為我們需要的是ArtMethod的一個結構體的成員變量,所以我們隻需要針對性地導入即可,art_method.h如下;

#ifndef DEMO02_ART_METHOD_H
#define DEMO02_ART_METHOD_H
#endif //DEMO02_ART_METHOD_H
#include "stdint.h"
namespace art{
    namespace mirror{
        class ArtMethod final {
        public:
            uint32_t declaring_class_;
            std::atomic<std::uint32_t> access_flags_;
            uint32_t dex_code_item_offset_;
            uint32_t dex_method_index_;
            uint16_t method_index_;
            union {
                uint16_t hotness_count_;
                uint16_t imt_index_;
            };
            struct PtrSizedFields {
                void* data_;

                void* entry_point_from_quick_compiled_code_;
            } ptr_sized_fields_;

        };

    }
}

最終在Java層調用JNI方法,執行到JNI層,獲取到ArtMethod

extern "C"
JNIEXPORT void JNICALL
Java_com_tal_andfix_AndFixManager_fix(
        JNIEnv *env,
        jclass clazz,
        jobject wrong,
        jobject right) {

    //獲取ArtMethod
    ArtMethod *artMethod = reinterpret_cast<ArtMethod *>(env->FromReflectedMethod(wrong));
}

這裡通過斷點可以看到,ArtMethod已經拿到瞭,而且關鍵信息entry_point_from_quick_compiled_code_,也就是arm指令集的內存地址拿到瞭! 

2.3.2 方法替換

public class FixDemo {

    public void run(){
        throw new IllegalArgumentException();
    }
}
public class FixDemo {
    public void run(){
        Log.e("TAG","已經被修復瞭");
    }
}

現在有一個場景就是,當執行FixDemo的run方法時拋出異常導致崩潰,這種場景下,使用熱修復技術怎麼修復呢,就是方法替換,arm指令集替換

public class AndFixManager {

    public static void bugFix(){
        try {
            Class clazz = Class.forName("com.take.andfix.FixDemo");
            Method wrong = clazz.getDeclaredMethod("run");
            //正確的方法
            Class clazz1 = Class.forName("com.take.andfix.fox.FixDemo");
            Method right = clazz1.getDeclaredMethod("run");
            AndFixManager.fix(wrong, right);
        }catch (Exception e){

        }
    }
    public static native void fix(Method wrong, Method right);
}

拋出異常的類是andfix包下的,當線上需要修復時,下發patch包,然後加載fox包下的方法,調用native fix方法

extern "C"
JNIEXPORT void JNICALL
Java_com_tal_andfix_AndFixManager_fix(JNIEnv *env, jclass clazz, jobject wrong, jobject right) {

    //獲取ArtMethod
    ArtMethod *wrongMethod = reinterpret_cast<ArtMethod *>(env->FromReflectedMethod(wrong));
    ArtMethod *rightMethod = reinterpret_cast<ArtMethod *>(env->FromReflectedMethod(right));

    //方法替換
    wrongMethod->declaring_class_ = rightMethod->declaring_class_;
    wrongMethod->access_flags_ = rightMethod->access_flags_;
    wrongMethod->dex_code_item_offset_ = rightMethod->dex_code_item_offset_;
    wrongMethod->dex_method_index_ = rightMethod->dex_method_index_;
    wrongMethod->method_index_ = rightMethod->method_index_;
    wrongMethod->ptr_sized_fields_.data_ = rightMethod->ptr_sized_fields_.data_;
    wrongMethod->ptr_sized_fields_.entry_point_from_quick_compiled_code_ = rightMethod->ptr_sized_fields_.entry_point_from_quick_compiled_code_;
}

然後再次執行run方法

binding.sampleText.setOnClickListener {
    AndFixManager.bugFix()
    val fixDemo = FixDemo()
    fixDemo.run()
}
打印出的結果:E/TAG: 已經被修復瞭

其實現在阿裡的AndFix和Sophix已經不維護瞭,但是這種熱修復的思想我們是需要瞭解的,尤其是通過hook native底層替換方法,能夠幫助我們更好地瞭解JVM虛擬機和Android虛擬機。

2.4 AndFix動態化配置

在上面簡單的demo中,我們是知道那個類的哪個方法發生異常,在代碼中寫死的,但真正的線上環境中,其實是不知道哪個類會報錯,一般我們都會使用bugly,像crash跟anr都能夠實時監控到 

在這裡插入圖片描述

 當app某個方法拋異常之後,通過bugly上報到後臺,比如com.take.andfix.FixDemo這個類中的run方法拋出瞭異常,那麼我們需要針對這個類的方法做修復,如果做到動態化,需要使用註解修飾這個修復類

/**
 * 修復類需要使用這個註解
 */
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface andfix {
    String clazz();
    String method();
}
public class FixDemo {
    @andfix(clazz = "com.tal.andfix.FixDemo",method = "run")
    public void run(){
        Log.e("TAG","已經被修復瞭");
    }
}

這樣在熱修復時,能夠知道這個修復類要修復線上環境中那個類的哪個方法

2.4.1 dex打包

在打包dex的時候,需要把整個包名路徑下的class文件一起打包,通過命令行完成dex打包

dx --dex --output fix.dex /xxxx/Desktop/dx

 將打包成功的dex修復包,放到sd卡中 :

2.4.2 dex文件加載

dex文件的加載,通過DexFile實現,如果不熟悉可以看下源碼,art虛擬機會將dex轉換為odex,因此加載dex文件的時候,需要傳入一個odex文件的緩存路徑。

將dex文件加載到內存之後,可以獲取到dex文件中全部的類,通過DexFile.loadClass就可以將這個類通過類加載器加載。

/**
* dex文件加載,將dex文件加載到內存
* @param context
* @param dexFile
*/
private static void loadFixDex(Context context, File dexFile) {
   try {

       DexFile odex = DexFile.loadDex(
               dexFile.getAbsolutePath(),
               new File(context.getCacheDir(), "odex").getAbsolutePath(),
               Context.MODE_PRIVATE
       );
       Enumeration<String> entries = odex.entries();
       while (entries.hasMoreElements()){
           //全類名
           String clazzName = entries.nextElement();
           //加載類
           Class aClass = odex.loadClass(clazzName, context.getClassLoader());
           //處理類
           if(aClass != null){
               processClass(aClass);
           }
       }


   } catch (Exception e) {
       e.printStackTrace();
   }
}

這裡會有一個問題,就是既然拿到瞭全類名,為什麼不能通過方式1獲取,而是需要通過方式2獲取?原因就是,Class.forName是從當前apk中查找這個類,但是這個類是在dex文件中,是從服務端下發的,並沒有放在apk中,因此通過Class.forName是找不到的,通過DexFile.loadClass才是真正加載類到瞭內存中

//方式1
Class.forName("xxxxxxxxxx")
//方式2
odex.loadClass(clazzName, context.getClassLoader())

2.4.3 動態替換方法

拿類之後,通過反射能夠拿到修復類中的方法,當然不是每個方法都是需要被修復的,我們需要判斷的是,上面是否有我們自定義的註解,如果有,那麼就能夠通過反射,拿到拋出異常的這個方法,因為註解上有我們傳入的類名和方法名,最終調用JNI的接口實現動態替換方法

private static void processClass(Class aClass) {
    //獲取方法上的註解
    Method[] methods = aClass.getMethods();
    for (Method method:methods){
        andfix annotation = method.getAnnotation(andfix.class);
        if(annotation != null){
            //如果存在這個註解,那麼就執行方法替換
            String clazz = annotation.clazz();
            String method1 = annotation.method();
            //獲取wrong方法
            try {
                Class<?> wrongMethodClass = Class.forName(clazz);
                //這裡註意,修復類的方法,要和被修復的方法,參數一致!!!!!
                Method wrongMethod = wrongMethodClass.getDeclaredMethod(method1,method.getParameterTypes());
                //動態方法替換
                fix(wrongMethod,method);
            } catch (Exception e) {
                e.printStackTrace();
            }

        }
    }
}

2.4.4 文件訪問問題

一切準備就緒之後,可以通過加載dex補丁包來修復

binding.sampleText.setOnClickListener {

//            AndFixManager.bugFix()
    AndFixManager.loadFixDex(
        this,
        File(System.getenv("EXTERNAL_STORAGE"), "fix.dex")
    )
    val fixDemo = FixDemo()
    fixDemo.run()
}

這裡可能會碰到一些加載SD卡中文件報錯的問題,比如:

No original dex files found for dex location /sdcard/fix.dex

這裡需要添加文件的讀寫權限,才能夠保證有效的熱修復,除此之外,在Android 10以上的版本,需要在清單文件中添加android:requestLegacyExternalStorage屬性

android:requestLegacyExternalStorage="true"

通過這種hook native底層的方式,最大的優勢在於能夠真正實現熱修復,不需要重新啟動app就能夠修復,但是存在的弊端也是比較明顯的,就是兼容性問題,每個Android的版本,native層都會有變化,比如art_method.h,其實每個版本都是不一樣的,我這次使用的就是Android 10中的art_method頭文件,有興趣的可以看看之前Android版本的頭文件,其實還是有差別的,所以在做兼容性問題的時候,需要根據版本來適配不同的頭文件

到此這篇關於Android AndFix熱修復原理詳情的文章就介紹到這瞭,更多相關Android AndFix 內容請搜索WalkonNet以前的文章或繼續瀏覽下面的相關文章希望大傢以後多多支持WalkonNet!

推薦閱讀: