《Android AOP探密系列》一步一步跟我ASM实战

本篇的内容非常长,涉及的知识点较多,建议【收藏】或【耐心观看】,并跟随文中来一步一步实现自己的 ASM插桩 !

Github源码地址:《传送门》

一、前言

在 Android 开发中,要想使用 ASM 库来开发自己的字节码插桩库,需要 Hook Android 的编译流程,基于 Gradle(Gradle 是基于 Groovy 语言来开发的) 的API 来实现 class / lib 文件的遍历与操作。

Hook Android 的 Transform 阶段:
transformClassesWithDexBuilderForXXX(XXX 即是 buildTypes 对应的环境)

(不用翻墙) 查阅 Google 官方的 gradle-api form android

二、模块 & 配置

2.1、新建模块

基于 Android Studio 新建『Module』 ,选择『Java Library』,输入模块名和包名,然后完成。

2.2、依赖配置

  • 首先,我们是基于 Gradle(即 Groovy)来开发的,所以需要引入『groovy插件』;
  • 其次,我们需要依赖『ASM』库;
  • 再次,我们还要使用『Gradle的API』;

OK,配置如下:

apply plugin: 'groovy'
apply plugin: 'java'

dependencies {
    implementation fileTree(dir: 'libs', include: ['*.jar'])

    // 使得 gradle 提供的 api
    implementation gradleApi()
    implementation localGroovy()

    // 最新版本的 asm 库是 9.0
    implementation 'org.ow2.asm:asm:9.0'
    implementation 'org.ow2.asm:asm-commons:9.0'
    
    // 目前最新的 gradle for android 的工具是 3.5
    implementation 'com.android.tools.build:gradle:3.5.0'
}

sourceCompatibility = "8"
targetCompatibility = "8"

2.3、实现 Gradle 的API:Plugin

  • 在 main 目录下创建『groovy』目录;
  • 创建包名;
  • 新建 groovy 类;

代码截图如下:

groovy.png

这是我们整个流程入口,但是,大家发现一个问题没?我们的 AopAsmPlugin 这个类有个波浪线,表明没有被引用,那如何解决呢?

2.4、Gradle Plugin 配置

在『main』目录下,新建『resources』目录,并依次创建『META-INF』目录,及再其下再创建『gradle-plugins』目录,然后创建一个自定义名称的 properties 文件,并填写如下:

resource.png

我们看到,完成了 properties 文件定义后,我们的入口类『AopAsmPlugin』就自动被引用了。

2.5、继承 Android Build 提供的API:Transform

继承 Transform,必需要实现以下四个方法

package com.chris.aop.asm.plugin;

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.TransformInvocation;
import com.android.build.gradle.internal.pipeline.TransformManager;

import java.io.IOException;
import java.util.Set;

public class AopAsmTransform extends Transform {
    @Override
    public String getName() {
        return "AopAsmPlugin";
    }
    
    @Override
    public Set<QualifiedContent.ContentType> getInputTypes() {
        return TransformManager.CONTENT_CLASS;
    }
    
    @Override
    public Set<? super QualifiedContent.Scope> getScopes() {
        return TransformManager.SCOPE_FULL_PROJECT;
    }
    
    @Override
    public boolean isIncremental() {
        return true; // 是否支持增量编译
    }
    
    @Override
    public void transform(TransformInvocation transformInvocation) throws TransformException, InterruptedException, IOException {
        // 重载该方法,用来 Hook 住 class -> dex 的过程
        // 这里就是我们来读取 class 文件,进行 asm 操纵的真正入口
        // 这里一定要实现,否则在 ./build/intermediates/transforms/dexBuilder目录下,是空目录
        // 可以前后对比 dexBuilder 使用插件与不使用插件的输出内容
    }
}

2.6、Gradle Plugin 注册自定义 Transform(registerTransform)

插件入口处注册 自定义 Transform

// AopAsmPlugin.groovy
package com.chris.aop.asm.plugin

