数据埋点:AOP 编程思想及其在 Android 埋点中的应用

ASM & AspectJ

什么是 AOP

OOP vs AOP

  • OOP 的局限:
    • OOP 的精髓是把功能或问题模块化,每个模块处理自己的事情,但在现实世界中,并不是所有问题都能完美得划分到模块中。
    • 举个最简单而又常见的例子,现在想为每个模块加上日志功能,要求模块运行时候能输出日志。一般的处理都是: - 先设计一个日志输出模块,这个模块提供日志输出 API,比如 Android 中的 Log 类。 - 然后,其他模块需要输出日志的时候调用 Log 类的几个函数,比如 e(TAG,…),w(TAG,…),d(TAG,…),i(TAG,…)等。 - 但是,从 OOP 角度看,除了日志模块本身,其他模块的业务动作、绝大部分情况下应该都不会包含日志输出功能。 - 以 ActivityManagerService 为例,显然 ActivityManagerService 的功能点中不包含输出日志这一项。 - 但实际上,软件中的众多模块确实又需要打印日志。
    • 这个日志输出功能,从整体来看,都是一个面上的。而这个面的范围,就不局限在单个模块里了,而是横跨多个模块。
  • 在 AOP 中
    • 我们要认识到 OOP 世界中,有些功能是横跨并嵌入众多模块里的,比如打印日志,数据上报等。这些功能在各个模块里分散得很厉害,可能到处都能见到。
    • AOP 的目标是把这些功能集中起来,放到一个统一的地方来控制和管理。

      如果说,OOP 如果是把问题划分到单个模块的话,那么 AOP 就是把涉及到众多模块的某一类问题进行统一管理。

      • 比如我们可以设计两个 Aspects,一个是管理某个软件中所有模块的日志输出的功能,另外一个是管理该软件中一些特殊函数调用的权限检查。

AOP 实现

  • 实现的时间点及思路
    • 编译时
      • 在 APK 打包过程中对 .clss 文件的字节码进行扫描更改,通过向编译过程添加额外的步骤来修改被编译的类;
    • 加载时
      • 当目标类被 Dalvik 或者 ART 加载的时候修改才会被执行,这是对 Java 字节码文件或者 Android 的 dex 文件进行的注入操作;
    • 运行时
      • Hook 某些关键方法,比如:使用动态代理(这可以说并不是真正的代码注入);
  • 实现方法
    • 切面编程库(AspectJ),案例:Hugo
    • 字节码注入(ASM),案例:GrowingIO

AOP 编程的具体使用场景

  • 日志记录
  • 数据持久化
  • 行为监测
  • 组件生命周期监控
  • 方法执行时间监控
  • 用户点击事件埋点
  • 弹窗事件埋点
  • 数据验证
  • 缓存

AspectJ

AspectJ 是什么

  • AspectJ 是广泛应用于 JavaEE 开发的 AOP 方案,是面向切面编程在 Java 中的一种具体实现,简单易用、功能强大。
    • 正如面向对象编程是对常见问题的模块化一样,面向切面编程是对横向的同一问题进行模块化;
      • 比如:在某个包下的所有类中的某一类方法中、都需要解决一个相似的问题,可以通过 AOP 的编程方式对此进行模块化封装,统一解决。
    • AspectJ 提供了简便的语法让我们定义切面逻辑,再通过提供的 AJC 编译器,在 Java 文件编译成 class 文件的过程里,把切面代码织入到目标业务代码里。

      本质上,AspectJ 仍然是以代理的方式实现 AOP,我们通过 AspectJ 就能方便的在目标方法执行前后执行我们的埋点代码。

