app优化方案怎么写(鸿洋都推荐要看,难得的App启动优化分析好文)

作者:九心

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

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

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

一、如何定义启动时长

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

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

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

创建应用进程前:

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

创建应用进程后:

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

一旦应用完成第一次绘制以后,系统进程就会换掉当前显示的后台窗口,替换为主 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、内存、网路和耗电,比如说像这样:

检测图片

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

1. 「Sample Java Methods」:简单来说,就是以一定的频率去记录 Java 代码执行的调用堆栈。

2. 「Trace Java Methods」:记录每一个 Java 方法的的时间和CPU信息,也就是每一段 Java 执行代码的调用堆栈都会被记录下来。对性能的消耗很高。

3. 「Sample C/C++ Functions」:通过 simpleperf 去记录 native 代码的调用轨迹,不过设备等级要在 Android 8.0 以上。

https://developer.android.com/ndk/guides/simpleperf?hl=zh-cn

4. 「Trace System Calls」:基于 Systrace,跟 Systrace 的功能一样,它主要记录了与系统资源的交互,比如多核CPU的线程执行情况、帧率等等你需要的设备信息。

https://developer.android.com/topic/performance/tracing/command-line?hl=zh-cn

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

• 「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部分记录了当前进程对应的线程的代码调度情况,其中横轴代表时间轴,纵轴代表代码的调度顺序,代码调度又分为了三种情况:

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

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

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

https://developer.android.com/studio/profile/inspect-traces?hl=zh-cn#events-table

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

很多时候,我们不需要记录整个启动流程,一个方法或者一段代码就够了,这种情况我们可以通过 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!

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

https://developer.android.com/studio/command-line/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 打开地址 https://ui.perfetto.dev/, 将生成文件直接移进去,就可以生成我们想要的信息了:

Perfetto生成信息

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

四、初步监控启动时长

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

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

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

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

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

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

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

五、进阶监控启动时长

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

思路是这样的:

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

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

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

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

asm官网
《Android ASM快速入门》

https://www.jianshu.com/p/d5333660e312

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

第一步 构建插件

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

https://www.jianshu.com/p/f902b51e242b

完成后:

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 的各个部分,比如成员变量、成员方法、静态变量、注解和类等信息。

app优化方案

第三步 字节码访问

我在上面自己实现了一个 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 的启动分析类似吗?

https://github.com/Tencent/matrix

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

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

总结

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

前段时间还收集整理了Android性能优化系统知识脑图和核心知识点笔记文档!既能够夯实底层原理、性能调优等核心技术点,又能够掌握普通开发者,难以触及的架构设计方法论。那你在工作中、团队里、面试时,也就拥有了同行难以复制的核心竞争力。需要完整版的朋友,可以直接私信我【性能优化】免费领取!

需要完整版的朋友,可以直接私信我【性能优化】免费领取!

共勉!

您可以还会对下面的文章感兴趣

最新评论

◎欢迎参与讨论,请在这里发表您的看法、交流您的观点。

使用微信扫描二维码后

点击右上角发送给好友