android字節碼插樁研究

背景

之前在極客時間上面學習張紹文老師的《Android開發高手課》的時候,有一章節講了android中編譯插樁的三種方法:AspectJ、ASM、Redex。覺得這個東西好厲害,就想着要弄懂它,在後面章節的Sample練習中也詳細講解了ASM與TransForm結合在android插樁中的運用。但是這個知識點還是有點難度的,想要弄懂這個知識點還是需要很多儲備知識的。

知識點

要想理解android中字節碼插樁的運用,需要掌握以下幾個知識點:

  • 面向切面編程思想(AOP)
  • 自定義gradle插件
  • Transform相關知識
  • ASM相關知識
  • 將plugin、Transform、ASM結合起來使用

AOP簡介

AOP(Aspect Oriented Program)是一種面向切面編程的思想。這種思想是相對於OOP(Object Oriented Programming)來說的。這裏可以參考鄧凡平老師的深入理解Android之AOP,這篇文章講的非常好。Java中的面向對象編程的特點是繼承、多態和封裝。這就使功能被劃分到一個一個模塊中,模塊之間通過設計好的接口交互。OOP的精髓就使把功能或者問題模塊化。
但是在現實中,我們會有一些這樣的需求,比如:在項目中所有模塊都添加日誌統計模塊,統計每個方法的運行時間等。這個如果用OOP的思想來實現的話,需要在每個模塊的每個方法中添加需要的代碼。而通過AOP就能很好的解決這個問題,AOP可以理解爲在代碼運行期間,動態地將代碼切入到類中的指定方法、指定位置上的編程思想。注意這是一種編程思想,它的實現方式有很多,比如java中的動態代理,aspect以及我們今天要講的通過asm來實現。

自定義Gradle插件

本來想直接將Transform相關的知識的,但是,Transform一般在自定義插件中使用,所以如果不先介紹自定義插件的話,可能看不懂要講的Transform,這裏就簡單介紹一下自定義插件。
這裏推薦在AndroidStudio中自定義Gradle插件,這篇文章詳細講解了如何在android studio中創建Gradle插件,這裏就不再細述。創建好了之後我們會在groovy文件夾下面創建一個繼承Plugin類的子類,如下:

package com.soulmate.plugin.lifecycle

import org.gradle.api.Plugin
import org.gradle.api.Project

class CustomPlugin implements Plugin<Project> {

    @Override
    void apply(Project project) {
       project.task("testTask"){
            doLast{
                println "hello from the CustomPlugin"
            }
        }
    }
}
複製代碼

當我們在Terminal中輸入gradle testTask的時候,會看到輸出“hello from the CustomPlugin”,後面通過Transform來處理時,也是在apply方法中進行處理的。

Transform簡介

在官方文檔中是這麼形容Transform:

Starting with 1.5.0-beta1,the Gradle Plugin includes a Transform API allowing 
3rd party plugins to manipulate compiled class files before they are converted to dex files     

The goal of this API is to simplify injecting custom class manipulations without having to deal with tasks,
and to offer more flexibility on what is manipulated. The internal code processing (jacoco, progard, multi-dex) 
have all moved to this new mechanism already in 1.5.0-beta1
複製代碼

簡單翻譯一下就是Gradle工具從1.5.0版本開始提供Transform API,在編譯後的class文件轉換成dex文件之前,通過Transform API來處理編譯後的class文件。

Transform API的目標是不需要通過處理任務來簡化注入自定義類的操作,在處理上面提供了更大的靈活性。包括(proguard、multi-dex等)都在1.5.0中遷移到這個新機制中。

簡單總結就是Transform API是操作編譯後的.class文件,而我們知道.class文件中是java編譯後的字節碼,所以Transform相當於提供了一個操作字節碼的入口。(具體java中的字節碼相關知識可以網上搜索,這裏我強烈推薦一下《深入理解Java虛擬機》這本書,這本書上面對字節碼有很詳細的講解)。而由於字節碼的操作比較複雜,我們一般需要藉助工具來處理java字節碼,ASM工具就是一個非常好的字節碼處理工具,後面我們會介紹ASM在處理字節碼方面的運用。

