写点什么

滴滴 DoKit Android 核心原理揭秘之函数耗时,app 架构图怎么做

用户头像
Android架构
关注
发布于: 41 分钟前

现有解决方案的原理


现有方案的原理是基于 Android SDK 中提供的工具 traceview 和 dmtracedump。其中 traceview 会生成.trace 文件,该文件记录了函数调用顺序,函数耗时,函数调用次数等等有用的信息。而 dmtracedump 工具就是基于 trace 文件生成报告的工具,具体用法不细说。dmtracedump 工具大家一般用的多的选项就是生成 html 报告,或者生成调用顺序图片(看起来很不直观)。首先说说为什么要用 traceview,和 dmtracedump 来作为得到函数调用顺序的,因为这个工具既然能知道 cpu 执行时间和调用次数以及函数调用树(看出函数调用顺序很费劲)比如在 Android Studio 是这样呈现.trace 文件的解析视图的:



或者是这样的:



(以上两张图片来源于网络)


通过以上两张图可以发现虽然官方提供的工具十分强大但是却有一个很严重的问题,那就是信息量太大,想要在这么繁杂的信息中找出你所需要的性能瓶颈点难度可想而知,一般的新手根本没有耐心和经验去操作,有时候甚至到懒得去使用这个工具。

DoKit 的解决方案

想要提升用户的开发体验,必须满足以下两点:


简单的操作(傻瓜式操作)


直观的数据展示


(以上两点也是我们 DoKit 团队在规划新功能时的重要指标)


本人经过一系列的调研和尝试,发现市面上现有的解决方案多多少少都存在的一定的问题,比如通过 AspectJ、Dexposed、Epic 等 AOP 框架,虽然能够实现我们的需求,但是却存在一定的兼容性问题,对于 DoKit 这样一个已经在 8000+ App 项目中集成使用的稳定性研发工具来说,我们不能保证用户在他自己的项目中是否也集成过此类框架,由于两个 AOP 框架之间由于版本不一致可能会导致编译失败。(其实一开始 DoKit 也是通过集成 AspectJ 等第三方框架来作为 AOP 编程的,后面社区反馈兼容性不好,所以针对整个 AOP 方案进行了优化和升级)。


经过多次的 Demo 实验,最终决定采用 Google 官方的插件+ASM 字节码框架作为 DoKit 的 AOP 解决方案。

DoKit 解决方法的思路

Dokit 提供了两个慢函数解决方案(通过插件可配置)


1、全量业务代码函数插装(代码量过大会导致编译时间过长)


2、指定入口函数并查找 N 级调用函数进行代码插装(默认方案)


(下文的分析主要针对第二种解决方案)


寻找指定的代码插桩节点


对于开发者说,我们的目的是为了在项目运行过程中第一时间发现有哪些函数耗时过长从而导致 UI 卡顿,然后对指定的慢函数进行耗时统计并给出友好的数据结构呈现。所以,既然要统计一个函数的耗时,我们就必须要在一个函数的开始和结束地方插入统计代码,最后相减即可得出一个函数方法的耗时时间。


举个例子:假如我们需要统计以下函数的耗时时间:


public void sleepMethod() {Log.i(TAG, "我是耗时函数");}


其实原理很简单我们只需要在函数的执行前后添加如下代码:


public void sleepMethod() {long begin = System.currentTimeMillis();Log.i(TAG, "我是耗时函数");long costTime = System.currentTimeMillis() - begin;}


其中 costTime 即为当前函数的执行时间,我们只需要将 costTime 根据函数的类名+函数名作为 key 保存在 Map 中,然后再根据一定的算法在运行期间去绑定函数的上下级调用关系(上下级调用关系会在编译时通过字节码增加框架动态插入,下文会分析)。最终在入口函数执行结束的将结果在控制台中打印出来即可。


插入指定的 Plugin Transform


Google 对于 Android 的插件开发提供了一个完整的开发套件,它允许我们在 Android 代码的编译期间插入专属的 Transform 去读取编译后的 class 文件并搭配相应的字节码增加工具(ASM、Javassist)并回调相应的生命周期函数来让开发者在指定的生命周期(比如:开始读取一个函数以及函数读取结束等等)函数中去操作 Java 字节码。


由于 AndroidStudio 是基于 Gradle 作为编译脚本,所以我们先来了解一下什么是 Gradle。


1、Gradle 是基于 Groovy 的一种领域专用语言(DSL/Domain Specific Launguage)


2、每个 Gradle 脚本文件编程生成的类除了继承自 groovy.lang.script,同时还实现了接口 org.gradle.api.script。 3、Gradle 工程 build 时,会执行 setting.gradle、build.gradle 脚本;setting 脚本的代理对象是 Setting 对象,build 脚本的代理对象是 Project 对象。


以下为 Gradle 的生命周期图示:



我们顺便来看一下 Transform 的工作原理



很明显的一个链式结构。其中红色代表自定义的 Transform,蓝色代表系统自带的 Transform。 每个 Transform 都是一个 Gradle 的 Task,Android 编译其中的 TaskManager 会将每个 Transform 串联起来。前一个 Transform 的执行产物将传递给下一个 Transform 作为输入。所以我们只需要将自定义的 Transform 插入到链表的最前面,这样我们就可以拿到 javac 的编译产物并利用字节码框架(ASM)对 javac 产物做字节码修改。


插入耗时统计代码


Dokit 选取了ASM作为 Java 字节码操作框架,因为 ASM 更偏向底层操作兼容性更好同时效率也更高。但是由于全量的字节码插装会导致用户的编译时间增加尤其对于大型项目来说,过长的编译时间会导致开发效率偏低。所以我们必须针对插桩节点进行取舍,以达到开发效率和满足功能需求的平衡点。 以下附上 ASM 的时序图:



