【Android】函式插樁(Gradle + ASM)

圖片來自Google
前言
第一次看到插樁,是在 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官方文件
-
ASM
一定要先熟悉上面的知識
一定要先熟悉上面的知識
一定要先熟悉上面的知識
以下內容 涉及知識過多 ,需熟練掌握以上知識。否則,可能會引起 頭大、目眩、煩躁 等一系列不良反應。 請在大人的陪同下閱讀
實戰
需求
你可能會遇到一個這樣需求: 在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
。
檢視log:
com.gavin.asmdemo I/TAG: -------> onCreate : MainActivity com.gavin.asmdemo I/TAG: -------> onCreate : SecondActivity com.gavin.asmdemo I/TAG: -------> onDestroy : SecondActivity
可以發現,頁面 開啟\關閉 都會執行我們插入的程式碼。 而且,在使用時對專案程式碼沒有任何入侵 。
結語
本文內容涉及知識較多,在熟悉 Android打包過程 、 位元組碼 、 Gradle Transform API 、 ASM 等之前,閱讀起來會很困難。不過,在對這些知識的瞭解並學習之後,對 Android 會有新的認識。
原始碼
參考
以上有錯誤之處,感謝指出