Android 性能第一篇,随着项目功能的迭(zeng)代(jia),启动速度也会受到影响,性能优化之路的第一步,也就是启动优化,个人认为非常重要,它可以直接影响 APP 的留存率,没有人希望自己应用半天打不开。这篇文章就带你解决🌞用户初体验-启动优化🌞。

注意:如果出现图片不显示问题请移步 图片不显示问题

本文主要记载

  • 1 启动
  • 2 检测启动消耗(敲黑板)
    • 2.1 启动时间测量方式
      • 2.1.1 adb 命令启动时间测量方式
      • 2.1.2 手动打点
    • 2.2 启动耗时检测方式
      • 2.2.1 TraceView
      • 2.2.2 systrace
      • 2.2.3 systrace + 函数插桩 AOP
  • 3 启动优化(干货)
    • 3.1 闪屏页展示
    • 3.2 异步启动实践
    • 3.3 数据重排
    • 3.4 类的加载
    • 3.5 延迟加载方案 IdleHandler
    • 3.6 其他优化思路

1 启动

Google官方文档 《Launch-Time Performance》 对应用启动优化的概述

应用的启动分为冷启动、热启动、温启动,而启动最慢、挑战最大的就是冷启动:系统和App本身都有更多的工作要从头开始!我们只要知道我们处理的是冷启动的情况。


2 检测启动消耗(敲黑板)

“工欲善其事必先利其器”,我们需要先找到一款适合做启动优化分析的工具。

2.1 启动时间测量方式

2.1.1 adb 命令启动时间测量方式

1
adb shell am start -W packagename/packagename.Activity

划掉的红色部分都是包名

字段 功能
ThisTime 最后一个 Activity 启动耗时
TotalTIme 所有 Activity 启动耗时,比如添加启动页
WaitTime AMS 启动 Activity 的总耗时
ThisTime ≤ TotalToime < WaitTime

线下方便使用、可以测量竞品、不能带到线上

2.1.2 手动打点


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class LaunchTimer {
private static long sTime;

public static void startRecord() {
sTime = System.currentTimeMillis();
}

public static void endRecord() {
endRecord("");
}

public static void endRecord(String msg) {
long cost = System.currentTimeMillis() - sTime;
LogUtils.e("LaunchTimer", msg + " cost " + cost);
}
}

在应用启动中,我们所能接收到最早的回调是 ApplicationattachBaseContext(Context context) ,所以只能将 startRecord() 写到这个回调中

应用启动的结束时间,是用户可操作的时间,先了解两个误区

onAttachedToWindow() 这个方法是在 onResume 之后,只调用一次

onWindowFocusChanged(boolean hasFocus) 这个方法是在获取焦点和失去焦点是调用,在 onAttachedToWindow() 之后调用

这两个方法,回调时,用户并不可以操作,计算启动耗时的最好的时机应该是某个数据被加载出来后,当然会掺杂一些网络因素,但这里不是为了数据,只是为了用户体验,可以使用一些监听方法

1
2
3
4
5
6
7
8
9
10
11
12
13
mView.getViewTreeObserver().addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener() {
@Override
public boolean onPreDraw() {
mView.getViewTreeObserver().removeOnPreDrawListener(this);
return true;
}
});
mView.getViewTreeObserver().addOnDrawListener(new ViewTreeObserver.OnDrawListener() {
@Override
public void onDraw() {
mView.getViewTreeObserver().removeOnDrawListener(this);
}
});

在 View 将要绘制的时候去进行耗时的统计,addOnDrawListener() 需要 API 16

2.2 启动耗时检测方式

2.2.1 TraceView

图形化界面的形式展示出执行时间、调用栈,包含所有线程,信息全面

但是加入 TraceView 的代码后,运行时开销严重,整体都会变慢,可能会带骗优化方向

  • 通过代码跟踪💦

只能用于某一个方法的检测

1
2
3
Debug.startMethodTracing("xxx");
...
Debug.stopMethodTracing();

默认生成文件在 mnt/sdcard/Android/data/packagename/fils

1
adb pull /sdcard/Android/data/packagename/fils/my.trace

这样可以将 trace 文件拷贝到项目根目录

也可以通过 Device File Explorer(AS 右下角) 找到文件,右键点击 Sava as…

  • 通过命令跟踪💦

可用于整个开始结束的过程(内容会比较多~)

开始跟踪

