Android 启动速度优化
最近做了一些 Android App 启动速度的优化,有一些心得,整理整理
影响启动速度的原因
高耗时任务
数据库初始化、某些第三方框架初始化、大文件读取、MultiDex 加载等,导致 CPU 阻塞
复杂的 View 层级
使用的嵌套 Layout 过多,层级加深,导致 View 在渲染过程中,递归加深,占用 CPU 资源,影响 Measure、Layout 等方法的速度
类过于复杂
Java 对象的创建也是需要一定时间的,如果一个类中结构特别复杂,new 一个对象将消耗较高的资源,特别是一些单例的初始化,需要特别注意其中的结构
主题及 Activity 配置
有一些 App 是带有 Splash 页的,有的则直接进入主界面,由于主题切换,可能会导致白屏,或者点了 Icon,过一会儿才出现主界面
一些典型的例子及优化方案
MultiDex
由于 Android 5.0 以下使用的 Dalvik 虚拟机天生对 MultiDex 支持不好,导致在 4.4(及以下)的系统上,如果使用了 MultiDex 做为分包方案,启动速度可能会慢的多,实际数值跟 dex 文件的大小、数量有关,估计会慢 300~500ms
解决方案:
限制 APP 在 5.0 以上使用:目前大多数用户已经在使用 Android 5.0 以上的版本了,当然,还有很多 4.4 用户,很多 APP 也是只支持 4.4 以上(比如:百度 APP),为了用户体验,可以考虑舍弃一部分用户
优化方法数:尽量避免方法超过 65535 个,同时可以开启 Release 配置的 Minify 选项,打包时删掉没有用的方法,不过如果框架引用的较多,基本没效果
少用一些不必要的框架:有些框架功能很强大,但不一定都能用得上,引进来会新增很多的方法,导致必须开启 MultiDex,可以自己造轮子,或者找轻量级的框架
慎用 Kotlin:由于 Kotlin 现在还没有内置在 Android 系统中,所以 APP 如果使用了 Kotlin,可能会导致引入很多的 Kotlin 方法,导致必须分割 Dex,这个有待 Google 在 Android P 中解决
Dex 懒加载:在 APP 功能日益复杂的今天,MultiDex 几乎是已经无法避免了,为了启动速度的优化,可以将启动时必需的方法,放在主 Dex 中(即 classes.dex),方法是在 Gradle 脚本中配置 multiDexKeepFile 或者 multiDexKeepProguard 属性(代码如下),详见:官方文档,待 App 启动完成后,再使用 MultiDex.install 来加载其他的 Dex 文件。这种方法风险比较高,而且实现成本比较大,如果启动依赖的库比较多,还是无法实现
1
2
3
4
5
6
7
8android {
buildTypes {
release {
multiDexKeepFile file('multidex-config.txt') // multiDexKeepFile 规则
multiDexKeepProguard file('multidex-config.pro') // 类似 ProGuard 的规则
}
}
}配置文件示例:
1
2
3
4
5
6
7
8
9
10
11# 常规的 multiDexKeepFile 规则
com/example/MyClass.class
com/example/MyOtherClass.class
# 类似 ProGuard 规则
-keep class com.example.MyClass
-keep class com.example.MyClassToo
-keep class com.example.** { *; } // All classes in the com.example package插件化或 H5/React Native 方案:即端只提供 Native 调用能力和容器,业务由插件来做,本地只需要加载基础的 Native 能力相关类即可,其他完全下发,或内置成资源文件调用
Glide 及其他图片框架
Glide 是一个很好用的图片加载框架,除了常用的图片加载、缓存功能以外,Glide 支持对网络层进行定制,比如换成 OkHttp 来支持 HTTP 2.0。不过,如果在追求启动速度的情况下,在 Splash 页或主界面加载某一张图片时,往往是第一次使用 Glide,由于 Glide 没有初始化,会导致这次图片加载的时间比较长(不管本地还是网络),特别是在其他操作也在同时抢占 CPU 资源的时候,慢的特别明显!而后面再使用 Glide 加载图片时,还是比较快的
Glide 初始化耗时分析:Glide 的初始化会加载所有配置的 Module,然后初始化 RequestManager(包括网络层、工作线程等,比较耗时),最后还要应用一些解码的选项(Options)
解决方案: 在 Application 的 onCreate 方法中,在工作线程调用一次 GlideApp.get(this)
1 | override fun onCreate() { |
greenDAO 和其他数据库框架
greenDAO 实现了一种 ORM 框架,数据库基于 SQLite,使用起来很方便,不需要自己写 SQL 语句、控制并发和事务等等,其他常见的数据库框架如:Realm、DBFlow 等等,使用起来也很方便,但他们的初始化,尤其是需要升级、迁移数据时,往往会带来不小的 CPU 和 I/O 开销,一旦数据量比较多(比如:很长时间的聊天记录、浏览器浏览历史记录等),往往都需要专门一个界面来告知用户:APP 正在做数据处理工作。所以,如果为了提高 APP 启动速度,避免在 APP 启动时做数据库的耗时任务,很有必要!
解决方案:
必要数据避免使用数据库: 如果首屏的展示内容需要根据配置来决定,那么干脆放弃数据库存储和读取,直接放在文件、SharedPreference 里面,特别是多组键值对的读取,如果使用数据库,在除过初始化占用的时间以后,可能还需要 30~50ms 来完成(因为需要多次读取),而如果存在 SharedPreference 中,即使是转换成 JSON 并解析,可能也就在 10ms 之内
数据库预先异步初始化: 使用 greenDAO 时,预先初始化很有必要,可以保证在第一次读取数据库时,不占用主线程资源,防止拖慢启动速度,具体做法如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23// Application
override fun onCreate() {
super.onCreate()
// 使用 Anko 提供的异步工作协程,或者自行创建一个并发线程池
doAsync {
DbManager.daoSession // 获取一次 greenDao 的 DaoSession 实例化对象即可
}
}
// DBManager(数据库相关单例)
object DbManager {
// greenDAO 的 DaoMaster,用来初始化数据库并建立连接
private val daoMaster: DaoMaster by lazy {
val openHelper = DaoMaster.OpenHelper(ContextUtils.getApplicationContext(), "Test.db")
DaoMaster(openHelper.writableDatabase)
}
// 具体的数据库会话
val daoSession: DaoSession by lazy {
daoMaster.newSession()
}
}
View 和主题
View 层级
主要在于首屏/Splash 页的 Layout 布局层次过深,导致 View 在渲染时,递归加深,消耗过多的 CPU 和内存资源,阻塞主线程,所以最根本的思路就是解决层级问题,检查一个 App 的 View 层级,可以使用 Android Studio 自带的 Layout Inspector 工具,如图:
在选择了需要检查的进程及 Window(Dialog 可能会创建新的 Window,但显示的 Activity 是同一个)以后,就可以看到 Android Studio 自动进行的 Capture 的内容了
根据左边 View 层级显示的内容,分析不必要的嵌套布局,通过改造,即可对 View 层级进行优化
除了根据上面的方法分析层级以外,可以使用 Google 最新推出的ConstraintLayout,官网链接:ConstraintLayout
ConstraintLayout 采用的约束布局概念,类似于 iOS 的 AutoLayout,但使用起来,远比 AutoLayout 方便、强大,个人感觉吸取了 RelativeLayout 的方便、FrameLayout 的灵活、LinearLayout 的高效等特点,通过控件见相互的约束控制,可以构建出近乎平面的布局,这样就可以减少布局层级,只用 ConstraintLayout 一层 Layout 实现复杂的 UI 布局,非常值得学习和使用!
如果分别使用 RelativeLayout、FrameLayout、LinearLayout 和 ConstraintLayout 构建一个复杂的布局,或许,ConstraintLayout,就要比其他几种布局快 50~100ms!
App 主题
我们可以做个实验,使用以下几种主题,看看 APP 的启动速度(以 ActivityManager 的 Log 为准):
@android:style/Theme.NoTitleBar.Fullscreen
@android:style/Theme.Black
默认(根据操作系统自动选择)
其中,MainActivity 的根布局是一个空的 LinearLayout,将 App 杀死冷启动 5,取平均时间
Black
平均启动时间:160ms
FullScreen
平均启动时间:126.8ms
默认:
平均启动时间:174.8ms
可以得出一个结论:使用一个没有 ActionBar 的主题,比较快,而如果连 StatusBar 也去掉了,速度最快!
原因是这样的,启动一个 Activity 的时候,系统会创建包含一个 DecorView 的 Window,而 StatusBar 也好,ActionBar 也好,都是这个 View 中的子元素,多了一个 View,当然多了一层布局,肯定是耗时的
所以,如果想提高 APP 的启动速度,尤其是使用 Splash 的 App,务必将第一个 Activity 的主题设为 FullScreen 的,这样能有效提高启动速度
进一步优化
某些 APP,如:微博,能够做到点了图标就立即做出响应,显示出它的 Splash 页,如下图:
而像一些没有 Splash 页的 APP 就不行,要么是点了桌面 Icon 以后没反应,过一会儿出主界面,要么是点了以后白屏一会儿才出主界面(在 Android 4.4 上由于 MultiDex 等问题特别明显)
这是因为微博这样有 Splash 页的 APP,其 Splash 页在使用了 FullScreen 主题以后,又将主题的 Background 进行了处理,使得 Splash 在启动时根本没有加载实际的 View,而仅仅是加载了主题,待 Activity 初始化完成以后,再渲染广告位等 View,这样就避免了白屏和空屏的等待时常,让用户感觉到启动速度快,我们来看一下微博在启动过程中 View 的变化
从上面的录屏 GIF 中,可以看到:当微博启动时,并没有实际的 View 布局,而是一整个 Layer,过了一会儿,Slogan 和 Logo 的 ImageView 布局才以渐现动画的方式逐渐加载出来,后面继续加载广告位的 Layout
这么做的理由很简单,为了让用户感觉到快!
apktool 一下微博的 apk,可以发现微博对首页的主题背景,使用了一个 drawable 来实现
1 | <!-- styles.xml --> |
1 | <!-- welcome_layler_drawable.xml --> |
由此可见,使用layer-list的形式,可以使一系列的 Bitmap 按照类似 View 布局的形式来排布,通过将生成的 drawable 设置为 background 的形式,最终并不会生成任何 View,极大程度减小 View 绘制占用的时间,提升启动速度!
通过实验,发现市面上很多的 APP(高德地图、大众点评、百度地图、ofo 小黄车等)都是采取了类似的方式,通过设置一个 FullScreen 主题的 Activity,并设置 background 为和 Splash 布局类似的形式,能够做到点下图标的即刻,展现界面
对于多线程的思考
在 App 启动时,为了加快启动速度,通常会使用多线程手段来并行执行任务,充分发挥多核 CPU 的优势,提高运算效率。此方法固然能够对启动速度的优化,起到一定作用,但实际开发中,有以下几点值得深思:
并发的线程数,多少合适?(效率高但不至于阻塞)
频繁切换线程,是否带来负面影响?(频繁地从主线程扔进辅助线程操作再将结果抛回来会不会比直接执行更慢)
何时并行?何时串行?(有的任务能只能串,有的任务可以并行)
这个时候,拿 Android 经典的 AsyncTask 类来说事,再合适不过了!
1 | private static final int CPU_COUNT = Runtime.getRuntime().availableProcessors(); |
上面的代码是 AsyncTask 确定线程池数量的部分,其中,核心执行池保证最少 2 个线程,最多不超过 CPU 可用核数-1,最大线程池数量为 CPU 核数的 2 倍+1
这样配置线程池的目的很简单:防止并发过大,导致 CPU 阻塞,影响效率
而 AsyncTask 从 Android 3.0 开始,就改为串行执行了,实际上也是为了防止并发过大,导致任务抢夺 CPU 时间片,造成阻塞,或者错误
这样做,也是为了让有前后依赖关系的任务按照我们希望的顺序执行,以便控制数据流程,防止造成不一致的情况,导致 Crash 或数据错误
虽然 AsyncTask 功能强大,但经常由于使用不当,造成内存泄露等问题,而且代码量比较多,所以在实际使用过程中,一般都使用自行封装的任务队列,更轻量,这样便于在需要的时候,让任务串行执行,以免造成过高的开销,使得速度不升反降
这里贴一段轻量串行队列的实现代码,可以在需要的时候进行参考:
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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
package com.xiyoumobile.kit.task
import android.os.Handler
import android.os.Looper
import android.os.Message
import java.util.concurrent.Executor
import java.util.concurrent.Executors
import java.util.concurrent.LinkedBlockingQueue
/**
* 加入到串行工作队列中,并执行
*/
fun dispatchSerialWork(task: (() -> Unit)): Runnable {
val runnable = Runnable {
task()
}
addSerialTask(runnable)
return runnable
}
/**
* 加入到主线程队列中,并执行
*/
fun dispatchMainLoopWork(task: (() -> Unit)): Runnable {
val runnable = object : MainTask {
override fun run() {
task()
}
}
val msg = Message()
msg.obj = runnable
msg.what = runnable.hashCode()
MAIN_HANDLER.sendMessage(msg)
return runnable
}
private val BACKGROUND_SERIAL_EXECUTOR = BackgroundSerialExecutor()
private fun addSerialTask(runnable: Runnable) {
BACKGROUND_SERIAL_EXECUTOR.execute(runnable)
}
private class BackgroundSerialExecutor : Executor {
private val tasks = LinkedBlockingQueue<Runnable>()
private val executor = Executors.newSingleThreadExecutor()
private var active: Runnable? = null
override fun execute(r: Runnable) {
tasks.offer(Runnable {
try {
r.run()
} finally {
scheduleNext()
}
})
if (active == null) {
scheduleNext()
}
}
private fun scheduleNext() {
active = tasks.poll()
if (active != null) {
executor.execute(active)
}
}
}
private val MAIN_HANDLER = MainLooperHandler()
private class MainLooperHandler : Handler(Looper.getMainLooper()) {
override fun handleMessage(msg: Message?) {
super.handleMessage(msg)
if (msg?.obj is MainTask) {
(msg.obj as MainTask).run()
}
}
}
private interface MainTask : Runnable
其次,可以配合 Kotlin 的协程doAsync
进行后台并发运行,其内部也使用了 Executor,但基于更轻量的协程操作,开销更小,适合做一些需要并发的操作,但不可随意使用,防止阻塞
总结
在 APP 功能日益增加和用户体验不断改良的今天,APP 启动速度,已然成为影响用户体验的第一道门槛。所谓快,其实是在用户感官上的一种反应,如果能够使用以上的手段对 APP 的启动速度优化,虽然实际上启动时的总操作量可能并没有真正减少,但经过合理的先后顺序安排,可以使得某些不必要的任务,延后再执行,起到在 APP 启动时,更轻量、更灵敏的作用,这样能够比较快的响应用户从 Launcher 点击 Icon 的操作,提升用户体验,让用户感觉到『快』。