Android字节码插桩采坑笔记

10,775 阅读18分钟

#1. 写在前面

俗话说 “任何技术都是脱离了业务都将是空中楼阁”。最开始有研究字节码插桩技术冲动的是我们接入了一款统计类的 SDK(这里我就不具体说是哪款了)。他们的套路是第三方开发者需要接入他们的插件 (Gradle Plugin), 然后便可以实现无埋点进行客户端的全量数据统计 (全量的意思是包括页面打开速度、方法耗时、各种点击事件等)。当时由于需求排期比较急, 一直没有时间研究他们的实现方式。春节假期, 我实在难以控制体内的求知欲, 通过查资料以及反编译他们的代码终于找到了技术的本源——字节码插桩。正好公司这段时间要继续搞一套统计系统, 为了不侵入原有的项目架构, 我也打算使用字节码插桩技术来实现。so 写这篇文章的目的是将预研期的坑 share 一下, 避免更多小伙伴入坑~

先简要描述一下接下来我们要干什么

简单来讲, 我们要实现无埋点对客户端的全量统计。这里的统计概括的范围比较广泛, 常见的场景有:

  • 页面 (Activity、Fragment) 的打开事件
  • 各种点击事件的统计, 包括但不限于 Click LongClick TouchEvent
  • Debug 期需要统计各个方法的耗时。注意这里的方法包括接入的第三方 SDK 的方法。
  • 待补充

要实现这些功能需要拥有哪些技术点呢?

  • 面向切面编程思想 (AOP)
  • Android 打包流程
  • 自定义 Gradle 插件
  • 字节码编织
  • 结合自己的业务实现统计代码
  • 没了。。。
  1. 开始恶补技术点 ==========

2.1 技术点——什么是 AOP

AOP(Aspect Oriented Program 的首字母缩写) 是一种面向切面编程的思想。这种编程思想是相对于 OOP(ObjectOriented Programming 即面向对象编程) 来说的。说破大天, 咱们要实现的功能还是统计嘛, 大规模的重复统计行为是典型的 AOP 使用场景。所以搞懂什么是 AOP 以及为什么要用 AOP 变得很重要

先来说一下大家熟悉的面向对象编程: 面向对象的特点是继承、多态和封装。而封装就要求将功能分散到不同的对象中去,这在软件设计中往往称为职责分配。实际上也就是说,让不同的类设计不同的方法。这样代码就分散到一个个的类中去了。这样做的好处是降低了代码的复杂程度,使类可重用。

But 面向对象的编程天生有个缺点就是分散代码的同时,也增加了代码的重复性。比如我希望在项目里面所有的模块都增加日志统计模块, 按照 OOP 的思想, 我们需要在各个模块里面都添加统计代码, 但是如果按照 AOP 的思想, 可以将统计的地方抽象成切面, 只需要在切面里面添加统计代码就 OK 了。

其实在服务端的领域 AOP 已经被各路大佬玩的风生水起, 例如 Spring 这类跨时代的框架。我第一次接触 AOP 就是在自学 Spring 框架的的时候。最常见实现 AOP 的方式就是代理。

2.2 技术点——Android 打包流程

既然想用字节码插桩来实现无埋点, 对 Android 的打包流程总是要了解一下的。不然咱们怎么系统什么时候会把 Class 文件生成出来供我们插桩呢?官网的打包流程不是那么的直观。所以一起来看一下更直观的构建流程吧。

一图顶千言, 经过 “Java Compiler 步骤”, 系统便生成了. class 文件。这些 class 文件经过 dex 步骤再次转化成 Android 识别的. dex 文件。既然我们要做字节码插桩, 就必须 hook 打包流程,在 dex 步骤之前对 class 字节码进行扫描与重新编织, 然后将编织好的 class 文件交给 dex 过程。这样就实现了所谓的无埋点。那么问题来了,我们怎么知道系统已经完成了“Java Compiler” 步骤呢? 这就引出下一个技术点——自定义 Gradle 插件。

2.3 技术点——自定义 Gradle 插件

