进阶应用启动分析,这一篇就够了!

前言

了解过启动时长的原理以后,下一步就是分析启动时长!

有了启动时长,我们才能进行下一步的分析,哪里的时间长了,哪里应该放到子线程初始化等。

现在很多教程中的分析启动时长的工具的落伍了,所以,在本文中,我会带你了解比较新的启动分析工具 Profiler 和 Perfetto 以及大厂常用的性能分析库。

当然,如果你对对应用启动的原理还不熟悉,可以查看我的上一篇文章:

《Android启动这些事儿,你都拎得清吗?》

一、如何定义启动时长

通常说启动时长的时候,我们一般指的是冷启动,用户对冷启动的感知最为明显,如果我们的应用启动时间太长,好家伙,用户分分钟抛弃我们的应用。

表情包

那冷启动一般包括哪些部分呢?

正常而言,一般是包括创建应用进程前应用进程后两个部分。

创建应用进程前:

  1. 加载并启动应用。
  2. 在启动后立即显示应用的空白启动窗口。
  3. 创建应用进程

创建应用进程后:

  1. 创建应用对象。
  2. 启动主线程。
  3. 创建主 Activity。
  4. 扩充视图。
  5. 布局屏幕。
  6. 执行初始绘制。

一旦应用完成第一次绘制以后,系统进程就会换掉当前显示的后台窗口,替换为主 Activity。

对于用户来说,能够见到我们应用的第一个界面就算启动完成了,一般的启动时长就是指的这个。

谷歌又在此基础上创建了 完全显示所用时间初步显示所用时间,我们在下面分析。

二、完全显示所用时间和初步显示所用时间

image.png

初步显示时间的英文是 Time-To-Initial-Display,简称是 TTID。

对应的是上图中的 「Displayed Time」 部分,也就是我们应用第一个 Activity 完成绘制后的时间,怎么看这个时间呢?

系统已经为我们准备好了,手机连上电脑,安装好我们的App,在启动的时候过滤 Displayed 日志,会出现以下信息:

I/ActivityManager: Displayed com.test.demo/.ui.activity.SplashActivity: +2s645ms
复制代码

见到了App第一页就意味着启动好了吗?显然并不是这样的,有的时候,你还需要从网络上拉取一些数据,这些数据加载好了,才意味着 App 的真正启动完成,所以还有一个完全显示所用时间。

完全显示所用时间的英文 Time-To-Full-Display,简称是 TTFD。

对应的是图中的 reportFullyDraw() 方法,注意!这个方法是需要手动调用的,因为系统也不知道我们应用什么时候算完全显示成功。当我们调用过这个方法以后,会出现下面的日志:

I/ActivityManager: Fully drawn com.test.demo/.ui.activity.SplashActivity: +2s312ms
复制代码

我们还有一种方法去测试初步显示时间,就是使用 ADB 工具,像这样使用 ADB 命令:

adb [-d|-e|-s <serialNumber>] shell am start -S -W
    com.example.app/.MainActivity
    -c android.intent.category.LAUNCHER
    -a android.intent.action.MAIN
复制代码

得出来的结果跟刚刚其实差不多,就不展示了。

三、更深入的分析

虽然知道了初步显示所用时间,但是我们并不知道细节每个方法运行了多长时间,所以就有了 Profiler 和 Perfetto,它们也是官方给我们推荐的性能分析利器。

1. 性能分析利器 - Profiler

在早期的版本中, 大家都喜欢使用 TraceView 去做性能分析。

不过,在 AS 3.2 或者更高的版本中,TraceView 已经成为过去式,取而代之的是更加强悍的性能分析工具 Profiler。

说起这个工具,那可太强了!当我们录制完我们想要的运行轨迹后,可以帮助我们分析CPU、内存、网路和耗电,比如说像这样;

检测图片

