【Android】函数插桩(Gradle + ASM)

*本篇文章已授权微信公众号 guolin_blog (郭霖)独家发布

图片来自Google

前言

第一次看到插桩,是在Android开发高手课中。看完去查了一下:“咦!还有这东西,有点意思”。

本着不断学习和探索的精神,便走上学习函数插桩的“不归路”。

函数插桩

是什么函数插桩

插桩:目标程序代码中某些位置插入或修改成一些代码,从而在目标程序运行过程中获取某些程序状态并加以分析。简单来说就是在代码中插入代码
那么函数插桩,便是在函数中插入或修改代码。

本文将介绍在Android编译过程中,往字节码里插入自定义的字节码,所以也可以称为字节码插桩

作用

函数插桩可以帮助我们实现很多手术刀式的代码设计,如无埋点统计上报、轻量级AOP等。
应用到在Android中,可以用来做用行为统计、方法耗时统计等功能。

技术点

在动手之前,需要掌握以下相关知识:

一定要先熟悉上面的知识
一定要先熟悉上面的知识
一定要先熟悉上面的知识

以下内容涉及知识过多,需熟练掌握以上知识。否则,可能会引起头大、目眩、烦躁等一系列不良反应。请在大人的陪同下阅读

实战

需求

你可能会遇到一个这样需求:在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插件生成代码
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.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项目中的字节码

以上有错误之处,感谢指出

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,230评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,261评论 2 380
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,089评论 0 336
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,542评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,542评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,544评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,922评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,578评论 0 257
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,816评论 1 296
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,576评论 2 320
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,658评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,359评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,937评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,920评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,156评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,859评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,381评论 2 342