Transform的代碼結構

我們寫一個TestTransform繼承Transform然後看一些重寫的方法。

public class TestTransform extends Transform {
    private static Project project

    TestTransform(Project project) {
        this.project = project
    }

    @Override
    public String getName() {
        return “TestTransform”;
    }

    /**
     * 需要處理的數據類型,有兩種枚舉類型
     * CLASSES 代表處理的編譯後的class文件,RESOURCES 代表要處理的java資源
     */
    @Override
    public Set<QualifiedContent.ContentType> getInputTypes() {
        return TransformManager.CONTENT_CLASS;
    }

    /**
     * 值Transform 的作用範圍,有一下7種類型:
     * 1.EXTERNAL_LIBRARIES        只有外部庫
     * 2.PROJECT                   只有項目內容
     * 3.PROJECT_LOCAL_DEPS        只有項目的本地依賴(本地jar)
     * 4.PROVIDED_ONLY             只提供本地或遠程依賴項
     * 5.SUB_PROJECTS              只有子項目
     * 6.SUB_PROJECTS_LOCAL_DEPS   只有子項目的本地依賴項(本地jar)
     * 7.TESTED_CODE               由當前變量(包括依賴項)測試的代碼
     */
    @Override
    public Set<? super QualifiedContent.Scope> getScopes() {
        return TransformManager.SCOPE_FULL_PROJECT;
    }

    //是否支持增量編譯
    @Override
    public boolean isIncremental() {
        return false;
    }

    //這個方法用來進行具體的輸入輸出處理,這裏可以獲取輸入的目錄文件以及jar包文件
    @Override
    public void transform(TransformInvocation transformInvocation) throws TransformException, InterruptedException, IOException {
        super.transform(transformInvocation);
    }
}
複製代碼

這裏要補充一下,現在自定義的Transform有了,自定義的plugin也有了,如何將兩者關聯起來了。這時我們需要用到一個類AppExtension,這個類繼承自BaseExtension。我們在TestPlugin類中改寫apply方法:

package com.soulmate.plugin.lifecycle

import com.android.build.gradle.AppExtension
import org.gradle.api.Plugin
import org.gradle.api.Project

class CustomPlugin implements Plugin<Project> {

    @Override
    void apply(Project project) {
        AppExtension appExtension = project.extensions.findByType(AppExtension.class)
        appExtension.registerTransform(new TestTransform(project))
    }
}
複製代碼

這樣我們就將自定義的插件和Transform關聯起來了。接下來我們介紹一下ASM相關的知識,然後最後在講解在transform()方法中使用ASM來處理相應的需求

ASM簡介

ASM是一個java字節碼操控框架。它能被用來 動態生成類或者增強既有類的功能。ASM採用的是Visitor設計模式對字節碼進行訪問和修改,核心類主要有以下幾個:

  • ClassReader: 它將字節數組或者class文件讀入內存當中,並以樹的數據結構表示,樹中的一個節點代表class文件中的某個區域。可以將ClassReader看做是接受訪問者(accept)的實現類,其每一個元素都可以被Visitor訪問
  • ClassVisitor(抽象類):ClassReader對象創建之後,調用ClassReader#accept()方法,傳入一個ClassVisitor對象。在ClassReader中遍歷樹結構的不同節點時會調用ClassVisitor對象不同的visit()方法,從而實現對字節碼的修改。在ClassVisitor中的一些訪問會產生子過程,比如visitMethod會產生MethodVisitor,visitField會產生FieldVisitor。我們也可以對這些Visitor進行實現,從而達到對這些子節點的字節碼的訪問和修改。如自帶的AdviceAdapter就是繼承自MethodVisitor.
  • ClassWriter:繼承自ClassVisitor,它是生成字節碼的工具類,它一般是責任鏈的最後一個節點,其之前的每一個ClassVisitor都是致力於對原始字節碼做修改,而ClassWriter的操作則是把每個節點修改後的字節碼輸出爲字節數組。

