Android 位元組碼插樁

王猛biu發表於2019-01-17

一、為什麼要插樁

        我們都知道JAVA是物件導向(繼承、封裝、多型),而插樁的意義在於面向切面(AOP),可想而知單方面的物件導向開發有許多的侷限性,而結合面向切面程式設計可以說補足了我們的這種侷限性。舉個例子:在onClick中一般都要做防抖動操作,這樣是為了避免多次開啟頁面的問題。一般實現的話是在每個onClick實現第二次點選的時候加個時間判斷。而插樁的話業務端可以不寫任何程式碼通過插樁的方法把這個時間判斷插入的位元組碼裡面。

從標題名字看

  • Java位元組碼:是Java虛擬機器執行的一種虛擬指令格式。通過JVM轉換生成機器指令
  • 插樁:是在保證被測程式原有邏輯完整性的基礎上在程式中插入一些探針(又稱為“探測儀”。

二、插樁能帶來什麼

Android 位元組碼插樁

三、AOP思想

Android 位元組碼插樁         Android 位元組碼插樁    Android 位元組碼插樁

       銀行系統會有一個取款流程,我們可以把方框裡的流程合為一個,另外系統還會有一個查詢餘額流程,我們先把這兩個流程放到一起,有沒有發現,這個兩者有一個相同的驗證流程,我們先把它們圈起來再說下一步,有沒有想過可以把這個驗證使用者的程式碼是提取出來,不放到主流程裡去呢,這就是AOP的作用了,有了AOP,你寫程式碼時不要把這個驗證使用者步驟寫進去,即完全不考慮驗證使用者。

  • 什麼是AOP:把這些橫跨並嵌入眾多模組裡的功能(如監控每個方法的效能) 集中起來,放到一個統一的地方來控制和管理
  • 能給我帶來什麼:不修改原始碼的情況下給程式動態統一新增功能的一種技術,把散落在程式中的公共部分提取出來,做成切面類,這樣的好處在於,程式碼的可重用,一旦涉及到該功能的需求發生變化,只要修改該程式碼就行,否則,你要到處修改,如果只要修改1、2處那還可以接受,萬一有1000處呢。

四、Android打包流程插樁入口

                     Android 位元組碼插樁

這是app打包流程的整個過程而我把這個打包流程主要分為一下步驟:

  • aapt來打包資原始檔,生成R.java檔案
  • 處理AIDL,生成對應的.java介面檔案
  • 編譯Java檔案,生成對應的.class檔案
  • 把.class檔案轉化成Davik VM支援的.dex檔案
  • 打包生成未簽名的.apk檔案。

位元組碼插樁入口:我們知道Android程式從Java原始碼到可執行的Apk包主要分析兩個環節:

  • javac:將原始檔編譯成class格式的檔案
  • dex:將class格式的檔案彙總到dex格式的檔案中

我們要想對位元組碼進行修改,只需要在javac之後,dex之前對class檔案進行位元組碼掃描,並按照一定規則進行過濾及修改就可以了,這樣修改過後的位元組碼就會在後續的dex打包環節被打到apk中,這就是我們的插樁入口。

插樁方式一、:transform api

每個Transform其實都是一個gradle task,Android編譯器中的TaskManager將每個Transform串連起來,第一個Transform接收來自javac編譯的結果,以及已經拉取到在本地的第三方依賴(jar. aar),還有resource資源,注意,這裡的resource並非android專案中的res資源,而是asset目錄下的資源。這些編譯的中間產物,在Transform組成的鏈條上流動,每個Transform節點可以對class進行處理再傳遞給下一個Transform。我們常見的混淆,Desugar等邏輯,它們的實現如今都是封裝在一個個Transform中,而我們自定義的Transform,會插入到這個Transform鏈條的最前面。

對於Android Gradle Plugin 版本在1.5.0及以上的情況,Google官方提供了transformapi用作位元組碼插樁的入口。

implementation 'com.android.tools.build:gradle:1.5.0'複製程式碼

一般使用方法為:extends Transform重寫transform()

插樁方式二:hook dx.jar

需要引入Instrumentation

Android 位元組碼插樁

通過Java Instrumentation機制,為獲得插樁入口,對於apk build過程進行了兩處插樁(即hook),圖中標紅部分:

Instrumentation:指的是可以用獨立於應用程式之外的代理(agent)程式來監測和協助執行在JVM上的應用程式。這種監測和協助包括但不限於獲取JVM執行時狀態,替換和修改類定義等。

  • 在build程式,對ProcessBuilder.start()方法進行插樁
    ProcessBuilder類是J2SE 1.5在java.lang中新新增的一個新類,此類用於建立作業系統程式,它提供一種啟動和管理程式的方法,start方法就是開始建立一個程式,對它進行插樁,使得通過下面方式啟動dx.jar程式執行dex任務時:

    java  dex.jar  com.android.dx.command.Main  --dex …........複製程式碼

    增加引數-javaagent agent.jar,使得dex程式也可以使用Java Instrumentation機制進行位元組碼插樁

  • 在dex程式
    對我們的目標方法com.android.dx.command.Main.processClasses進行位元組碼插入,從而實現打入apk的每一個專案中的類都按照我們制定的規則進行過濾及位元組碼修改。

build程式使用Instrumentation的方式時之前敘述過的VirtualMachine.loadAgent方式(方式二),dex程式中的方式則是-javaagent agent.jar方式(方式一)。

  由此,我們獲得了進行位元組碼插樁的入口,下面我們就使用ASM庫的API,對專案中的每一個類進行掃描,過濾,及位元組碼修改。



五、自定義Gradle外掛


1、建立一個Android library Module工程

å建module

2、build.gradle改成groovy方式

apply plugin: 'groovy'

    dependencies {
        compile gradleApi()
        compile localGroovy()
    }複製程式碼

3、新建.groovy類繼承 Plugin並實現apply方法,注意:類的字尾不再是.java而是.groovy

Android 位元組碼插樁

4、在main下建立resources目錄

Android 位元組碼插樁

5、增加對應的maven deployer釋出到本地或遠端倉庫

Android 位元組碼插樁

6、使用已釋出的倉庫


六、ASM


ASM 是一個 Java 位元組碼操控框架。它能被用來動態生成類或者增強既有類的功能。ASM 可以直接產生二進位制 class 檔案,也可以在類被載入入 Java 虛擬機器之前動態改變類行為。Java class 被儲存在嚴格格式定義的 .class 檔案裡,這些類檔案擁有足夠的後設資料來解析類中的所有元素:類名稱、方法、屬性以及 Java 位元組碼(指令)。ASM 從類檔案中讀入資訊後,能夠改變類行為,分析類資訊,甚至能夠根據使用者要求生成新類。


為什麼選擇ASM來進行位元組碼編織?

Framework First time Later times
Javassist 257 5.2
BCEL 473 5.5
ASM 62.4 1.1

可以使用一個外掛[ASM Bytecode Outline]更有效的用ASM編寫位元組碼

ASM(core api) 按照visitor模式按照class檔案結構依次訪問class檔案的每一部分,有如下幾個重要的visitor。

操作流程

  1. 需要建立一個 ClassReader 物件,將 .class 檔案的內容讀入到一個位元組陣列中
  2. 然後需要一個 ClassWriter 的物件將操作之後的位元組碼的位元組陣列回寫
  3. 需要事件過濾器 ClassVisitor。在呼叫 ClassVisitor 的某些方法時會產生一個新的 XXXVisitor 物件,當我們需要修改對應的內容時只要實現自己的 XXXVisitor 並返回就可以了

 ClassReader 類

這個類會將 .class 檔案讀入到 ClassReader 中的位元組陣列中,它的 accept 方法接受一個 ClassVisitor 實現類,並按照順序呼叫 ClassVisitor 中的方法

 ClassWriter 類

ClassWriter 是一個 ClassVisitor 的子類,是和 ClassReader 對應的類,ClassReader 是將 .class 檔案讀入到一個位元組陣列中,ClassWriter 是將修改後的類的位元組碼內容以位元組陣列的形式輸出。

ClassVisitor 抽象類

  • void visit(int version, int access, String name, String signature, String superName, String[] interfaces)
    該方法是當掃描類時第一個呼叫的方法,主要用於類宣告使用。下面是對方法中各個引數的示意:visit( 類版本 , 修飾符 , 類名 , 泛型資訊 , 繼承的父類 , 實現的介面)
  • AnnotationVisitor visitAnnotation(String desc, boolean visible)
    該方法是當掃描器掃描到類註解宣告時進行呼叫。下面是對方法中各個引數的示意:visitAnnotation(註解型別 , 註解是否可以在 JVM 中可見)。
  • FieldVisitor visitField(int access, String name, String desc, String signature, Object value)
    該方法是當掃描器掃描到類中欄位時進行呼叫。下面是對方法中各個引數的示意:visitField(修飾符 , 欄位名 , 欄位型別 , 泛型描述 , 預設值)
  • MethodVisitor visitMethod(int access, String name, String desc, String signature, String[] exceptions)
    該方法是當掃描器掃描到類的方法時進行呼叫。下面是對方法中各個引數的示意:visitMethod(修飾符 , 方法名 , 方法簽名 , 泛型資訊 , 丟擲的異常)
  • void visitEnd()
    該方法是當掃描器完成類掃描時才會呼叫,如果想在類中追加某些方法

  • MethodVisitor & AdviceAdapter

    MethodVisitor 是一個抽象類,當 ASM 的 ClassReader 讀取到 Method 時就轉入 MethodVisitor 介面處理。
    AdviceAdapter 是 MethodVisitor 的子類,使用 AdviceAdapter 可以更方便的修改方法的位元組碼。

    AdviceAdapter

    其中比較重要的幾個方法如下:

    1. void visitCode():表示 ASM 開始掃描這個方法
    2. void onMethodEnter():進入這個方法
    3. void onMethodExit():即將從這個方法出去
    4. void onVisitEnd():表示方法掃碼完畢



    位元組碼基礎

    • 全限定名即為全類名中的“.”,換為“/”,舉例:
      類android.widget.AdapterView.OnItemClickListener的全限定名為:
      android/widget/AdapterView$OnItemClickListener複製程式碼
    • 描述符(descriptors):
      1.型別描述符,如下圖所示:

    Android 位元組碼插樁

    在class檔案中型別 boolean用“Z”描述,陣列用“[”描述(多維陣列可疊加),那麼我們最常見的自定義引用型別呢?“L全限定名;”.例如:
    Android中的android.view.View類,描述符為“Landroid/view/View;”

    2.方法描述符的組織結構為:

    (引數型別描述符)返回值描述符複製程式碼複製程式碼

    其中無返回值void用“V”代替,舉例:

    方法boolean onGroupClick(ExpandableListView parent, View v, int groupPosition, long id)  的描述符如下:
    (Landroid/widget/ExpandableListView;Landroid/view/View;IJ)Z複製程式碼



    Android 位元組碼插樁


    對上圖中三個步驟的詳細說明:

    步驟一:

    ASM的ClassVisitor對所有類的class檔案進行掃描,在visitMethod()方法中判斷是不是BaseActivity,如果是進行步驟二,否則終止掃描;

    步驟二:

    ClassVisitor每掃描到一個方法時,在visitMethod中進行如下判定:

    1. 是不是要過濾的<init>方法

    如果判定通過,則證明本次掃描到的方法是需要注入位元組碼的方法,然後將
    將掃描邏輯交給MethodVisitor,進行位元組碼的修改(步驟三)。

    步驟三:修改掃碼到的方法位元組碼

    假設待修改的方法如下:

    public int test() {
      try {  
          Thread.sleep(1000);
      } catch (InterruptedException e) {
          e.printStackTrace();
      }
    }複製程式碼

    修改之後需要變成:

    public int test() {
       long startTime = System.currentTimeMillis();
       try {  
           Thread.sleep(1000);}
       catch (InterruptedException e){ 
           e.printStackTrace();  
       }
       long timing = System.currentTimeMillis() - startTime;
       BlockManager.timingPage(getLocalClassName(), timing);
    }複製程式碼


    相關文章