字節碼插樁--你也能夠輕鬆掌握

1 什麼是插樁?

聽到關於「插樁」的詞語,第一眼以爲會很高深,那到底什麼是插樁呢?用通俗的話來說,插樁就是將一段代碼經過某種策略插入到另外一段代碼,或替換另外一段代碼。這裏的代碼能夠分爲源碼和字節碼,而咱們所說的插樁通常指字節碼插樁。 圖1是Android開發者常見的一張圖,咱們編寫的源碼(.java)經過javac編譯成字節碼(.class),而後經過dx/d8編譯成dex文件。 html

圖1:Java-字節碼-dex,圖片來自-極客時間
咱們下面要講的插樁,就是在.class轉爲.dex以前,修改.class文件從而達到修改或替換代碼的目的。 那有人確定會有這樣的疑問?既然插樁是插入或替換代碼,那爲什麼我不本身直接插入或替換呢?爲什麼還要用這麼「複雜」的工具?彆着急,第二個問題將會給你答案。

2 插樁的應用場景有哪些?

技術是服務於業務的,一個沒法推動業務進步的技術並不值得咱們學習。在上面,咱們對插樁的理解是:插入,替換代碼。那麼,結合這個核心主線咱們來挖掘插樁能被應用的場景有哪些?java

代碼插入

咱們所熟悉的ButterKnife,Dagger這些經常使用的框架,也是在編譯期間生成了代碼,簡化了程序員的操做。假設有這麼一個需求,要監控某些或者全部方法的執行耗時?你會怎麼作呢?若是你監控的方法只有十幾個或者幾十個,那麼也許經過程序員自身的編碼就能輕鬆解決;可是若是監控的方法達到百千甚至萬級別,你還經過編碼來解決?那麼程序員存在的價值在哪裏?面對這樣的重複勞動問題,最早想到的就應該是自動化,也就是咱們今天所講的插樁。經過插樁,咱們掃描每個class文件,並針對特定規則進行字節碼修改從而達到監控每一個方法耗時的目的。關於如何實現這樣的需求,後面我會詳細講述。node

代碼替換

若是遇到這麼一個需求,須要將項目中全部使用某個方法(如Dialog.show())的地方替換成本身包裝的方法(MyDialog.show()),那麼你該如何解決呢?有人會說,直接使用快捷鍵就能全局替換。那麼有兩個問題android

  1. 若是有其餘類定義了show()方法,並被調用了,直接使用快捷鍵是否會被錯誤替換?
  2. 若是其餘引用包使用了該方法,你怎麼替換呢?

不要緊,插樁一樣能夠解決你的問題。 綜合上面所說的兩點,其實不少業務場景都使用了插樁技術,好比無痕埋點,性能監控等。程序員

3 掌握插樁應該具有的基礎知識有哪些?

上面講了插樁的應用場景,是否如今想躍躍欲試呢?彆着急,想掌握好插樁技術,練就紮實的插樁功底,咱們是須要具有一些基礎知識的。api

  • 熟練掌握字節碼相關技術。可參考 一文讓你明白Java字節碼數組

  • Gradle自定義插件,直接參考官網 Writing Custom pluginsbash

  • 若是你想運用在Android項目中,那麼還須要掌握Transform API, 這是android在將class轉成dex以前給咱們預留的一個接口,在該接口中咱們能夠經過插件形式來修改class文件。app

  • 字節碼修改工具。如AspectJ,ASM,javasisst。這裏我推薦使用ASM,關於ASM相關知識,在下一章我給你們簡單介紹。一樣你們能夠參考 Asm官方文檔框架

  • groovy語言基礎

若是你具有了上面5塊知識,那麼恭喜你,會很順利的完成字節碼插樁技術了。下面,我經過實戰一個很簡單的例子,帶領你們一塊兒領略插樁的風采。

4 使用ASM進行字節碼插樁

1 什麼是ASM?

ASM是生成和轉換已編譯的Java類工具,就是咱們插樁須要使用的工具。

2 兩種API?

ASM提供了兩種API來生成和轉換已編譯類,一個是核心API,以基於事件形式來表示類;另外一個是樹API,以基於對象形式來表示類。