import com.android.build.gradle.AppExtension
import org.gradle.api.Plugin
import org.gradle.api.Project

class AopAsmPlugin implements Plugin<Project> {
    @Override
    void apply(Project project) {
        if (!project.plugins.hasPlugin("com.android.application")) {
            throw new Exception("AopAsmPlugin must run at application")
        }

        /*****************************************************************************
         * 注册 Transform
         *****************************************************************************/
        def extension = project.extensions.getByType(AppExtension)
        extension.registerTransform(new AopAsmTransform())
    }
}
  • 自定义 Transform 重载 transform 方法
// AopAsmTransform.java
package com.chris.aop.asm.plugin;

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.TransformInvocation;
import com.android.build.gradle.internal.pipeline.TransformManager;

import java.io.IOException;
import java.util.Set;

public class AopAsmTransform extends Transform {
    @Override
    public String getName() {
        return "AopAsmPlugin";
    }

    @Override
    public Set<QualifiedContent.ContentType> getInputTypes() {
        return TransformManager.CONTENT_CLASS;
    }

    @Override
    public Set<? super QualifiedContent.Scope> getScopes() {
        return TransformManager.SCOPE_FULL_PROJECT;
    }

    @Override
    public boolean isIncremental() {
        return true;
    }

    @Override
    public void transform(TransformInvocation transformInvocation) throws TransformException, InterruptedException, IOException {
        System.out.println("=========> " + System.currentTimeMillis());
        // 重载该方法,用来 Hook 住 class -> dex 的过程
        // 这里就是我们来读取 class 文件,进行 asm 操纵的真正入口
        // 这里一定要实现,否则在 ./build/intermediates/transforms/dexBuilder目录下,是空目录
        // 可以前后对比 dexBuilder 使用插件与不使用插件的输出内容
    }
}

三、窥探transform

3.1、QualifiedContent

该接口定义了一个输入内容的类型(ContentType)和表现形式(Scope),它有两个子接口:

  • DirectoryInput
  • JarInput

这两个子接口就代表了两种文件:目录/Class文件、Jar/AAR包;输入源(XXXInput)取决于你的 ContentType + Scope 的定义。

package com.android.build.api.transform;

import com.android.annotations.NonNull;
import java.io.File;
import java.util.Set;

public interface QualifiedContent {
    interface ContentType {
        .......
    }
    enum DefaultContentType implements ContentType {
        .......
    }


    interface ScopeType {
        .......
    }
    enum Scope implements ScopeType {
        .......
    }

    /**
     * 返回输入源的名称,不可信任(因为会用在 transform 的不同阶段)
     */
    @NonNull
    String getName();

    @NonNull
    File getFile();

    /**
     * 定义输入流的类型(Class/Jar/AAR,或者是 Resources)
     * 但是,即便指定了期望 transform 的类型,实际也可能会返回包含有其它类型的文件(源英文如下):
     *
     * Even though this may return only {RESOURCES} or {CLASSES}, the actual content (the folder
     * or the jar) may contain files representing other content types. This is because the 
     * transform mechanism avoids duplicating files around to remove unwanted types for performance.
     */
    @NonNull
    Set<ContentType> getContentTypes();

    @NonNull
    Set<? super Scope> getScopes();
}

3.1.1、QualifiedContent.ContentType

public interface QualifiedContent {
    /**
     * A content type that is requested through the transform API.
     */
    interface ContentType {
        String name();
        int getValue();
    }
    enum DefaultContentType implements ContentType {
        // 可能是 JAR/AAR,也可能是 Directory
        CLASSES(0x01),

        /** The content is standard Java resources. */
        RESOURCES(0x02);
    }
    ......
}

3.1.2、QualifiedContent.Scope