1
adb shell am start -n packagename/packagename.Activity --start-profiler /data/local/tmp/my.trace --sampling 1000

终止跟踪

1
adb shell am profile stop

拉取文件

1
adb pull /data/local/tmp/my.trace

这种方法可以检测启动到调用终止期间的全部内容,但是有些手机出来的 trace 文件时 0B,这就需要在需要停止的地方写上

1
Debug.stopMethodTracing();

然后调用开始追踪,会自动停止。文件会放在 data/local/tmp 下。

  • 查看 Trace 文件💦

这就是用 AndroidStudio 查看生成的 TraceView 文件

  1. 范围选择

    标有 Cpu usage details unavailable 的地方可以选择时间范围;标有 ·THREADS· 的地方可以选择某条线程,括号中的 67 表示检测全称有 67 条线程。选择时间或线程底部的 Call Chart 都会有相应的变化。

  2. 执行选择

    在线程选择下有一个 Wall Clock Time 这个表示真正执行的时间,可以切换为 Thread Time 表示 CPU 执行的时间,Thread Time 始终小于 Wall Clock Time

  3. CallChart

    从上到下,调用者在上方,被调用者在下方;系统 API 是橙色、应用自身调用 是绿色、第三方 API 是蓝色,看绿色的部分很直观的可以看出我们自己代码在哪里耗时了~

  4. TopDown

标题 作用
Total 整个函数执行时间
Self 函数代码内执行时间
Children 子函数耗时

🌞举个栗子🌞:调用 A 函数整体时间是 Total ,在函数中执行了一行代码耗时 Self ,然后调用 B 函数耗时 Children

这两个方式是比较常用的方式,如果是应用自身方法都可以点击右键进入源码所在位置。

  • 总结💦
  1. 运行时开销严重,整体都会变慢(因为 TraceView 要抓取所有线程的所有执行函数以及顺序)
  2. 可能会带偏优化方向(本来不好时间的函数可能加入 TraceView 后,变的非常耗时)

2.2.2 systrace

结合 Android 内核的数据,生成 HTML 报告,API 18 以上,推荐 TraceCompat

  • 首先清空后台💦

杀掉所有应用,防止出现莫名莫名方法。

  • 在需要检测的地方写入代码(可以不写,但是会少一些 tag 提示)💦
1
2
3
TraceCompat.beginSection("AppOnCreate");
...
TraceCompat.endSection();
  • 执行检测💦

首先进入 SDK 目录,Sdk\platform-tools\systrace\ 目录下有一个 systrace.py 文件,打开 cmd 输入

1
python systrace.py -b 32768 -t 5 -a <packagename> -o test.log.html sched gfx view wm am

这种方式是 5 秒后自动输出

1
python systrace.py gfx view wm am pm ss dalvik sched -b 32768 -a <packagename> -o test.log.html

这种方式可以在自己收集完后,点击 Enter 键停止收集

两种内容是不太一样的, -t 表示时间,-a 表示包名,-o 输出文件名,最终在当前目录打开文件即可看到,文件只能使用 Chrome 来打开,如果打开 HTML 出现

Unable to select a master clock domain because no path can be found from "SYSTRACE" to "LINUX_FTRACE_GLOBAL".

那就是命令出错了,命令我也是收集了好久,一定要注意必须是 python 2.x,而不是能 3.x,否则可能会出现问题。另外,buffer 大小不可过大,否则会出现 oom 异常。最终找到可用的,想了解更多的请查看 Gityuan官方文档

  • 分析文件

这里可以看到 CPU 核心数以及运行状态,还有各个线程。

UI Thread 中的 AppOnCreate 正是我们在之前的代码埋 tag 点,点击条目并按 M 键可以查看具体信息。右上角是可以搜索 tag 的。

具体信息中有 Wall Duration (代码执行时间) CPU Duration (代码消耗 CPU 的时间)两者出现差值的原因是同步锁冲突。

这种方式不仅可以帮助监控启动过程中性能问题,卡顿优化的时候也可以用这种方式。因为它会把 UI 的渲染也检测到。

  • 总结💦
  1. 轻量级,开销小
  2. 直观的反应 CPU 利用率

想要了解更多,全力推荐 Android Systrace

2.2.3 systrace + 函数插桩 AOP(Aspect Oriented Programming)


面向切面编程,针对同一类问题的统一处理,无侵入添加代码。

