Android Transform + ASM 初探

背景

隨著項(xiàng)目中對(duì) APM (Application Performance Management) 越來越關(guān)注汉柒,諸如像 Debug 日志吆豹,運(yùn)行耗時(shí)監(jiān)控等都會(huì)陸陸續(xù)續(xù)加入到源碼中吃警,隨著功能的增多灌闺,這些監(jiān)控日志代碼在某種程度上會(huì)影響甚至是干擾業(yè)務(wù)代碼的閱讀顷编,筆者于是查閱有沒有一些可以自動(dòng)化在代碼中插入日志的方法灵份,“插樁”就映入眼簾了,本質(zhì)的思想都是 AOP沉眶,在編譯或運(yùn)行時(shí)動(dòng)態(tài)注入代碼打却。本文選了一種在編譯期間修改字節(jié)碼的方法,實(shí)現(xiàn)在方法執(zhí)行前后插入日志代碼的方式進(jìn)行一些初步的試探谎倔,目的旨在學(xué)習(xí)這個(gè)流程柳击。

概述

交待完背景后,先對(duì)接下來要講的內(nèi)容做一個(gè)簡(jiǎn)要的說明片习。因?yàn)槭蔷幾g期間搞事情捌肴,所以首先要在編譯期間找一個(gè)時(shí)間點(diǎn),這也就是標(biāo)題前半部分 Transform 的內(nèi)容藕咏;找到“作案”地點(diǎn)后哭靖,接下來就是“作案對(duì)象”了,這里選擇的是對(duì)編譯后的 .class 字節(jié)碼下手侈离,要到的工具就是后半部分要介紹的 ASM 了试幽。至此,希望讀者能對(duì)本文要講的內(nèi)容有一個(gè)初步的印象了卦碾。

Transform

image.png

官方出品的編譯打包簽名流程铺坞,我們要搞事情的位置就是 Java Compiler 編譯成 .class Files 之到打包為 .dex Files 這之間。Google 官方在 Android Gradle 的 1.5.0 版本以后提供了 Transfrom API, 允許第三方自定義插件在打包 dex 文件之前的編譯過程中操作 .class 文件洲胖,所以這里先要做的就是實(shí)現(xiàn)一個(gè)自定義的 Transform 進(jìn)行.class文件遍歷拿到所有方法济榨,修改完成對(duì)原文件進(jìn)行替換。
下面說一下如何引入 Transform 依賴绿映,在 Android gradle 插件 1.5 版本以前擒滑,是有一個(gè)單獨(dú)的 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'

每個(gè) Transform 其實(shí)都是一個(gè) Gradle task藻糖,他們鏈?zhǔn)浇M合,前一個(gè)的輸出作為下一個(gè)的輸入库车,而我們自定義的 Transform 是作為第一個(gè) task 最先執(zhí)行的巨柒。
本文是基于 buildSrc 的方式定義 Gradle 插件的,因?yàn)橹辉?Demo 項(xiàng)目中應(yīng)用柠衍,所以 buildSrc 的方式就夠了洋满。需要注意一點(diǎn)的是,buildSrc 方式要求 library module 的名稱必須為 buildSrc珍坊,在實(shí)現(xiàn)中注意一下牺勾。
廢話少說,直接上圖:
buildSrc module:


image.png

在 buildSrc 中自定義一個(gè)基于 Groovy 的插件


image.png

[圖片上傳中...(image.png-d105c7-1556521723794-0)]

在主項(xiàng)目 App 的 build.gradle 中引入自定義的 AsmPlugin

apply plugin: AsmPlugin

最后阵漏,在 settings.gradle 中加入 buildSrc module

include ':app', ':buildSrc'

至此驻民,我們就完成了一個(gè)自定義的插件,功能十分簡(jiǎn)陋袱饭,只是在控制臺(tái)輸出 “hello gradle plugin",讓我們編譯一下看看這個(gè)插件到底有沒有生效呛占。


image.png

好了虑乖,看到控制臺(tái)的輸出表明我們自定義的插件生效了,“作案地方”就此埋伏完畢晾虑。
后面會(huì)定義一個(gè) AsmTransform疹味,注冊(cè)到 AsmPlugin 中,具體代碼會(huì)在介紹 ASM 的時(shí)候貼出來帜篇。

ASM

有了搞事情的時(shí)機(jī)糙捺,怎么去修改字節(jié)碼呢?此時(shí)神器 ASM 就出場(chǎng)了笙隙。