public interface QualifiedContent {
    /**
     * Definition of a scope.
     */
    interface ScopeType {
        String name();
        int getValue();
    }
    enum Scope implements ScopeType {
        /** 主项目模块 */
        PROJECT(0x01),
        /** 子项目或子模块 */
        SUB_PROJECTS(0x04),
        /** 外部依赖库 jar/aar */
        EXTERNAL_LIBRARIES(0x10),
        /** 用于当前环境变体的测试代码,包括其依赖荐 */
        TESTED_CODE(0x20),
        /** Local or remote dependencies that are provided-only */
        PROVIDED_ONLY(0x40),

        /**
         * 项目本地jar/aar包,改用 EXTERNAL_LIBRARIES
         */
        @Deprecated
        PROJECT_LOCAL_DEPS(0x02),
        /**
         * 子项目本地jar/aar包,改用 EXTERNAL_LIBRARIES
         */
        @Deprecated
        SUB_PROJECTS_LOCAL_DEPS(0x08);
    }
}

3.2、Transform.transform

该方法就是中间结果转换的处理方法,而中间信息(数据)是通过 TransformInvocation 这个对象来传递的。

A Transform that processes intermediary build artifacts.
Transform负责处理中间内容

For each added transform, a new task is created. The action of adding a transform takes care of handling dependencies between the tasks.This is done based on what the transform processes. The output of the transform becomes consumable by other transforms and these tasks get automatically linked together.
对于每个被加入进来的 transform,都会对应一个 gradle task 的创建。
一个 trasform 的输出,将会被下一个 transform 来消费,这些都是自动链接在一起的。

Transform 是一个『转换链』:

TransformChain.png

整个编译过程会有多个 Transform 参与:
如果你的项目里用了多个 Gradle Plugin,每个 Plugin 都有Hook Transform 的方法,每个 Plugin 肯定各司其责,因此,就有多个 Transform Chain了。

  • TransformInvocation
/**
 * An invocation object used to pass of pertinent information for a
 * Transform#transform(TransformInvocation) call.
 */
public interface TransformInvocation {
    /**
     * transform 上下文
     */
    @NonNull
    Context getContext();
    /**
     * 输入源(XXXInput):DirectoryInput / JarInput
     */
    @NonNull
    Collection<TransformInput> getInputs();
    /**
     * 引用源:当前 transform 中不会去操作,但可查看
     */
    @NonNull Collection<TransformInput> getReferencedInputs();
    /**
     * Only secondary files that this
     * transform can handle incrementally will be part of this change set.
     * 其它输入源:返回上次编译后改动的文件列表。
     */
    @NonNull Collection<SecondaryInput> getSecondaryInputs();
    /**
     * 输出内容
     */
    @Nullable
    TransformOutputProvider getOutputProvider();
    /**
     * 是否支持增量
     */
    boolean isIncremental();
}

Transform 暂时就讲这么多,不然就跑题了。

四、工欲善其事必先利其器: ASM Bytecode Viewer

  • IDE:默认 Android Studio
  • 安装 IDE 插件
IDEPlugin.png

选择安装第一个,安装好了后,会提示『RestartIDE』。注:

  1. 第二个其实不用安装,因为 Kotlin 自带『Show Kotlin Bytecode』;
  2. 第二个插件我也尝试过,Kotlin 代码下,结果是一样的;
  3. 千万不要将上图中的两个插件都同时安装,否则 AS(我的是3.5)无法启动(只能手动去目录中删除一个才解决);
  • ASM Bytecode Viewer使用
    1. 打开任意 Java 文件;
    2. IDE的编辑器右键 -> 选择『ASM Bytecode Viewer』;
    3. 此时插件会判断:
    • 如果该文件已经有 Class 文件,则直接加载显示;
    • 没有则先 Recompile,再加载显示 ;
IDEPluginUse.png

五、插桩实战

我们有了ASM Plugin工具,但是我们不懂 Java字节码啊!没关系,对于不懂的,我们可以学实践,通过修改 Java 源码,然后查看 ASM 编译后的字节码,前后比对,就能发现不同点,我们只需要记下差异点,去通过我们自己自定义的插件工具,插入到源文件的Class文件对应处即可。

