Android Transform + ASM 初探

有風度開荒隊發表於2019-04-27

背景

隨著專案中對 APM (Application Performance Management) 越來越關注,諸如像 Debug 日誌,執行耗時監控等都會陸陸續續加入到原始碼中,隨著功能的增多,這些監控日誌程式碼在某種程度上會影響甚至是干擾業務程式碼的閱讀,筆者於是查閱有沒有一些可以自動化在程式碼中插入日誌的方法,“插樁”就映入眼簾了,本質的思想都是 AOP,在編譯或執行時動態注入程式碼。本文選了一種在編譯期間修改位元組碼的方法,實現在方法執行前後插入日誌程式碼的方式進行一些初步的試探,目的旨在學習這個流程。

概述

交待完背景後,先對接下來要講的內容做一個簡要的說明。因為是編譯期間搞事情,所以首先要在編譯期間找一個時間點,這也就是標題前半部分 Transform 的內容;找到“作案”地點後,接下來就是“作案物件”了,這裡選擇的是對編譯後的 .class 位元組碼下手,要到的工具就是後半部分要介紹的 ASM 了。至此,希望讀者能對本文要講的內容有一個初步的印象了。

Transform

先上圖

Android Transform + ASM 初探
官方出品的編譯打包簽名流程,我們要搞事情的位置就是 Java Compiler 編譯成 .class Files 之到打包為 .dex Files 這之間。Google 官方在 Android Gradle 的 1.5.0 版本以後提供了 Transfrom API, 允許第三方自定義外掛在打包 dex 檔案之前的編譯過程中操作 .class 檔案,所以這裡先要做的就是實現一個自定義的 Transform 進行.class檔案遍歷拿到所有方法,修改完成對原檔案進行替換。

下面說一下如何引入 Transform 依賴,在 Android gradle 外掛 1.5 版本以前,是有一個單獨的 transform api 的;從 2.0 版本開始,就直接併入到 gradle api 中了。

Gradle 1.5:

Compile ‘com.android.tools.build:transfrom-api:1.5.0複製程式碼

Gradle 2.0 開始:

implementation 'com.android.tools.build:gradle-api:3.0.1'
複製程式碼

每個 Transform 其實都是一個 Gradle task,他們鏈式組合,前一個的輸出作為下一個的輸入,而我們自定義的 Transform 是作為第一個 task 最先執行的。

本文是基於 buildSrc 的方式定義 Gradle 外掛的,因為只在 Demo 專案中應用,所以 buildSrc 的方式就夠了。需要注意一點的是,buildSrc 方式要求 library module 的名稱必須為 buildSrc,在實現中注意一下。

廢話少說,直接上圖:

buildSrc module:

Android Transform + ASM 初探
在 buildSrc 中自定義一個基於 Groovy 的外掛

Android Transform + ASM 初探
在主專案 App 的 build.gradle 中引入自定義的 AsmPlugin

apply plugin: AsmPlugin
複製程式碼

最後,在 settings.gradle 中加入 buildSrc module

include ':app', ':buildSrc'
複製程式碼

至此,我們就完成了一個自定義的外掛,功能十分簡陋,只是在控制檯輸出 “hello gradle plugin",讓我們編譯一下看看這個外掛到底有沒有生效。

Android Transform + ASM 初探
好了,看到控制檯的輸出表明我們自定義的外掛生效了,“作案地方”就此埋伏完畢。

後面會定義一個 AsmTransform,註冊到 AsmPlugin 中,具體程式碼會在介紹 ASM 的時候貼出來。

ASM

有了搞事情的時機,怎麼去修改位元組碼呢?此時神器 ASM 就出場了。

ASM 是一個功能比較齊全的 Java 位元組碼操作與分析框架。它能被用來動態生成類或者增強既有類的功能。ASM 可以直接 產生二進位制 class 檔案,也可以在類被載入入 Java 虛擬機器之前動態改變類的行為。