它支持四种模式录制程序运行轨迹,分别是:

  1. Sample Java Methods:简单来说,就是以一定的频率去记录 Java 代码执行的调用堆栈
  2. Trace Java Methods:记录每一个 Java 方法的的时间和CPU信息,也就是每一段 Java 执行代码的调用堆栈都会被记录下来。对性能的消耗很高
  3. Sample C/C++ Functions:通过 simpleperf 去记录 native 代码的调用轨迹,不过设备等级要在 Android 8.0 以上
  4. Trace System Calls:基于 Systrace,跟 Systrace 的功能一样,它主要记录了与系统资源的交互,比如多核CPU的线程执行情况、帧率等等你需要的设备信息

掌握了四种操作方式后,对于只统计启动时长而言,前面两种足够进行初步分析了,那么选哪一种呢?

  • Sample Java Methods 时间计算更加精确,但是可能会漏掉记录一些执行时间超级超级短的一些方法

  • Trace Java Methods 会记录每一个 Java 方法,这也造成了性能负担,会拉长启动时长。但如果想暴露启动过程中的耗时方法,那么这种方式无疑是最合适的

对我而言,我初期就想暴露主线程的耗时方法,所以会选择 Trace Java Methods,虽然统计方法耗时没有那么精确,但是每个方法的相对在执行过程的耗时占比还是比较准确的。

整个使用过程是这样的:

第一步 更改运行App的配置项

点击启动 「App配置」图标,如图:

点击配置

之后选中第四个 Tab 下的 「Profiling」,点击下拉框选中 「Trace Java Methods」,点击确认。

image.png

第二步 启动App

在用手机连接上 Android Studio 以后,点击 「Profile App」 按钮:

启动按钮

之后会出现 Profie 工具,录制过程就开始了:

image.png

当我们的应用启动完成以后,可以点击 「Stop」按钮,录制过程就完成了。

第三步 分析调用堆栈

录制完成我们就可以见到分析界面:

调用区域

整个界面我给分成了A、B和C三个部分。

A部分可以记录对应时间的CPU、用户的操作和对应活动的生命周期的情况。

B部分记录了当前进程对应的线程的代码调度情况,其中横轴代表时间轴,纵轴代表代码的调度顺序,代码调度又分为了三种情况:

    1. 绿色部分:应用中自有代码的执行。
    1. 橙色部分:系统API的执行。
    1. 蓝色部分:第三方SDK的代码执行,包括Java语言API。

通过对B部分的一顿分析,我们大概就清楚哪些方法在主线程比较耗时了!

最后就是C部分了,C部分可以查看 Flame Chart、Top Down 和 Bottom up,如果你还清楚这些图该怎么看,建议看一下官方文档:《检查轨迹》

如何只统计我想统计的代码调用栈时长?

很多时候,我们不需要记录整个启动流程,一个方法或者一段代码就够了,这种情况我们可以通过 Debug Api 去实现。

就两静态方法,我们用 Debug.startMethodTracing 方法启动调用堆栈的生成,最终回生成 .trace 文件,调用处主要有两个参数:

  1. tracePath:.trace 文件的生成路径。
  2. bufferSize:.trace 文件大小的限制,默认可就只有 8 MB 哟。

当我们觉得需要结束的时候调用 Debug.stopMethodTracing

我们可以在代码的结束处调用 Debug.startMethodTracing 方法,在我们指定的地址生成 .trace 文件。

导入Trace

生成的 Trace 文件,可以通过点击 AS 底部的 「Profiler」 栏目下的 「+」按钮添加,分析方法跟刚刚一样。

2. 性能分析利器 - Perfetto

正常情况下,通过 Profiler 记录 Trace System Calls 已经足够我们去分析和系统资源交互的情况了,如下图:

Trace System Calls

它记录了 CPU 的资源调度、显示信息、用户交互、Activity的生命周期、进程内存和当前进程拥有的线程等重要信息