5.1、保存修改前状态

如何保存?方法太多了,要么 Copy & Paste,要么截图,等等.....

[图片上传中...(ViewASM.png-2b9c12-1612181084651-0)]

这个我们修改前的 Java源码(左)和对应的 ASM字节码(右)。

5.2、添加进入/退出时间戳

compare.png

经过修改 & 对比,我们发现了两处与原来不同的地方,两处改动唯一不同的,就是打印的字符串,其它都相同:

   LDC "Chris"
   NEW java/lang/StringBuilder
   DUP
   INVOKESPECIAL java/lang/StringBuilder.<init> ()V
   LDC "onCreate.onEnter timestamp = " // onEnter / onExit
   INVOKEVIRTUAL java/lang/StringBuilder.append (Ljava/lang/String;)Ljava/lang/StringBuilder;
   INVOKESTATIC java/lang/System.currentTimeMillis ()J
   INVOKEVIRTUAL java/lang/StringBuilder.append (J)Ljava/lang/StringBuilder;
   INVOKEVIRTUAL java/lang/StringBuilder.toString ()Ljava/lang/String;
   INVOKESTATIC android/util/Log.e (Ljava/lang/String;Ljava/lang/String;)I
   POP

可是知道了指令集,还是不会写,怎么办?ASM库连代码都为我们生成好了,直接用就行(不过建议大家还是学学基本的指令集,不要太依赖工具):

apiForASM.png

还原 Java 源码,开始编写我们的 Gradle Plugin。

5.3、编译后产物所在路径

再真正开始编写插件前,我们要先了解,我们的 Class 以及 Dex 文件最终会在哪里,这样我们才能查看我们操纵字节码插桩后,是否是正确的。

  • 对于 Java 文件,其 Class 路径在:

./app/build/intermediates/javac/环境变体/classes 目录下

javac.png
  • 对于 Kotlin 文件,其 Class 路径在:

./app/build/tmp/kotlin-classes/环境变体/ 目录下

kc.png
  • 之后 Android Gradle Plugin 会将所有的 classes 文件,合并成不同的 dex,统一放在:

./app/build/intermediates/transforms/环境变体/ 目录下

merge.png

OK,接下来,我们真的要实现我们自定义的插件了!

5.4、自定义插件:AopAsmTransform

我们先尝试着打印输入源,代码与日志分别如下(如何使用插件之后会讲,别急!):

public class AopAsmTransform extends Transform {
    @Override
    public void transform(TransformInvocation transformInvocation) throws TransformException, InterruptedException, IOException {
        // 空方法,删除也可以
        super.transform(transformInvocation);
        System.out.println("【AOP ASM】---------------- begin");

        for (TransformInput input : transformInvocation.getInputs()) {

            for (DirectoryInput di : input.getDirectoryInputs()) {
                System.out.println("DirectoryInput = " + di.getFile().getAbsolutePath());
            }

            for (JarInput ji : input.getJarInputs()) {
                System.out.println("JarInput = " + ji.getFile().getAbsolutePath());
            }

        }

        System.out.println("【AOP ASM】----------------  end  ----------------");
    }
}

App模块编译时,Build 版面日志:

build.png

我们可以看到,有 JarInput 和 DirectoryInput 两种输入源,及其绝对路径。

如果细心的同学,应该会发现,AS IDE 最下面有个提示:

Session 'app': Installation did not succeed. The application could not be installed: INSTALL_FAILED_INVALID_APK

我们切换到 Run 面版,以及查看 transforms 路径

trans.png

dexBuilder 目录下是空的,没有 dex 文件,也就没有 apk 文件。

\color{red}{还记得我前面说的吗?}