ASM 是一個(gè)功能比較齊全的 Java 字節(jié)碼操作與分析框架洪灯。它能被用來動(dòng)態(tài)生成類或者增強(qiáng)既有類的功能。ASM 可以直接 產(chǎn)生二進(jìn)制 class 文件竟痰,也可以在類被加載入 Java 虛擬機(jī)之前動(dòng)態(tài)改變類的行為签钩。

更多細(xì)節(jié)可以去 ASM 官網(wǎng) 看看。
筆者寫 Demo 的時(shí)候最新的版本是 7.0坏快。
ASM 提供一種基于 Visitor 的 API铅檩,通過接口的方式,分離讀 class 和寫 class 的邏輯莽鸿,提供一個(gè) ClassReader 負(fù)責(zé)讀取class字節(jié)碼昧旨,然后傳遞給 Class Visitor 接口拾给,Class Visitor 接口提供了很多 visitor 方法,比如 visit class兔沃,visit method 等蒋得,這個(gè)過程就像 ClassReader 帶著 ClassVisitor 游覽了 class 字節(jié)碼的每一個(gè)指令。
光有讀還不夠粘拾,如果我們要修改字節(jié)碼窄锅,ClassWriter 就出場(chǎng)了。ClassWriter 其實(shí)也是繼承自 ClassVisitor 的缰雇,所做的就是保存字節(jié)碼信息并最終可以導(dǎo)出入偷,那么如果我們可以代理 ClassWriter 的接口,就可以干預(yù)最終生成的字節(jié)碼了械哟。
好疏之,還是廢話少說,直接上代碼暇咆。
先看一下插件目錄的結(jié)構(gòu)

image.png

這里新建了 AsmTransform 插件锋爪,以及 class visitor 的 adapter(TestMethodClassAdapter),使得在 visit method 的時(shí)候可以調(diào)用自定義的 TestMethodVisitor爸业。
同時(shí)其骄,buildSrc 的 build.gradle 中也要引入 ASM 依賴

// ASM 相關(guān)
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}")

        //當(dāng)前是否是增量編譯
        boolean isIncremental = transformInvocation.isIncremental()
        //消費(fèi)型輸入,可以從中獲取jar包和class文件夾路徑扯旷。需要輸出給下一個(gè)任務(wù)
        Collection<TransformInput> inputs = transformInvocation.getInputs()
        //引用型輸入拯爽,無需輸出。
        Collection<TransformInput> referencedInputs = transformInvocation.getReferencedInputs()
        //OutputProvider管理輸出路徑钧忽,如果消費(fèi)型輸入為空毯炮,你會(huì)發(fā)現(xiàn)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)
                //將修改過的字節(jié)碼copy到dest,就可以實(shí)現(xiàn)編譯期間干預(yù)字節(jié)碼的目的了        
                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)
                //將修改過的字節(jié)碼copy到dest耸黑,就可以實(shí)現(xiàn)編譯期間干預(yù)字節(jié)碼的目的了
                //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 提供的位置去。生成的位置見下圖:

image.png

對(duì)照代碼大刊,主要有兩個(gè) transform 方法为迈,一個(gè) transformJar 就是簡(jiǎn)單的拷貝,另一個(gè) transformSingleFile缺菌,我們就是在這里用 ASM 對(duì)字節(jié)碼進(jìn)行修改的曲尸。
關(guān)注一下 weave 方法,可以看到我們借助 ClassReader 從 inputPath 中讀取輸入流男翰,在 ClassWriter 之前用一個(gè) adapter 進(jìn)行了封裝另患,接下來就讓我們看看 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);
    }
}

這個(gè) adapter 接收一個(gè) classVisitor 作為輸入(即 ClassWriter)蛾绎,在 visitMethod 方法時(shí)使用自定義的 TestMethodVisitor 進(jìn)行訪問昆箕,再看看 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);
        //方法執(zhí)行之前打印
        mv.visitLdcInsn(" before method exec");
        mv.visitLdcInsn(" [ASM 測(cè)試] 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);

        //方法執(zhí)行之后打印
        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 方法鸦列,在默認(rèn)方法前后插入了一些 “字節(jié)碼”,這些字節(jié)碼近似 bytecode鹏倘,可以認(rèn)為是 ASM 格式的 bytecode薯嗤。具體做的事情其實(shí)就是分別輸出了兩條日志:

Log.i("before method exec", "[ASM 測(cè)試] method in" + owner + ", name=" + name);
Log.i("after method exec", "method in" + owner + ", name=" + name);

話說這么啰哩啰嗦的寫一堆就是干這么點(diǎn)兒事兒啊,寫起來也太麻煩了吧纤泵。
別擔(dān)心骆姐,ASM 提供了一款的插件,可以轉(zhuǎn)化源碼為 ASM bytecode捏题。地址在這里
找一個(gè)簡(jiǎn)單的方法試一下玻褪,見下圖:

image.png

左邊是源碼,test 方法也是只打了一條日志公荧,右圖是插件翻譯出來的“ASMified” 代碼带射,如果想看 bytecode,也是有的哈循狰。
最后讓我們看看編譯后的 AsmTest.class 變成了什么樣


image.png

可以看到窟社,不單在 test() 方法中原本的日志前后新加入日志,連構(gòu)造函數(shù)方法前后都加了绪钥,這是因?yàn)閷?duì) visitorMethod 方法沒有進(jìn)行任何區(qū)分和限制灿里,所以任何方法調(diào)用前后都被“插樁”了。