使用 Profiler 的缺点就是只能记录当前进程的信息,想要更多进程内容,还得靠 Systrace 和 Perfetto,Profiler 的 Trace System Calls 就是基于 Systrace,不过 Systrace 是过去式了,官方推荐我们使用 Perfetto!

详细的文档可以查看:《官方文档》

perfetto 从我们的设备上收集性能跟踪数据时会使用多种来源,例如:

  • 使用 ftrace 收集内核信息
  • 使用 atrace 收集服务和应用中的用户空间注释
  • 使用 heapprofd 收集服务和应用的本地内存使用情况信息

下面就是具体的操作。

第一步 生成一份配置文件

电脑上创建一个文件,以 .pbtxt 结尾,我是把它当 .txt 文件处理的,内容是:

buffers: {
    size_kb: 20522240
    fill_policy: DISCARD
}
data_sources: {
    config {
        name: "linux.process_stats"
        target_buffer: 1
        process_stats_config {
            scan_all_processes_on_start: true
        }
    }
}
data_sources: {
    config {
        name: "android.log"
        android_log_config {
            log_ids: LID_DEFAULT
            log_ids: LID_SYSTEM
        }
    }
}
data_sources: {
    config {
        name: "linux.sys_stats"
        sys_stats_config {
            stat_period_ms: 250
            stat_counters: STAT_CPU_TIMES
            stat_counters: STAT_FORK_COUNT
        }
    }
}
data_sources: {
    config {
        name: "linux.ftrace"
        ftrace_config {
            ftrace_events: "sched/sched_switch"
            ftrace_events: "power/suspend_resume"
            ftrace_events: "sched/sched_wakeup"
            ftrace_events: "sched/sched_wakeup_new"
            ftrace_events: "sched/sched_waking"
            ftrace_events: "power/cpu_frequency"
            ftrace_events: "power/cpu_idle"
            ftrace_events: "power/gpu_frequency"
            ftrace_events: "raw_syscalls/sys_enter"
            ftrace_events: "raw_syscalls/sys_exit"
            ftrace_events: "sched/sched_process_exit"
            ftrace_events: "sched/sched_process_free"
            ftrace_events: "task/task_newtask"
            ftrace_events: "task/task_rename"
            ftrace_events: "ftrace/print"
            atrace_categories: "gfx"
            atrace_categories: "input"
            atrace_categories: "view"
            atrace_categories: "wm"
            atrace_categories: "am"
            atrace_categories: "hal"
            atrace_categories: "res"
            atrace_categories: "dalvik"
            atrace_categories: "bionic"
            atrace_categories: "pm"
            atrace_categories: "ss"
            atrace_categories: "database"
            atrace_categories: "aidl"
            atrace_categories: "binder_driver"
            atrace_categories: "binder_lock"
            atrace_apps: "*"
        }
    }
}
duration_ms: 10000
复制代码

因为我的应用代码量比较多,所以我设置的缓存 buffers 比较多,测试时间 duration_ms 也比较长,在 10000 ms。

文件生成好后,使用 adb 命令将电脑上的文件导入到手机中:

adb push 电脑文件路径 /data/local/tmp/perfetto.pbtxt
复制代码

第二步 开始抓日志

生成Trace命令:

adb shell 'cat /data/local/tmp/perfetto.pbtxt | perfetto --txt -c - -o /data/misc/perfetto-traces/trace'
复制代码

之后再使用 adb pull 命令,将手机中的文件导出到电脑,像这样

adb pull /data/misc/perfetto-traces/trace /Users/jiuxin/Downloads/
复制代码

第三步 将文件导入到Perfetto

在 Chrome 打开地址 ui.perfetto.dev/, 将生成文件直接移进去,就可以生成我们想要的信息了:

Perfetto生成信息

剩下的就得靠自己分析了!

四、初步监控启动时长

不知道大家发现了没有,虽然上面的操作骚得狠,挖掘到的信息也很丰富,但也只能在本地用。