Transform 是中间产物链中的一环,它的输出,会影响到下一个 Transform 的输入,而 transform 方法是空函数,如果我们没有正确设置 TrannsformOutputProvider,那么,下一个环节『Task :app:transformClassesWithDexBuilderForDebug』无法将 classes 转成 dex 文件,也就最终导致无法生成 apk 文件。

5.4.1、确保输入源能放到指定的目录下

如果获取文件将要放置在哪里?总不能我们自己写死路径吧!

其实,文件的去向,transformInvocation 对象已经告诉我们了,我们通过 getOutputProvider.getContentLocation ,传入正确的 contentType, scope 和 format ,就能够获取正确的目标目录绝对路径:

public class AopAsmTransform extends Transform {
    @Override
    public void transform(TransformInvocation transformInvocation) throws TransformException, InterruptedException, IOException {
        // 空方法,删除也可以
        super.transform(transformInvocation);
        System.out.println("【AOP ASM】---------------- begin ----------------");

        TransformOutputProvider provider = transformInvocation.getOutputProvider();
        for (TransformInput input : transformInvocation.getInputs()) {

            for (DirectoryInput di : input.getDirectoryInputs()) {
                copyQualifiedContent(provider, di, null, Format.DIRECTORY);
            }

            for (JarInput ji : input.getJarInputs()) {
                copyQualifiedContent(provider, ji, getUniqueName(ji.getFile()), Format.JAR);
            }

        }

        System.out.println("【AOP ASM】----------------  end  ----------------");
    }

    /***********************************************************************************************
     * 重名名输出文件,因为可能同名(N个classes.jar),会覆盖
     ***********************************************************************************************/
    private String getUniqueName(File jar) {
        String name = jar.getName();
        String suffix = "";
        if (name.lastIndexOf(".") > 0) {
            suffix = name.substring(name.lastIndexOf("."));
            name = name.substring(0, name.lastIndexOf("."));
        }
        String hexName = DigestUtils.md5Hex(jar.getAbsolutePath());
        return String.format("%s_%s%s", name, hexName, suffix);
    }

    private void copyQualifiedContent(TransformOutputProvider provider, QualifiedContent file, String fileName, Format format) throws IOException {
        boolean useDefaultName = fileName == null;
        File dest = provider.getContentLocation(useDefaultName ? file.getName() : fileName, file.getContentTypes(), file.getScopes(), format);
        if (!dest.exists()) {
            dest.mkdirs();
            dest.createNewFile();
        }

        if (useDefaultName) {
            FileUtils.copyDirectory(file.getFile(), dest);
        } else {
            FileUtils.copyFile(file.getFile(), dest);
        }
    }
}

再次编译,效果如下:

succ.png

能正常编译、通过,并且成功安装应用。
虽然成功了,但是,目录下多了很多的 jar 文件,对比我们 『5.3』中的图片,我们查看一下content.json文件:

__content__json.png

发现,这些 jar 包全是外部依赖,『5.4中 Build 日志』中可以看到这些外部依赖有些是 kotlin的,有些是 Maven / JCenter 仓库中的;

5.4.2、递归遍历 Class 文件

因为本篇 Demo 暂不涉及到 Jar 包中 Class 文件的操纵,因此,我们只关注 DirectoryInput 中的 Class 文件(Jar包中的 Class 操作与之类似)。由于Java 的包名,与目录层次紧密相关,因此,我们需要一级一级目录向下递归来遍历出那些我们真正需要插桩的 Class 文件,有些 Class 文件可能是资源、编译配置等,所以我们还需要将这些给过滤掉。

// TransConstant.java
public class TransConstant {
    // 配置过滤文件信息
    public static final String[] CLASS_FILE_IGNORE = {"R.class", "R$", "Manifest", "BuildConfig"};
}
public class AopAsmTransform extends Transform {
    /***********************************************************************************************
     * 根据输入的目录,遍历需要插桩的 Class 文件
     ***********************************************************************************************/
    private void doDirectoryInputTransform(DirectoryInput input) {
        List<File> files = new ArrayList<>();
        listFiles(files, input.getFile());

        // TODO: 实际要插桩的 Class 文件
       ......
    }
    