更多細節可以去 [ASM 官網](https://asm.ow2.io/) 看看。

筆者寫 Demo 的時候最新的版本是 7.0。

ASM 提供一種基於 Visitor 的 API,通過介面的方式,分離讀 class 和寫 class 的邏輯,提供一個 ClassReader 負責讀取class位元組碼,然後傳遞給 Class Visitor 介面,Class Visitor 介面提供了很多 visitor 方法,比如 visit class,visit method 等,這個過程就像 ClassReader 帶著 ClassVisitor 遊覽了 class 位元組碼的每一個指令。

光有讀還不夠,如果我們要修改位元組碼,ClassWriter 就出場了。ClassWriter 其實也是繼承自 ClassVisitor 的,所做的就是儲存位元組碼資訊並最終可以匯出,那麼如果我們可以代理 ClassWriter 的介面,就可以干預最終生成的位元組碼了。

好,還是廢話少說,直接上程式碼。

先看一下外掛目錄的結構

Android Transform + ASM 初探
這裡新建了 AsmTransform 外掛,以及 class visitor 的 adapter(TestMethodClassAdapter),使得在 visit method 的時候可以呼叫自定義的 TestMethodVisitor。

同時,buildSrc 的 build.gradle 中也要引入 ASM 依賴

// ASM 相關
implementation 'org.ow2.asm:asm:7.1'
implementation 'org.ow2.asm:asm-util:7.1'
implementation 'org.ow2.asm:asm-commons:7.1'
複製程式碼

下面先來看一下 AsmTransform

import com.android.build.api.transform.DirectoryInput
import com.android.build.api.transform.Format
import com.android.build.api.transform.JarInput
import com.android.build.api.transform.QualifiedContent
import com.android.build.api.transform.Transform
import com.android.build.api.transform.TransformException
import com.android.build.api.transform.TransformInput
import com.android.build.api.transform.TransformInvocation
import com.android.build.api.transform.TransformOutputProvider
import com.android.build.gradle.internal.pipeline.TransformManager
import me.sure.asm.TestMethodClassAdapter
import org.apache.commons.io.FileUtils
import org.gradle.api.Project
import org.objectweb.asm.ClassReader
import org.objectweb.asm.ClassWriter

class AsmTransform extends Transform {

    Project project

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

    @Override
    void transform(TransformInvocation transformInvocation) throws TransformException, InterruptedException, IOException {
        super.transform(transformInvocation)
        println("===== ASM Transform =====")
        println("${transformInvocation.inputs}")
        println("${transformInvocation.referencedInputs}")
        println("${transformInvocation.outputProvider}")
        println("${transformInvocation.incremental}")

        //當前是否是增量編譯
        boolean isIncremental = transformInvocation.isIncremental()
        //消費型輸入,可以從中獲取jar包和class資料夾路徑。需要輸出給下一個任務
        Collection<TransformInput> inputs = transformInvocation.getInputs()
        //引用型輸入,無需輸出。
        Collection<TransformInput> referencedInputs = transformInvocation.getReferencedInputs()
        //OutputProvider管理輸出路徑,如果消費型輸入為空,你會發現OutputProvider == null
        TransformOutputProvider outputProvider = transformInvocation.getOutputProvider()
        for (TransformInput input : inputs) {
            for (JarInput jarInput : input.getJarInputs()) {
                File dest = outputProvider.getContentLocation(
                        jarInput.getFile().getAbsolutePath(),
                        jarInput.getContentTypes(),
                        jarInput.getScopes(),
                        Format.JAR)
                //將修改過的位元組碼copy到dest,就可以實現編譯期間干預位元組碼的目的了        
                transformJar(jarInput.getFile(), dest)
            }
            for (DirectoryInput directoryInput : input.getDirectoryInputs()) {
                println("== DI = " + directoryInput.file.listFiles().toArrayString())
                File dest = outputProvider.getContentLocation(directoryInput.getName(),
                        directoryInput.getContentTypes(), directoryInput.getScopes(),
                        Format.DIRECTORY)
                //將修改過的位元組碼copy到dest,就可以實現編譯期間干預位元組碼的目的了
                //FileUtils.copyDirectory(directoryInput.getFile(), dest)
                transformDir(directoryInput.getFile(), dest)
            }
        }
    }

    @Override
    String getName() {
        return AsmTransform.simpleName
    }

    @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
    }

    private static void transformJar(File input, File dest) {
        println("=== transformJar ===")
        FileUtils.copyFile(input, dest)
    }

    private static void transformDir(File input, File dest) {
        if (dest.exists()) {
            FileUtils.forceDelete(dest)
        }
        FileUtils.forceMkdir(dest)
        String srcDirPath = input.getAbsolutePath()
        String destDirPath = dest.getAbsolutePath()
        println("=== transform dir = " + srcDirPath + ", " + destDirPath)
        for (File file : input.listFiles()) {
            String destFilePath = file.absolutePath.replace(srcDirPath, destDirPath)
            File destFile = new File(destFilePath)
            if (file.isDirectory()) {
                transformDir(file, destFile)
            } else if (file.isFile()) {
                FileUtils.touch(destFile)
                transformSingleFile(file, destFile)
            }
        }
    }

    private static void transformSingleFile(File input, File dest) {
        println("=== transformSingleFile ===")
        weave(input.getAbsolutePath(), dest.getAbsolutePath())
    }

    private static void weave(String inputPath, String outputPath) {
        try {
            FileInputStream is = new FileInputStream(inputPath)
            ClassReader cr = new ClassReader(is)
            ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES)
            TestMethodClassAdapter adapter = new TestMethodClassAdapter(cw)
            cr.accept(adapter, 0)
            FileOutputStream fos = new FileOutputStream(outputPath)
            fos.write(cw.toByteArray())
            fos.close()
        } catch (IOException e) {
            e.printStackTrace()
        }
    }
}
複製程式碼

