android 配置aspect_强大!ASM 插桩实现 Android 端无埋点性能监控!

本文介绍了在Android中使用ASM字节码插桩实现无埋点性能监控的解决方案,包括计算方法耗时的思路、集成到打包流程的方法,以及AspectJ和ASM插桩方案的详细实现。此外,还探讨了如何通过动态下发配置来控制监控的开关和阈值。
摘要由CSDN通过智能技术生成
code小生  一个专注大前端领域的技术平台公众号回复 Android加入安卓技术群
作者: 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),案例:ButterKnifeDagger2Room

  • 切面编程库(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文件,在目标方法开始和结束时插入本来需要手动埋点的计时代码(称之为字节码插桩)。注解的作用是提供插入点, AspectJASM既支持以注解作为切入点,也支持根据类方法名/类继承关系等规则来确定切入点。
2. 如何集成到打包流程
Android工程的构建工具是Gradle, 构建过程由一系列Task构成。Gradle支持自定义Task加入到原有的构建流程,以实现自己的处理逻辑 [参考3]Hugo pluginjavaCompile 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 // 代码1public class JsonAspect {  private static final String TAG = "JsonAspect";@Around("call(org.json.JSONObject.new(..))") // 代码2public JSONObject onJsonConstruct(ProceedingJoinPoint joinPoint) throws Throwable { // 代码3
        JSONObject result = null;long start = System.currentTimeMillis();
        result = (JSONObject) joinPoint.proceed(); // 代码4long end = System.currentTimeMillis();
        Log.d(TAG, "onJsonConstruct: " + (end - start) + (joinPoint.getArgs()[0].toString()));return result;
    }
}
  • 代码1:通过@Aspect注解,告知ajc编译期这个类是一个Aspect, 我们在这个类里定义在哪里切入,如何切入

  • 代码2: 这里定义了一个匿名的Pointcut,@Around是一个Advice, 表示要在pointcut的前后进行插入,对应的还有beforeafter@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);
    }@TraceTimepublic 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);
    }@Overridepublic 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);
    }@Overridepublic 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) {@OverrideAnnotationVisitor visitAnnotation(String desc, boolean visible) {
                System.out.println("visitAnnotation: desc: " + desc);return super.visitAnnotation(desc, visible)
            }@Overridevoid 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:  , 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{  @Overridepublic 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时,就可以注入了。怎么注入进去呢?这样写就可以了:
  @Overridevoid 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对应的构造函数替换成我们的函数
@Overridevoid visitMethodInsn(int opcode, String owner, String methodName, String descriptor, boolean isInterface) {  if(opcode == Opcodes.INVOKESPECIAL && owner.equals("org/json/JSONObject")
        && methodName.equals("") && 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> {  @Overridevoid 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 {  
    ...@Overridevoid transform(
            @NonNull Context context,
            @NonNull Collection inputs,
            @NonNull Collection 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.fileif (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文件读取,插桩的统一处理等。另外考虑到实现的复杂度和对性能的消耗,无埋点并不能完全代替手工埋点,部分埋点信息仍然需要手工补全。

成果

通过上述方案,我们实现了无埋点监控。...

附录

  • AspectJ官方介绍

  • ASM官方文档

  • 看AspectJ在Android中的强势插入

  • Android Plugin for Gradle

  • JVM指令

  • JCTree

----------  END  ----------

推荐阅读

Android 轻量级无痕埋点事件监听项目埋点的演进Android 可视化埋点方案MixPanel -Android 端埋点技术研究

如果你有写博客的好习惯欢迎投稿

如有收获,点个在看,诚挚感谢b8c5a7c9ae1984318aa683de22673123.png

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值