既然我们需要在指定的入口函数中去查找调用的子函数,那么如何去确定这个入口函数呢?DoKit 的选择是将 Application 的 attachBaseContex 和 onCreate 这个两个方法作为默认的入口函数,即大家最为关心的 App 启动耗时统计,当然做为一个成熟的框架,我们也开放了用户指定入口函数的配置,具体可以参考Android接入指南


那么我们该如何找到用户自定义的 Application 呢?大家都知道我们的 Application 是需要在 AndroidManifest.xml 中注册才能使用的,而且 AndroidManifest.xml 中就包含了 Application 的全路径名。所以我们只要在编译时找到 AndroidManifest.xml 的文件路径,然后再针对 xml 文件进行解析就可以得到 Application 的全路径名。具体的示例代码如下:


appExtension.getApplicationVariants().all(applicationVariant -> {if (applicationVariant.getName().contains("debug")) {VariantScopeKt.getMergedManifests(BaseVariantKt.getScope(applicationVariant)).forEach(file -> {try {String manifestPath = file.getPath() + "/AndroidManifest.xml";//System.out.println("Dokit==manifestPath=>" + manifestPath);File manifest = new File(manifestPath);if (manifest.exists()) {SAXParser parser = SAXParserFactory.newInstance().newSAXParser();CommHandler handler = new CommHandler();parser.parse(manifest, handler);DoKitExtUtil.getInstance().setApplications(handler.getApplication());}} catch (Exception e) {e.printStackTrace();}});}


});


通过上文我们已经拿到了 Application 类的全路径名以及入口函数,那么接下来的操作就是查找 attachBaseContex 和 onCreat 中调用了哪些方法。其实 ASM 的 AdviceAdapter 这个类的 visitMethod 生命周期函数会在读取 class 文件流时输出当前函数的所有字节码(关于 visitMethodInsn 方法的具体用户可以参考官方文档,本文只会介绍相关原理),所以我们只需要根据自己的需要过滤出属于函数调用的部分就行。为了避免全量字节码插入带来的编译耗时过长问题,我限制函数插桩调用层级最大为 5 级。在每一级函数的遍历过程中,我们需要对函数的父级进行绑定。因为只有确定了父级函数,我们才能在下一次 Transform 中精准的知道需要在哪些子函数中进行代码插装。


函数调用栈查找代码:


@Overridepublic void visitMethodInsn(int opcode, String innerClassName, String innerMethodName, String innerDesc, boolean isInterface) {//全局替换 URL 的 openConnection 方法为 dokit 的 URLConnection


//普通方法 内部方法 静态方法 if (opcode == Opcodes.INVOKEVIRTUAL || opcode == Opcodes.INVOKESTATIC || opcode == Opcodes.INVOKESPECIAL) {//过滤掉构造方法 if (innerMethodName.equals("<init>")) {super.visitMethodInsn(opcode, innerClassName, innerMe


《Android学习笔记总结+最新移动架构视频+大厂安卓面试真题+项目实战源码讲义》
浏览器打开:qq.cn.hn/FTe 免费领取
复制代码


thodName, innerDesc, isInterface);return;}


MethodStackNode methodStackNode = new MethodStackNode();methodStackNode.setClassName(innerClassName);methodStackNode.setMethodName(innerMethodName);methodStackNode.setDesc(innerDesc);methodStackNode.setParentClassName(className);methodStackNode.setParentMethodName(methodName);methodStackNode.setParentDesc(desc);switch (level) {case MethodStackNodeUtil.LEVEL_0:methodStackNode.setLevel(MethodStackNodeUtil.LEVEL_1);MethodStackNodeUtil.addFirstLevel(methodStackNode);break;case MethodStackNodeUtil.LEVEL_1:methodStackNode.setLevel(MethodStackNodeUtil.LEVEL_2);MethodStackNodeUtil.addSecondLevel(methodStackNode);break;case MethodStackNodeUtil.LEVEL_2:methodStackNode.setLevel(MethodStackNodeUtil.LEVEL_3);MethodStackNodeUtil.addThirdLevel(methodStackNode);break;case MethodStackNodeUtil.LEVEL_3:methodStackNode.setLevel(MethodStackNodeUtil.LEVEL_3);MethodStackNodeUtil.addFourthlyLevel(methodStackNode);break;


case MethodStackNodeUtil.LEVEL_4:methodStackNode.setLevel(MethodStackNodeUtil.LEVEL_3);MethodStackNodeUtil.addFifthLevel(methodStackNode);break;default:break;}


}super.visitMethodInsn(opcode, innerClassName, innerMethodName, innerDesc, isInterface);}


字节码插桩代码:


@Overrideprotected void onMethodEnter() {super.onMethodEnter();try {if (isStaticMethod) {//静态方法需要插入的代码 mv.visitMethodInsn(INVOKESTATIC, "com/didichuxing/doraemonkit/aop/method_stack/MethodStackUtil", "getInstance", "()Lcom/didichuxing/doraemonkit/aop/method_stack/MethodStackUtil;", false);mv.visitIntInsn(SIPUSH, thresholdTime);mv.visitInsn(level + ICONST_0);mv.visitLdcInsn(className);mv.visitLdcInsn(methodName);mv.visitLdcInsn(desc);mv.visitMethodInsn(INVOKEVIRTUAL, "com/didichuxing/doraemonkit/aop/method_stack/MethodStackUtil", "recodeStaticMethodCostStart", "(IILjava/lang/String;Ljava/lang/String;Ljava/lang/String;)V", false);


} else {//普通方法插入的代码

用户头像

Android架构

关注

还未添加个人签名 2021.10.31 加入

还未添加个人简介

评论

发布
暂无评论
滴滴DoKit Android核心原理揭秘之函数耗时,app架构图怎么做