强大!ASM插桩实现Android端无埋点性能监控!
BAT
作者:
kyleada_dl
https://blog.csdn.net/wangkai0681080/article/details/82659516
背景
当我们需要了解页面加载性能时,可以通过手动埋点的方式记录页面阶段耗时、网络耗时、数据库加载耗时以及其他耗时点,配合slardar平台,能直观地了解到页面的性能情况。
但随着业务变动,手动埋点存在易写错,难维护的麻烦。业界广泛使用了插桩技术来实现无埋点监控,我们也结合现有监控库,实现了自己的无埋点监控方案。本文旨在介绍实现原理,方便大家对监控库的使用。
功能需求
我们希望提供以下功能
和业务无关的代码,我们希望能够以无需手动埋点的方式进行监控,包括页面生命周期、JSON耗时,网络耗时、SQL查询耗时、点击事件、页面进入等
对特定方法进行耗时监控,我们希望用户给方法加上注解就可以,称之为半埋点
编译期,需要能够支持配置,包括对哪些页面、哪些操作进行监控
运行期,能够动态下发配置,包括各类耗时监控的上报开关和阈值等
思路
实现自动监控需要解决2个问题
You solve one problem… and you solve the next one… and then the next. And If you solve enough problems, you get to come home -- The Martian
1. 如何计算方法耗时
统计方法耗时是典型的面向切面编程(Aspect-Oriented Programming
,AOP)的应用场景。实现AOP有一些成熟的技术方案
静态代理 和 运行期注解 + 动态代理
编译时代码生成(APT),案例:
ButterKnife
,Dagger2
,Room
切面编程库(AspectJ),案例:
Hugo
字节码注入(ASM),案例:
GrowingIO
...
方案1:
手动代理模式实现AOP显然不适用本场景。
方案2:
在编译时根据Annotation生成相关的代码是非常便利的技术,但APT主要适合用来生成辅助类,用户仍然需要通过手动调用方法使生成的代码在切入点执行。这一点其实不算AOP编程,也不适合本场景。
方案3:
AspectJ[参考1]
是广泛应用于JavaEE开发的AOP方案,简单易用,功能强大。它提供了简便的语法让我们定义切面逻辑,再通过提供的AJC编译器,在Java文件编译成class文件的过程里,把切面代码织入到目标业务代码里。本质上,仍然是以代理的方式实现AOP。我们通过AspectJ就能方便的在目标方法执行前后执行我们的计时代码。
方案4:
我们还可以直接对class文件进行修改,ASM[参考2]是字节码操作库,支持对字节码进行编辑,实现类、属性和方法的增删改查。字节码操作库还有Javaassit库可以选择,但ASM灵活度和效率都是最高的。利用操作字节码实现方法计时,可以的做法是修改class文件,在目标方法开始和结束时插入本来需要手动埋点的计时代码(称之为字节码插桩)。
注解的作用是提供插入点,AspectJ
和ASM
既支持以注解作为切入点,也支持根据类方法名/类继承关系等规则来确定切入点。
2. 如何集成到打包流程
Android工程的构建工具是Gradle, 构建过程由一系列Task构成。Gradle支持自定义Task加入到原有的构建流程,以实现自己的处理逻辑[参考3]
。
Hugo plugin
在javaCompile
Task最后插入一个Action,调用ajc函数对class文件进行处理,把AspectJ的能力引入到了Android打包流程,AspectJx[参考4]
是参考Hugo实现的一个在Android上通用的使用AspectJ
的开源库,方案3利用这个库使用AspectJ。
Android官方提供了Transform API
支持在class文件到dex转换期间修改class文件,这个阶段正是ASM字节码操作库工作的阶段,所以,我们可以通过在自定义插件中使用Transform的方式,把插桩过程集成到打包流程,方案4使用这个处理方式。
实现
下面分别用AspectJ方案和ASM插桩方案进行Demo实现。
AspectJ方案
AspecJ完整给出了AOP编程里的一些概念:切面(Aspect)
、通知(Advice)
、切入点(Pointcut)
,这些概念通过代码可以很清晰的理解。
网上有较多的统计Activity生命周期耗时的例子。本文以统计JSON反序列化耗时为例。
通过new JSONObject(String jsonStr)
方法可以把JSON格式的字符串反序列化为JSON对象处理,我们要切入的点就是JSONObject
的构造函数,需要做的处理是在构造函数执行前后插入我们的计时代码
@Aspect // 代码1
public class JsonAspect {
private static final String TAG = "JsonAspect";
@Around("call(org.json.JSONObject.new(..))") // 代码2
public JSONObject onJsonConstruct(ProceedingJoinPoint joinPoint) throws Throwable { // 代码3
JSONObject result = null;
long start = System.currentTimeMillis();
result = (JSONObject) joinPoint.proceed(); // 代码4
long end = System.currentTimeMillis();
Log.d(TAG, "onJsonConstruct: " + (end - start) + (joinPoint.getArgs()[0].toString()));
return result;
}
}
代码1:
通过@Aspect注解
,告知ajc编译期这个类是一个Aspect, 我们在这个类里定义在哪里切入,如何切入代码2:
这里定义了一个匿名的Pointcut
,@Around
是一个Advice, 表示要在pointcut的前后进行插入,对应的还有before
和after
。@Around
里的字符串定义了怎么寻找这个pointcut
,"call(org.json.JSONObject.new(..))"
表示pointcut是当JSONObject的构造函数被调用的时候代码3:
我们定义了一个方法,进行我们的逻辑处理。需要了解的是方法的参数joinPoint, joinPoint表达的是连接点对象.代码4
: 通过joinPoint.proceed()
实现对原有逻辑的调用,我们正是在这一处前后插入我们的执行逻辑
上面的代码就已经实现了无埋点进行JSON反序列化耗时统计。
通过注解来统计方法耗时,可以参照Hugo的源码。
可以看出,AspectJ方案写起来很简单,非常适合做一些Android里需要的AOP编程操作,比如动态权限检查。但AspectJ还是有一些局限,我们统计Activity页面生命周期耗时需要以生命周期为切点,在实际工程代码里,我们最终使用的页面Activity类一般是经过多次抽象后继承实现的,代码里已经不包含OnCreate/onResume
方法了,这时候AspectJ就无能为力了。另外查看处理后的class文件,可以发现除了桩点代码外,还会增加额外的一些代码,对包大小限制不利。
ASM插桩方案
我们知道,class文件是按照JVM规范格式存储的二进制文件,本质上是一个表,记录了类的常量池、访问标志、属性和方法等。ASM库不仅能够对class文件进行解读,还提供了方便的API进行字节码的修改,支持直接产生二进制class文件。
ASM提供了基于事件的API,ClassReader
用于读取class文件的二进制流,ClassVisitor
以事件的形式输出class的结构信息, ClassWriter
则用于把修改后的字节码生成二进制流。
我们先以Java工程的方式演示对Class文件的处理,不考虑集成打包。
我们定义一个简单的页面MainActivity
,增加一个加了编译期注解的方法
public class MainActivity extends AppCompatActivity {
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
}
@TraceTime
public void fun(){
Log.d("tt_apm", "annotated function");
}
}
它的class文件在工程的app/build/intermediates/classes
目录下,用ASM读取分析
public static void main(String[] args) {
try {
File classFile = new File("./source/MainActivity.class");
File dir = new File(".");
transformClassFile(dir, classFile)
} catch (Exception e){}
}
private static File transformClassFile(File dir, File sourceFile){
String className = sourceFile.getName();
// 得到class文件二进制流
FileInputStream fileInputStream = new FileInputStream(sourceFile);
byte[] sourceClassBytes = IOUtils.toByteArray(fileInputStream);
// 定义classWriter,用于输出修改后的二进制流
ClassWriter classWriter = new ClassWriter(ClassWriter.COMPUTE_MAXS);
// 自定义ClassVisitor, 负责字节码的消费
MyClassVisitor myClassVisitor = new MyClassVisitor(classWriter);
// ClassReader负责字节码的读取
ClassReader classReader = new ClassReader(sourceClassBytes);
// 开始字节码处理
classReader.accept(myClassVisitor, 0);
// 生成二进制流并保存成新的文件
byte[] destByte = classWriter.toByteArray();
File modified = new File(dir, className)
if (modified.exists()) {
modified.delete()
}
modified.createNewFile();
new FileOutputStream(modified).write(destByte)
return modified;
}
private static class MyClassVisitor extends ClassVisitor {
public MyClassVisitor(ClassVisitor classVisitor) {
super(Opcodes.ASM6, classVisitor);
}
@Override
public void visit(int version, int access, String name, String signature, String superName, String[] interfaces) {
System.out.println("visit:access: " + access + " ,name: " + name + " , superName: " + superName + " ,singature: " + signature + ", interfaces: " + interfaces.join("/"));
super.visit(version, access, name, signature, superName, interfaces);
}
@Override
public MethodVisitor visitMethod(int access, String name, String descriptor, String signature, String[] exceptions) {
System.out.println("visitMethod:access: " + access + " ,name: " + name + " , desc: " + descriptor + " ,singature: " + signature);
MethodVisitor mv = super.visitMethod(access, name, descriptor, signature, exceptions);
MethodVisitor myMv = new MethodVisitor(Opcodes.ASM6, mv) {
@Override
AnnotationVisitor visitAnnotation(String desc, boolean visible) {
System.out.println("visitAnnotation: desc: " + desc);
return super.visitAnnotation(desc, visible)
}
@Override
void visitCode() {
super.visitCode()
}
}
return myMv;
}
}
我们用ClassReader读取了MainActivity.java
的class文件,并用自定义的ClassVisitor
接收事件。查看输出:
visit:access: 33 ,name: com/example/wangkai/MainActivity , superName: android/support/v7/app/AppCompatActivity ,singature: null, interfaces:
visitMethod:access: 1 ,name: <init> , desc: ()V ,singature: null
visitMethod:access: 4 ,name: onCreate , desc: (Landroid/os/Bundle;)V ,singature: null
visitMethod:access: 1 ,name: fun , desc: ()V ,singature: null
visitAnnotation: desc: Lcom/example/wangkai/annotation/TraceTime;
我们通过visit
回调可以读取到class的名字、父类名和接口,这样就可以判断出一个类是否是我们要插桩的白名单页面,是不是Activity子类以及是否实现了点击事件接口View$onClickListener
(实现对点击事件的监控)
通过visitMethod
我们拿到了方法名,这样就可以判断这个方法是不是我们要监控的生命周期方法
通过在visitMethod
方法里返回自定义的MethodVisitor
对象,我们拿到了方法上的注解,从而可以知道这个方法是否是要插桩的方法
visitCode
表示方法开始执行,如果能在这里插入代码,那我们的代码就能在原始代码执行前执行。
我们已经找到了切入点,下一步就是插入代码了。插入代码要难一些,因为我们是在字节码层面操作,插入的也只能是字节码,这就需要对字节码有一定了解。包括局部变量表和操作数栈的概念,常见指令(ALOAD, INVOKEVIRTUAL等)的含义[参考5]
。
这里以实现监听点击事件为例。手动埋点时,我们需要插入这样的代码:
private static class MyClickListener implements View.OnClickListener{
@Override
public void onClick(View v) {
ClickAgent.click(v); //待插入代码,方法里获取view的ID和当前时间,实现对点击事件的记录
Log.d(TAG, "onClick: ");
}
}
我们要做的是,通过ASM methodVisitor
提供的API,把ClickAgent.click(v)
的字节码,注入到原始onClick
方法里。
查看字节码:
L0
LINENUMBER 27 L0
ALOAD 1
INVOKESTATIC com/example/wangkai/ClickAgent.click (Landroid/view/View;)V
L1
LINENUMBER 28 L1
LDC "MainActivity"
LDC "onClick: "
INVOKESTATIC android/util/Log.d (Ljava/lang/String;Ljava/lang/String;)I
可以看到ClickAgent.click(v)
对应的字节码是两行
ALOAD 1
表示把局部变量表里索引为1的值,推到操作数栈上,也就是参数值View v
。对应到ASM,是methodVisitor.visitVarInsn(Opcodes.ALOAD, 1);
INVOKESTATIC com/example/wangkai/ClickAgent.click (Landroid/view/View;)V就是调用ClickAgent的静态方法click。对应到ASM,是methodVisitor.visitMethodInsn(Opcodes.INVOKESTATIC, "com/example/wangkai/ClickAgent", "click", "(Landroid/view/View;)V", false)
当我们在visitorMethod
回调里判断name、desc和signature和原始方法一致,并且该类实现的interfaces
包含了View$onClickListener
时,就可以注入了。
怎么注入进去呢?这样写就可以了:
@Override
void visitCode() {
super.visitCode()
mv.visitVarInsn(Opcodes.ALOAD, 1);
mv.visitMethodInsn(Opcodes.INVOKESTATIC, "com/example/wangkai/ClickAgent", "click", "(Landroid/view/View;)V", false)
}
修改后执行,会生成插桩后的class文件,可以用JD_GUI查看插桩后的效果,
实际编码中,我们可以借助于Bydecode Outline
插件。
怎么实现AspectJ方案里演示的对JSONObject
的反序列化监控呢?只需要将JSONObject
对应的构造函数替换成我们的函数
@Override
void visitMethodInsn(int opcode, String owner, String methodName, String descriptor, boolean isInterface) {
if(opcode == Opcodes.INVOKESPECIAL && owner.equals("org/json/JSONObject")
&& methodName.equals("<init>") && descriptor.equals("(Ljava/lang/String;)V")) {
super.visitMethodInsn(Opcodes.INVOKESTATIC, "com.example.apm.JSONObjectAgent",
"init", "(Ljava/lang/String;)Lorg/json/JSONObject;", false);
} else {
super.visitMethodInsn(opcode, owner, methodName, descriptor, isInterface)
}
}
插桩的问题已经解决了!
Oh, we have solved one problem.
我们再把插桩的处理过程集成到Gradle打包里就可以
我们知道,通过在build.gradle里配置apply plugin: 'xxplugin'
,可以实现调用自定义的plugin。自定义plugin:
class ApmPlugin implements Plugin<Project> {
@Override
void apply(Project project) {
android = project.extensions.getByType(AppExtension);
ApmTransform transform = new ApmTransform(project)
android.registerTransform(transform)
}
}
apply方法会在build.gradle apply plugin: 'xxplugin'
行执行时被调用。我们在apply
方方法里注册了自定义的Transform,实现对class文件的处理。
Transform是Android gradle提供的修改class的一套API,Transform每次都是将一个输入进行处理,然后将处理结果输出,而输出的结果将会作为另一个Transform的输入。
我们在回调里可以拿到输入
class ApmTransform extends Transform {
...
@Override
void transform(
@NonNull Context context,
@NonNull Collection<TransformInput> inputs,
@NonNull Collection<TransformInput> referencedInputs,
@Nullable TransformOutputProvider outputProvider,
boolean isIncremental) throws IOException, TransformException, InterruptedException {
}
}
transform
回调方法里的inputs
即上一个Transform
输出的class文件目录,是本工程自己的代码文件,referencedInputs
是上一个Transform
输出的jar包,是本工程依赖的jar包。我们遍历inputs
就能拿到class文件
input.directoryInputs.each { DirectoryInput directoryInput ->
File dest = outputProvider.getContentLocation(directoryInput.name, directoryInput.contentTypes, directoryInput.scopes, Format.DIRECTORY);
File dir = directoryInput.file
if (dir) {
dir.traverse(type: FileType.FILES, nameFilter: ~/.*\.class/) {
File classFile ->
File modified = modifyClassFile(dir, classFile, context.getTemporaryDir()); // 修改class文件
...
}
...
注意到,修改class文件这部分,我们在之前的Java工程里已经实现了!
I love what I do and I'm good at it. GO HOME!
以上简单介绍了无埋点插桩实现的过程。实际的插件工程要复杂,需要考虑黑白名单处理,Manifest文件读取,插桩的统一处理等。另外考虑到实现的复杂度和对性能的消耗,无埋点并不能完全代替手工埋点,部分埋点信息仍然需要手工补全。
成果
通过上述方案,我们实现了无埋点监控。
推荐阅读
• 耗时2年,Android进阶三部曲第三部《Android进阶指北》出版!
• 『BATcoder』做了多年安卓还没编译过源码?一个视频带你玩转!
BATcoder技术群,让一部分人先进大厂
大家好,我是刘望舒,腾讯云最具价值专家TVP,著有畅销书《Android进阶之光》《Android进阶解密》《Android进阶指北》,蝉联四届电子工业出版社年度优秀作者,谷歌开发者社区特邀讲师,百度百科收录的技术专家。
前华为面试官,现大厂技术负责人。
想要加入 BATcoder技术群,公号回复BAT
即可。
为了防止失联,欢迎关注我的小号
微信改了推送机制,真爱请星标本公号👇