3 基於事件形式

咱們經過上面的基礎知識,瞭解到類的結構,類包含字段,方法,指令等;基於事件的API把類看做是一系列事件來表示,每個類的事件表示一個類的元素。相似解析XML的SAX

4 基於對象形式

基於對象的API將類表示成一棵對象樹,每一個對象表示類的一部分。相似解析XML的DOM

5 優缺點比較

事件形式 對象形式
內存佔用
實現難度

經過上面表格,咱們清楚的瞭解到:

  • 事件API內存佔用少於對象API,由於事件API不須要在內存中建立和存儲對象樹
  • 事件API實現難度比對象API大,由於事件API在任意時刻類中只有一個元素可以使用,可是對象API能得到整個類。

那麼接下來,咱們就經過比較容易實現的對象API入手,一塊兒完成上面的需求。 咱們Android的構建工具是Gradle,所以咱們結合transform和Gradle插件方式來完成該需求,接下來咱們來看看gradle官方提供的3種插件形式 6 Gradle插件的3種形式

插件形式 說明
Build script 直接在build script中寫插件代碼,不可複用
buildSrc 獨立項目結構,只能在本構建體系中複用,沒法提供給其餘項目
Standalone 獨立項目結構,發佈到倉庫,能夠複用

因爲咱們是demo,並不須要共享給其餘項目,所以採用buildSrc方式便可,可是正常項目中都採用Standalone形式。

5 插樁實踐

目標 : 刪除全部以test開頭的方法

接下來咱們來完成一個很是小的需求,刪除全部以test開頭的方法。爲何說這是一個小需求,由於這並不涉及指令的操做,全部操做經過方法名完成便可。經過完成這個demo,只是拋磚引玉。如若後期須要,能夠逐步深刻到指令級別替換。 接下來的步驟就是建立demo的過程

  • 1 新建buildSrc目錄,用來存放源代碼位置。針對不一樣語言能夠新建不一樣目錄。
    圖2-項目總體結構
    如上圖所示的是buildSrc的結構。
  • 2 在buildSrc的gradle文件中咱們須要配置以下代碼
apply plugin: 'groovy'
dependencies {
   compile gradleApi()//在使用自定義插件時候,必定要引用org.gradle.api.Plugin
   compile 'com.android.tools.build:gradle:3.3.2'//使用自定義transform時候,須要引用com.android.build.api.transform.Transform
   compile 'org.ow2.asm:asm:6.0'
   compile 'commons-io:commons-io:2.6'
}
repositories {
   mavenCentral()
   jcenter()
   google()
}
複製代碼
  • 3 重寫Transform API 在groovy目錄下新建一個groovy類並繼承Transform,注意導包com.android.build.api.transform,並實現抽象方法和transform方法,以下