    private void listFiles(List<File> list, File file) {
        if (file == null) {
            return;
        }
    
        if (file.isDirectory()) {
            File[] files = file.listFiles();
            if (files == null || files.length == 0) {
                return;
            }
    
            for (File f : files) {
                listFiles(list, f);
            }
        } else if (needTrack(file.getName())) {
            list.add(file);
        }
    }
    
    private boolean needTrack(String name) {
        boolean ret = false;
        if (name.endsWith(".class")) {
            int len = TransConstant.CLASS_FILE_IGNORE.length;
            int i = 0;
            while (i < len) {
                if (name.contains(TransConstant.CLASS_FILE_IGNORE[i])) {
                    break;
                }
                i ++;
            }
            if (i == len) {
                ret = true;
            }
        }
        return ret;
    }
}

六、万事俱备,只待插桩

6.1、了解ASM框架

在插桩前,我们需要先稍微了解下 ASM 这个框架(之后我会单独开一篇文章,讲解 Class 数据结构),在这里只需让大家了解一下流程:

  1. IO读取 Class 文件;
  2. 基于IO流,创建 ClassReader 实例;
  3. 创建 ClassWriter 实例,用于修改字节码;
  4. 基于 ClassWriter 创建 ClassVisitor 实例;
  5. 触发 ClassReader 对象解析 Class 信息;

从上面的步骤可以看出,ASM 用到了访问者模式来读取和解析 Class 信息的,下图是访问 / 遍历流程:

ClassVisitor.png

6.2、继承 ClassVisitor 类

ASM 提供的 ClassVisitor 是一个抽象类,需要我们去继承;对于上面的流程,如果我们需要针对某块代码,例如:Annotation、Field、Method 等读取或是修改,就需要重载相应的方法;本篇是针对 Method 进行插桩,因此我们需要重载『visitMethod』方法,以及还要使用到 Method访问者类,在 ASM中,该类为『AdviceAdapter』抽象类,我们继承它,并需要重载『onMethodEnter』和『onMethodExit』方法,来实现我们的需求。

6.2.1、自定义 ClassVisitor 继承类

// ClassVisitorAdapter.java
public class ClassVisitorAdapter extends ClassVisitor {
    private String clazzName;

    public ClassVisitorAdapter(int api, ClassVisitor classVisitor) {
        super(api, classVisitor);
    }

    @Override
    public void visit(int version, int access, String name, String signature, String superName, String[] interfaces) {
        super.visit(version, access, name, signature, superName, interfaces);
        this.clazzName = name;
    }

    @Override
    public MethodVisitor visitMethod(int access, String name, String descriptor, String signature, String[] exceptions) {
        MethodVisitor methodVisitor = cv.visitMethod(access, name, descriptor, signature, exceptions);
        return new MethodAdviceAdapter(api, methodVisitor, access, name, descriptor, this.clazzName);
    }
}

6.2.2、自定义 MethodVisitor 继承类

// MethodAdviceAdapter.java
public class MethodAdviceAdapter extends AdviceAdapter {
    private String qualifiedName;
    private String clazzName;
    private String methodName;
    private int access;
    private String desc;

    protected MethodAdviceAdapter(int api, MethodVisitor methodVisitor, int access, String name, String descriptor, String clazzName) {
        super(api, methodVisitor, access, name, descriptor);
        this.qualifiedName = clazzName.replaceAll("/", ".");
        this.clazzName = clazzName;
        this.methodName = name;
        this.access = access;
        this.desc = descriptor;
    }

    @Override
    protected void onMethodEnter() {
        enter();
    }

    @Override
    protected void onMethodExit(int opcode) {
        exit(opcode);
    }