AspectJ 组成部分

  • AspectJ 向 Java 引入了一个新的概念—— join point,它包括几个新的结构:
    • pointcuts
    • advice
    • inter-type declarations
    • aspects。
  • 含已解释
    • join point 是在程序流中被定义好的点;
    • pointcut 在那些点上选出特定的 join point 和值;
    • advice 是到达 join point 时被执行的代码;
    • inter-type declarations 是 AspectJ 具有的不同类型的类型间声明,允许程序员修改程序的静态结构,即其类的成员和类之间的关系。
  • AspectJ 几个名词术语解释

    • Cross-cutting concerns
      • 即使在面向对象编程中大多数类都是执行一个单一的、特定的功能,它们也有时候需要共享一些通用的辅助功能。
      • 比如:我们想要在一个线程进入和退出一个方法时,在数据层和UI层加上输出log的功能。尽管每一个类的主要功能时不同的,但是它们所需要执行的辅助功能是相似的。
    • Advice
      • 需要被注入到.class字节码文件的代码。通常有三种:before,after和around,分别是在目标方法执行前,执行后以及替换目标代码执行。
      • 除了注入代码到方法中外,更进一步的,你还可以做一些别的修改,例如添加成员变量和接口到一个类中。
    • Join point
      • 程序中执行代码插入的点,例如方法调用时或者方法执行时。
    • Pointcut
      • 告诉代码注入工具在哪里注入特定代码的表达式(即需要在哪些Joint point应用特定的Advice)。它可以选择一个这样的点(例如,一个单一方法的执行)或者许多相似的点(例如,所有被自定义注解@DebugTrace标记的方法)。
    • Aspect
      • Aspect 将 pointcut 和 advice 联系在一起。
      • 例如:我们通过定义一个 pointcut 和给出一个准确的 advice 实现向我们的程序中添加一个打印日志功能的 aspect。
    • Weaving
      • 向目标位置(join point)注入代码(advice)的过程。

ASM 字节码插桩

ASM 是什么

  • ASM 是字节码操作库,支持对字节码进行编辑,实现类、属性和方法的增删改查;
  • 字节码操作库还有 Javaassit 库可以选择,但 ASM 灵活度和效率都是最高的;
  • ASM 可以直接产生二进制的 class 文件,也可以增强既有类的功能。
    • Java class 被存储在严格格式定义的 .class文件里,这些类文件拥有足够的元数据来解析类中的所有元素,包括:
      • 类名称
      • 方法
      • 属性
      • Java 字节码(指令)。

字节码插桩

  • 利用操作 ASM 字节码实现方法计时,可以的做法是修改 class 文件,在目标方法开始和结束时插入本来需要手动埋点的代码,这个过程称之为字节码插桩。

  • 如图所示是Android打包流程:.java 文件 -> .class 文件 -> .dex 文件;
  • 只要在红圈处拦截住,拿到所有方法进行修改完再放生就可以了,而做到这一步也不难,Google 官方在Android Gradle 的 1.5.0 版本以后提供了 Transfrom API,允许第三方 Plugin 在打包 dex 文件之前的编译过程中操作 .class 文件;
  • ASM 字节码插桩,要做的就是实现 Transform 进行 .class 文件遍历拿到所有方法,修改完成对原文件进行替换。

ASM 框架中的核心类

  • ClassReader
    • 该类用来解析编译过的class字节码文件。
  • ClassWriter
    • 该类用来重新构建编译后的类,比如说修改类名、属性以及方法,甚至可以生成新的类的字节码文件。
  • ClassViitor
    • 主要负责 “拜访” 类成员信息,包括:
      • 标记在类上的注解,
      • 类的构造方法,
      • 类的字段,
      • 类的方法,
      • 静态代码块。
  • AdviceAdapter
    • 实现了 MethodVisitor 接口,主要负责 “拜访” 方法的信息,用来进行具体的方法字节码操作。

AspectJ vs ASM

共同点

  • AspectJ 和 ASM 既支持以注解作为切入点,也支持根据类方法名/类继承关系等规则来确定切入点。
    • 注解的作用是提供插入点;

不同点

  • AspectJ 本质上仍然是以代理的方式实现 AOP;
  • ASM 通过直接对 class 文件进行修改的方式,实现 AOP 埋点需求。