接着 2.2 小节的问题, 我们怎么知道打包系统已经完成 “Java Compiler” 步骤?即使知道打包系统生成了 class 字节码文件又怎么 Hook 掉该流程在完成自定义字节码编织后再进行 “dex” 过程呢? 原来,对于 Android Gradle Plugin 版本在 1.5.0 及以上的情况,Google 官方提供了 transformapi 用作字节码插桩的入口。说的直白一点通过自定义 Gradle 插件, 重写里面 transform 方法就可以在 “Java Compiler” 过程结束之后 “dex”过程开始之前获得回调。这正是字节码重新编织的绝佳时机。

关于怎样定义 Gradle 插件值得参考的资源

因为本文重点讲字节码插桩的技术流程, 强调从面上覆盖这套技术所涉及到的技术点,所以关于自定义插件的内容不展开讲解了。按照上面推荐的资源自己基本可以跑通自定义 Gradle 插件的流程。如果大家自定义插件的详细内容请联系我, 如果有必要我可以出一篇自定义 Gradle 插件的教程。文末会给出邮箱。

关于 transform 值得参考的资源:

  • 官方文档
  • 滴滴插件化项目 VirtualApk, 该项目中的 virtualapk-gradle-plugin 就是利用这个插桩入口将插件的资源与宿主的资源进行剥离, 防止宿主 apk 与插件 apk 资源冲突。详见该项目里面 StripClassAndResTransform 类。

2.4 技术点——字节码编织

字节码的相关知识是本文的核心技术点

2.4.1 什么是字节码

Java 字节码(英语:Java bytecode)是 Java 虚拟机执行的一种指令格式。通俗来讲字节码就是经过 javac 命令编译之后生成的 Class 文件。Class 文件包含了 Java 虚拟机指令集和符号表以及若干其他的辅助信息。Class 文件是一组以 8 位字节为基础单位的二进制流, 哥哥数据项目严格按照顺序紧凑的排列在 Class 文件之中, 中间没有任何分隔符,这使得整个 Class 文件中存储的内容几乎全是程序运行时的必要数据。

因为 Java 虚拟机的提供商有很多,其具体的虚拟机实现逻辑都不相同,但是这些虚拟机的提供商都严格遵守《Java 虚拟机规范》的限制。所以一份正确的字节码文件是可以被不同的虚拟机提供商正确的执行的。借用《深入理解 Java 虚拟机》一书的话就是 “代码编译的结果从本地机器码转变成字节码,是存储格式发展的一小步,确实编程语言发展的一大步”。

2.4.2 字节码的内容

这张图是一张 java 字节码的总览图。一共含有 10 部分,包含魔数,版本号,常量池,字段表集合等等。同样本篇文章不展开介绍具体内容请参考这篇博文,有条件的同学请阅读《深入理解 Java 虚拟机》一书。我现在读了两遍,每次读都有新的感悟。推荐大家也读一下,对自己的成长非常有好处。

关于字节码几个重要的内容:

全限定名

Class 文件中使用全限定名来表示一个类的引用,全限定名很容易理解,即把类名所有 “.” 换成了“/”

例如

android.widget.TextView
复制代码

的全限定名为

android/widget/TextView
复制代码

描述符

描述符的作用是描述字段的数据类型、方法的参数列表 (包括数量、类型以及顺序) 和返回值。根据描述符的规则, 基本数据类型 (byte char double float int long short boolean) 以及代表无返回值的 void 类型都用一个大写字符来表示,对象类型则用字符 “L” 加对象的全限定名来表示, 一般对象类型末尾都会加一个 “;” 来表示全限定名的结束。如下表

标志字符含义
B基本类型 byte
C基本类型 char
D基本类型 double
F基本类型 float
I基本类型 int
J基本类型 long
S基本类型 short
Z基本类型 boolean
V特殊类型 void
L对象类型,例如 Ljava/lang/Object

