启动优化
App 启动基础
应用启动方式
冷启动
冷启动: 当启动应用时,后台没有该应用的进程(常见如:进程被杀、首次启动等),这时系统会重新创建一个新的进程分配给该应用
一旦系统创建了 App 进程,那么 App 进程就会执行以下步骤:
- 创建 App 对象 (Zygote 进程 fork 出来)
- 启动 main thread(ActivityThread,创建 Application、attachApplication)
- 创建入口 MainActivity
- 加载布局(Inflating views)
- 渲染布局(Laying out)
- 进行首次绘制
一旦应用完成了第一次绘制,系统进程就把当前显示的启动视图切换为应用的界面,这时用户就可以开始使用应用了。
温启动
温启动:当启动应用时,后台已有该应用的进程(常见如:按 back 键、home 键,应用虽然会退出,但是该应用的进程是依然会保留在后台,可进入任务列表查看),所以在已有进程的情况下,这种启动会从已有的进程中来启动应用。
温启动场景:
- 用户在退出应用后又重新启动应用。进程可能已继续运行,但应用必须通过调用 onCreate() 从头开始重新创建 activity。
- 系统将您的应用从内存中逐出,然后用户又重新启动它。进程和 activity 需要重启,但传递到 onCreate() 的已保存的实例 state bundle 对于完成此任务有一定助益。
热启动
热启动: 进程还在,activity 也还在,只需要把 activity 切换到前台;只要应用的所有 activity 仍驻留在内存中,应用就不必重复执行对象初始化、布局膨胀和呈现。
但是,如果一些内存为响应内存整理事件(如 onTrimMemory())而被完全清除,则需要为了响应热启动事件而重新创建相应的对象
App 启动时间统计
ADB Shell Activity Manager
adb shell start -S -W 包名/启动类全名 -c Intent的category -a Intent的action
1
2
3
4
5
6
7
8
9
10
adb shell am start -S -W com.android.contacts/.activities.PeopleActivity
-c android.intent.category.LAUNCHER
-a android.intent.action.MAIN
Starting: Intent { act=android.intent.action.MAIN cat=[android.intent.category.LAUNCHER] cmp=com.android.contacts/.activities.PeopleActivity }
Status: ok
Activity: com.android.contacts/.activities.PeopleActivity
ThisTime: 770
TotalTime: 770
WaitTime: 848
- Status: ok 超过 10 秒会 timeout
- ThisTime 表示启动新的一连串 Activity,最后一个启动的 Activity 的启动耗时
- TotalTime 表示新应用启动的耗时,包括新进程的启动和 Activity 的启动,不包括前一个 Activity 的 pause 时间
- WaitTime 表示从 startActivity 到应用第一帧完全显示的耗时,包括前一个 Activity 的 pause 时间和新应用启动的时间
开发者只关心 TotalTime 即可,这个时间才是自己应用真正启动的耗时。
Logcat 的 Displayed
从 Android 4.4(API 19) 开始,logcat 的输出包括了一行 Displayed 的值。这个值表示了应用启动进程到 Activity 完成屏幕绘制经过的时间。经过的时间包括以下事件,按顺序为:
- 启动进程
- 初始化对象
- 创建和初始化 Activity
- 布局渲染
- 完成第一次绘制
ActivityTaskManager pid-1831 Displayed me.hacket.assistant.samples/.Android 技术: +581ms
reportFullyDrawn()
reportFullyDrawn()
方法来测量应用启动到所有资源和视图层次结构的完整显示之间所经过的时间。
ActivityTaskManager pid-1831 Fully drawn me.hacket.assistant.samples/.AndroidDemos: +424ms
log
在 Application 的 onCreate
和第一个 Activity 的 onWindowFocusChanged
之间的时间
启动耗时分析方法
- TraceView 性能损耗太大
- Systrace 可以方便的追踪关键系统调用的耗时情况,如 Choreographer,但不支持应用程序代码的耗时分析
Systrace 工具
ASM 方法 hook
BlockCanary
BlockCanary 可以监听主线程耗时的方法,将阈值设置低一点,比如 200 毫秒,这样的话如果一个方法执行时间超过 200 毫秒,获取堆栈信息并通知开发者。
App 冷启动流程及优化方向
App 冷启动流程
应用进程不存在的情况下,从点击桌面应用图标,到应用启动(冷启动),大概会经历以下流程:
1. 点击桌面图标
- 1.1 Launcher startActivity // Launcher 进程
- 1.2 ATMS startActivity // SystemServer 进程
2. fork 创建应用进程
- Zygote fork app 进程 // Zygote 进程,ATMS 通过 Socket 告知 Zygote
3. 启动主线程
- ActivityThread main() // App 进程
- ActivityThread attach
- handleBindApplication
- attachBaseContext
- installContentProviders
- Application onCreate
- ActivityThread 进入 loop 循环
4. 回调 Activity 生命周期
- Activity 生命周期回调,onCreate、onStart、onResume…
5. UI 展示
- 填充加载布局
- view 的绘制过程:measure→layout→draw
整个启动流程我们能干预的主要是 Application attachBaseContext
、Application onCreate
、Activity生命周期
及 UI展示
,应用启动优化主要从这几个地方入手。很多开源库一般都是在 Application onCreate 方法初始化,有的在 ContentProvider 初始化,没得优化机会。
优化方向:
除了 Application attachBaseContext
、Application onCreate
和 Activity lifecyle
,无需 hook 系统源码,其余流程都是系统层面的,所以我们的优化空间只有这几处。
启动优化实践
启动优化从两个方向进行优化
- 视觉体验优化
- 代码逻辑优化
视觉优化:冷启动引起的白屏/黑屏优化
白屏还是黑屏,取决于 APP 的 theme 使用的是 dark 还是 light 主题。
白屏出现的原因
系统启动一个 app 前,需要创建进程;在创建进程完成前,会有一个 PreviewWindow,对应的 WindowType 是 TYPE_APPLICATION_STARTING
,目的是告诉用户,系统已经接受到用户操作,正在响应,这个 PreviewWindow 就是所谓的白屏。
白屏解决
以下的解决方案,都是针对视觉上的优化,并没有真正解决启动的耗时
解决 1:禁用 PreviewWindow
在 Activity 的主题上设置:<item name="android:windowDisablePreview">true</item>
这种方式存在的问题:导致用户点击 App 图标后过个几秒才有反应。
解决 2:设置一个透明背景
PreviewWindow 被设置了透明,点击 App 后,背景是桌面,假象是点没有反应,将锅甩给 Launcher。
1
2
3
4
<style name="SplashTheme" parent="AppTheme">
<item name="android:windowFullscreen">true</item>
<item name="android:windowIsTranslucent">true</item>
</style>
这种方式存在的问题:导致用户点击 App 图标后过个几秒才有反应。
解决 3:设置 windowBackground
Splash 启动页设置一个单独 style,设置 android:windowBackground
为你的闪屏页默认背景图:
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
// 1. 配置windowBackground主题
// API21以下:
<style name="AppTheme.NoActionBar.Splash">
<item name="android:windowNoTitle">true</item>
<item name="windowNoTitle">true</item>
<item name="android:windowFullscreen">true</item>
<item name="android:windowBackground">@drawable/ic_splash</item>
</style>
// API21及以上
<style name="AppTheme.NoActionBar.Splash">
<item name="android:windowNoTitle">true</item>
<item name="windowNoTitle">true</item>
<item name="android:windowFullscreen">true</item>
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
<item name="android:windowBackground">@drawable/ic_splash_v21</item>
</style>
// 2. 给Launcher Activity设置主题
<activity android:name=".ui.activity.DemoSplashActivity"
android:configChanges="orientation|screenSize|keyboardHidden"
android:theme="@style/AppTheme.NoActionBar.Splash"
android:screenOrientation="portrait">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
// 3. 这样的话启动Activity之后背景会一直在,所以在Activity的onCreate方法中切换成正常主题
protected void onCreate(@Nullable Bundle savedInstanceState) {
setTheme(R.style.AppTheme); // 切换正常主题
super.onCreate(savedInstanceState);
}
存在的问题:
- PreviewWindow 过渡到 Splash 页:大图全屏图会被切割,PreviewWindow 跳转到 Splash 页会有跳转过程
- 内存占用,windowBackgroud 需要还原 windowBackgroud 是针对所有的 Window,所以只在 Splash 页的 onCreate 之前设置,onCreate 之后应该将 windowBackground 置为 null;Splash 页设置 window 为一张图片主题后,在 Splash 页要置为 null,不然一直在内存中。
- **虚拟按键 **在有虚拟按键的手机上,闪屏底部会会被虚拟按键挡信,这点在设计时需要考虑到。
解决大图切割:可以让小图居中
1
2
3
4
5
6
7
8
9
10
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android"
android:opacity="opaque">
<item android:drawable="@color/white"/>
<item>
<bitmap
android:gravity="center"
android:src="@drawable/ic_splash"/>
</item>
</layer-list>
Android12 windowBackground 无效了
用 SplashScreen
代码优化
启动优化目标 启动优化的本质,就是把 CPU 打满。
优化 Application
任务异步化
- 耗时的任务放在子线程操作,启动任务 task 化,排好序,可参考阿里系的
- 优化 Application 中的逻辑,将其中的逻辑异步初始化,更快的进入 SplashActivity
任务延迟初始化
偏业务的优化,应该从业务角度拆出来启动任务的优先级,这次启动优化,一共梳理了 40+ 启动任务,有好几个都是没必要在启动阶段做的,完全可以放到业务使用时做懒加载,在增加启动任务时应该先考虑是不是一定要放在启动阶段初始化,能不能做懒加载?
从业务上来看,小概率场景需要的能力应该在用到时做懒加载,为了小部分用户牺牲大部分用户的体验是不划算的,研发在做业务开发时也要有这种意识。
,比如 Glide 库
Multidex 启动优化(启动一个单独的进程去初始化)
MultiDex 优化:一种是直接在闪屏页开个子线程去加载 dex,难维护,不推荐;一种是今日头条的方案,在单独一个进程加载 dex,加载完主进程再继续。
- 在 Application 的 attachBaseContext 方法里,启动另一个进程的 LoadDexActivity 去异步执行 MultiDex 逻辑,显示 Loading。
- 然后主进程 Application 进入 while 循环,不断检测 MultiDex 操作是否完成
- MultiDex 执行完之后主进程 Application 继续走,ContentProvider 初始化和 Application onCreate 方法,也就是执行主进程正常的逻辑。
- 为什么要优化?
答:安装完成并初次启动 APP 的时候,5.0 以下某些低端机会出现 ANR 或者长时间卡顿不进入引导页,而罪魁祸首是 MultiDex.install(Context context) 的 dexopt 过程耗时过长,所以打算新开个进程异步加载,主进程被挂起所以不会产生 ANR,- 为什么不直接使用子线程的原因
答:是因为如果使用线程加载,主进程轮循等待还是会产生 ANR,不让主线程等待,则会因为没有加载完,而产生 NoClassDefFoundError 错误。- 为什么 5.0 以上的手机没问题?
答:5.0 及以上,默认使用 ART 虚拟机,与 Dalvik 的区别在于安装时已经将全部的 Class.dex 转换为了 oat 文件,优化过程在安装时已经完成;因此无需执行。- 这么优化快在哪里
感觉就是一个障眼法,无论是在主进程里执行,还是在子进程里执行,该走的流程都会走,该等待的时间也都差不多。个人感觉就是为了解决 ANR 的问题
优化 Activity
- 优化布局耗时
- 异步 AsyncInflater:在 Application 创建过程中就完成异步 Inflate,进到 Activity 时,肯定有已经可用的结果。
GC 抑制
消息重排
启动接口收敛
启动阶段网络请求应该收敛,可通过延后请求或接口合并来解决
提前加载 SP
SP 在第一次读取时,会一次性从磁盘读入内存,可以做预加载。因为项目业务代码已经全部使用了自研基于 mmap 的实现,未使用 SP,只有部分二方三方库在使用 SP,ROI 较低。
BoostMultiDex 解决低端机首次执行耗时过长问题
问题:现代 Android APP 的代码量通常都比较大,很容易就会带上多个 DEX 文件。Android 低版本的设备采用的 Java 运行环境是 Dalvik 虚拟机,如果含有多个 DEX 想要在这些设备上正常运行,就需要使用官方的 MultiDex 方案。MultiDex 需要对 APK 内的原始 DEX 文件做 ODEX 优化,所以执行时间过于漫长,这就会使得安装或者升级后首次 MultiDex 花费的时间很久。
BoostMultiDex 是一个用于 Android 低版本设备(4.X 及以下,SDK < 21)快速加载多 DEX 的解决方案,由抖音/Tiktok Android 技术团队出品。
相比于 Android 官方原始 MultiDex 方案,它能够减少 80% 以上的黑屏等待时间,挽救低版本 Android 用户的升级安装体验。并且,不同于目前业界所有优化方案,BoostMultiDex 方案是从 Android Dalvik 虚拟机底层机制入手,从根本上解决了安装 APK 后首次执行 MultiDex 耗时过长问题。
class 预加载
[[冷启动优化-class预加载]]
Retrofit ServiceMethod 预解析注入
存在几十毫秒收益
ARouter 启动初始化
- [[冷启动优化-ARouter]]
合并多个 FileProvider
其他
在 x2c
、class verify
、禁用 JIT
、 disableDex2AOT
等方面继续尝试优化
启动优化监控
如何防劣化?
首先利用启动器将启动任务颗粒化,然后针对任务的时长统计上报,最后通过 Appium 、Mockio、Hamcrest、UIAutomator 等自动化测试架构进行测试,app 每个版本集成回归时,测试同学会在测试平台跑一遍性能测试并输出测试报告。
线下
线下监测是指利用 adb 命令查看TotalTime或Systrace工具在严格控制的环境下监控应用,该方案存在明显的不足:无法精确到每个函数级别,统计过程会比较复杂,数据也不够直观。
adb shell
systrace
systrace 提供了全局的视角,能清楚的看到 CPU 当前负载以及调度情况,比如这个 case:
这个启动任务的 Wall Duration
有 112ms,如果通过打日志的方式,我们得到的结论就是这个任务耗时过长,但是从 systrace 上,我们看到 CPU Duration 只有 18ms,真正占用了很多时间的,是多次锁的竞用。
因此这个任务优化的重点应该是解决锁竞用的问题,如果用打日志的方式,只能看到表面现象,很容易把优化方向带偏了。
如果是过度并行,导致很多任务在 Runnable 的状态等待 CPU 时间片,这种情况通过日志也会得出错误的信息,线下分析还是建议用 systrace。
分析 systrace,建议用 release 包,debug 包很多行为和 release 包不一样,release 包默认是关闭 systrace 的
线上
线上监控用的埋点到 APM,日志等方法
启动优化项目案例
Mashi
启动时长,平均 400+ms 左右:
2023-04-02 22:39:06.056 1831-1871 ActivityTaskManager pid-1831 I Displayed club.jinmei.mgvoice/.splash.SplashActivity: +520ms 2023-04-02 22:39:11.794 1831-1871 ActivityTaskManager pid-1831 I Displayed club.jinmei.mgvoice/.splash.SplashActivity: +406ms 2023-04-02 22:39:16.793 1831-1871 ActivityTaskManager pid-1831 I Displayed club.jinmei.mgvoice/.splash.SplashActivity: +406ms 2023-04-02 22:39:21.436 1831-1871 ActivityTaskManager pid-1831 I Displayed club.jinmei.mgvoice/.splash.SplashActivity: +426ms 2023-04-02 22:39:41.361 1831-1871 ActivityTaskManager pid-1831 I Displayed club.jinmei.mgvoice/.splash.SplashActivity: +390ms
启动优化遇到的问题
- 初始化依赖的问题,很多工具类依赖 LogUtils,全局的 Context,在他们初始化之前就用到了