在根目录的 build.gradle 下,最新版本请查看 AspectJX 开源地址

1
classpath 'com.hujiang.aspectjx:gradle-android-plugin-aspectjx:2.0.8'

app 模块下添加如下,通过我的测试这些引用只能添加在 app 模块下,添加在其他子模块下不生效。最新版本请查看 AspectJ官网

顶部

1
apply plugin: 'android-aspectjx'

dependencies

1
api 'org.aspectj:aspectjrt:1.9.5'

下面是通过注解的方式结合 Systrace 进行埋点。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
/**
* AnnotationRetention.SOURCE:不存储在编译后的 Class 文件。
* AnnotationRetention.BINARY:存储在编译后的 Class 文件,但是反射不可见。
* AnnotationRetention.RUNTIME:存储在编译后的 Class 文件,反射可见。
*/
@Retention(AnnotationRetention.RUNTIME)
/**
* AnnotationTarget.CLASS:类,接口或对象,注解类也包括在内。
* AnnotationTarget.ANNOTATION_CLASS:只有注解类。
* AnnotationTarget.TYPE_PARAMETER:Generic type parameter (unsupported yet)通用类型参数(还不支持)。
* AnnotationTarget.PROPERTY:属性。
* AnnotationTarget.FIELD:字段,包括属性的支持字段。
* AnnotationTarget.LOCAL_VARIABLE:局部变量。
* AnnotationTarget.VALUE_PARAMETER:函数或构造函数的值参数。
* AnnotationTarget.CONSTRUCTOR:仅构造函数(主函数或者第二函数)。
* AnnotationTarget.FUNCTION:方法(不包括构造函数)。
* AnnotationTarget.PROPERTY_GETTER:只有属性的 getter。
* AnnotationTarget.PROPERTY_SETTER:只有属性的 setter。
* AnnotationTarget.TYPE:类型使用。
* AnnotationTarget.EXPRESSION:任何表达式。
* AnnotationTarget.FILE:文件。
* AnnotationTarget.TYPEALIAS:@SinceKotlin("1.1") 类型别名,Kotlin1.1已可用。
*/
@Target(AnnotationTarget.FUNCTION)
annotation class TraceCompat

具体的切入代码,需要将 packagename 换成自己的路径名字

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Aspect
class TraceCompatAspect {
@Pointcut("execution(@packagename.TraceCompat * *(..))") //方法切入点
fun methodAnnotated() {
}

@Around("methodAnnotated()")
fun aroundJoinPoint(joinPoint: ProceedingJoinPoint) {
TraceCompat.beginSection("TraceCompat")
//执行原方法
try {
joinPoint.proceed()
} finally {
TraceCompat.endSection()
}
}
}

在需要埋点的加入注解

1
2
3
4
5
@TraceCompat
@Override
public void onCreate() {
...
}

这样就完成了 AOP 埋点辅助systrace 监控的方式。AOP 还有很多更强大更方便的用法,这里只是一种注解切入的方式。更多用法请查看其它文章。

由于我现在使用 AOP 还存在很多问题,还在研究中。。。(上面的例子是可以使用的)

2.2.4 Nanoscope

还未使用过 ~

极客时间 《Android开发高手课》 中提到了 Nanoscope ,它是在 instrument 类型的性能分析工具中性能损耗比较小的。

它的实现原理是直接修改 Android 虚拟机源码,在 ArtMethod 执行入口和执行结束位置增加埋点代码,将所有的信息先写到内存,等到 trace 结束后才统一生成结果文件。在使用过程可以明显感觉到应用不会因为开启 Nanoscope 而感到卡顿,但是 trace 结束生成结果文件这一步需要的时间比较长。另一方面它可以支持分析任意一个应用,可用于做竞品分析。

但是它也有不少限制:

  • 需要自己刷 ROM,并且当前只支持 Nexus 6P,或者采用其提供的 x86 架构的模拟器。
  • 默认只支持主线程采集,其他线程需要 代码手动设置 。考虑到内存大小的限制,每个线程的内存数组只能支持大约 20 秒左右的时间段。

Uber 写了一系列自动化脚本协助整个流程,使用起来还算简单。Nanoscope 作为基本没有性能损耗的 instrument 工具,它非常适合做启动耗时的自动化分析。

Nanoscope 生成的是符合 Chrome tracing 规范的 HTML 文件。我们可以通过脚本来实现两个功能:

第一个是反混淆。通过 mapping 自动反混淆结果文件。

第二个是自动化分析。传入相同的起点和终点,实现两个结果文件的diff,自动分析差异点。

这样我们可以每天定期去跑自动化启动测试,查看是否存在新增的耗时点。


3 启动优化(干货)

3.1 闪屏页展示

闪屏页是优化启动速度的一个小技巧,虽然对实际的启动速度没有任何帮助,但是能让用户感觉应用到应用在第一时间已经被打开。

合并闪屏和主页面的 Activity,减少一个 Activity 会给线上带来 100 毫秒左右的优化。但是如果这样做的话,管理时会非常复杂。

闪屏如果存在网络请求,一般都是提前准备好闪屏页的,在下一次生效。

具体操作文末参考中会有 ~ 这里不再说明。

3.2 异步启动实践

3.2.1 启动器

微信内部使用的 mmkernel

阿里最近开源的 Alpha 启动框架

历时1年,上百万行代码!首次揭秘手淘全链路性能优化(上)

慕课网中学习到的 启动器 Task

它们为各个任务建立依赖关系,最终构成一个有向无环图。对于可以并发的任务,会通过线程池最大程度提升启动速度。

3.2.1 异步Layout子线程预加载

参考文章 Android - 一种新奇的冷启动速度优化思路(Fragment极度懒加载 + Layout子线程预加载)

遇到个点,如果 xml 中是 com.google.android.material.appbar.AppBarLayoutgoogle 包里的,需要加上一条属性

1
android:theme="@style/Theme.AppCompat.Light.DarkActionBar"

这个属性与自己 Activitytheme 相对应。

预加载:页面启动速度优化利器 PreLoader

3.3 数据重排

支付宝 《通过安装包重排布优化 Android 端启动性能》

3.4 类重排

本人费劲心血自己自己实现了一次~ Redex Ubuntu 实现

更多资料:

Facebook 开源的 Dex 优化工具 ReDex

Redex 初探与 Interdex:Andorid 冷启动优化

都9102年了,Android 冷启动优化除了老三样还有哪些新招?

3.5 延迟加载方案 IdleHandler

利用 IdleHandler 特性,空闲时执行初始化。

IdleHandler 可以用来提升性能,主要用在我们希望能够在当前线程消息队列空闲时做些事情 (譬如 UI 线程在显示完成后,如果线程空闲我们就可以提前准备其他内容)的情况下,不过最好不要做耗时操作。

1
2
3
4
5
6
7
Looper.myQueue().addIdleHandler(new IdleHandler() {  
@Override
public boolean queueIdle() {
//你要处理的事情
return false;
}
});

如果返回 true, 则会一直执行,如果返回 false,执行完一次后就会被移除消息队列。我们可以将从服务器获取推送 Token 的任务放在延迟 IdleHandler中执行,或者把一些不重要的 View 的加载放到 IdleHandler 中执行

3.6 其他优化思路

AndroidX App Startup

App 启动运行时会初始化一些逻辑,它们为了方便开发者使用,避免开发者手动调用,使用 ContentProvider 进行初始化

  • 多个 ContentProvider 会增加了 App 启动运行的时间。
  • ContentProvideronCreate 方法会先于 ApplicationOnCreate 方法执行,这是在冷启动阶段自动运行初始化的,

这样只会增加 App 的加载时间,用户希望 App 加载得快,启动慢会带来糟糕的用户体验,AndroidX App Startup 正是为了解决这个问题而出现的。

App Startup Jetpack 最新成员 AndroidX App Startup 实践以及原理分析

5.0 以下机型 MultiDex 优化

面试官:今日头条启动很快,你觉得可能是做了哪些优化?

GC 优化

支付宝提出一种 GC 抑制 的方案

在启动过程,要尽量减少 GC 的次数,避免造成主线程长时间的卡顿。

特别是对 Dalvik 来说,我们可以通过 systrace 单独查看整个启动过程 GC 的时间。

1
python systrace.py dalvik -b 90960 -a com.sample.gc
1
2
3
4
// GC使用的总耗时,单位是毫秒
Debug.getRuntimeStat("art.gc.gc-time");
// 阻塞式GC的总耗时
Debug.getRuntimeStat("art.gc.blocking-gc-time");
  • 避免进行大量的字符串操作,特别是序列化和反序列化
  • 频繁创建的对象需要考虑复用
  • 转移到 Native 实现