不能将启动时长上传到线上,也就意味着不能进行很好的监控,开发仔也不可能每次开发应用的时候,都去统计一下启动时长吧。

这里有一个简单的方法,思路是:

利用函数打点的方式统计一下每个重要过程的时间,然后将这些信息上传到埋点,之后利用自动化工具每周查询启动时长发布到企业微信或者钉钉里,一个简单的监控方案就形成了!

具体的操作就是写一个计时工具类,在 Application#attachBaseContext 方法里面记一个时间戳,然后在下面几个点进行时间统计:

  • Application#onCreate 方法结束处
  • 第一个 Activity 的 onWindowFocusChanged 方法
  • 主界面的 onWindowFocusChanged 方法

如需统计更加详细的方法,可多加入一些时间戳,监控具体方法的耗时。

五、进阶监控启动时长

用代码打点这种方式对代码的侵入性比较高,看着不太优雅,那就换一种方式!

思路是这样的:

  1. 启动点同样可以设置在 Application 的构造方法或者 attachBaseContext方法
  2. 接着用反射的方法拦截 ActivityThread 中的 mH 中的消息,当第一个 Activity\Service\BroadCastReceiver 启动后,就预示着启动完成。
  3. 在 Activity 的 onWindowFocusChanged 插入方法,统计用户看见第一个 Activity 时的启动时长

1、3部分我们不直接写,而使用字节码插桩,库选择 ASM。

ASM 是一个字节码操作库,它可以直接修改已经存在的 Class 文件或者生成 Class 文件。与其他的一些字节码操作框架对比,ASM 更底层,可直接操作字节码,设计上更小更快,性能也更好!

如果各位同学对 ASM 不太了解,可以阅读以下网站:

asm官网
《Android ASM快速入门》

除了入门 ASM,我们还得具备自定义 Plugin 和 Transform 的基础。简单聊聊具体的步骤吧。

第一步 构建插件

自定义插件的知识就不细讲了,感兴趣可以移步:《Android 自定义Gradle插件的3种方式》

完成后:

class CusPlugin implements Plugin<Project>{
    @Override
    void apply(Project project) {
        AppExtension appExtension = project.extensions.getByType(AppExtension)
        appExtension.registerTransform(new AsmTran())
    }
}
复制代码

第二步 实现Transform

Transform 的作用时间就在打包流程中下图的红色箭头处,所以它可以帮助我们修改字节码。

打包流程

Transform 过程像这样:

image.png

其实我们只需要处理 class 文件:

void handleDirectoryInput(DirectoryInput directoryInput, TransformOutputProvider outputProvider) {
    directoryInput.file.eachFileRecurse {File file ->
        def fileName = file.name
        if(checkClassFile(fileName)){
            println "fileName: ${fileName}"
            ClassReader cr =new ClassReader(file.bytes)
            ClassWriter cw = new ClassWriter(cr, ClassWriter.COMPUTE_MAXS)
            ClassVisitor cv = new LifecycleClassVisitor(cw)
            cr.accept(cv, ClassReader.EXPAND_FRAMES)
            byte[] bytes = cw.toByteArray()

            FileOutputStream ots = new FileOutputStream(file.path)
            ots.write(bytes)
            ots.close()
        }
    }
    File dest = outputProvider.getContentLocation(
            directoryInput.getName(),
            directoryInput.getContentTypes(),
            directoryInput.getScopes(),
            Format.DIRECTORY
    )
    FileUtils.copyDirectory(directoryInput.getFile(), dest)
}
复制代码

上面的代码中,ClassReaderClass 文件进行读取和解析,ClassWriterClass 文件进行写入,ClassVisitor 可以访问 Class 的各个部分,比如成员变量、成员方法、静态变量、注解和类等信息。

第三步 字节码访问

我在上面自己实现了一个 LifecycleClassVisitor 的类,目的是用来拦截对应的类方法:

public class LifecycleClassVisitor extends ClassVisitor {
    //...

