背景
隨著項(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
官方出品的編譯打包簽名流程铺坞,我們要搞事情的位置就是 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:
在 buildSrc 中自定義一個(gè)基于 Groovy 的插件
[圖片上傳中...(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è)插件到底有沒有生效呛占。
好了虑乖,看到控制臺(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)
這里新建了 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 提供的位置去。生成的位置見下圖:
對(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)單的方法試一下玻褪,見下圖:
左邊是源碼,test 方法也是只打了一條日志公荧,右圖是插件翻譯出來的“ASMified” 代碼带射,如果想看 bytecode,也是有的哈循狰。
最后讓我們看看編譯后的 AsmTest.class 變成了什么樣
可以看到窟社,不單在 test() 方法中原本的日志前后新加入日志,連構(gòu)造函數(shù)方法前后都加了绪钥,這是因?yàn)閷?duì) visitorMethod 方法沒有進(jìn)行任何區(qū)分和限制灿里,所以任何方法調(diào)用前后都被“插樁”了。