class MyTransform extends Transform {
   Project project
   MyTransform(Project project) {
       this.project = project
   }
   @Override
   String getName() {
       return "MyTransform"
   }
   //設置輸入類型,咱們是針對class文件處理
   @Override
   Set<QualifiedContent.ContentType> getInputTypes() {
       return TransformManager.CONTENT_CLASS
   }
   //設置輸入範圍,咱們選擇整個項目
   @Override
   Set<? super QualifiedContent.Scope> getScopes() {
       return TransformManager.SCOPE_FULL_PROJECT
   }
   @Override
   boolean isIncremental() {
       return true
   }
   //重點就是該方法,咱們須要將修改字節碼的邏輯就從這裏開始
   @Override
   void transform(Context context, Collection<TransformInput> inputs, Collection<TransformInput> referencedInputs, TransformOutputProvider outputProvider, boolean isIncremental) throws IOException, TransformException, InterruptedException {
       inputs.each {
           TransformInput input ->
               input.getJarInputs().each {
               //處理jar文件,代碼太多,這裏暫時不貼
               }
               input.getDirectoryInputs().each {
               //處理目錄文件,這裏的ASMHelper.transformClass()是修改字節碼邏輯
                   def destDir = transformInvocation.outputProvider.getContentLocation(
                           "${dir.name}_transformed",
                           dir.contentTypes,
                           dir.scopes,
                           Format.DIRECTORY)
                   if (dir.file) {
                       def modifiedRecord = [:]
                       dir.file.traverse(type: FileType.FILES, nameFilter: ~/.*\.class/) {
                           File classFile ->
                               def className = classFile.absolutePath.replace(dir.getFile().getAbsolutePath(), "")
                               if (!ASMHelper.filter(className)) {
                                   def transformedClass = ASMHelper.transformClass(classFile, dir.file, transformInvocation.context.temporaryDir)
                                   modifiedRecord[(className)] = transformedClass
                               }
                       }
                       FileUtils.copyDirectory(dir.file, destDir)
                       modifiedRecord.each { name, file ->
                           def targetFile = new File(destDir.absolutePath, name)
                           if (targetFile.exists()) {
                               targetFile.delete()
                           }
                           FileUtils.copyFile(file, targetFile)
                       }
                       modifiedRecord.clear()
               }
       }
   }
}
複製代碼
  • 4 實現字節碼修改邏輯 Transform咱們已經定義完成,接下來就要針對讀入的字節碼進行修改。咱們採用對象API進行解析class文件。一共就是3個步驟:
  1. 將輸入流轉化爲ClassNode
  2. 處理ClassNode,這裏就是咱們的業務邏輯所在
  3. 將ClassNode轉爲字節數組輸出 固然還有其餘文件的IO操做,這裏由於篇幅限制未貼出,如若須要demo,能夠私信。
static byte[] modifyClass(InputStream inputStream) {
       ClassNode classNode = new ClassNode(Opcodes.ASM5)
       ClassReader classReader = new ClassReader(inputStream)
       //1 將讀入的字節轉爲classNode
       classReader.accept(classNode, 0)
       //2 對classNode的處理邏輯
       Iterator<MethodNode> iterator = classNode.methods.iterator();
       while (iterator.hasNext()) {
           MethodNode node = iterator.next()
           if (node.name.startsWith("test")) {
               iterator.remove()
           }
       }
       ClassWriter classWriter = new ClassWriter(0)
       //3  將classNode轉爲字節數組
       classNode.accept(classWriter)
       return classWriter.toByteArray()
   }
複製代碼
  • 5 插件化 上面咱們完成了字節碼修改邏輯以及定義Transform,可是並無完成插件的定義。結合Transform API咱們瞭解到,須要將咱們自定義的Transform註冊到插件中,以下
class MyPlugin implements Plugin<Project> {
    @Override
    void apply(Project project) {
        project.android.registerTransform(new MyTransform(project))
    }
}
複製代碼
  • 6 提供可對外使用的插件 插件完成了,可是怎麼才能對外使用呢?上面咱們說到,咱們採起3種插件形式之一的buildSrc。咱們上文中建立了plugin.properties文件。只須要在該文件中編輯實現類便可
implementation-class=MyPlugin
複製代碼
  • 7 應用方應用插件 在應用方的gradle文件中作以下配置
apply plugin: 'plugin'
複製代碼

上面代碼咱們注意到,plugin這個插件和plugin.properties的文件名是同樣的。是的,應用方應用的插件名和咱們定義的properties文件名保持一致。

  • 8 結果展現 源代碼以下,通過咱們插件處理以後,編譯後的字節碼應該沒有了testDemo方法。
public class MainActivity extends Activity {
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(android.R.layout.activity_list_item);
    }
    public void testDemo() {
        System.out.println("demo test");
    }
}
複製代碼

那麼,處理後的字節碼在哪呢?在*$project/build/intermediates/transforms/MyTransform/...* MyTransform是我自定義Transform的類名,下面有debug和release包。繼續下去你們應該能找到對應的類。

圖3-結果展現.png
上圖咱們看到,已經沒有的testDemo方法。 成功!

6 結束語

經過上面實戰練習,相信你已經初步掌握了插樁的基本技術,可是這還遠遠不夠;在項目中會遇到各式各樣的問題,現實狀況可能沒有demo這麼簡單;不過不要緊,若是在插樁過程當中遇到任何問題,均可以私信給我,我將盡我所能的給你提供最優質的免費諮詢服務。同時,我也很是歡迎你們互相交流技術,共同成長。

相關文章
相關標籤/搜索