*本篇文章已授權微信公眾號 guolin_blog (郭霖)獨家發布
前言
第一次看到插樁,是在Android開發高手課中??赐耆ゲ榱艘幌拢骸斑?!還有這東西,有點意思”。
本著不斷學習和探索的精神,便走上學習函數插樁的“不歸路”。
函數插樁
是什么函數插樁
插樁:目標程序代碼中某些位置插入或修改成一些代碼,從而在目標程序運行過程中獲取某些程序狀態并加以分析。簡單來說就是在代碼中插入代碼。
那么函數插樁,便是在函數中插入或修改代碼。
本文將介紹在Android編譯過程中,往字節碼里插入自定義的字節碼,所以也可以稱為字節碼插樁。
作用
函數插樁可以幫助我們實現很多手術刀式的代碼設計,如無埋點統計上報、輕量級AOP等。
應用到在Android中,可以用來做用行為統計、方法耗時統計等功能。
技術點
在動手之前,需要掌握以下相關知識:
Android打包流程
相關資料:Apk 打包流程梳理、Android APK打包流程Java字節碼
相關資料:一文讓你明白Java字節碼、Java字節碼(維基百科)、如何閱讀JAVA 字節碼(一)、《深入理解Java虛擬機》第6章(有條件的話,推薦看書)自定義Gradle插件、Transform API
相關資料:在AndroidStudio中自定義Gradle插件、深入理解Android之Gradle、打包Apk過程中的Transform API 、Transform官方文檔
一定要先熟悉上面的知識
一定要先熟悉上面的知識
一定要先熟悉上面的知識
以下內容涉及知識過多,需熟練掌握以上知識。否則,可能會引起頭大、目眩、煩躁等一系列不良反應。請在大人的陪同下閱讀
實戰
需求
你可能會遇到一個這樣需求:在Android應用中,記錄每個頁面的打開\關閉。
開工前的思考
記錄頁面被打開\關閉,一般來說就是記錄Activity
的創建和銷毀(這里以Activity
區分頁面)。所以,我們只要在Activity
的onCreate()
和onDestroy()
中插入對應的代碼即可。
這時候就會遇到一個問題:如何為Activity插入代碼?
一個個寫?不可能!畢竟我們是高(懶)效(惰)的程序員;
寫在BaseActivity中?好像可以,不過項目中如果有第三方的頁面就顯得有些無力了,而且不通用;
我們希望實現一個可以自動在Activity
的onCreate()
和onDestroy()
中插入代碼的工具,可以在任意工程中使用。
于是,自定義Gradle插件 + ASM便成了一個不錯的選擇
實現思路
對Android打包過程和自定義Gradle插件了解后發現,java文件會先轉化為class
文件,然后在轉化為dex
文件。而通過Gradle
插件提供的Transform API
,可以在編譯成dex
文件之前得到class
文件。
得到class
文件之后,便可以通過ASM對字節碼進行修改,即可完成字節碼插樁。
步驟如下:
-
了解Android打包過程,在過程中找插入點(
class
轉換成.dex
過程);
插入點(部分打包過程) 了解自定義Gradle插件、Transform API,在
Transform#transform()
中得到class
文件;找到
FragmentActivity
的class
文件,通過ASM庫,在onCreate()
中插入代碼;(為什么是FragmentActivity
而不是Activity
后面會說到)將原文件替換為修改后的
class
文件。
如下圖:
class文件:java源文件經過
javac
后生成一種緊湊的8位字節的二進制流文件。
插入點:“dex”節點,表示將class
文件打包到dex
文件的過程,其輸入包括class
文件以及第三方依賴的class
文件。
關于Transform API:從
1.5.0-beta1
開始,Gradle插件包含一個Transform API,允許第三方插件在將編譯后的類文件轉換為dex
文件之前對其進行操作。
關于混淆:關于混淆可以不用當心?;煜鋵嵤莻€
ProguardTransform
,在自定義的Transform之后執行。
動手實現
主要實現以下功能:
- 自定義Gradle插件
- 處理class文件
- 替換
(以下為部分關鍵代碼,完整源碼點擊這里)
自定義Gradle插件
如何自定義插件這里就不詳細介紹了,具體參考在AndroidStudio中自定義Gradle插件、打包Apk過程中的Transform API。
目錄結構
目錄結構分為兩部分:插件部分(src/main/groovy
中)、ASM部分(src/main/java
中)
LifecyclePlugin.groovy
繼承Transform
,實現Plugin
接口,通過Transform#transform()
得到Collection<TransformInput> inputs
,里面有我們想要的class
文件。
class LifecyclePlugin extends Transform implements Plugin<Project> {
@Override
void apply(Project project) {
//registerTransform
def android = project.extensions.getByType(AppExtension)
android.registerTransform(this)
}
@Override
String getName() {
return "LifecyclePlugin"
}
@Override
Set<QualifiedContent.ContentType> getInputTypes() {
return TransformManager.CONTENT_CLASS
}
@Override
Set<? super QualifiedContent.Scope> getScopes() {
return TransformManager.SCOPE_FULL_PROJECT
}
@Override
boolean isIncremental() {
return false
}
@Override
void transform(@NonNull TransformInvocation transformInvocation) {
...
...
...
}
}
主要看方法transform()
@Override
void transform(@NonNull TransformInvocation transformInvocation) {
println '--------------- LifecyclePlugin visit start --------------- '
def startTime = System.currentTimeMillis()
Collection<TransformInput> inputs = transformInvocation.inputs
TransformOutputProvider outputProvider = transformInvocation.outputProvider
//刪除之前的輸出
if (outputProvider != null)
outputProvider.deleteAll()
//遍歷inputs
inputs.each { TransformInput input ->
//遍歷directoryInputs
input.directoryInputs.each { DirectoryInput directoryInput ->
//處理directoryInputs
handleDirectoryInput(directoryInput, outputProvider)
}
//遍歷jarInputs
input.jarInputs.each { JarInput jarInput ->
//處理jarInputs
handleJarInputs(jarInput, outputProvider)
}
}
def cost = (System.currentTimeMillis() - startTime) / 1000
println '--------------- LifecyclePlugin visit end --------------- '
println "LifecyclePlugin cost : $cost s"
}
通過參數inputs
可以拿到所有的class
文件。inputs
中包括directoryInputs
和jarInputs
,directoryInputs
為文件夾中的class
文件,而jarInputs
為jar包中的class
文件。
對應兩個處理方法handleDirectoryInput
、handleJarInputs
LifecyclePlugin#handleDirectoryInput()
/**
* 處理文件目錄下的class文件
*/
static void handleDirectoryInput(DirectoryInput directoryInput, TransformOutputProvider outputProvider) {
//是否是目錄
if (directoryInput.file.isDirectory()) {
//列出目錄所有文件(包含子文件夾,子文件夾內文件)
directoryInput.file.eachFileRecurse { File file ->
def name = file.name
if (name.endsWith(".class") && !name.startsWith("R\$")
&& !"R.class".equals(name) && !"BuildConfig.class".equals(name)
&& "android/support/v4/app/FragmentActivity.class".equals(name)) {
println '----------- deal with "class" file <' + name + '> -----------'
ClassReader classReader = new ClassReader(file.bytes)
ClassWriter classWriter = new ClassWriter(classReader, ClassWriter.COMPUTE_MAXS)
ClassVisitor cv = new LifecycleClassVisitor(classWriter)
classReader.accept(cv, EXPAND_FRAMES)
byte[] code = classWriter.toByteArray()
FileOutputStream fos = new FileOutputStream(
file.parentFile.absolutePath + File.separator + name)
fos.write(code)
fos.close()
}
}
}
//處理完輸入文件之后,要把輸出給下一個任務
def dest = outputProvider.getContentLocation(directoryInput.name,
directoryInput.contentTypes, directoryInput.scopes,
Format.DIRECTORY)
FileUtils.copyDirectory(directoryInput.file, dest)
}
LifecyclePlugin#handleJarInputs()
/**
* 處理Jar中的class文件
*/
static void handleJarInputs(JarInput jarInput, TransformOutputProvider outputProvider) {
if (jarInput.file.getAbsolutePath().endsWith(".jar")) {
//重名名輸出文件,因為可能同名,會覆蓋
def jarName = jarInput.name
def md5Name = DigestUtils.md5Hex(jarInput.file.getAbsolutePath())
if (jarName.endsWith(".jar")) {
jarName = jarName.substring(0, jarName.length() - 4)
}
JarFile jarFile = new JarFile(jarInput.file)
Enumeration enumeration = jarFile.entries()
File tmpFile = new File(jarInput.file.getParent() + File.separator + "classes_temp.jar")
//避免上次的緩存被重復插入
if (tmpFile.exists()) {
tmpFile.delete()
}
JarOutputStream jarOutputStream = new JarOutputStream(new FileOutputStream(tmpFile))
//用于保存
while (enumeration.hasMoreElements()) {
JarEntry jarEntry = (JarEntry) enumeration.nextElement()
String entryName = jarEntry.getName()
ZipEntry zipEntry = new ZipEntry(entryName)
InputStream inputStream = jarFile.getInputStream(jarEntry)
//插樁class
if (entryName.endsWith(".class") && !entryName.startsWith("R\$")
&& !"R.class".equals(entryName) && !"BuildConfig.class".equals(entryName)
&& "android/support/v4/app/FragmentActivity.class".equals(entryName)) {
//class文件處理
println '----------- deal with "jar" class file <' + entryName + '> -----------'
jarOutputStream.putNextEntry(zipEntry)
ClassReader classReader = new ClassReader(IOUtils.toByteArray(inputStream))
ClassWriter classWriter = new ClassWriter(classReader, ClassWriter.COMPUTE_MAXS)
ClassVisitor cv = new LifecycleClassVisitor(classWriter)
classReader.accept(cv, EXPAND_FRAMES)
byte[] code = classWriter.toByteArray()
jarOutputStream.write(code)
} else {
jarOutputStream.putNextEntry(zipEntry)
jarOutputStream.write(IOUtils.toByteArray(inputStream))
}
jarOutputStream.closeEntry()
}
//結束
jarOutputStream.close()
jarFile.close()
def dest = outputProvider.getContentLocation(jarName + md5Name,
jarInput.contentTypes, jarInput.scopes, Format.JAR)
FileUtils.copyFile(tmpFile, dest)
tmpFile.delete()
}
}
這兩個方法都在做同一件事,就是遍歷directoryInputs
、jarInputs
,得到對應的class
文件,然后交給ASM處理,最后覆蓋原文件。
發現:在
input.jarInputs
中并沒有android.jar
。本想在Activity
中做處理,因為找不到android.jar
,只好退而求其次選擇android.support.v4.app
中的FragmentActivity
。
那么,所以如何的到android.jar ?請指教
處理class文件
在handleDirectoryInput
和handleJarInputs
中,可以看到ASM的部分代碼了。這里以handleDirectoryInput
為例。
handleDirectoryInput
中ASM代碼:
ClassReader classReader = new ClassReader(file.bytes)
ClassWriter classWriter = new ClassWriter(classReader, ClassWriter.COMPUTE_MAXS)
ClassVisitor cv = new LifecycleClassVisitor(classWriter)
classReader.accept(cv, EXPAND_FRAMES)
其中,關鍵處理類LifecycleClassVisitor
LifecycleClassVisitor
用于訪問class
的工具,在visitMethod()
里對類名和方法名進行判斷是否需要處理。若需要,則交給MethodVisitor
。
public class LifecycleClassVisitor extends ClassVisitor implements Opcodes {
private String mClassName;
public LifecycleClassVisitor(ClassVisitor cv) {
super(Opcodes.ASM5, cv);
}
@Override
public void visit(int version, int access, String name, String signature,
String superName, String[] interfaces) {
System.out.println("LifecycleClassVisitor : visit -----> started :" + name);
this.mClassName = name;
super.visit(version, access, name, signature, superName, interfaces);
}
@Override
public MethodVisitor visitMethod(int access, String name, String desc,
String signature, String[] exceptions) {
System.out.println("LifecycleClassVisitor : visitMethod : " + name);
MethodVisitor mv = cv.visitMethod(access, name, desc, signature, exceptions);
//匹配FragmentActivity
if ("android/support/v4/app/FragmentActivity".equals(this.mClassName)) {
if ("onCreate".equals(name) ) {
//處理onCreate
return new LifecycleOnCreateMethodVisitor(mv);
} else if ("onDestroy".equals(name)) {
//處理onDestroy
return new LifecycleOnDestroyMethodVisitor(mv);
}
}
return mv;
}
@Override
public void visitEnd() {
System.out.println("LifecycleClassVisitor : visit -----> end");
super.visitEnd();
}
}
在visitMethod()
中判斷是否為FragmentActivity
,且為方法onCreate
或onDestroy
,然后交給LifecycleOnDestroyMethodVisitor
或LifecycleOnCreateMethodVisitor
處理。
回到需求,我們希望在onCreate()
中插入對應的代碼,來記錄頁面被打開。(這里通過Log代替)
Log.i("TAG", "-------> onCreate : " + this.getClass().getSimpleName());
于是,在LifecycleOnCreateMethodVisitor
中如下處理
(LifecycleOnDestroyMethodVisitor
與LifecycleOnCreateMethodVisitor
相似,完整代碼點擊這里
)
LifecycleOnCreateMethodVisitor
public class LifecycleOnCreateMethodVisitor extends MethodVisitor {
public LifecycleOnCreateMethodVisitor(MethodVisitor mv) {
super(Opcodes.ASM4, mv);
}
@Override
public void visitCode() {
//方法執行前插入
mv.visitLdcInsn("TAG");
mv.visitTypeInsn(Opcodes.NEW, "java/lang/StringBuilder");
mv.visitInsn(Opcodes.DUP);
mv.visitMethodInsn(Opcodes.INVOKESPECIAL, "java/lang/StringBuilder", "<init>", "()V", false);
mv.visitLdcInsn("-------> onCreate : ");
mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/lang/StringBuilder", "append", "(Ljava/lang/String;)Ljava/lang/StringBuilder;", false);
mv.visitVarInsn(Opcodes.ALOAD, 0);
mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/lang/Object", "getClass", "()Ljava/lang/Class;", false);
mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/lang/Class", "getSimpleName", "()Ljava/lang/String;", false);
mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/lang/StringBuilder", "append", "(Ljava/lang/String;)Ljava/lang/StringBuilder;", false);
mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/lang/StringBuilder", "toString", "()Ljava/lang/String;", false);
mv.visitMethodInsn(Opcodes.INVOKESTATIC, "android/util/Log", "i", "(Ljava/lang/String;Ljava/lang/String;)I", false);
mv.visitInsn(Opcodes.POP);
super.visitCode();
//方法執行后插入
}
@Override
public void visitInsn(int opcode) {
super.visitInsn(opcode);
}
}
只需要在visitCode()
中插入上面的代碼,即可實現onCreate()
內容執行之前,先執行我們插入的代碼。
如果想在onCreate()
內容執行之后插入代碼,該怎么做?
和上面相似,只要在visitInsn()
方法中插入對應的代碼即可。代碼如下:
@Override
public void visitInsn(int opcode) {
//判斷RETURN
if (opcode == Opcodes.RETURN) {
//在這里插入代碼
...
}
super.visitInsn(opcode);
}
如果對字節碼不是很了解,看到上面visitCode()
中的代碼可能會覺得既熟悉又陌生,那是ASM插入字節碼的用法。
如果你寫不來,沒關系,這里介紹一個插件——ASM Bytecode Outline,包教包會。
通過ASM Bytecode Outline插件生成代碼
1、在Android Studio中安裝ASM Bytecode Outline插件;
2、安裝后,在編譯器中,點擊右鍵,選擇Show Bytecode outLine;
3、在ASM標簽中選擇ASMified,即可在右側看到當前類對應的ASM代碼。(可以忽略Label相關的代碼,以下選框的內容為對應的代碼)
提示:
ClassVisitor#visitMethod()
只能訪問當前類定義的method
(一開始想訪問父類的方法,陷入誤區)。
如,在MainActivity
中只重寫了onCreate()
,沒有重寫onDestroy()
。那么在visitMethod()
中只會出現onCreate()
,不會有onDestroy()
。
替換
class
文件的插樁已經說完,剩下最后一步——替換。眼尖的同學應該發現,代碼上面已經出現過了。還是以LifecyclePlugin#handleDirectoryInput()
中的代碼為例:
byte[] code = classWriter.toByteArray()
FileOutputStream fos = new FileOutputStream(
file.parentFile.absolutePath + File.separator + name)
fos.write(code)
fos.close()
從classWriter
得到class
修改后的byte
流,然后通過流的寫入覆蓋原來的class
文件。
(Jar包的覆蓋會稍微復雜一點,這里就不細說了)
File.separator
:文件的分隔符。不同系統分隔符可能不一樣。
如:同樣一個文件,Windows下是C:\tmp\test.txt
;Linux 下卻是/tmp/test.txt
使用
插件寫完,便可以投入使用了。
創建一個Android項目app
,在app.gradle
中引用插件。(完整代碼點擊這里)
apply plugin: 'com.gavin.gradle'
運行后,按步驟操作:
打開MainActivity
——>打開SecondActivity
——>返回MainActivity
。
查看效果:
com.gavin.asmdemo I/TAG: -------> onCreate : MainActivity
com.gavin.asmdemo I/TAG: -------> onCreate : SecondActivity
com.gavin.asmdemo I/TAG: -------> onDestroy : SecondActivity
可以發現,頁面打開\關閉都會打印對應的log。說明我們插入的代碼被執行了,而且,使用時對項目沒有任何“入侵”。
結語
本文內容涉及知識較多,在熟悉Android打包過程、字節碼、Gradle Transform API、ASM等之前,閱讀起來會很困難。不過,在了解并學習這些知識的之后,相信你對Android會有新的認識。
源碼
參考
Android字節碼插樁采坑筆記
手摸手增加字節碼往方法體內插代碼
Android AOP之字節碼插樁
通過Gradle的Transform配合ASM實戰路由框架和統計方法耗時
一起玩轉Android項目中的字節碼
以上有錯誤之處,感謝指出