对于数组类型,每一个维度将使用 “[” 字符来表示 例如我们需要定义一个 String 类型的二维数组

java.lang.String[][]
将会被表示成
[[java/lang/String;
 
int[]
将会被表示成
[I;
复制代码

用描述符来描述方法时,按照先参数列表后返回值的顺序进行描述。参数列表按照参数的顺序放到一组小括号 “()” 之内。举几个栗子:

void init()
会被描述成
()V
 
void setText(String s)
会被描述成
(Ljava/lang/String)V;
 
java.lang.String toString()
会被描述成
()Ljava/lang/String;
复制代码

2.4.3 虚拟机字节码执行引擎知识

执行引擎是虚拟机最核心的组成部分之一。本篇仍然控制版面,避免长篇大论的讨论具体内容而忽略需要解决的问题的本质。下面我们重点讨论一下 Java 的运行时内存布局:

虚拟机的内存可以分为堆内存与栈内存。堆内存是所有线程共享的,栈内存则是线程私有的。下图为虚拟机运行时数据区

这里重点解释一下栈内存。Java 虚拟机栈是线程私有的,它描述的是 Java 方法执行的内存模型:每个方法在执行的同时会创建一个栈帧用于存局部变量表、操作数栈、动态链接、方法返回地址等信息。每一个方法从调用到执行完毕的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。每一个栈帧都包含了局部变量表、操作数栈、动态链接、方法返回地址和一些额外的附加信息。在编译成 class 文件后,栈帧中需要多大的局部变量表和多深的操作数栈已经保存在字节码文件(class 文件)的 code 属性中,因此一个栈帧需要分配多少内存,不会受到程序运行的影响,只会根据虚拟机的具体实现不同。一个线程中的方法调用链可能会很长,即有很多栈帧。对于一个当前活动的线程中,只有位于线程栈顶的栈帧才是有效的,称为当前栈帧(current stack Frame),这个栈帧关联的方法称为当前方法(current method),栈帧的概念图如下:

解释一下上图相关概念:

  • 局部变量表: 局部变量表是一组变量存储空间,用于存储方法参数 (就是方法入参) 和方法内部定义局部变量。局部变量表的容量以容量槽为最小单位(slot)。虚拟机通过索引的定位方式使用局部变量表,索引值的范围为 0 到局部变量的最大 slot 值,在 static 方法中,0 代表的是 “this”,即当前调用该方法的引用 (主调方),其余参数从 1 开始分配,当参数列表中的参数分配完后,就开始给方法内的局部变量分配。用 Android 的 click 方法举个栗子:
 public void onClick(View v) {
                
            }
复制代码

这个方法的局部变量表的容量槽为:

Slot Numbervalue
0this
1View v
  • 操作数栈:操作数栈又被称为操作栈,它是一个后入先出的栈结构。当一个方法刚开始执行时,操作数栈里是空的,在方法的执行过程中,会有各种字节码指令向操作数栈中写入和提取内容,也就是出栈和入栈的过程。例如,在执行字节码指令 iadd(两个 int 类型整数相加)时要求操作数栈中最接近栈顶的两个元素已经存入两个 int 类型的值,然后执行相加时,会将这两个 int 值相加,然后将相加的结果入栈。具体的字节码操作指令可以参考维基百科,也可以参考国内巴掌的文章

2.4.4 字节码编织之 ASM 简介

恶补完前面的知识点, 终于到了最后的一步。怎样对字节码进行编织呢?这里我选了一个强大的开源库 ASM。

什么是 ASM?

ASM 是一个 Java 字节码操控框架。它能被用来动态生成类或者增强既有类的功能。ASM 可以直接产生二进制 class 文件,也可以在类被加载入 Java 虚拟机之前动态改变类行为。Java class 被存储在严格格式定义的 .class 文件里,这些类文件拥有足够的元数据来解析类中的所有元素:类名称、方法、属性以及 Java 字节码(指令)。ASM 从类文件中读入信息后,能够改变类行为,分析类信息,甚至能够根据用户要求生成新类。

为什么选择 ASM 来进行字节码编织?

因为有了前人做的实验,我没有对字节码编织的库进行效率测试。参考网易乐得团队的实验结果:

FrameworkFirst timeLater times
Javassist2575.2
BCEL4735.5
ASM62.41.1

通过上表可见,ASM 的效率更高。不过效率高的前提是该库的语法更接近字节码层面。所以上面的虚拟机相关知识显得更加重要。

这个库也没什么可展开描述的,值得参考的资源:

为了快速上手 ASM,安利一个插件 [ASM Bytecode Outline]。这里需要感谢巴掌的文章。ASM 的内容就介绍到这里,具体怎么使用大家参考项目代码或者自己研究一波文档就好了。

  1. 项目实战 =======

我们以 Activity 的开启为切面,对客户端内所有 Activity 的 onCreate onDestroy 进行插桩。建议先 clone 一份 demo 项目

3.1 新建 Gradle 插件

按照 2.3 小节的内容,聪明的你一定能很快新建一个 Gradle 插件并能跑通流程吧。如果你的流程没跑通可以参考项目源码。

需要注意的点:

注意点 1:

项目中需要将 Compile 的地址换成你的本机地址,否则编译会失败。需要改动的文件有 traceplugin/gradle.properties 中的 LOCAL_REPO_URL 属性。

以及跟项目下的 build.gradle 文件中的 maven 地址

3.2 完善自定义插件, 添加扫描与修改逻辑

例如 demo 项目中的 TracePlugin.groovy 就是扫描的入口,通过重写 transform 方法,我们可以获得插桩入口, 将对 Class 文件的处理转化成 ASM 处理。

public class TracePlugin extends Transform implements Plugin<Project> {
    void apply(Project project) {
        def android = project.extensions.getByType(AppExtension);
        //对插件进行注册,添加插桩入口
        android.registerTransform(this)
    }
 
 
    @Override
    public String getName() {
        return "TracePlugin";
    }
 
    @Override
    public Set<QualifiedContent.ContentType> getInputTypes() {
        return TransformManager.CONTENT_CLASS;
    }
 
    @Override
    public Set<QualifiedContent.Scope> getScopes() {
        return TransformManager.SCOPE_FULL_PROJECT;
    }
 
    @Override
    public boolean isIncremental() {
        return false;
    }
    @Override
    void transform(Context context, Collection<TransformInput> inputs, Collection<TransformInput> referencedInputs,
                   TransformOutputProvider outputProvider, boolean isIncremental) 
            throws IOException, TransformException, InterruptedException {
        println '//===============TracePlugin visit start===============//'
        //删除之前的输出
        if (outputProvider != null)
            outputProvider.deleteAll()
        //遍历inputs里的TransformInput
        inputs.each { TransformInput input ->
            //遍历input里边的DirectoryInput
            input.directoryInputs.each {
                DirectoryInput directoryInput ->
                    //是否是目录
                    if (directoryInput.file.isDirectory()) {
                        //遍历目录
                        directoryInput.file.eachFileRecurse {
                            File file ->
                                def filename = file.name;
                                def name = file.name
                                //这里进行我们的处理 TODO
                                if (name.endsWith(".class") && !name.startsWith("R\$") &&
                                        !"R.class".equals(name) && !"BuildConfig.class".equals(name)) {
                                    ClassReader classReader = new ClassReader(file.bytes)
                                    ClassWriter classWriter = new ClassWriter(classReader, ClassWriter.COMPUTE_MAXS)
                                    def className = name.split(".class")[0]
                                    ClassVisitor cv = new TraceVisitor(className, 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)
            }
 
 
            input.jarInputs.each { JarInput jarInput ->
                /**
                 * 重名名输出文件,因为可能同名,会覆盖
                 */
                def jarName = jarInput.name
                def md5Name = DigestUtils.md5Hex(jarInput.file.getAbsolutePath())
                if (jarName.endsWith(".jar")) {
                    jarName = jarName.substring(0, jarName.length() - 4)
                }
 
                File tmpFile = null;
                if (jarInput.file.getAbsolutePath().endsWith(".jar")) {
                    JarFile jarFile = new JarFile(jarInput.file);
                    Enumeration enumeration = jarFile.entries();
                    tmpFile = new File(jarInput.file.getParent() + File.separator + "classes_trace.jar");
                    //避免上次的缓存被重复插入
                    if (tmpFile.exists()) {
                        tmpFile.delete();
                    }
                    JarOutputStream jarOutputStream = new JarOutputStream(new FileOutputStream(tmpFile));
                    //用于保存
                    ArrayList<String> processorList = new ArrayList<>();
                    while (enumeration.hasMoreElements()) {
                        JarEntry jarEntry = (JarEntry) enumeration.nextElement();
                        String entryName = jarEntry.getName();
                        ZipEntry zipEntry = new ZipEntry(entryName);
                        //println "MeetyouCost entryName :" + entryName
                        InputStream inputStream = jarFile.getInputStream(jarEntry);
                        //如果是inject文件就跳过
 
                        //重点:插桩class
                        if (entryName.endsWith(".class") && !entryName.contains("R\$") &&
                                !entryName.contains("R.class") && !entryName.contains("BuildConfig.class")) {
                            //class文件处理
                            jarOutputStream.putNextEntry(zipEntry);
                            ClassReader classReader = new ClassReader(IOUtils.toByteArray(inputStream))
                            ClassWriter classWriter = new ClassWriter(classReader, ClassWriter.COMPUTE_MAXS)
                            def className = entryName.split(".class")[0]
                            ClassVisitor cv = new TraceVisitor(className, classWriter)
                            classReader.accept(cv, EXPAND_FRAMES)
                            byte[] code = classWriter.toByteArray()
                            jarOutputStream.write(code)
 
                        } else if (entryName.contains("META-INF/services/javax.annotation.processing.Processor")) {
                            if (!processorList.contains(entryName)) {
                                processorList.add(entryName)
                                jarOutputStream.putNextEntry(zipEntry);
                                jarOutputStream.write(IOUtils.toByteArray(inputStream));
                            } else {
                                println "duplicate entry:" + entryName
                            }
                        } else {
 
                            jarOutputStream.putNextEntry(zipEntry);
                            jarOutputStream.write(IOUtils.toByteArray(inputStream));
                        }
 
                        jarOutputStream.closeEntry();
                    }
                    //写入inject注解
 
                    //结束
                    jarOutputStream.close();
                    jarFile.close();
                }
 
                //处理jar进行字节码注入处理 TODO
 
                def dest = outputProvider.getContentLocation(jarName + md5Name,
                        jarInput.contentTypes, jarInput.scopes, Format.JAR)
                if (tmpFile == null) {
                    FileUtils.copyFile(jarInput.file, dest)
                } else {
                    FileUtils.copyFile(tmpFile, dest)
                    tmpFile.delete()
                }
            }
        }
        println '//===============TracePlugin visit end===============//'
 
    }
复制代码

上述 TracePlugin.groovy 文件完成了字节码与 ASM 的结合,那具体怎么修改字节码呢?新建继承自 ClassVisitor 的 Visitor 类

  • 重写里面的 visit 方法以便筛选哪些类需要插桩,例如筛选所有继承自 Activity 的类才插桩。
  • 重写 visitMethod 方法以便筛选当前类哪些方法需要插桩。例如筛选所有 onCreate 方法才插桩。 具体注释见代码:
/**
 * 对继承自AppCompatActivity的Activity进行插桩
 */
 
public class TraceVisitor extends ClassVisitor {
 
    /**
     * 类名
     */
    private String className;
 
    /**
     * 父类名
     */
    private String superName;
 
    /**
     * 该类实现的接口
     */
    private String[] interfaces;
 
    public TraceVisitor(String className, ClassVisitor classVisitor) {
        super(Opcodes.ASM5, classVisitor);
    }
 
    /**
     * ASM进入到类的方法时进行回调
     *
     * @param access
     * @param name       方法名
     * @param desc
     * @param signature
     * @param exceptions
     * @return
     */
    @Override
    public MethodVisitor visitMethod(final int access, final String name, final String desc, final String signature,
                                     String[] exceptions) {
        MethodVisitor methodVisitor = cv.visitMethod(access, name, desc, signature, exceptions);
        methodVisitor = new AdviceAdapter(Opcodes.ASM5, methodVisitor, access, name, desc) {
 
            private boolean isInject() {
                //如果父类名是AppCompatActivity则拦截这个方法,实际应用中可以换成自己的父类例如BaseActivity
                if (superName.contains("AppCompatActivity")) {
                    return true;
                }
                return false;
            }
 
            @Override
            public void visitCode() {
                super.visitCode();
 
            }
 
            @Override
            public AnnotationVisitor visitAnnotation(String desc, boolean visible) {
                return super.visitAnnotation(desc, visible);
            }
 
            @Override
            public void visitFieldInsn(int opcode, String owner, String name, String desc) {
                super.visitFieldInsn(opcode, owner, name, desc);
            }
 
 
            /**
             * 方法开始之前回调
             */
            @Override
            protected void onMethodEnter() {
                if (isInject()) {
                    if ("onCreate".equals(name)) {
                        mv.visitVarInsn(ALOAD, 0);
                        mv.visitMethodInsn(INVOKESTATIC,
                                "will/github/com/androidaop/traceutils/TraceUtil",
                                "onActivityCreate", "(Landroid/app/Activity;)V",
                                false);
                    } else if ("onDestroy".equals(name)) {
                        mv.visitVarInsn(ALOAD, 0);
                        mv.visitMethodInsn(INVOKESTATIC, "will/github/com/androidaop/traceutils/TraceUtil"
                                , "onActivityDestroy", "(Landroid/app/Activity;)V", false);
                    }
                }
            }
 
            /**
             * 方法结束时回调
             * @param i
             */
            @Override
            protected void onMethodExit(int i) {
                super.onMethodExit(i);
            }
        };
        return methodVisitor;
 
    }
 
    /**
     * 当ASM进入类时回调
     *
     * @param version
     * @param access
     * @param name       类名
     * @param signature
     * @param superName  父类名
     * @param interfaces 实现的接口名
     */
    @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.className = name;
        this.superName = superName;
        this.interfaces = interfaces;
    }
}
复制代码

注意:

如果你对 ASM 用的并不是那么熟练, 别忘了 ASM Bytecode Outline 插件。上面 TraceVisitor.java 中的 onMethodEnter 方法内部代码便是从 ASM Bytecode Outline 生成直接拷贝过来的。至于这个插件怎么使用 2.4.4 小节已经介绍过了。

3.3 完善自定义统计工具,实现最终数据统计

demo 项目中 app/TraceUtil.java 类是用来统计的代码, 项目中我只是在 onCreate 与 onDestroy 时弹出了一个 Toast, 你完全可以把这两个函数执行的时间记录下来,实现统计用户在线时长等逻辑。TraceUtils.java 代码如下:

/**
 * Created by will on 2018/3/9.
 */
 
public class TraceUtil {
    private final String TAG = "TraceUtil";
 
    /**
     * 当Activity执行了onCreate时触发
     *
     * @param activity
     */
    public static void onActivityCreate(Activity activity) {
        Toast.makeText(activity
                , activity.getClass().getName() + "call onCreate"
                , Toast.LENGTH_LONG).show();
    }
 
 
    /**
     * 当Activity执行了onDestroy时触发
     *
     * @param activity
     */
    public static void onActivityDestroy(Activity activity) {
        Toast.makeText(activity
                , activity.getClass().getName() + "call onDestroy"
                , Toast.LENGTH_LONG).show();
    }
}
复制代码

看到这里有人会有疑问, 这个 TraceUtil 的 onActivityCreate 与 onActivityDestroy 是什么时候被执行的?当然是通过 TraceVisitor 的 visitMethod 方法插桩插进去的呀。

3.4 自己运行一下 Demo & Enjoy

项目代码

看下项目的效果, 统计代码已经被成功注入。

  1. 其他的小 Tips ============
  • 字节码插桩是面向整个应用的插桩, 如果我们只想插某一个函数的桩应该怎么办呢?例如我只想插 MainActivity 的 onCreate 函数,而不想插其他 Activity 的 onCreate。这时候可以使用自定义注解来解决。方案是自定义一个注解,在想统计的方法上打上这个注解,在 ASM 的 ClassVisitor 类中重写 visitAnnotation 方法来确定要不要插桩。怎样自定义注解可以看我的这篇博文
  • 如果想插不同的桩该怎么办呢? 例如我既想统计 Activity 的生命周期函数又想统计 View 的 Click 事件。讲道理这块我的经验不够丰富,我的方案比较 low,我是通过在 ClassVisitor 中判断当前类的名字、当前类的父类名字、当前类实现了哪些接口、以及当前类方法的名字来判断的,比较臃肿。小伙伴们有什么好的想法可以留言或联系我

写在最后

由于这篇博文所涉及到的知识点比较多,很多地方我可能没有展开写的比较糙。如果写的有什么问题希望大家及时提出来,一起学习,一起进步。

参考资源

About Me

contact wayvalue
mailweixinjie1993@gmail.com
wechatW2006292
githubhttps://github.com/weixinjie
bloghttps://juejin.im/user/57673c83207703006bb92bf6