    @Override
    public MethodVisitor visitMethod(int access, String name, String desc, String signature, String[] exceptions) {
        System.out.println("ClassVisitor visitMethod name-------" + name);
        MethodVisitor mv = cv.visitMethod(access, name, desc, signature, exceptions);
        if(name.startsWith("on")){
            return new LifecycleMethodVisitor(mv, className, name);
        }
        return mv;
    }

    //...
}
复制代码

上面拦截了以 on 开头的方法,并替换成了对应的 LifecycleMethodVisitor 方法字节码读取工具。

public class LifecycleMethodVisitor extends MethodVisitor {

    private String className;
    private String methodName;

    public LifecycleMethodVisitor(MethodVisitor methodVisitor, String className, String methodName) {
        super(Opcodes.ASM6, methodVisitor);

        this.className = className;
        this.methodName = methodName;
    }

    @Override
    public void visitCode() {
        super.visitCode();

        if(methodName != null && methodName.equals("onWindowFocusChanged")){
            mv.visitMethodInsn(Opcodes.INVOKESTATIC, "com/example/transformdemo/hook/ActivityThreadHacker", "getCurActivityDisplayTime", "()V", false);
        }else {
            mv.visitMethodInsn(Opcodes.INVOKESTATIC, "com/example/transformdemo/hook/ActivityThreadHacker", "hackSysHandlerCallback", "()V", false);
        }
    }
}
复制代码

这个方法里面很简单,如果是 onWindowFocusChanged 方法,就插入 ActivityThreadHacker#getCurActivityDisplayTime 静态方法,否则就插入 ActivityThreadHacker#hackSysHandlerCallback 静态方法,这个方法就是我前面说,用来统计时间的。

当然,实际做的时候不可能这么简单,比如,当 Activity 没有 onWindowFocusChanged 方法,你还需要考虑去插入这个方法,再进行插桩。

第四步 拦截ActivityThread中的Handler

ActivityThreadHacker 就是一个统计时长的工具,并对 ActivityThread 中的 Handler 消息处理进行一遍拦截,用的是反射的方式,代码虽然比较多,但是一遍就能懂:

public class ActivityThreadHacker {

    private static final String TAG = "T.ActivityThreadHacker";
    private static long sApplicationCreateBeginTime = 0L;
    private static long sApplicationCreateEndTime = 0L;
    private static long sLastLaunchActivityTime = 0L;
    private static long sCurActivityDisplayTime = 0L;
    public static int sApplicationCreateScene = -100;
    private static boolean sIsInit = false;

    public static void hackSysHandlerCallback(){
        if(sIsInit)
            return;
        try {
            sApplicationCreateBeginTime = SystemClock.uptimeMillis();
            Log.d("wangjie", "hackSysHandlerCallback begin");
            Class<?> forName = Class.forName("android.app.ActivityThread");
            Field field = forName.getDeclaredField("sCurrentActivityThread");
            field.setAccessible(true);
            Object activityThreadValue = field.get(forName);
            Field mH = forName.getDeclaredField("mH");
            mH.setAccessible(true);
            Object handler = mH.get(activityThreadValue);
            Class<?> handlerClass = handler.getClass().getSuperclass();
            Field callbackField = handlerClass.getDeclaredField("mCallback");
            callbackField.setAccessible(true);
            Handler.Callback originCallback = (Handler.Callback) callbackField.get(handler);
            HackCallback hackCallback = new HackCallback(originCallback);
            callbackField.set(handler, hackCallback);
        }catch (Exception e) {
            e.printStackTrace();
        }
        sIsInit = true;
    }

    public static long getApplicationCost() {
        return ActivityThreadHacker.sApplicationCreateEndTime - ActivityThreadHacker.sApplicationCreateBeginTime;
    }

    public static long getEggBrokenTime() {
        return ActivityThreadHacker.sApplicationCreateBeginTime;
    }

