链接:https://juejin.cn/post/7354233812593246248 本文由作者授权发布
启动是指用户从点击 icon 到看到页面首帧的整个过程,启动优化的目标就是减少这一过程的耗时。启动性能是 APP 使用体验的门面,启动过程耗时较长很可能导致用户使用 APP 的兴趣骤减。提高启动速度是每一个 APP 在体验优化方向上必须要做的关键技术突破。
用户如果想打开一个应用,就一定要经过启动这个步骤。APP启动时间的长短,不只是用户体验的问题,对于淘宝、京东等大型APP来说,会直接影响用户的留存和转化等核心数据。对研发人员来说,启动速度是我们的门面,它清清楚楚可以被所有人看到,我们都希望自己应用的启动速度可以秒杀所有竞争对手。工欲善其事,必先利其器。特别是在性能优化领域,大量复杂繁琐的疑难杂症问题,需要借助工具的能力,在解决这些问题上会有质的飞跃。正所谓磨刀不误砍柴工,个人认为,用好性能分析工具,性能优化的提升就成功了一大半。下面也会详细介绍八种定位耗时问题的方式。
从用户点击应用图标开始,整个启动过程经过哪几个关键阶段,启动过程又究竟会出现哪些问题,又会给用户带来哪些体验问题。
应用进程是如何被创建的?每个 App 在启动前必须先创建一个进程,该进程是由 zygote 进程 fork 出来,进程具有独立的资源空间,用于承载 App 上运行的各种 Activity/Service 等组件。大多数情况一个 App 就运行在一个进程中。进程的创建主要为以下三个步骤:
应用的启动流程主要分为三步:启动主线程,创建 Application,创建 MainActivity。
在冷启动开始时,系统有以下三项任务:
系统创建应用进程后续阶段:
这是进程创建和启动的整个流程。
应用有三种启动状态:冷启动、温启动和热启动。每种状态都会影响应用向用户显示所需的时间。在冷启动中,应用从头开始启动。在另外两种状态中,系统需要将后台运行的应用带入前台。启动优化一般是在冷启动的基础上进行优化,这样做也可以提升温启动和热启动的性能。
冷启动是指应用从头开始启动,也就是用户点击桌面 Icon 到应用创建完成的过程。所以系统进程是在冷启动后才创建应用进程。发生冷启动的情况包括应用自设备启动后或系统终止应用后首次启动。
常见的场景是 APP 首次启动或 APP 被完全杀死后重新启动。这种启动需要的时间最长的,因为系统和应用要做的工作比温启动和热启动状态下更多。冷启动具有耗时最多,是衡量启动耗时的标准。
以时间值的形式衡量,也就是指应用与用户进入可交互状态所需的时间。冷启动包含以下事件序列的总经过时间:
温启动只是冷启动操作的一部分。当启动应用时,后台已有该应用的进程,但是 Activity 需要重新创建。这样系统会从已有的进程中来启动这个 Activity,这个启动方式叫温启动。它的开销要比热启动高,比冷启动低。
温启动常见的场景有两种:
在热启动中,系统的工作就是将 Activity 带到前台。只要应用的所有 Activity 仍留在内存中,就不会重复执行进程,应用以及 Activity 的创建,避免重复初始化对象、布局解析和显现。应用的热启动开销比温启动更低,也是最简单的一种。热启动常见的场景: 当我们按了 Home 键或其它情况 App 被切换到后台,再次启动 App 的过程。
但是,如果一些内存为响应内存整理事件(如 onTrimMemory())而被完全清除,则需要为了响应热启动而重新创建相应的对象。热启动显示的屏幕上行为和冷启动场景相同。系统进程显示空白屏幕,直到应用完成 Activity 呈现。
这就是应用三种启动状态的生命周期图。
启动速度的优化方向是 Application 和 Activity 生命周期阶段,创建进程阶段都是系统做的,从启动应用阶段开始,随后的任务和我们自己写的代码有一定的关系。比如 Application 的 onCreate() 和 attachBaseContext() 这两个生命周期回调方法的执行时间,在 Application 和 Activity 的回调方法中做的事情是我们可以干预的。
在 Application阶段,可以在 attachBaseContext,installProvider 和 app:onCreate 三个时间段进行相关优化。
创建主 Activity 并且执行相关生命周期方法。在启动优化的专项中,Activity 阶段最关键的生命周期是 onCreate(),这个阶段中包含了大量的 UI 构建、首页相关业务、对象初始化等耗时任务,是我们在优化启动过程中非常重要的一环,我们可以通过异步、预加载、延迟执行等手段做各方面的优化。
来到 View 构建的阶段,该阶段也是比较耗时,可采用异步 Inflate 配合 X2C(编译期将 xml 布局转代码)并提升相应异步线程优先级的方法综合优化。
View 的整体渲染阶段,涵盖 measure、layout、draw 三部分,这里可尝试从层级、布局、渲染上取得优化收益。
最后是首屏数据加载阶段,这部分涵盖非常多数据相关的操作,也需要综合性优化,可尝试预加载、三级缓存或网络优先级调度等手段进行优化。
我们在应用中能触达到的 attachBaseContext 阶段,这是最早的预加载时机。可以把这个方法的回调时间当作启动开始时间,因为 attachBaseContext() 是应用进程的第一个生命周期。但是准确来说,应用的启动时间包括应用进程的创建,它应该是在冷启动时用户点击应用 Icon 开始计算(下面会介绍统计方法)。但是结束时间点该如何来统计呢?
Activity#onWindowFocusChanged() 这个方法的调用时机是用户与 Activity 交互的最佳时间点,当 Activity 中的 View 测量绘制完成之后会回调 Activity 的 onWindowFocusChanged() 方法,可以选择它来当作时间结束的时间点。
但是这种还不够准确,大部分数据是通过请求接口回来之后,才能填充页面才能显示出来,当执行到 onWindowFocusChanged() 的时候,请求数据还没完成,页面上依旧是没有数据的,用户仅仅可以交互写死在 XML 布局当中的视图,更多的内容还是不可见,不可交互的。
所以结束时间点通常选择在列表上面第一个 itemView 的 perDrawCallback() 方法的回调时机当作时间结束点,也就是首帧时间。当列表上面第一个 itemView 被显示出来的时候说明网络请求已经完成。页面上的 View 已经填充了数据,并且开始重新渲染了。此时用户是可以交互的,这个才是比较有意义的时间节点。
// itemView添加预绘制回调监听
itemView.viewTreeObserver.addOnPreDrawListener(object : ViewTreeObserver.OnPreDrawListener {
override fun onPreDraw(): Boolean {
return false
}
})
从用户点击应用 icon 到页面生成首帧帧所用的时间,就是 App 启动耗时时间。
从启动流程的 3 个关键阶段,我们可以推测出用户启动过程会遇到比较多的 3 个问题。这 3 个问题其实也是大多数应用在启动时可能会遇到的。
如果我们禁用了预览窗口或者指定了透明的皮肤,那用户点击了图标之后,需要在创建启动页后才能真正看到应用闪屏。对于用户体验来说,点击了图标,过了几秒还是停留在桌面,看起来就像没有点击成功,这在中低端机中更加明显。
现在应用启动流程越来越复杂,闪屏广告、热修复框架、插件化框架、各种SDK初始化,所有准备工作都需要集中在启动阶段完成。上面说的 Activity 阶段显示时间对于中低端机来说简直就是噩梦,经常会达到十秒的时间。
既然首页显示那么慢,那我能不能把尽量多的工作都通过异步化延后执行呢?很多应用的确就是这么做的,但这会造成两种后果:要么首页会出现白屏,要么首页出来后用户根本无法操作交互。
很多开发者把启动结束时间的统计放到首页刚出现的时候,这对用户是不负责任的。看到一个首页,但是停住几秒都不能滑动,这对用户来说完全没有意义。启动优化不能过于 KPI 化,要从用户的真实体验出发,要着眼从点击图标到用户可操作的整个过程。
在 Android 4.4(API 级别 19)及更高版本中,在 Android Studio Logcat 中过滤关键Displayed,可以看到对应的冷启动耗时日志值。此值代表从启动进程到在屏幕上完成对应 Activity 的绘制所用的时间。
Displayed com.sum.tea/com.sum.main.MainActivity: +2s141ms
时间测量值是从应用进程启动时开始计算,仅针对第一个绘制的 Activity。它可能会省去布局文件中未引用的资源或被应用作为对象初始化一部分创建的资源。因为加载它们是一个内嵌进程,并且不会阻止应用的初步显示。
这种方式最简单,适用于收集 App 与竞品 App 启动耗时对比分析。
通过 adb shell activity Manager 命令运行应用来测量耗时时间。
adb shell am start -W [packageName]/[启动Activity的全路径]
比如命令窗口输入:
adb shell am start -W com.sum.tea/com.sum.main.MainActivity
命令窗口会显示以下内容:
Starting: Intent { act=android.intent.action.MAIN cat=[android.intent.category.LAUNCHER] cmp=com.sum.tea/com.sum.main.MainActivity }
Status: ok
Activity: com.sum.tea/com.sum.main.MainActivity
ThisTime: 1913
TotalTime: 1913
WaitTime: 2035
这三者之间的关系为 ThisTime <= TotalToime < WaitTime。TotalTime 就是应用的启动时间,它包括创建进程 + Application初始化 + Activity初始化到界面显示的过程。
这种方式只适合线下使用,而且还能用于测量竞品 App 的耗时。缺点是不能带到线上而且不能精确控制启动时间的开始和结束,数据不够严谨。
当我们在使用异步的方式来加载数据,这会导致的一个问题就是应用画面已经显示,同时 Displayed 日志已经打印,可是内容却还在加载中。为了衡量这些异步加载资源所耗费的时间,可以在异步加载完毕之后调用 activity.reportFullyDrawn() 方法来让系统打印到调用此方法为止的启动耗时数据。
在首页的请求 Banner 数据完成时:
private fun refresh() {
mViewModel.getBannerList().observe(this) { banners ->
activity?.reportFullyDrawn()
}
}
Logcat 打印数据如下:
Displayed com.sum.tea/com.sum.main.MainActivity: +3s171ms
Fully drawn com.sum.tea/com.sum.main.MainActivity: +4s459ms
如果要统计每个函数或者任务具体的耗时时间,最简单的方式就是自定义埋点工具。在函数执行前进行埋点,执行结束时埋点,两者差值就是函数执行耗时的时间。
object LaunchTimer {
private var currentTime: Long = 0
// 记录开始时间
fun startRecord() {
currentTime = System.currentTimeMillis()
}
// 记录结束时间,某个tag的耗时
@JvmOverloads
fun stopRecord(title: String = "") {
val t = System.currentTimeMillis() - currentTime
Log.d("LaunchTimer", "$title | time:$t")
}
}
注意:还可以用 SystemClock.currentThreadTimeMillis() 获取 CPU 真正执行的时间。
开始记录的位置放在 Application 的 attachBaseContext() 中,它是我们应用能接收到的最早的一个生命周期回调方法。
class SumApplication : Application() {
// 应用最早回调的生命周期方法
override fun attachBaseContext(base: Context?) {
super.attachBaseContext(base)
LaunchTimer.startRecord()
MultiDex.install(base)
}
}
在数据加载显示首帧时添加结束计时打点,列表上面第一个 itemView 被显示出来的时候说明网络请求已经完成。
class HomeBannerAdapter : BaseBannerAdapter<Banner, BannerImageHolder>() {
var isRecord = false
override fun onBindView(holder: BannerImageHolder, data: Banner, position: Int, pageSize: Int) {
// 第一个item,并且没有没注册监听
if (position == 0 && !isRecord) {
isRecord = true
holder.itemView.viewTreeObserver.addOnPreDrawListener(object : ViewTreeObserver.OnPreDrawListener {
override fun onPreDraw(): Boolean {
LaunchTimer.stopRecord("首帧绘制时间:")
// 移除监听
holder.itemView.viewTreeObserver.removeOnPreDrawListener(this)
return false
}
})
}
}
}
在 Adapter 中记录启动耗时要加一个布尔值变量 isRecord 进行判断,避免 onBindViewHolder 方法被多次调用。
但是这种方式优点是可以精确控制开始和结束的位置而且可以带到线上,进行用户数据的采集,把数据上报给服务器,服务器可以针对所有用户上报的启动数据。
缺点是与业务强耦合,入侵性很强,大型app启动流程复杂,业务繁多,工作量大,这种方案就显得很普通。
Aspect Oriented Programming 是面向切面编程,通过预编译和运行期动态代理实现程序功能统一维护的一种技术。在统计方法耗时更多是使用切面编程的方式,可以在编译时期插入一些代码。
其实编译插桩技术早已经深入 Android 开发中的各个领域,而 AOP 技术正是一种高效实现插桩的模式。
buildscript {
dependencies {
// AOP切面编程
classpath 'com.hujiang.aspectjx:gradle-android-plugin-aspectjx:2.0.10'
}
}
在 app 的 build.gradle 文件中加入插件和依赖:
// 加入插件
apply plugin: 'android-aspectjx'
// AOP 切面编程
implementation 'org.aspectj:aspectjrt:1.9.5'
@Before("execution(* android.app.Activity.on**(..))")
fun onActivityCalled(joinPoint: JoinPoint) {
// 这里可以插入任意代码段
}
在 execution 中的是一个匹配规则,第一个 * 代表匹配任意的方法返回值,后面的语法代码匹配所有 Activity 中 on 开头的方法,..代表该方法可以是任意参数。这样就可以在 App 中所有 Activity 中以 on 开头的方法中输出代码了。
// 注解产生关联
@Aspect
class SumOptAop {
// @Around每个函数前后都插入代码
@Around("call(* com.sum.tea.SumApplication**(..))")
fun getApplicationTime(joinPoint: ProceedingJoinPoint) {
val signature = joinPoint.signature // 获取函数名称
val curTime = System.currentTimeMillis()
// 上面的代码会插入在函数前面
try {
// 执行原方法代码
joinPoint.proceed()
} catch (e: Exception) {
e.printStackTrace()
}
// 下面的代码会插入在函数后面
LogUtil.e("${signature.name} | time:${System.currentTimeMillis() - curTime}")
}
}
要注意不同的 Action 类型其对应的方法入参是不同的:
它们的区别是:ProceedingPoint 是提供了 proceed() 方法执行目标方法的,而 JoinPoint 是没有的。
@Before("execution(* set*(..))")
// 任意公共方法的执行
@After("execution(public * *(..))")
// Activity 的任意方法
@Around("execution(android.app.Activity. *(..))")
// View的点击事件
@Around("execution(void android.view.View.OnClickListener.onClick(..))")
利用 AOP 可以对业务逻辑的各个部分进行隔离,从而使得业务逻辑各部分之间的耦合性降低,提高程序的可重用性,同时大大提高了开发效率。并且无侵入性,修改方便。
Traceview 是 android 平台配备一个很好的性能分析的工具。它可以通过图形化的方式让我们了解我们要跟踪的程序的性能,并且能具体到每个方法的执行时间。
首先调用 startMethodTracing(String tracePath) 开始记录 CPU 使用情况,调用 stopMethodTracing() 停止记录。系统就会为我们生成一个 .trace 文件,我们可以通过 CPU Profiler 查看这个文件记录的内容。
class SumApplication : Application() {
override fun onCreate() {
super.onCreate()
// 自定义路径和文件大小
// Debug.startMethodTracing(getExternalFilesDir(null) + "SumTea.trace", 12 * 1024)
// 开始记录
Debug.startMethodTracing()
initSumHelper()
initMmkv()
initAppManager()
initRefreshLayout()
initArouter()
// 结束记录
Debug.stopMethodTracing()
}
}
文件生成的位置默认在(SD卡) Android/data/包名/files 目录下,可以通过 Android Studio 的 Device File Exploer 设备文件管理器中查看:
注意:文件最大默认是8M,可以手动扩充大小,也可以自定义文件路径。
在 Android Studio 中双击该文件可以在 CPU Profiler 直接打开:
这里有三个主要区域,时间范围区域,线程区域,分析数据区域。分析数据区域有四种方式,分别是Call Chart、Flame Chart、Top Down、Bottom Up。
左侧的程序执行时间和 CPU 消耗时间:
数据分析区域中有几种时间单位:
Call Chart 是 Traceview 和 systrace 默认使用的展示方式。当我们不想知道应用程序的整个调用流程,只想直观看出哪些代码路径花费的 CPU 时间较多时,火焰图就是一个非常好的选择。
通过 TraceView 主要可以得到两种数据:单次执行耗时的方法以及执行次数多的方法。它的优点是可以在代码中埋点,埋点后可以用 CPU Profiler 进行分析,因为我们现在优化的是启动阶段的代码。
但是 TraceView 是利用 Android Runtime 函数调用的 event 事件,将函数运行的耗时和调用关系写入 trace 文件中。它属于 instrument 类型,能查看整个过程有哪些函数调用,但是工具本身带来的性能开销过大,有时无法反映真实的情况,可能会带偏优化方向。比如一个函数本身的耗时是 1 秒,开启 Traceview 后可能会变成 3 秒,而且这些函数的耗时变化并不是成比例放大。
在 Android 5.0 之后,新增了 startMethodTracingSampling 方法,可以使用基于样本的方式进行分析,以减少分析对运行时的性能影响。新增了 sample 类型后,就需要我们在开销和信息丰富度之间做好权衡。
另一种方式就是使用 Android Studio3.2 或更高版本,通过 CPU Profiler 来查看 App 的启动时间:
记录配置 Trace types 有四种类型:
当工具运行起来后,点击 stop 按钮停止记录,然后工具会自动分析并生成生成一份 Java Method Trace Record 文件:
文件生成后会自动跳转到数据分析面板,和 TraceView 中的数据分析面板是一样的(这里不重复分析了)。
CPU Profiler 的默认视图包括以下时间轴:
线程活动时间线不同的颜色表示的含义:
使用 CPU Profiler 在与 App 交互时能实时检查 CPU 的使用率和线程活动,也可以检查记录的方法轨迹、函数轨迹和系统轨迹的详情。CPU 性能分析器记录和显示的详细信息取决于您选择的记录配置。它是性能优化方面非常重要的工具之一。
Systrace 结合了 Android 内核数据,分析了线程活动后会给我们生成一个非常精确 HTML 格式的报告。我们一般是通过代码插桩的形式配合使用。
Systrace 原理是在系统的一些关键链路(如 SystemServcie、虚拟机、Binder 驱动)插入一些信息(Label)。然后,通过 Label 的开始和结束来确定某个核心过程的执行时间,并把这些 Label 信息收集起来得到系统关键路径的运行时间信息,最后得到整个系统的运行性能信息。其中,Android Framework 里面一些重要的模块都插入了 label 信息,用户 App 中也可以添加自定义的 Lable。
Systrace 工具只能监控特定系统调用的耗时情况,所以它是属于 sample 类型,而且性能开销非常低。它不支持应用程序代码的耗时分析,所以在使用时有一些局限性。
但是由于系统预留了 Trace.beginSection 接口来监听应用程序的调用耗时,通过编译时给每个函数插桩的方式来实现,也就是在重要函数的入口和出口分别增加 Trace.begainSection(),Trace.endSection()方法。
class SumApplication : Application() {
// 应用最早回调的生命周期方法
override fun attachBaseContext(base: Context?) {
super.attachBaseContext(base)
Trace.beginSection("MultiDex")
MultiDex.install(base)
Trace.endSection()
}
override fun onCreate() {
super.onCreate()
// 添加标识,方便查询
Trace.beginSection("initMmkv")
initMmkv()
Trace.endSection()
Trace.beginSection("initAppManager")
initAppManager()
Trace.endSection()
Trace.beginSection("initRefreshLayout")
initRefreshLayout()
Trace.endSection()
Trace.beginSection("initArouter")
initArouter()
Trace.endSection()
}
}
python systrace.py -t 8 -a "com.sum.tea" -o sum_tea_01.html
这里定义的一些具体参数含义如下:
//切换到systrace目录下
dnsb1389@SUM ~ % cd /Users/dnsb1389/Library/Android/sdk/platform-tools/systrace
// 输入命令
dnsb1389@SUM systrace % python systrace.py -t 8 -a "com.sum.tea" -o sum_tea_01.html
Agent cgroup_data not started.
These categories are unavailable: memory workq
Warning: Only 2 of 3 tracing agents started.
Starting tracing (8 seconds)
Tracing completed. Collecting output...
Outputting Systrace results...
Tracing complete, writing results
Wrote trace HTML file: file:///Users/dnsb1389/Library/Android/sdk/platform-tools/
/sum_tea_01.html
到这里追踪完毕,追踪的数据信息都写到 sum_tea_01.html 文件中了,接下来用浏览器打开这个文件:
在这个数据分析表中我们主要关注四个区域:
在上面的例子中 Wall Duration 是 844 毫秒,Self Time 是 830 毫秒,也就是在这段时间内一共有 14 毫秒 CPU 是处于休息状态的,真正执行代码的时间只花了 830 毫秒。
注意:
优缺点
Systrace 主要用于分析绘制性能方面的问题和分析系统关键方法和应用方法耗时。而且系统版本越高,Android Framework 中添加的系统可用 Label 就越多,能够支持和分析的系统模块也就越多。
另外,Perfetto 是 Android 10 中引入的全新平台级跟踪工具,可以看作 Systrace 的升级版。这是适用于 Android、Linux 和 Chrome 的更加通用和复杂的开源跟踪项目。与 Systrace 不同,它提供数据源超集,可让你以 protobuf 编码的二进制流形式记录任意长度的跟踪记录,可以在 Perfetto 界面中打开这些跟踪记录。这里就不做分析了,有兴趣的同学可以参考Perfetto。
在 Android 中,内存和 CPU 是描述低端机型的比较关键的两个指标,我们根据 Android 用户的不同设备做了性能划分,初步可划分为高、中、低3种等级。启动性能优化目标是以低端机为重点,辐射中高端机。
由于 App 启动速度在不同的设备上差别很大,我们在获取耗时数据时也最好对低、中、高机型都进行统计分析。可以使用低端机型,中端机型,高端机型三种定制不同的目标。
数据统计为后续启动优化提高应用启动速度做好数据准备。耗时统计从用户点击 App 开始统计,直到首帧时间结束。表格数据在同一机型下冷启动三次结果取平均值,这样才更具代表性和意义。因为单次数据的启动可能存在较大的误差,取均值能将误差降到最低。
我们都希望自己应用的启动速度可以秒杀所有竞争对手。统计竞品 APP 启动耗时与自身 App 对比,更清楚了解到当前 App 与竞品 App 之间的差距。
只有准确的数据评估才能指引优化的方向,这一步是非常非常重要的。太多同学在没有充分评估或者评估使用了错误的方法,最终得到了错误的方向。辛辛苦苦一两个月,最后发现根本达不到预期的效果。
由于篇幅有限,启动速度优化的实战方案在下一篇中讲解,请不要走开,我马上回来……