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

*本篇文章已受權微信公衆號 guolin_blog (郭霖)獨家發佈 java

圖片來自Google

前言

第一次看到插樁,是在Android開發高手課中。看完去查了一下:「咦!還有這東西,有點意思」。android

本着不斷學習和探索的精神,便走上學習函數插樁的「不歸路」。git

函數插樁

是什麼函數插樁

插樁:目標程序代碼中某些位置插入或修改成一些代碼,從而在目標程序運行過程當中獲取某些程序狀態並加以分析。簡單來講就是在代碼中插入代碼。 那麼函數插樁,即是在函數中插入或修改代碼。程序員

本文將介紹在Android編譯過程當中,往字節碼裏插入自定義的字節碼,因此也能夠稱爲字節碼插樁github

做用

函數插樁能夠幫助咱們實現不少手術刀式的代碼設計,如無埋點統計上報、輕量級AOP等。 應用到在Android中,能夠用來作用行爲統計、方法耗時統計等功能。api

技術點

在動手以前,須要掌握如下相關知識:緩存

必定要先熟悉上面的知識 必定要先熟悉上面的知識 必定要先熟悉上面的知識

如下內容涉及知識過多,需熟練掌握以上知識。不然,可能會引發頭大、目眩、煩躁等一系列不良反應。請在大人的陪同下閱讀

實戰

需求

你可能會遇到一個這樣需求:在Android應用中,記錄每一個頁面的打開\關閉

開工前的思考

記錄頁面被打開\關閉,通常來講就是記錄**Activity的建立和銷燬**(這裏以Activity區分頁面)。因此,咱們只要在ActivityonCreate()onDestroy()中插入對應的代碼便可。

這時候就會遇到一個問題:如何爲Activity插入代碼? **一個個寫?不可能!畢竟咱們是高(懶)效(惰)**的程序員; **寫在BaseActivity中?**好像能夠,不過項目中若是有第三方的頁面就顯得有些無力了,並且不通用;

咱們但願實現一個能夠自動在ActivityonCreate()onDestroy()中插入代碼的工具,能夠在任意工程中使用

因而,自定義Gradle插件 + ASM便成了一個不錯的選擇

實現思路

Android打包過程自定義Gradle插件瞭解後發現,java文件會先轉化爲class文件,而後在轉化爲dex文件。而經過Gradle插件提供的Transform API,能夠在編譯成dex文件以前獲得class文件。 獲得class文件以後,即可以經過ASM對字節碼進行修改,便可完成字節碼插樁

步驟以下:

  • 瞭解Android打包過程,在過程當中找插入點class轉換成 .dex過程);

    插入點(部分打包過程)

  • 瞭解自定義Gradle插件、Transform API,在Transform#transform()中獲得class文件;

  • 找到FragmentActivityclass文件,經過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中包括directoryInputsjarInputsdirectoryInputs爲文件夾中的class文件,而jarInputs爲jar包中的class文件。

對應兩個處理方法handleDirectoryInputhandleJarInputs

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()
    }
}
複製代碼

這兩個方法都在作同一件事,就是遍歷directoryInputsjarInputs,獲得對應的class文件,而後交給ASM處理,最後覆蓋原文件。

發現:在input.jarInputs中並無android.jar。本想在Activity中作處理,由於找不到android.jar,只好退而求其次選擇android.support.v4.app中的FragmentActivity那麼,因此如何的到android.jar ?請指教

處理class文件

handleDirectoryInputhandleJarInputs中,能夠看到ASM的部分代碼了。這裏以handleDirectoryInput爲例。

handleDirectoryInputASM代碼:

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,且爲方法onCreateonDestroy,而後交給LifecycleOnDestroyMethodVisitorLifecycleOnCreateMethodVisitor處理。

回到需求,咱們但願在onCreate()中插入對應的代碼,來記錄頁面被打開。(這裏經過Log代替)

Log.i("TAG", "-------> onCreate : " + this.getClass().getSimpleName());
複製代碼

因而,在LifecycleOnCreateMethodVisitor中以下處理 (LifecycleOnDestroyMethodVisitorLifecycleOnCreateMethodVisitor類似,完整代碼點擊這裏

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插件生成代碼 一、在Android Studio中安裝ASM Bytecode Outline插件; 二、安裝後,在編譯器中,點擊右鍵,選擇Show Bytecode outLine

三、在 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.txtLinux 下倒是/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 APIASM等以前,閱讀起來會很困難。不過,在瞭解並學習這些知識的以後,相信你對Android會有新的認識。

源碼

Github

參考

Android字節碼插樁採坑筆記

手摸手增長字節碼往方法體內插代碼

Android AOP之字節碼插樁

經過Gradle的Transform配合ASM實戰路由框架和統計方法耗時

一塊兒玩轉Android項目中的字節碼

以上有錯誤之處,感謝指出

相關文章
相關標籤/搜索