我們的 InputTypes 是 CONTENT_CLASS, 表明是 class 檔案,Scope 先無腦選擇 SCOPE_FULL_PROJECT 在 transform 方法中主要做的事情就是把 Inputs 儲存到 outProvider 提供的位置去。生成的位置見下圖:

Android Transform + ASM 初探

對照程式碼,主要有兩個 transform 方法,一個 transformJar 就是簡單的拷貝,另一個 transformSingleFile,我們就是在這裡用 ASM 對位元組碼進行修改的。 關注一下 weave 方法,可以看到我們藉助 ClassReader 從 inputPath 中讀取輸入流,在 ClassWriter 之前用一個 adapter 進行了封裝,接下來就讓我們看看 adapter 做了什麼。

public class TestMethodClassAdapter extends ClassVisitor implements Opcodes {

    public TestMethodClassAdapter(ClassVisitor classVisitor) {
        super(ASM7, classVisitor);
    }

    @Override
    public MethodVisitor visitMethod(int access, String name, String descriptor, String signature, String[] exceptions) {
        MethodVisitor mv = super.visitMethod(access, name, descriptor, signature, exceptions);
        return (mv == null) ? null : new TestMethodVisitor(mv);
    }
}
複製程式碼

這個 adapter 接收一個 classVisitor 作為輸入(即 ClassWriter),在 visitMethod 方法時使用自定義的 TestMethodVisitor 進行訪問,再看看 TestMethodVisitor:

public class TestMethodVisitor extends MethodVisitor {

    public TestMethodVisitor(MethodVisitor methodVisitor) {
        super(ASM7, methodVisitor);
    }

    @Override
    public void visitMethodInsn(int opcode, String owner, String name, String descriptor, boolean isInterface) {
        System.out.println("== TestMethodVisitor, owner = " + owner + ", name = " + name);
        //方法執行之前列印
        mv.visitLdcInsn(" before method exec");
        mv.visitLdcInsn(" [ASM 測試] method in " + owner + " ,name=" + name);
        mv.visitMethodInsn(INVOKESTATIC,
                "android/util/Log", "i", "(Ljava/lang/String;Ljava/lang/String;)I", false);
        mv.visitInsn(POP);

        super.visitMethodInsn(opcode, owner, name, descriptor, isInterface);

        //方法執行之後列印
        mv.visitLdcInsn(" after method exec");
        mv.visitLdcInsn(" method in " + owner + " ,name=" + name);
        mv.visitMethodInsn(INVOKESTATIC,
                "android/util/Log", "i", "(Ljava/lang/String;Ljava/lang/String;)I", false);
        mv.visitInsn(POP);
    }
}
複製程式碼

TestMethodVisitor 重寫了 visitMethodInsn 方法,在預設方法前後插入了一些 “位元組碼”,這些位元組碼近似 bytecode,可以認為是 ASM 格式的 bytecode。具體做的事情其實就是分別輸出了兩條日誌:

Log.i("before method exec", "[ASM 測試] method in" + owner + ", name=" + name);
Log.i("after method exec", "method in" + owner + ", name=" + name);
複製程式碼

話說這麼囉哩囉嗦的寫一堆就是幹這麼點兒事兒啊,寫起來也太麻煩了吧。 別擔心,ASM 提供了一款的外掛,可以轉化原始碼為 ASM bytecode。地址在[這裡](https://plugins.jetbrains.com/plugin/5918-asm-bytecode-outline)

找一個簡單的方法試一下,見下圖:

Android Transform + ASM 初探
左邊是原始碼,test 方法也是隻打了一條日誌,右圖是外掛翻譯出來的“ASMified” 程式碼,如果想看 bytecode,也是有的哈。

最後讓我們看看編譯後的 AsmTest.class 變成了什麼樣

Android Transform + ASM 初探
可以看到,不單在 test() 方法中原本的日誌前後新加入日誌,連建構函式方法前後都加了,這是因為對 visitorMethod 方法沒有進行任何區分和限制,所以任何方法呼叫前後都被“插樁”了。

結語

至此,通過 Transform + ASM 的方式在編譯期間修改位元組碼的流程就算介紹完畢了,因為只是一個初探,距離實際應用還有許多細節需要優化,希望本文可以對此種方式感興趣的朋友提供一點初試的方便,全當拋磚引玉了。

參考資料

google.github.io/android-gra…

asm.ow2.io/

asm.ow2.io/asm4-guide.…

quinnchen.me/2018/09/13/…

plugins.jetbrains.com/plugin/5918…

www.sensorsdata.cn/blog/201812…

相關文章