前言
了解过启动时长的原理以后,下一步就是分析启动时长!
有了启动时长,我们才能进行下一步的分析,哪里的时间长了,哪里应该放到子线程初始化等。
现在很多教程中的分析启动时长的工具的落伍了,所以,在本文中,我会带你了解比较新的启动分析工具 Profiler 和 Perfetto 以及大厂常用的性能分析库。
当然,如果你对对应用启动的原理还不熟悉,可以查看我的上一篇文章:
一、如何定义启动时长
通常说启动时长的时候,我们一般指的是冷启动,用户对冷启动的感知最为明显,如果我们的应用启动时间太长,好家伙,用户分分钟抛弃我们的应用。
那冷启动一般包括哪些部分呢?
正常而言,一般是包括创建应用进程前和应用进程后两个部分。
创建应用进程前:
- 加载并启动应用。
- 在启动后立即显示应用的空白启动窗口。
- 创建应用进程。
创建应用进程后:
- 创建应用对象。
- 启动主线程。
- 创建主 Activity。
- 扩充视图。
- 布局屏幕。
- 执行初始绘制。
一旦应用完成第一次绘制以后,系统进程就会换掉当前显示的后台窗口,替换为主 Activity。
对于用户来说,能够见到我们应用的第一个界面就算启动完成了,一般的启动时长就是指的这个。
谷歌又在此基础上创建了 完全显示所用时间 和 初步显示所用时间,我们在下面分析。
二、完全显示所用时间和初步显示所用时间
初步显示时间的英文是 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、内存、网路和耗电,比如说像这样;
它支持四种模式录制程序运行轨迹,分别是:
- Sample Java Methods:简单来说,就是以一定的频率去记录 Java 代码执行的调用堆栈
- Trace Java Methods:记录每一个 Java 方法的的时间和CPU信息,也就是每一段 Java 执行代码的调用堆栈都会被记录下来。对性能的消耗很高
- Sample C/C++ Functions:通过 simpleperf 去记录 native 代码的调用轨迹,不过设备等级要在 Android 8.0 以上
- Trace System Calls:基于 Systrace,跟 Systrace 的功能一样,它主要记录了与系统资源的交互,比如多核CPU的线程执行情况、帧率等等你需要的设备信息
掌握了四种操作方式后,对于只统计启动时长而言,前面两种足够进行初步分析了,那么选哪一种呢?
-
Sample Java Methods 时间计算更加精确,但是可能会漏掉记录一些执行时间超级超级短的一些方法
-
Trace Java Methods 会记录每一个 Java 方法,这也造成了性能负担,会拉长启动时长。但如果想暴露启动过程中的耗时方法,那么这种方式无疑是最合适的
对我而言,我初期就想暴露主线程的耗时方法,所以会选择 Trace Java Methods,虽然统计方法耗时没有那么精确,但是每个方法的相对在执行过程的耗时占比还是比较准确的。
整个使用过程是这样的:
第一步 更改运行App的配置项
点击启动 「App配置」图标,如图:
之后选中第四个 Tab 下的 「Profiling」,点击下拉框选中 「Trace Java Methods」,点击确认。
第二步 启动App
在用手机连接上 Android Studio 以后,点击 「Profile App」 按钮:
之后会出现 Profie 工具,录制过程就开始了:
当我们的应用启动完成以后,可以点击 「Stop」按钮,录制过程就完成了。
第三步 分析调用堆栈
录制完成我们就可以见到分析界面:
整个界面我给分成了A、B和C三个部分。
A部分可以记录对应时间的CPU、用户的操作和对应活动的生命周期的情况。
B部分记录了当前进程对应的线程的代码调度情况,其中横轴代表时间轴,纵轴代表代码的调度顺序,代码调度又分为了三种情况:
-
- 绿色部分:应用中自有代码的执行。
-
- 橙色部分:系统API的执行。
-
- 蓝色部分:第三方SDK的代码执行,包括Java语言API。
通过对B部分的一顿分析,我们大概就清楚哪些方法在主线程比较耗时了!
最后就是C部分了,C部分可以查看 Flame Chart、Top Down 和 Bottom up,如果你还清楚这些图该怎么看,建议看一下官方文档:《检查轨迹》。
如何只统计我想统计的代码调用栈时长?
很多时候,我们不需要记录整个启动流程,一个方法或者一段代码就够了,这种情况我们可以通过 Debug Api 去实现。
就两静态方法,我们用 Debug.startMethodTracing
方法启动调用堆栈的生成,最终回生成 .trace 文件,调用处主要有两个参数:
tracePath
:.trace 文件的生成路径。bufferSize
:.trace 文件大小的限制,默认可就只有 8 MB 哟。
当我们觉得需要结束的时候调用 Debug.stopMethodTracing
我们可以在代码的结束处调用 Debug.startMethodTracing
方法,在我们指定的地址生成 .trace 文件。
生成的 Trace 文件,可以通过点击 AS 底部的 「Profiler」 栏目下的 「+」按钮添加,分析方法跟刚刚一样。
2. 性能分析利器 - Perfetto
正常情况下,通过 Profiler 记录 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/, 将生成文件直接移进去,就可以生成我们想要的信息了:
剩下的就得靠自己分析了!
四、初步监控启动时长
不知道大家发现了没有,虽然上面的操作骚得狠,挖掘到的信息也很丰富,但也只能在本地用。
不能将启动时长上传到线上,也就意味着不能进行很好的监控,开发仔也不可能每次开发应用的时候,都去统计一下启动时长吧。
这里有一个简单的方法,思路是:
利用函数打点的方式统计一下每个重要过程的时间,然后将这些信息上传到埋点,之后利用自动化工具每周查询启动时长发布到企业微信或者钉钉里,一个简单的监控方案就形成了!
具体的操作就是写一个计时工具类,在 Application#attachBaseContext
方法里面记一个时间戳,然后在下面几个点进行时间统计:
Application#onCreate
方法结束处- 第一个 Activity 的
onWindowFocusChanged
方法 - 主界面的
onWindowFocusChanged
方法
如需统计更加详细的方法,可多加入一些时间戳,监控具体方法的耗时。
五、进阶监控启动时长
用代码打点这种方式对代码的侵入性比较高,看着不太优雅,那就换一种方式!
思路是这样的:
- 启动点同样可以设置在 Application 的构造方法或者
attachBaseContext
方法 - 接着用反射的方法拦截
ActivityThread
中的mH
中的消息,当第一个 Activity\Service\BroadCastReceiver 启动后,就预示着启动完成。 - 在 Activity 的
onWindowFocusChanged
插入方法,统计用户看见第一个 Activity 时的启动时长
1、3部分我们不直接写,而使用字节码插桩,库选择 ASM。
ASM 是一个字节码操作库,它可以直接修改已经存在的 Class 文件或者生成 Class 文件。与其他的一些字节码操作框架对比,ASM 更底层,可直接操作字节码,设计上更小更快,性能也更好!
如果各位同学对 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 过程像这样:
其实我们只需要处理 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)
}
复制代码
上面的代码中,ClassReader
对 Class
文件进行读取和解析,ClassWriter
对 Class
文件进行写入,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启动速度优化(上)》