作者简介:ASCE1885, 《Android 高级进阶》作者。
本文由于潜在的商业目的,未经授权不开放全文转载许可,谢谢!
本文分析的源码版本已经 fork 到我的 Github。
在 Android 性能调优中,通常存在需要对方法的执行时间进行统计的需求,这样就可以看出哪些方法耗时多,是系统的瓶颈。最容易想到的方案是在每个方法的开头处获取系统时间,在方法的结尾处再次获取系统时间,前后两个时间戳的差值就是这个方法执行所消耗的总时间。这个方案虽然简单易懂,但实际操作起来要写很多样板代码,同时对原有的代码浸入性太高。那么有没有更好的方案实现方法的性能监控呢?当然是有的,它就是本文的主角:hugo。
hugo 也是 Android 平台著名的日志框架,跟 timber 一样出自 JakeWharton 之手。在《Android 高级进阶》一书的《面向切面编程及其在 Android 中的应用》一节中其实已经介绍过 hugo 相关内容,本文会再做拓展,对 hugo 源码做更详细的剖析。
基本用法
在介绍 hugo 的核心原理前,有必要先了解其基本用法。hugo 以 gradle 插件的形式供开发者集成和使用,分为两步:
- 在项目全局添加对 hugo 插件的依赖
- 在需要使用 hugo 的 module 中应用 hugo 插件
buildscript {
repositories {
mavenCentral()
}
dependencies {
classpath 'com.jakewharton.hugo:hugo-plugin:1.2.1' // 添加 Hugo 的 Gradle 插件依赖
}
}
apply plugin: 'com.jakewharton.hugo' // 应用 Hugo 插件
就这么简单,之后这个插件会帮我们下载一些依赖库,分别是:
- aspectjrt.jar:aspectJ 运行时的依赖库,想要使用 aspectJ 的功能都需要引入这个库
- hugo-annotations:hugo 的注解库,定义了
DebugLog
这个注解,后面会介绍到 - hugo-runtime:hugo 的运行时库,是实现 hugo 日志功能的核心库
hugo 的使用很简单,在需要进行日志记录的类名或者方法名处使用 @DebugLog
注解标记即可,之后 hugo 就会在编译时织入(weaving)打印日志的代码,从而省去了开发者手动编写日志代码的繁琐。例如下面这个方法使用 @DebugLog
注解:
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
printArgs("The", "Quick", "Brown", "Fox");
}
@DebugLog
private void printArgs(String... args) {
for (String arg : args) {
Log.i("Args", arg);
}
}
在程序运行的时候会打印出下面的日志信息,其中 ⇢ printArgs(args=["The", "Quick", "Brown", "Fox"])
和 ⇠ printArgs [0ms]
是 Hugo 这个函数库为我们自动添加的日志信息。
com.asce1885.hugodemo V/MainActivity: ⇢ printArgs(args=["The", "Quick", "Brown", "Fox"])
com.asce1885.hugodemo I/Args: The
com.asce1885.hugodemo I/Args: Quick
com.asce1885.hugodemo I/Args: Brown
com.asce1885.hugodemo I/Args: Fox
com.asce1885.hugodemo V/MainActivity: ⇠ printArgs [0ms]
通过查看编译后生成的 .class
文件(位于 build/intermediates/classes/类所在包名
中),可以看到 printArgs
方法经过 AspectJ 框架的代码织入后,已经面目全非了:
@DebugLog
private void printArgs(String... args) {
JoinPoint var7 = Factory.makeJP(ajc$tjp_0, this, this, args);
Hugo var10000 = Hugo.aspectOf();
Object[] var8 = new Object[]{this, args, var7};
var10000.logAndExecute((new HugoActivity$AjcClosure1(var8)).linkClosureAndJoinPoint(69648));
}
核心知识点
hugo 这个框架麻雀虽小但五脏俱全,它使用了很多 Android 开发中流行的技术,例如注解,AOP,AspectJ,Gradle 插件等。在进行 hugo 源码解读之前,你需要首先对这些知识点有一定的了解。
注解
注解是 Java 语言的特性之一,它是在源代码中插入的标签,这些标签在后面的编译或者运行过程中起到某种作用,每个注解都必须通过注解接口 @interface 进行声明,接口的方法对应着注解的元素。
元注解,注解的一种类型,顾名思义,就是用来定义和实现注解的注解,总共有如下五种,在 hugo 中会用到 @Target
和 @Retention
这两个元注解,我们来做个简单的介绍。
- @Target:这个注解的取值是一个
ElementType
类型的数组,用来指定注解所适用的对象范围,总共有十种不同的类型,根据定义的注解进行灵活的组合,如下所示(加粗的三个元素类型是 hugo 用到的,可重点关注):
元素类型 | 适用于 |
---|---|
ANNOTATION_TYPE | 注解类型声明 |
CONSTRUCTOR | 构造函数 |
FIELD | 实例变量 |
LOCAL_VARIABLE | 局部变量 |
METHOD | 方法 |
PACKAGE | 包 |
PARAMETER | 方法参数或者构造函数的参数 |
TYPE | 类(包含enum)和接口(包含注解类型) |
TYPE_PARAMETER | 类型参数 |
TYPE_USE | 类型的用途 |
-
@Retention:用来指明注解的访问范围,也就是在什么级别保留注解,有如下三种选择:
- 源码级注解:在定义注解接口时,使用
@Retention(RetentionPolicy.SOURCE)
修饰的注解,该类型的注解信息只会保留在.java
源码里,源码经过编译后,注解信息会被丢弃,不会保留在编译好的.class
文件中 - 编译时注解:在定义注解接口时,使用
@Retention(RetentionPolicy.CLASS)
修饰的注解,该类型的注解信息会保留在.java
源码里和.class
文件里,在执行的时候,会被 Java 虚拟机丢弃,不会加载到虚拟机中,hugo 就是用的这一种 - 运行时注解:在定义注解接口时,使用
@Retention(RetentionPolicy.RUNTIME)
修饰的注解,Java 虚拟机在运行期也保留注解信息,可以通过反射机制读取注解的信息(.java
源码、.class
文件和执行的时候都有注解的信息)
- 源码级注解:在定义注解接口时,使用
未指定类型时,默认是 CLASS 类型。
更多关于注解的相关知识点,可以参考《Android 高级进阶》中的《注解在 Android 中的应用》一节。
AOP
AOP,全称为 Aspect Oriented Programming,即面向切面编程。AOP 是软件开发中的一个编程范式,通过预编译方式或者运行期动态代理等实现程序功能的统一维护的一种技术,它是 OOP(面向对象编程)的延续,利用 AOP 开发者可以实现对业务逻辑中的不同部分进行隔离,从而进一步降低耦合,提高程序的可复用性,进而提高开发的效率。AOP 能够实现将日志纪录,性能统计,埋点统计,安全控制,异常处理等代码从具体的业务逻辑代码中抽取出来,放到统一的地方进行处理。AOP 涉及到的基本概念有:
- 横切关注点(Cross-cutting concerns):在面向对象编程中,经常需要在不同的模块代码中添加一些类似的代码,例如在函数入口处打印日志,在 View 的点击处添加点击事件的埋点统计,或者对一个函数进行性能监控,查看它的执行耗时等等,在 AOP 中把软件系统分成两个部分:核心关注点和横切关注点,核心关注点就是业务逻辑处理的主要流程,而横切关注点就是上面所说的经常发生在核心关注点的多个地方,且基本相似的日志纪录,埋点统计等等。
- 连接点(Joint point):在核心关注点中可能会存在横切关注点的地方,例如方法调用的入口,View 的点击处理等地方,在 AOP 中习惯称为连接点。
- 增强(Advice):特定连接点处所执行的动作,也就是 AOP 织入的代码,目的是对原有代码进行功能的增强,典型的有:
- before:在目标方法执行之前的动作
- around:在目标方法之前前后的动作
- after:在目标方法执行之后的动作
- 切入点(Pointcut):连接点的集合,这些连接点可以确定什么时机会触发一个通知。切入点通常使用正则表达式或者通配符语法表示,可以指定执行某个方法,也可以指定多个方法,例如指定标记了某个注解的所有方法。
- 切面(Aspect):切入点和通知可以组合成一个切面。
- 织入(Weaving):将通知注入到连接点的过程。
AOP 中代码的织入根据类型的不同,主要可以分为三类:
- 编译时织入:在 Java 类文件编译的时候进行织入,这需要通过特定的编译器来实现,例如使用 AspectJ 的织入编译器。
- 类加载时织入:通过自定义类加载器 ClassLoader 的方式在目标类被加载到虚拟机之前进行类的字节代码的增强。
- 运行时织入:切面在运行的某个时刻被动态织入,基本原理是使用 Java 的动态代理技术。
hugo 使用到的代码织入属于编译时织入,用到了 AspectJ 这样一个面向切面的框架,它扩展了 Java 语言,定义了一套 AOP 语法,实现了一个专门的编译器来在编译期生成遵守 Java 字节码规范的 .class
文件。
AspectJ
AspectJ 框架主要包含三部分内容:
- aspectjrt:运行时函数库,AOP 所需要用到的
- aspectjtools:工具库
- aspectjweaver:实现织入功能的函数库
AspectJ 涉及的知识点比较多,可以独立成书,这里我们只介绍 hugo 使用到的相关知识点,主要包括切点表达式,类型匹配通配符,逻辑运算符,增强类型等。
切点表达式
AspectJ 的切点表达式由关键字和操作参数组成,以切点表达式 execution(* helloWorld(..))
为例,其中 execution
是关键字,为了便于理解,通常也称为函数,而 * helloWorld(..)
是操作参数,通常也称为函数的入参。切点表达式函数的类型很多,例如方法切点函数,方法入参切点函数,目标类切点函数等,hugo 用到的有两种类型:
- 方法切点函数之一
execution()
- 目标类切点函数之一
within()
具体涵义如下表所示:
函数名 | 入参 | 说明 |
---|---|---|
execution() | 方法匹配模式字符串 | 表示所有目标类中满足某个匹配模式的方法连接点,例如 execution(* helloWorld(..)) 表示所有目标类中的 helloWorld 方法,返回值和参数任意 |
within() | 类名匹配模式字符串 | 表示满足某个匹配模式的特定域中的类的所有连接点,例如 within(com.asce1885.debug.*) 表示 com.asce1885.debug 中的所有类的所有方法 |
接下来我们来介绍这两个切入点函数入参的语法格式,先来看 execution()
的入参语法格式:
execution([注解] [修饰符] 返回值类型 方法名(参数列表) [异常列表])
其中,[] 号中的签名组件是可选的。
对于 execution(* *(..))
这个切入点而言,
- 第一个
*
号对应方法的返回值,*
号表示方法返回值是任意的; - 第二个
*
号对应方法名,*
表示可以匹配该类中的所有方法; -
(..)
表示方法的参数是任意的
within()
函数入参语法格式如下:
within(类匹配模式)
可以看出,execution()
和 within()
两者的主要区别是 within()
所指定的连接点最小范围只能到类,而 execution()
所指定的连接点可以实现包,类,方法,方法入参范围全覆盖。
类型匹配通配符
AspectJ 切点表达式中的操作参数支持通配符,有三种类型的通配符可供选择,具体的涵义如下表所示:
通配符 | 涵义 |
---|---|
* | 匹配任意字符,但只能匹配上下文中的一个元素 |
.. | 匹配任意字符,可以匹配上下文中多个元素,比如在目标类模式的匹配中,表示匹配任意数量的子包;在方法参数模式的匹配中,表示匹配任意数量的参数 |
+ | 匹配指定类型的子类型,只能作为后缀放在类名后面 |