    private void enter() {
        System.out.println("【MethodAdviceAdapter.enter】 => " + clazzName + ", " + methodName + ", " + access + ", " + desc);
    }

    private void exit(int opcode) {
        System.out.println("【MethodAdviceAdapter.exit】 => " + opcode);
    }
}

编译再测试一下我们的输出结果:

> Task :app:transformClassesWithAopAsmPluginForDebug
【AOP ASM】---------------- begin ----------------

【MethodAdviceAdapter.enter】 => com/chris/aop/MainActivity, <init>, 1, ()V
【MethodAdviceAdapter.exit】 => 177
【MethodAdviceAdapter.enter】 => com/chris/aop/MainActivity, onCreate, 4, (Landroid/os/Bundle;)V
【MethodAdviceAdapter.exit】 => 177
 /Users/chris/Desktop/Source/AOPDemo/app/build/intermediates/javac/debug/classes/com/chris/aop/MainActivity.class/MainActivity.class

【MethodAdviceAdapter.enter】 => com/chris/aop/SecondActivity, onCreate, 4, (Landroid/os/Bundle;)V
【MethodAdviceAdapter.exit】 => 177
【MethodAdviceAdapter.enter】 => com/chris/aop/SecondActivity, <init>, 1, ()V
【MethodAdviceAdapter.exit】 => 177
【MethodAdviceAdapter.enter】 => com/chris/aop/SecondActivity, _$_findCachedViewById, 1, (I)Landroid/view/View;
【MethodAdviceAdapter.exit】 => 176
【MethodAdviceAdapter.enter】 => com/chris/aop/SecondActivity, _$_clearFindViewByIdCache, 1, ()V
【MethodAdviceAdapter.exit】 => 177
 /Users/chris/Desktop/Source/AOPDemo/app/build/tmp/kotlin-classes/debug/com/chris/aop/SecondActivity.class/SecondActivity.class

【AOP ASM】----------------  end  ----------------
  • 对于Java源文件:我们只有一个方法,编译生成时,有两个方法,其中一个是默认构造函数 <init>;
  • 对于Kotlin源文件:我们只有一个方法,编译生成时,有四个方法,一个是默认构造函数 <init>,还有两个是 kotlin 合成的方法,快速根据控件id查找视图控件,以及退出时清除内存;

6.3、插入打印日志

public class MethodAdviceAdapter extends AdviceAdapter {
    private String qualifiedName;
    private String clazzName;
    private String methodName;
    private int access;
    private String desc;

    protected MethodAdviceAdapter(int api, MethodVisitor methodVisitor, int access, String name, String descriptor, String clazzName) {
        super(api, methodVisitor, access, name, descriptor);
        this.qualifiedName = clazzName.replaceAll("/", ".");
        this.clazzName = clazzName;
        this.methodName = name;
        this.access = access;
        this.desc = descriptor;
    }

    @Override
    protected void onMethodEnter() {
        enter();
    }

    @Override
    protected void onMethodExit(int opcode) {
        exit(opcode);
    }

    private void enter() {
        // 如果是构造函数则跳过
        if (methodName.equals("<init>")) {
            return;
        }

        mv.visitLdcInsn("Chris");
        mv.visitTypeInsn(NEW, "java/lang/StringBuilder");
        mv.visitInsn(DUP);
        mv.visitMethodInsn(INVOKESPECIAL, "java/lang/StringBuilder", "<init>", "()V", false);
        mv.visitLdcInsn(qualifiedName + ".onCreate.onEnter timestamp = ");
        mv.visitMethodInsn(INVOKEVIRTUAL, "java/lang/StringBuilder", "append", "(Ljava/lang/String;)Ljava/lang/StringBuilder;", false);
        mv.visitMethodInsn(INVOKESTATIC, "java/lang/System", "currentTimeMillis", "()J", false);
        mv.visitMethodInsn(INVOKEVIRTUAL, "java/lang/StringBuilder", "append", "(J)Ljava/lang/StringBuilder;", false);
        mv.visitMethodInsn(INVOKEVIRTUAL, "java/lang/StringBuilder", "toString", "()Ljava/lang/String;", false);
        mv.visitMethodInsn(INVOKESTATIC, "android/util/Log", "e", "(Ljava/lang/String;Ljava/lang/String;)I", false);
        mv.visitInsn(POP);

    }