ASM工作流程

  1. ClassReader讀取字節碼到內存中,生成用於表示該字節碼的內部表示的樹,ClassReader對應於訪問者模式被訪問的元素
  2. 組裝ClassVisitor責任鏈,這一系列ClassVisitor完成對字節碼一系列不同的字節碼修改工作
  3. 然後調用ClassReader#accept()方法,傳入ClassVisitor對象,此ClassVisitor是責任鏈的頭結點,經過責任鏈中每一個ClassVisitor對已加載進內存的字節碼的樹結構上的每個節點的訪問和修改
  4. 最後,在責任鏈的末端,調用ClassWriter中的visitor進行修改後的字節碼的輸出工作。

ASM使用demo

這裏主要給的是巴巴巴巴巴巴掌的文章手摸手增加字節碼往方法體內插代碼,這個例子對於理解asm中具體的插入代碼方式有非常直觀的理解。這裏我就不貼出具體代碼了,我只是將main()方法中的

FileOutputStream fos = new FileOutputStream("out/Bazhang223.class");
fos.write(code);
fos.close();
複製代碼

替換成了

FileOutputStream fos = new FileOutputStream("Bazhang223.class");
fos.write(code);
fos.close();
複製代碼

運行main方法後,會在as的根目錄下面生成Bazhang223.class文件。打開這個class文件,你會發現你想要添加的兩個輸出已經添加成功了。

Plugin、Transform和ASM的結合使用

前面我們已經對每一個都進行了介紹,現在我們對這三者的概念應該有了清晰的認識,接下來就要看看如何將三者結合起來使用了。
自定義plugin這個不用說,肯定是首先需要做的事。

然後我們需要做的是重寫自定義自定義的Transform子類中的transform()方法,這個方法非常重要,這個方法是所有業務邏輯的入口,在這個方法裏面你可以遍歷所有目錄和jar包,獲取所有的class文件,然後做需要的處理。具體遍歷的代碼如下。

//Transform 的 inputs 有兩種類型,一種是目錄,一種是 jar 包,要分開遍歷
inputs.each { TransformInput input ->
    //遍歷directoryInputs
    input.directoryInputs.each { DirectoryInput directoryInput ->
        //do Something
    }

    //遍歷jarInputs
    input.jarInputs.each { JarInput jarInput ->
        //do Something
    }
}
複製代碼

既然我們可以獲取所有的class文件了,那麼現在我們就可以對每個class文件進行修改了,修改class文件就用到了ASM。這裏就以《android高手開發課》上面的例子講一下,將每個class文件轉換成字節數組,然後傳給下面的方法:

public static run(InputStream is) throws IOException {
    ClassReader classReader = new ClassReader(is);
    //COMPUTE_MAXS 說明使用 ASM 自動計算本地變量表最大值和操作數棧的最大值
    ClassWriter classWriter = new ClassWriter(ClassWriter.COMPUTE_MAXS);
    ClassVisitor classVisitor = new TraceClassAdapter(Opcodes.ASM5, classWriter);
    //EXPAND_FRAMES 說明在讀取 class 的時候同時展開棧映射幀 (StackMap Frame),在使用 AdviceAdapter 裏這項是必須打開的
    classReader.accept(classVisitor, ClassReader.EXPAND_FRAMES);
}
複製代碼

具體在ASM中如何修改這裏就不詳細說了,可以參考《android高手開發課》中的代碼。

好了,到這裏我們終於將這三者的關係講完了,這樣你應該對字節碼插樁的實現有了清晰的認識了。後面你就可以結合網上的一些案例來自己實現字節碼插樁了。

總結

編譯插樁技術還是非常重要的,我們平時用到的很多框架包括butterknifeDagger以及數據庫ORM框架都會在編譯過程中生成代碼。所以對於一名開發人員來說還是要很好的掌握這門技術的。

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