    public static long getLastLaunchActivityTime() {
        return ActivityThreadHacker.sLastLaunchActivityTime;
    }

    public static long curActivityDisplayTime() {
        return sCurActivityDisplayTime;
    }

    public static void getCurActivityDisplayTime() {
        sCurActivityDisplayTime =  SystemClock.uptimeMillis() - ActivityThreadHacker.sLastLaunchActivityTime;
    }

    private final static class HackCallback implements Handler.Callback {
        private static final int LAUNCH_ACTIVITY = 100;
        private static final int CREATE_SERVICE = 114;
        private static final int RECEIVER = 113;
        public static final int EXECUTE_TRANSACTION = 159; // for Android 9.0
        private static boolean isCreated = false;
        private static int hasPrint = 10;

        private final Handler.Callback mOriginCallback;

        public HackCallback(Handler.Callback mOriginCallback) {
            this.mOriginCallback = mOriginCallback;
        }

        @Override
        public boolean handleMessage(@NonNull Message msg) {

            boolean isLaunchActivity = isLaunchActivity(msg);

            if(isLaunchActivity) {
                Log.d("wangjie", "hook handleMessage begin isLaunchActivity");
                ActivityThreadHacker.sLastLaunchActivityTime = SystemClock.uptimeMillis();
            }

            if(!isCreated) {
                if(isLaunchActivity || msg.what == CREATE_SERVICE || msg.what == RECEIVER){
                    ActivityThreadHacker.sApplicationCreateEndTime = SystemClock.uptimeMillis();
                    ActivityThreadHacker.sApplicationCreateScene = msg.what;
                    isCreated = true;
                }
            }
            return null != mOriginCallback && mOriginCallback.handleMessage(msg);
        }

        private Method method = null;

        private boolean isLaunchActivity(Message msg) {
            if (Build.VERSION.SDK_INT > Build.VERSION_CODES.O_MR1) {
                if (msg.what == EXECUTE_TRANSACTION && msg.obj != null) {
                    try {
                        if (null == method) {
                            Class clazz = Class.forName("android.app.servertransaction.ClientTransaction");
                            method = clazz.getDeclaredMethod("getCallbacks");
                            method.setAccessible(true);
                        }
                        List list = (List) method.invoke(msg.obj);
                        if (!list.isEmpty()) {
                            return list.get(0).getClass().getName().endsWith(".LaunchActivityItem");
                        }
                    } catch (Exception e) {
                        Log.d(TAG, "[isLaunchActivity] %s", e);
                    }
                }
                return msg.what == LAUNCH_ACTIVITY;
            } else {
                return msg.what == LAUNCH_ACTIVITY;
            }
        }
    }
}
复制代码

等应用启动后,我们就可以通过调用 ActivityThreadHacker 静态方法去获取对应的时间。

熟悉 Matrix 的同学可能发现了,这不是跟 Matrix 的启动分析类似吗?

事实确实如此,只不过 Matrix 整个流程远比整个复杂的多,记得第一次反编译我们自己应用,几乎所有方法都被插了记时统计方法的时候,我整个人都惊呆了!

麻了

不过,我们性能分析的工具仍然是基于 Matrix 实现的,毕竟,有这么强大的工具!

总结

Profiler、Perfetto 可以帮助我们分析启动时长,利用方法打点或者Matrix可以帮助我们建立一套启动时长监控,一套组合拳下来,简单的启动时长治理就可以做完了。

下一篇文章将和大家分析,如何进行启动速度优化,如果有什么问题,评论区见!

如果觉得本文不错,「点赞」是最好的肯定!

参考文章

《性能工具Traceview》
《手把手教你使用Systrace(一)》
《Android ASM快速入门》
《Android Gradle Transform 详解》
《Matrix源码分析系列-如何计算App启动耗时》
《# 深入探索Android启动速度优化(上)》

Supongo que te gusta

Origin juejin.im/post/7047377813199912968
Recomendado
Clasificación