    private void exit(int opcode) {
        if (methodName.equals("<init>")) {
            return;
        }

        mv.visitLdcInsn("Chris");
        mv.visitTypeInsn(NEW, "java/lang/StringBuilder");
        mv.visitInsn(DUP);
        mv.visitMethodInsn(INVOKESPECIAL, "java/lang/StringBuilder", "<init>", "()V", false);
        mv.visitLdcInsn(qualifiedName+ ".onCreate.onExit timestamp = ");
        mv.visitMethodInsn(INVOKEVIRTUAL, "java/lang/StringBuilder", "append", "(Ljava/lang/String;)Ljava/lang/StringBuilder;", false);
        mv.visitMethodInsn(INVOKESTATIC, "java/lang/System", "currentTimeMillis", "()J", false);
        mv.visitMethodInsn(INVOKEVIRTUAL, "java/lang/StringBuilder", "append", "(J)Ljava/lang/StringBuilder;", false);
        mv.visitMethodInsn(INVOKEVIRTUAL, "java/lang/StringBuilder", "toString", "()Ljava/lang/String;", false);
        mv.visitMethodInsn(INVOKESTATIC, "android/util/Log", "e", "(Ljava/lang/String;Ljava/lang/String;)I", false);
        mv.visitInsn(POP);
    }
}

重新编译,查看编译后的 Class 文件是否插入了我们写入的字节码

javacIns.png

我们再看看 Kotlin 是否也同样插桩了

kcIns.png

如我们所想,Kotlin 也完美插桩,并且,大家仔细看『_$_findCachedViewById』方法,return 前也正确插入了我们的日志代码。

运行APP,查看 Logcat:

Logcat.png

七、APP使用AopAsmPlugin

7.1、使用 Gradle + Maven插件 打包与发布

查看《自定义Gradle Plugin远程发布》,了解如何制作本地maven包,以及发布至 JCenter 中央仓库。

7.2、配置顶级build.gradle依赖

// Top-level build file where you can add configuration options common to all sub-projects/modules.

buildscript {
    ext.kotlin_version = '1.3.72'
    repositories {
        google()
        jcenter()
        maven {
            url uri('./repo')
        }
    }
    dependencies {
        classpath 'com.android.tools.build:gradle:3.5.0'
        classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"

        classpath 'com.jfrog.bintray.gradle:gradle-bintray-plugin:1.8.5'

        classpath 'com.chris.aop:aop-asm-gradle-plugin:1.0.0' // 添加依赖
    }
}

apply from: rootProject.file('gradle/project-mvn-config.gradle')

allprojects {
    repositories {
        google()
        jcenter()
        maven {
            url uri('./repo')
        }
    }

    tasks.withType(Javadoc) {
        options.addStringOption('Xdoclint:none', '-quiet')
        options.addStringOption('encoding', 'UTF-8')
    }
}

task clean(type: Delete) {
    delete rootProject.buildDir
}

7.3、配置App的build.gradle

apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-android-extensions'

apply plugin: 'com.chris.aop.asm-plugin' // apply 插件

android {.....}
dependencies {....}

八、总结

本篇内容,只是一个抛砖引玉,让大家了解 Java 字节码的修改,其实在现有的工具下,一切都变的非常的简单和容易;当然,我们在实际修改字节码时,也会遇到各种奇怪的遭遇,这时,我们要多通过修改源码,并用『ASM Bytecode Viewer』来查看,做反复比对,寻找正确的解决方案。

Github源码地址:《传送门》

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

推荐阅读更多精彩内容