参考 支付宝客户端架构解析:Android 客户端启动速度优化之「垃圾回收」

系统调用优化

通过 systrace 的 System Service 类型,我们可以看到启动过程 System Server 的 CPU 工作情况。在启动过程,我们尽量不要做系统调用,例如

  • 启动过程中减少系统调用,避免与 AMSWMS 竞争锁。启动过程中本身 AMSWMS 的工作就很多,且 AMSWMS 很多操作都是带锁的,如果此时 App 再有过多的 Binder 调用与 AMSWMS 通信,SystemServer 就会出现大量的锁等待,阻塞关键操作
  • 启动过程中不要启动子进程,如果好几个进程同时启动,系统负担则会加倍,SystemServer 也会更繁忙
  • 启动过程中除了 Activity 之外的组件启动要谨慎,因为四大组件的启动都是在主线程的,如果组件启动慢,占用了 Message 通道,也会影响应用的启动速度
  • Application 和主 ActivityonCreate 中异步初始化某些代码

在启动过程也不要过早地拉起应用的其他进程,System Server 和新的进程都会竞争 CPU 资源。

线程优化

线程优化就像做填空题和解锁题,我们希望能把所有的时间片都利用上,因此主线程和各个线程都是一直满载的。当然我们也希望每个线程都开足马力向前跑,而不是作为接力棒。所以线程的优化主要在于减少 CPU 调度带来的波动,让应用的启动时间更加稳定。

从具体的做法来看,线程的优化一方面是控制线程数量,线程数量太多会相互竞争 CPU 资源,因此要有统一的线程池,并且根据机器性能来控制数量。

3.2.1 启动器 提到,合理分配线程数量。

另一方面是检查线程间的锁。

业务优化

我们首先需要梳理清楚当前启动过程正在运行的每一个模块,哪些是一定需要的、哪些可以砍掉、哪些可以懒加载。

通过梳理之后,剩下的都是启动过程一定要用的模块。

这个时候,我们只能硬着头皮去做进一步的优化。优化前期需要“抓大放小”,先看看主线程究竟慢在哪里。

退而求其次,我们要考虑这些任务是不是可以通过异步线程预加载实现(上面有讲到),但需要注意的是过多的线程预加载会让我们的逻辑变得更加复杂。

I/O 优化

启动过程不建议出现网络 I/O

磁盘 I/O 是启动优化一定要抠的点

还有一个是数据结构的选择问题,我们在启动过程只需要读取 Setting.sp 的几项数据,不过 SharedPreference 在初始化的时候还是要全部数据一起解析。如果它的数据量超过 1000 条,启动过程解析时间可能就超过 100 毫秒。如果只解析启动过程用到的数据项则会很大程度减少解析时间,启动过程适合使用随机读写的数据结构。

保活

保活可以减少 Application 创建跟初始化的时间,让冷启动变成温启动。不过在 Target 26 之后,保活的确变得越来越难。

对于大厂来说,可能需要寻求厂商合作的机会,例如微信的 Hardcoder 方案和 OPPO 推出的 Hyper Boost 方案。根据OPPO 的数据,对于手机 QQ、淘宝、微信启动场景会直接有 20% 以上的优化。

有的时候你问为什么微信可以保活?为什么它可以运行的那么流畅?这里可能不仅仅是技术上的问题,当应用体量足够大,就可以倒逼厂商去专门为它们做优化。

插件化和热修复

大部分的框架在设计上都存在大量的 Hook 和私有 API 调用,带来的缺点主要有两个:

  • 稳定性差。虽然大家都号称兼容 100% 的机型,由于厂商的兼容性、安装失败、dex2oat 失败等原因,还是会有那么一些代码和资源的异常。Android P 推出的 non-sdk-interface 调用限制,以后适配只会越来越难,成本越来越高。
  • 性能差。Android Runtime 每个版本都有很多的优化,因为插件化和热修复用到的一些黑科技,导致底层 Runtime 的优化我们是享受不到的。Tinker 框架在加载补丁后,应用启动速度会降低 5%~10%。

应用加固对启动速度来说简直是灾难,有时候我们需要做一些权衡和选择。

感谢

极客时间 Android开发高手课

Android App 启动优化全记录

支付宝客户端架构解析:Android 客户端启动速度优化之「垃圾回收」

以及上文中的链接