Android Transform + ASM 初探

背景

隨着項目中對 APM (Application Performance Management) 愈來愈關注,諸如像 Debug 日誌,運行耗時監控等都會陸陸續續加入到源碼中,隨着功能的增多,這些監控日誌代碼在某種程度上會影響甚至是干擾業務代碼的閱讀,筆者因而查閱有沒有一些能夠自動化在代碼中插入日誌的方法,「插樁」就映入眼簾了,本質的思想都是 AOP,在編譯或運行時動態注入代碼。本文選了一種在編譯期間修改字節碼的方法,實如今方法執行先後插入日誌代碼的方式進行一些初步的試探,目的旨在學習這個流程。java

概述

交待完背景後,先對接下來要講的內容作一個簡要的說明。由於是編譯期間搞事情,因此首先要在編譯期間找一個時間點,這也就是標題前半部分 Transform 的內容;找到「做案」地點後,接下來就是「做案對象」了,這裏選擇的是對編譯後的 .class 字節碼下手,要到的工具就是後半部分要介紹的 ASM 了。至此,但願讀者能對本文要講的內容有一個初步的印象了。android

Transform

先上圖git

官方出品的編譯打包簽名流程,咱們要搞事情的位置就是 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 中了。github

Gradle 1.5:web

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

Gradle 2.0 開始:apache

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

每一個 Transform 其實都是一個 Gradle task,他們鏈式組合,前一個的輸出做爲下一個的輸入,而咱們自定義的 Transform 是做爲第一個 task 最早執行的。api

本文是基於 buildSrc 的方式定義 Gradle 插件的,由於只在 Demo 項目中應用,因此 buildSrc 的方式就夠了。須要注意一點的是,buildSrc 方式要求 library module 的名稱必須爲 buildSrc,在實現中注意一下。app

廢話少說,直接上圖:框架

buildSrc module:ide

在 buildSrc 中自定義一個基於 Groovy 的插件

在主項目 App 的 build.gradle 中引入自定義的 AsmPlugin

apply plugin: AsmPlugin
複製代碼

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

include ':app', ':buildSrc'
複製代碼

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

好了,看到控制檯的輸出代表咱們自定義的插件生效了,「做案地方」就此埋伏完畢。

後面會定義一個 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 的接口,就能夠干預最終生成的字節碼了。

好,仍是廢話少說,直接上代碼。

先看一下插件目錄的結構

這裏新建了 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 提供的位置去。生成的位置見下圖:

對照代碼,主要有兩個 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)

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

左邊是源碼,test 方法也是隻打了一條日誌,右圖是插件翻譯出來的「ASMified」 代碼,若是想看 bytecode,也是有的哈。

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

能夠看到,不單在 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…

相關文章
相關標籤/搜索