什么是启动耗时
分为两个角度:
- 冷启动:就是点击应用图标到打开应用的冷启动响应时间,且前提是应用从未被创建过进程,
- 热启动:测量点击应用图标到打开应用的热启动响应时间,被测应用之前已经被打开过,无关闭应用行为,测试时被重新切换到前台
启动耗时影响什么
第一想到的肯定是用户体验,如果你的应用半分钟没有启动起来,那谁还想用呢?所以很多大厂App,虽然一个App承载的业务多的数不胜数,但肯定都有一个特点,一点就开,即开即用。
启动耗时的标准是什么
各类应用的冷启动时间应≤2000毫秒、游戏类应用和影音娱乐类应用冷启动时间≤3000毫秒,各类应用的热启动时间应≤500毫秒、游戏类应用和影音娱乐类应用冷启动时间≤1000毫秒。同样来源于软件绿色联盟应用体验标准3.0——性能标准,请点击链接查看详情。
如何查看启动耗时呢
其实查看启动耗时,官方已经给我提供了很多工具,如TraceView,我们就可以通过它来查看图形执行时间,调用栈等,但是它的缺点也很明显,运行时开销严重,得出的结果并不真实,同样的我们还可以借助android studio terminal,来一个简单的测试,如下:
命令:
adb shell am start -W [packageName]/[AppstartActivity全路径]
可以看到,我用同一个命令,测试了两次,第一次是应用进程存活时,LaunchState是HOT,TotalTime就是启动耗时,WaitTime是AMS启动Activity的总耗时,包括创建进程 + Application初始化 + Activity初始化到界面显示的过程。第二次的冷启动低于500ms,还算是比较合理的,热启动在115ms,是不是很优秀,其实这个应用不具有代表性,因为是测试Demo,代码比较简单,所以启动很快,对于一些大型引用肯定就不是这样了,虽然这种方式可以很快的拿到指标数据,但有个缺点你会发现,即使我知道了耗时,如果出现耗时不正常的操作,就不知道哪里出现的问题。所以我就想,是不是Matrix能解决这些问题呢,待我们去验证。我们还是不着急去看Matrix的源码,我们先来看下如何通过代码实现一个启动耗时统计,除了以上方法,google还给我们提供了Systrace 命令行工具,可以结合代码插桩一起完成耗时分析,插桩就是在需要监听的方法前后,插入一行代码。 最新消息google 在Android 10 中引入的全新平台级跟踪工具Perfetto,具体请看developer.android.com/topic/perfo…,具体可以理解为Systrace的升级版本,我们现在不研究这些工具,先来看看,我们如何通过代码插桩的方式来监控应用的启动耗时。插桩也是Matrix实现的核心,所以我们仔细聊聊。
需要监控的函数
既然我们决定使用代码的插桩来实现,那么就需要知道对哪些函数做操作,具体什么函数,这要看App整个启动过程的函数调用顺序,我整理了几个流程图,请看:
大致流程就是这样,并没有特别详细,基本原理给大家搞清楚,然后知道函数的调用顺序就ok,从图中分析出的知识点:
- 包括SystemServer在内,我们的app也都是zygote进程fork出来的
- 当别人通过startActivity启动我们的app时,其实是ActivityManagerService通过startProcessLocked告知zygote进程
- 当app进程被创建后,进程中会创建出ActivityThread,通过源码我们发现ActivityThread中有个java的main函数,main函数调用attach函数,如图
attach函数通过binder又返回到ActivityManagerService中,再由ActivityManagerService调用attachApplicationLocked,然后再通过binder调到ApplicationThread.bindApplication,ApplicationThread是ActivityThread中的私有类,如图
- ApplicationThread 通过handler message通信,最终调用ActivityThread的handleBindApplication函数,然后在该方法中根据拿到的appInfo信息,创建appContext,最后创建Application,调用application的onCreate函数。
- Activity的创建通过ActivityStackSupervisor.realStartActivityLocked,最终通过binder,在ActivityThread中执行handleLaunchActivity,紧接着attach到对应的上下文中。
从这张图中,我们了解了App的启动过程,其实在Android不同的SDK版本中都有升级,会导致部分代码找不到,但大同小异。我们的目的其实是为了找到插桩的地方,且有一点我们用到的插桩是java字节码,所以有binder通信的地方,我们只能改动java层的代码,所以基本可以敲定,插桩代码就是在我们的App进程中。
简单定义一个计算公式: App的启动耗时 = 第一个Activity创建好的时间 - Application onCreate 时间
当然有的app是启动页+Home主页才算是app启动完成,这里先不纠结这个,我们现在已经可以明确的点,Application onCreate方法和 Activity的相关方法(后面再分析哪个方法更合适)都是我们要插桩的点。那么接下来我们简单说下插桩的几个框架,来看看哪个更加合适。
插桩方案选择哪个?AspectJ、ASM、ReDex
AspectJ 和 ASM 框架是我们最常用的 Java 字节码处理框架。AspectJ是 Java 中流行的 AOP(aspect-oriented programming)编程扩展框架,从使用上来看,作为字节码处理元老,AspectJ 的框架的确有自己的一些优势,但官方建议切换到 ObjectWeb 的 ASM 框架,而ReDex是 Facebook 开源的工具,通过对字节码进行优化,以减小 Android Apk 大小,同时提高 App 启动速度。ReDex 里甚至提供了 Method Tracing 和 Block Tracing 工具,可以在所有方法或者指定方法前面插入一段跟踪代码。我们为什么不用它呢,因为Matrix用的ASM,并且ASM可以实现 100% 场景的 Java 字节码操作,已经满足了我们的需求。那么接下来,我们用ASM来实现一个代码插桩用例。
ASM实现插桩用例
我们的目标是给Android的某个类,做函数插桩,下面我们做一个demo作为本次的用例,带你有序的了解,该如何通过ASM做函数插桩。
1.Demo项目创建
这一步不用多说,直接在Android Studio中,new project 就行,等待项目第一次编译完成
2.gradle插件创建
在项目的根目录中,创建buildSrc文件夹,然后构建一下项目,然后在buildSrc文件夹中创建build.gradle配置文件,如下:
plugins{ //使用 java groovy 插件 id 'java' id 'groovy' } group 'com.julive.sam' version '0.0.1' sourceCompatibility = 1.8 repositories{ //使用阿里云的maven代理 maven { url 'https://maven.aliyun.com/repository/google' } maven { url 'https://maven.aliyun.com/repository/public' } maven { url 'http://maven.aliyun.com/nexus/content/groups/public/' } maven { url 'http://maven.aliyun.com/nexus/content/repositories/jcenter' } } def asmVersion = '8.0.1' dependencies { //引入gradle api implementation gradleApi() implementation localGroovy() //引入android studio扩展gradle的相关api implementation "com.android.tools.build:gradle:4.1.0" //引入apache io implementation 'org.apache.directory.studio:org.apache.commons.io:2.4' //引入ASM相关api,这是我们插桩的关键,要靠他实现方法插桩 implementation "org.ow2.asm:asm:$asmVersion" implementation "org.ow2.asm:asm-util:$asmVersion" implementation "org.ow2.asm:asm-commons:$asmVersion" }
接下来创建插件代码目录,由于我们使用java写的插件,所以需要选中buildSrc,然后鼠标右键选择new,再选择directory,最后出现的对话框中选择 src/main/java,下图中是因为我的项目已经创建完了,所以只有groovy目录,如果你需要写groovy的实现就创建下图中文件夹路径,创建完这个下一步就是创建插件。
在java目录中,创建包名com.julive.sam,在该包路径下创建Plugins插件,代码如下:
public class Plugins implements Plugin<Project> { @Override public void apply(Project target) { } }
然后创建插件的配置resources文件夹,和java文件夹同级,在resources下创建文件夹META-INF/gradle-plugins/,最终在gradle-plugins中创建com.julive.sam.properties,意思是你的包名.properties ,一定要对应好包名,然后在该文件中加入代码
implementation-class=com.julive.sam.Plugins
com.julive.sam.Plugins 你点击后,看能否跳转至 上面创建的Plugins插件中,如果可以直接跳转那就ok了。
3.下一步在App的build.gradle中配置插件
4.创建gradle的Transform实现
Transform是在.class -> .dex转换期间,用来修改.class文件的一套标准API,所以你现在应该知道了,在transform中我们肯定要调用ASM的实现,来实现.class文件的修改,最终转换为.dex文件。创建Transform的实现如下:
public class TransformTest extends Transform { @Override public String getName() { // 随便起个名字 return "TransformSam"; } @Override public Set<QualifiedContent.ContentType> getInputTypes() { //代表处理的 java 的 class 文件 return TransformManager.CONTENT_CLASS; } @Override public Set<? super QualifiedContent.Scope> getScopes() { //要处理所有的class字节码 return TransformManager.SCOPE_FULL_PROJECT; } @Override public boolean isIncremental() { // 是否增量编译,我们先不考虑,返回false return false; } @Override public void transform(TransformInvocation transformInvocation) throws TransformException, InterruptedException, IOException { super.transform(transformInvocation); try { //待实现 doTransform(transformInvocation); // hack } catch (Exception e) { e.printStackTrace(); } } }
看上面注释是不是就对Transform有了一定的了解呢,那么如何处理.class文件呢?我们来实现doTransform函数,来看如何处理
private void doTransform(TransformInvocation transformInvocation) throws IOException { System.out.println("doTransform ======================================================="); //inputs中是传过来的输入流,其中有两种格式,一种是jar包格式一种是目录格式。 Collection<TransformInput> inputs = transformInvocation.getInputs(); //获取到输出目录,最后将修改的文件复制到输出目录,这一步必须做不然编译会报错 TransformOutputProvider outputProvider = transformInvocation.getOutputProvider(); //删除之前的输出 if (outputProvider != null) outputProvider.deleteAll(); inputs.forEach(transformInput -> { //遍历directoryInputs transformInput.getDirectoryInputs().forEach(directoryInput -> { }); //jarInputs transformInput.getJarInputs().forEach(jarInput -> { }); }); }
从transformInvocation的api中,我们获取了两个东西,一个是inputs,一个是outputProvider,我们遍历inputs后发现,他有两个api getDirectoryInputs和getJarInputs 这俩是什么东西呢?我描述不太好,我加了日志,来看下日志输出:
这下是不是看明白了,其实我对getDirectoryInputs做了一层文件筛选处理
transformInput.getDirectoryInputs().forEach(directoryInput -> { ArrayList<File> list = new ArrayList<>(); getFileList(directoryInput.getFile(), list); }); //递归查找该文件夹下所有文件,因为我们修改的是.class 文件,而不关系文件夹 void getFileList(File file, ArrayList<File> fileList) { if (file.isFile()) { fileList.add(file); } else { File[] list = file.listFiles(); for (File value : list) { getFileList(value, fileList); } } }
好,从上面我们看出,已经找到了MainActivity的class文件,那么接下来给MainActivity.class的onCreate函数,插入两行代码,
5.现在开始操作ASM的api
首先要实现ASM的 ClassVisitor 类来操作我们想要操作的类,它可以访问class
文件的各个部分,比如方法
、变量
、注解
等
基本的实现如下:
public class TestClassVisitor extends ClassVisitor{ private String className; private String superName; TestClassVisitor(final ClassVisitor classVisitor) { super(Opcodes.ASM6, classVisitor); } /** * 这里可以拿到关于.class的所有信息,比如当前类所实现的接口类表等 * * @param version 表示jdk的版本 * @param access 当前类的修饰符 (这个和ASM 和 java有些差异,比如public 在这里就是ACC_PUBLIC) * @param name 当前类名 * @param signature 泛型信息 * @param superName 当前类的父类 * @param interfaces 当前类实现的接口列表 */ @Override public void visit(int version, int access, String name, String signature, String superName, String[] interfaces) { super.visit(version, access, name, signature, superName, interfaces); this.className = name; this.superName = superName; } @Override public MethodVisitor visitMethod(int access, String name, String descriptor, String signature, String[] exceptions) { //委托函数 MethodVisitor methodVisitor = cv.visitMethod(access, name, descriptor, signature, exceptions); //找到我们需要修改的类,注意这里是/ 斜杠来表示文件的路径,并不是java代码中的. if (className.equals("com/julive/samtest/MainActivity")) { // 判断方法name是onCreate if (name.startsWith("onCreate")) { //插桩函数的实现,同样用到ASM提供的对象,下面看具体实现代码 return new TestMethodVisitor(Opcodes.ASM6, methodVisitor, access, name, descriptor, className, superName); } } return methodVisitor; } }
这里集成AdviceAdapter,其实AdviceAdapter是继承自MethodVisitor,这是不是就跟ClassVisitor一一呼应呢,使用它是因为它比较方便的实现,提供了onMethodEnter,onMethodExit,正好是我们的需求。在onCreate的函数的前后各插入一行代码。但仔细看onMethodEnter的函数实现,你会发现一脸懵逼,不知道是啥玩意。往下看
public class TestMethodVisitor extends AdviceAdapter { private String className; private String superName; protected TestMethodVisitor(int i, MethodVisitor methodVisitor, int i1, String s, String s1,String className,String superName) { super(i, methodVisitor, i1, s, s1); this.className = className; this.superName = superName; } @Override protected void onMethodEnter() { super.onMethodEnter(); mv.visitLdcInsn("TAG"); mv.visitLdcInsn(className + "---->" + superName); mv.visitMethodInsn(Opcodes.INVOKESTATIC, "android/util/Log", "i", "(Ljava/lang/String;Ljava/lang/String;)I", false); mv.visitInsn(Opcodes.POP); } @Override protected void onMethodExit(int opcode) { mv.visitLdcInsn("TAG"); mv.visitLdcInsn("this is end"); mv.visitMethodInsn(Opcodes.INVOKESTATIC, "android/util/Log", "i", "(Ljava/lang/String;Ljava/lang/String;)I", false); mv.visitInsn(Opcodes.POP); super.onMethodExit(opcode); } }
在这里推荐一个插件,plugins.jetbrains.com/plugin/1486…,用插件测试代码如下:
public class Test { void aa() { Log.i("TAG", "this is end"); } }
转换ASM代码如下:
public static byte[] dump() throws Exception { ClassWriter classWriter = new ClassWriter(0); FieldVisitor fieldVisitor; MethodVisitor methodVisitor; AnnotationVisitor annotationVisitor0; classWriter.visit(V1_8, ACC_PUBLIC | ACC_SUPER, "com/julive/samtest/Test", null, "java/lang/Object", null); classWriter.visitSource("Test.java", null); { methodVisitor = classWriter.visitMethod(ACC_PUBLIC, "<init>", "()V", null, null); methodVisitor.visitCode(); Label label0 = new Label(); methodVisitor.visitLabel(label0); methodVisitor.visitLineNumber(5, label0); methodVisitor.visitVarInsn(ALOAD, 0); methodVisitor.visitMethodInsn(INVOKESPECIAL, "java/lang/Object", "<init>", "()V", false); methodVisitor.visitInsn(RETURN); Label label1 = new Label(); methodVisitor.visitLabel(label1); methodVisitor.visitLocalVariable("this", "Lcom/julive/samtest/Test;", null, label0, label1, 0); methodVisitor.visitMaxs(1, 1); methodVisitor.visitEnd(); } { methodVisitor = classWriter.visitMethod(0, "aa", "()V", null, null); methodVisitor.visitCode(); Label label0 = new Label(); methodVisitor.visitLabel(label0); methodVisitor.visitLineNumber(8, label0); methodVisitor.visitLdcInsn("TAG"); methodVisitor.visitLdcInsn("this is end"); methodVisitor.visitMethodInsn(INVOKESTATIC, "android/util/Log", "i", "(Ljava/lang/String;Ljava/lang/String;)I", false); methodVisitor.visitInsn(POP); Label label1 = new Label(); methodVisitor.visitLabel(label1); methodVisitor.visitLineNumber(9, label1); methodVisitor.visitInsn(RETURN); Label label2 = new Label(); methodVisitor.visitLabel(label2); methodVisitor.visitLocalVariable("this", "Lcom/julive/samtest/Test;", null, label0, label2, 0); methodVisitor.visitMaxs(2, 1); methodVisitor.visitEnd(); } classWriter.visitEnd(); return classWriter.toByteArray(); }
是不是很长,哈哈,这段代码其实是将整个Test类的东西,都通过ASM的方式生成,我们只需要找到对应的日志如下:
methodVisitor.visitLdcInsn("TAG"); methodVisitor.visitLdcInsn("this is end"); methodVisitor.visitMethodInsn(INVOKESTATIC, "android/util/Log", "i", "(Ljava/lang/String;Ljava/lang/String;)I", false); methodVisitor.visitInsn(POP);
然后将其放入到onMethodExit函数中,就可以了。
6.Tranfrom结合ASM实现
现在万事具备只欠东风,就是将Tranform拿到的class文件通过ASM做修改,具体如何关联,请看,回到刚才的doTransform中,改成如下代码:
private void doTransform(TransformInvocation transformInvocation) throws IOException { System.out.println("doTransform ======================================================="); //inputs中是传过来的输入流,其中有两种格式,一种是jar包格式一种是目录格式。 Collection<TransformInput> inputs = transformInvocation.getInputs(); //获取到输出目录,最后将修改的文件复制到输出目录,这一步必须做不然编译会报错 TransformOutputProvider outputProvider = transformInvocation.getOutputProvider(); //删除之前的输出 if (outputProvider != null) outputProvider.deleteAll(); inputs.forEach(transformInput -> { //遍历directoryInputs transformInput.getDirectoryInputs().forEach(directoryInput -> { ArrayList<File> list = new ArrayList<>(); getFileList(directoryInput.getFile(), list); list.forEach(file -> { System.out.println("getDirectoryInputs =======================================================" + file.getName()); // 判断是.class文件 if (file.isFile() && file.getName().endsWith(".class")) { try { //ASM提供的读取类信息的对象 ClassReader classReader = new ClassReader(new FileInputStream(file)); //ASM提供的类修改对象,并将读到的信息交给classWriter ClassWriter classWriter = new ClassWriter(classReader, ClassWriter.COMPUTE_MAXS); //创建修改规则,TestClassVisitor ClassVisitor visitor = new TestClassVisitor(classWriter); //将修改规则给classReader classReader.accept(visitor, ClassReader.EXPAND_FRAMES); //通过toByteArray方法,将变更后信息转成byte数组 byte[] bytes = classWriter.toByteArray(); //放入输出流中往原文件中写入 FileOutputStream fileOutputStream = new FileOutputStream(file.getAbsolutePath()); fileOutputStream.write(bytes); fileOutputStream.close(); } catch (IOException e) { e.printStackTrace(); } } }); if (outputProvider != null) { File dest = outputProvider.getContentLocation(directoryInput.getName(), directoryInput.getContentTypes(), directoryInput.getScopes(), Format.DIRECTORY); try { //将该文件放入到目标目录中,这步骤必须实现,否则会导致dex文件找不到该文件 FileUtils.copyDirectory(directoryInput.getFile(), dest); } catch (IOException e) { e.printStackTrace(); } } }); //jarInputs transformInput.getJarInputs().forEach(jarInput -> { ArrayList<File> list = new ArrayList<>(); getFileList(jarInput.getFile(), list); list.forEach(file -> { System.out.println("getJarInputs =======================================================" + file.getName()); }); if (outputProvider != null) { File dest = outputProvider.getContentLocation( jarInput.getName(), jarInput.getContentTypes(), jarInput.getScopes(), Format.JAR); //将该文件放入到目标目录中,这步骤必须实现,否则会导致dex文件找不到该文件 try { FileUtils.copyFile(jarInput.getFile(), dest); } catch (IOException e) { e.printStackTrace(); } } }); }); }