喜歡請(qǐng)點(diǎn)擊+關(guān)注哦

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末程腹,一起剝皮案震驚了整個(gè)濱河市匣吊,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌跪楞,老刑警劉巖缀去,帶你破解...
    沈念sama閱讀 206,839評(píng)論 6 482
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件侣灶,死亡現(xiàn)場(chǎng)離奇詭異甸祭,居然都是意外死亡,警方通過查閱死者的電腦和手機(jī)褥影,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 88,543評(píng)論 2 382
  • 文/潘曉璐 我一進(jìn)店門池户,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人凡怎,你說我怎么就攤上這事校焦。” “怎么了统倒?”我有些...
    開封第一講書人閱讀 153,116評(píng)論 0 344
  • 文/不壞的土叔 我叫張陵寨典,是天一觀的道長(zhǎng)。 經(jīng)常有香客問我房匆,道長(zhǎng)耸成,這世上最難降的妖魔是什么报亩? 我笑而不...
    開封第一講書人閱讀 55,371評(píng)論 1 279
  • 正文 為了忘掉前任,我火速辦了婚禮井氢,結(jié)果婚禮上弦追,老公的妹妹穿的比我還像新娘。我一直安慰自己花竞,他們只是感情好劲件,可當(dāng)我...
    茶點(diǎn)故事閱讀 64,384評(píng)論 5 374
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著约急,像睡著了一般零远。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上烤宙,一...
    開封第一講書人閱讀 49,111評(píng)論 1 285
  • 那天遍烦,我揣著相機(jī)與錄音,去河邊找鬼躺枕。 笑死服猪,一個(gè)胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的拐云。 我是一名探鬼主播罢猪,決...
    沈念sama閱讀 38,416評(píng)論 3 400
  • 文/蒼蘭香墨 我猛地睜開眼,長(zhǎng)吁一口氣:“原來是場(chǎng)噩夢(mèng)啊……” “哼叉瘩!你這毒婦竟也來了膳帕?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 37,053評(píng)論 0 259
  • 序言:老撾萬榮一對(duì)情侶失蹤薇缅,失蹤者是張志新(化名)和其女友劉穎危彩,沒想到半個(gè)月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體泳桦,經(jīng)...
    沈念sama閱讀 43,558評(píng)論 1 300
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡汤徽,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 36,007評(píng)論 2 325
  • 正文 我和宋清朗相戀三年,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了灸撰。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片谒府。...
    茶點(diǎn)故事閱讀 38,117評(píng)論 1 334
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡,死狀恐怖浮毯,靈堂內(nèi)的尸體忽然破棺而出完疫,到底是詐尸還是另有隱情,我是刑警寧澤债蓝,帶...
    沈念sama閱讀 33,756評(píng)論 4 324
  • 正文 年R本政府宣布壳鹤,位于F島的核電站,受9級(jí)特大地震影響饰迹,放射性物質(zhì)發(fā)生泄漏芳誓。R本人自食惡果不足惜讯嫂,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 39,324評(píng)論 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望兆沙。 院中可真熱鬧欧芽,春花似錦、人聲如沸葛圃。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,315評(píng)論 0 19
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽(yáng)库正。三九已至曲楚,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間褥符,已是汗流浹背龙誊。 一陣腳步聲響...
    開封第一講書人閱讀 31,539評(píng)論 1 262
  • 我被黑心中介騙來泰國(guó)打工, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留喷楣,地道東北人趟大。 一個(gè)月前我還...
    沈念sama閱讀 45,578評(píng)論 2 355
  • 正文 我出身青樓,卻偏偏與公主長(zhǎng)得像铣焊,于是被迫代替她去往敵國(guó)和親逊朽。 傳聞我的和親對(duì)象是個(gè)殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 42,877評(píng)論 2 345

推薦閱讀更多精彩內(nèi)容