Android性能优化系列:启动优化
1 应用启动类型
应用的启动类型分为三种:
-
冷启动
-
温启动
-
热启动
三种类型启动耗时时间从大到小排序:冷启动 > 温启动 > 热启动
1.1 冷启动
在Android系统中,系统为每个运行的应用至少分配一个进程(多进程应用申请多个进程)。从进程的角度上讲,冷启动就是在启动应用前,系统中没有该应用的任何进程信息。
冷启动的场景比如设备开机后应用的第一次启动、系统杀掉应用进程后再次启动等。所以,冷启动的启动时间最长,因为相比另外两种启动方式,系统和我们的应用要做的工作最多。
冷启动一般会作为启动速度的一个衡量标准。
冷启动详细的启动过程可以参考:Android 从点击应用图标到界面显示的过程,这里简单说明下启动过程。
梳理上图的冷启动过程:
-
用户从
ClickEvent
点击应用图标开始,会经过IPC
和Process.start
即创建应用进程 -
ActivityThread
是每一个单独进程的入口,相当于java的main方法,在这里会处理消息的循环以及主线程Handler的创建等 -
bindApplication
会通过反射创建Application对象以及走Application的生命周期 -
LifeCycle
就是走的Activity生命周期,如onCreate()
、onStart()
、onResume()
等 -
ViewRootImpl
最后经历界面的measure
、layout
、draw
冷启动详细流程可以简单分为三个步骤,其中创建进程步骤是系统做的,启动应用和绘制界面是应用做的:
-
创建进程
-
启动App
-
显示一个空白的启动Window
-
创建应用进程
-
-
启动应用
-
创建Application
-
启动主线程(UI线程)
-
创建第一个Activity(MainActivity)
-
-
绘制界面
-
加载视图布局(Inflating)
-
计算视图在屏幕上的位置排版(Laying out)
-
首帧视图绘制(Draw)
-
只有当应用完成首帧绘制时,系统当前展示的空白背景才会消失被Activity的内容视图替换掉。也就是这个时候用户才能和我们的应用开始交互。
下图展示了冷启动过程系统和应用的一个工作时间流,参考自Android官方文档:App startup time
上图是应用启动Application和Activity的两个creation,它们均在View绘制展示之前。所以,在应用自定义的Application和入口Activity,如果它们的 onCreate()
做的事情越多,冷启动消耗的时间越长。
冷启动优化的方向是Application和Activity的生命周期阶段,这是我们开发者能控制的时间,其他阶段都是系统做的。
1.2 温启动
温启动包含在冷启动期间发生的一些操作,它的开销大于热启动。有许多可能的状态可以被认为是温启动,例如:
-
用户退出应用到Launcher,但随后重新启动应用。此时进程可能还在运行,但应用程序必须通过调用
onCreate()
重新创建Activity -
应用程序因为内存原因被系统强制退出,然后用户重新启动应用。进程和Activity需要被重新启动,但是保存的
Bundle
实例状态会被传递给onCreate()
使用
简单理解温启动就是它会重新走Activity的一些生命周期,它不会重新走进程的创建、Application的生命周期等。
1.3 热启动
热启动比冷启动简单得多且开销更低,在热启动时,系统会将Activity从后台切回到前台,如果应用的所有Activity仍旧驻留在内存中,那么应用可以避免重复对象初始化、布局加载和绘制。
然而,如果应用响应了系统内存清理的通知清理了内存,比如回调 onTrimMemory()
,那么这些被清理的对象在热启动就会被重新创建。
热启动和冷启动展示在屏幕的行为相同:系统进程展示一个空白屏幕直到应用绘制完成显示出Activity。
2 查看启动耗时
在启动耗时分析之前,有必要了解怎么查看启动耗时。根据输出的启动耗时记录,我们就可以先记录优化前的冷启动耗时,然后再对比优化后的启动耗时时间。
2.1 adb命令查看
确保手机USB连上电脑,可以通过adb命令查看启动耗时:
adb shell am start -W 包名/入口Activity全限定名
例如:
adb shell am start -W com.example.test/com.example.test.SplashActivity
或
adb shell am start -W com.example.test/.SplashActivity
Starting: Intent { act=android.intent.action.MAIN cat=[android.intent.category.LAUNCHER] cmp=com.example.test/.MainActivity }
Statys: ok
Activity: com.example.test/.MainActivity
ThisTime: 782
TotalTime: 1102
WaitTime: 1149
Complete
上面有三个时间指标:
-
ThisTime:表示一连串启动Activity的最后一个Activity的启动耗时
-
TotalTime:表示应用启动的耗时,包括创建启动应用进程和入口Activity的启动耗时,但不包括前一个应用Activity pause的耗时(即所有Activity启动耗时)。一般我们主要关心这个数值,这个时间才是自己应用真正启动的耗时
-
WaitTime:返回从其他应用进程
startActivity()
到应用首帧完全显示这段时间,即总的耗时,包括前一个应用Activity pause的时间和新应用启动的时间(即AMS启动Activity的总耗时)
注:前一个应用的Activity pause的时间,需要知道的是Launcher桌面也是一个应用,如果你的应用是在桌面点击app图标启动的,那么这里所说的 前一个应用的Activity
就是Launcher的Activity。
一般情况下启动耗时对比:This Time
< Total Time
< Wait Time
。
上面三个指标简单理解:
-
如果关心应用界面Activity启动耗时,参考
ThisTime
-
如果只关心某个应用自身启动耗时,参考
TotalTime
-
如果关心系统启动应用耗时,参考
WaitTime
测试冷启动前可以先强制杀死进程:
adb shell am force-stop com.example.test
如果需要统计多次可以使用命令:
adb shell am start -S -W -R 10 com.example.test/.MainActivity
-
-S:关闭Activity所属的App进程后再启动Activity
-
-W:等待启动完成
-
-R:重复次数
2.2 Logcat Displayed查看启动耗时
在Android 4.4(API 19)或以上版本,Android提供了一个指标可以让我们在Logcat就可以查看打印出应用的启动时间。这个时间值从应用启动(创建应用进程)开始计算到完成视图的首帧绘制(即Activity内容对用户可见)为止。
在Android Studio的Logcat查看,过滤tag为Displayed,勾选No Filters:
2.3 手动记录启动耗时
在网上搜索其他博客可能会告诉你,要在Application的 attachBaseContext()
和MainActivity的 onWindowFocusChanged()
分别记录冷启动的开始和结束时间。那为什么选择在这两个地方记录启动耗时?在这里记录是否就是准确的?
2.3.1 Application.attachBaseContext()
选择在Application的 attachBaseContext()
记录冷启动开始时间,是因为在创建应用进程时,应用Application会被反射创建,并且跟随的是将Application对象 attachContext
:
static public Application newApplication(Class<?> clazz, Context context)
throws InstantiationException, IllegalAccessException,
ClassNotFoundException {
Application app = (Application) clazz.newInstance();
app.attach(context);
return app;
}
应用的冷启动记录开始是应用被创建时,所以选择Application的 attachBaseContext()
是一个不错的选择。
2.3.2 Activity.onWindowFocusChanged()?draw?
onWindowFocusChanged(boolean hasFocus)
会在当前窗口焦点变化时回调,在Activity生命周期中,onStart()
、onResume()
都不是布局可见的时间点,因为回调 onResume()
时 ViewRootImpl
并没有开始View的 measure
、layout
、draw
(参考文章:View绘制流程源码解析);而在 onWindowFocusChanged()
时View已经完成了 measure
、layout
,但是View还没有 draw
,通过打印可以查看:
2020-06-26 16:03:46.781 24278-24278/? I/MainActivity: onStart, size = 0,0
2020-06-26 16:03:46.786 24278-24278/? I/MainActivity: onResume, size = 0,0
2020-06-26 16:03:46.837 24278-24278/? I/MainActivity: onMeasure
2020-06-26 16:03:46.864 24278-24278/? I/MainActivity: onMeasure
2020-06-26 16:03:46.865 24278-24278/? I/MainActivity: onLayout
2020-06-26 16:03:46.888 24278-24278/? I/MainActivity: onWindowFocusChanged, size = 112,54
2020-06-26 16:03:46.899 24278-24278/? I/MainActivity: onDraw
2020-06-26 16:03:46.899 24278-24278/? I/MainActivity: dispatchDraw
所以Activity界面展示上在回调 onWindowFocusChanged()
时只显示一个Window背景,因为后续才开始View的 draw
。
onWindowFocusChanged()
源码中文档也有说明:
/**
* Called when the current {@link Window} of the activity gains or loses
* focus. This is the best indicator of whether this activity is visible
* to the user. The default implementation clears the key tracking
* state, so should always be called.
*/
public void onWindowFocusChanged(boolean hasFocus) {
}
所以在 onWindowFocusChanged()
记录启动的结束时间是不准确的,因为我们需要的是界面对用户可见时作为结束时间。那什么时候才记录结束时间呢?
我们可以在第一个View展示给用户时通过 ViewTreeObserver
在回调记录结束的时间:
// RecyclerView第一个位置的View对用户可见时记录启动结束时间
@Override
public void onBindViewHolder(...) {
if (position == 0 && !mHasRecord) {
mHasRecord = true;
// 也可以使用addOnDrawListener但要求API 16
holder.view.getViewTreeObserver().addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener() {
@Override
public boolean onPreDraw() {
holder.view.getViewTreeObserver.removeOnPreDrawListener(this);
// 记录启动结束时间
...
return true;
}
});
}
}
2.4 AOP记录方法耗时
手动代码记录时间的方式有一定弊端:
-
代码侵入性强,需要在统计耗时的方法前后打点
-
工作量大,当涉及到多个统计耗时会很难以维护
使用AOP面向切面编程,在Android中这种方式就是在编译期动态的将要处理的代码插入到目标方法达到目的。
Android的AOP有多种方式:谈谈Android AOP技术方案。在上手难度上Aspect J框架成熟且容易入手,具体Aspect J的使用:AOP面向切面编程:Aspect J的使用。
- 在项目根目录
build.gradle
添加编译插件:
classpath 'com.hujiang.aspectjx:gradle-android-plugin-aspectjx:2.0.0'
- 在定义切面和被hook的module在
build.gradle
添加插件:
apply plugin: 'android-aspectjx'
下面用一个示例说明怎么使用Aspect J实现耗时方法的统计,示例非常简单:点击时模拟执行方法耗时延时3秒,使用Aspect J在点击执行前后记录下开始和结束时间。
findViewById(R.id.text_view).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
SystemClock.sleep(3000);
Log.i(TAG, "onClick");
}
});
@Aspect
public class AopHelper {
// 这里为了演示方便下面的表达式是会对所有的点击监听都生效的
// 实际项目代码中不要这样使用
@Around("execution(* android.view.View.OnClickListener.onClick(..))")
public void pointcutOnClick(ProceedingJoinPoint proceedingJoinPoint) {
// 点击前记录下开始时间
long startTime = System.currentTimeMillis();
try {
// 执行点击的耗时方法
proceedingJoinPoint.proceed();
} catch (Throwable throwable) {
throwable.printStackTrace();
} finally {
// 点击后记录下结束时间并计算出方法耗时
Log.i(TAG, "total time = " + (System.currentTimeMillis() - startTime));
}
}
}
3 启动耗时分析工具
3.1 CPU Profiler
CPU Profiler是Google在Android Studio 3.0开始推出的性能分析工具。CPU Profiler提供了 Call Chart
、Flame Chart
、Top Down
和 Bottom Up
四种可视界面展示CPU数据让我们可以更方便分析,这四种可视界面类型的区别和数据查看可以参考Android Studio Profiler工具解析应用的内存和CPU使用数据。
旧版本CPU Profiler界面:
新版本CPU Profiler界面:
1、位置①:记录的开始到结束的时间范围。如果需要查看具体某个范围的时间可以用鼠标拖动选择
2、位置②:新版本的CPU Profiler更直观的将 Call Chart
按线程排列出来,如果需要查看具体的某个线程执行的 Call Chart
,可以双击左边的线程名称展开具体查看。如图显示 Thread(9)
表示在这段时间有9个线程在运行,main
表示我们的主线程,还有展示其他的线程。
其中,Call Chart
中橙色表示的是系统或native的方法调用,蓝色表示第三方库的方法调用,绿色表示自己项目的方法调用。
从上到下是方法的调用栈,比如A方法调用了B方法,那么A方法在上面,B方法在下面。
一般情况我们会关注蓝色第三方库和绿色自己项目的方法调用耗时,如果项目有native方法当然也要关注橙色部分。如果调用方法过于耗时,就要考虑将方法异步加载或者延迟加载。
比如现在需要查看 initFlavorApp()
方法耗时,可以鼠标选中 initFlavorApp()
或点击 Call Chart
再对比右边的 Top Down
、Flame Chart
或 Bottom Up
查看具体方法调用栈耗时:
3、位置③:Top Down
、Flame Chart
和 Bottom Up
切换不同的Tab查看具体的方法调用栈耗时。一般情况下我们会用 Call Chart
和 Top Down
比较多一些。
Top Down
可以非常直观的从上到下查看具体的方法调用栈。如下图是初始化log库的具体方法调用栈(可以右键点击 Jump to Source
查看源码调用位置):
Bottom Up
则相反,从上到下是查看某个方法是在哪个地方哪个线程被调用。如下图 InitRetrofitTask
的 run()
方法是属于 TaskDispatcherPool-1-Thread-1()
线程:
关于其中的 Total
、Self
和 Children
数值具体表示的是什么,参考文章:Android Studio Profiler工具解析应用的内存和CPU使用数据
4、位置④:当前查看的是哪个线程
5、位置⑤:
-
Wall Clock Time:程序执行消耗的时间
-
Thread Time:CPU的执行程序消耗的时间
上面切换到 Wall Clock Time
和 Thread Time
展示了不同的执行时间。比如上图的 Wall Clock Time
的 main()
方法显示了139591(即139ms左右),表示这个方法的程序执行时间就是139ms左右;而 Thread Time
是82080(即82ms左右),表示CPU实际执行这个方法的时间只有82ms左右。
我们需要明白 Wall Clock Time
和 Thread Time
的区别,否则有可能会误导我们的优化方向。具体为什么会有可能误导,下面会说明讲解。
3.2 TraceView
TraceView
是Android平台特有的数据采集和分析工具,它主要用于分析Android中应用程序的耗时热点。TraceView
本身只是一个数据分析工具,而数据的采集则需要使用Android SDK中的 Debug
类生成 .trace
文件再结合CPU Profiler分析。
TraceView
具备以下特点:
-
图形的形式展示执行时间、调用栈等
-
信息全面,包含所有线程
3.2.1 TraceView的操作步骤
在代码中加入 Debug.startMethodTracing()
和 Debug.stopMethodTracing()
开始和停止CPU记录,运行程序生成 .trace
文件
public class MyApplication extends Application {
@Override
public void onCreate() {
// 在开始分析的地方调用,传入路径
// 如果是放到外部路径,需要添加权限
// 默认存储在Android/data/data/packagename/files
Debug.startMethodTracing("App");
...
// 在结束的地方调用
Debug.stopMethodTracing();
}
}
将 .trace
文件导入CPU Profiler分析:
3.2.2 TraceView使用注意事项
-
Debug
控制CPU活动的记录,需要将应用部署到Android 8.0(API 26)或以上 -
Debug
应该与用于开始和停止CPU活动记录的其他方法(即Debug
和CPU Profiler图形界面中的按钮以及在应用启动时执行的自动记录的记录配置中的设置)分开使用 -
Debug.startMethodTracing()
和Debug.stopMethodTracing()
是配套使用的,Debug.stopMethodTracing()
之前如果有调用多个Debug.startMethodTracing()
,它会寻找最近的一个Debug.startMethodTracing()
作为开始 -
Debug.startMethodTracing()
和Debug.stopMethodTracing()
必须在同一个线程中
3.2.3 TraceView的缺点和使用场景
TraceView
收集的信息比较全面,比如上面演示 TraceView
的例子中只是在主线程加了埋点,它就会抓取所有的线程所有执行函数以及顺序。但也是这个工具太强大,所以它也带来了一些问题:
-
使用
TraceView
时运行时开销严重:整体App的运行变慢,可能会导致无法区分是不是TraceView
影响了启动耗时 -
可能会带偏优化方向:就如上面提到的,
TraceView
开销大影响了整体性能,可能方法A正常情况下执行时间并不耗时,但加上TraceView
受影响可能就变得耗时
列出上面的问题并不是想表明 TraceView
就不能作为启动耗时工具分析使用,而是要根据对应的分析场景使用。
比如如果单纯的使用 CPU Profiler
基本不能抓取到准确的启动耗时的,但结合 TraceView
先在代码埋点之后,运行程序生成 .trace
文件再导入 CPU Profiler
分析就是一个很好的方式。
3.3 Systrace
Systrace
将来自Android内核的数据(如CPU调度程序、磁盘活动和应用程序线程)结合起来生成一个HTML报告,帮助确定如何最好地提高应用程序的性能。从报告中可以看到各个线程的执行时间、方法耗时、CPU执行时间等,该报告突出了它观察到的问题(如在显示动作或动画时的ui jank),并且提供了有关如何修复这些问题的建议。
我们经常能够在系统源码看到 Systrace
的调用,通过 Trace/TraceCompat.traceBegin()
和 Trace/TraceCompoat.endSection()
配套使用,比如 Looper.loop()
:
public static void loop() {
...
for (;;) {
...
if (traceTag != 0 && Trace.isTagEnabled(traceTag)) {
Trace.traceBegin(traceTag, msg.target.getTraceName(msg)); // 开始记录
}
...
try {
msg.target.dispatchMessage(msg);
dispatchEnd = needEndTime ? SystemClock.uptimeMillis() : 0;
} finally {
if (traceTag != 0) {
Trace.traceEnd(traceTag); // 结束记录
}
}
...
}
}
3.3.1 Systrace环境安装
Systrace
工具存放在目录 platform-tools/systrace/systracce.py
,在使用前需要安装相关环境:
1、安装python 2.7
2、电脑系统Win10和较高版本Android Studio在运行 Systrace
准备导出 trace.html
文件时可能会出现如下问题:
ImportError: No module named win32con
需要安装pywin32(选择python 2.7版本,根据32位或64位系统区分下载):pywin32
3、在安装完pywin32后可能还有提示如下问题:
ImportError: No module named six
需要安装six库:six
然后在解压后的目录使用python安装:
3.3.2 Systrace的操作步骤
1、在代码中加入 Trace.beginSection()
和 Trace.endSection()
开始和停止记录
// 在开始的地方调用
TraceCompat.beginSection("SystraceAppOnCreate");
// 在结束的地方调用
TraceCompat.endSection();
2、命令行进入到systrace目录启动 systrace.py
,程序运行启动后回车导出 trace.html
:
cd sdk\platform-tools\systrace
// systrace支持的命令参考Android文档:
// https://developer.android.com/topic/performance/tracing/command-line#command_options
python systrace.py -a packageName sched gfx view wm am app
注:如果是分析的冷启动比如在Application的 onCreate()
前后加上的Systrace埋点,那么python运行 systrace.py
要在程序运行前就启动,否则导出的 trace.html
会无法找到设置的 sectionName
。
3、在Chrome浏览器或 perfetto 打开 trace.html
分析
Systrace
显示了CPU的核数从0到7,说明运行设备是8核的CPU。
后面的数据信息是CPU的时间片(CPU Slice),可以发现8核的CPU并不是时刻都一起使用,有些CPU核运行密集,有些比较稀疏,这种情况也比较普遍,这是和设备有关。有些手机厂商默认情况下是提供8核CPU,有些厂商只提供4核。比如上面的设备就是只使用了4核,CPU 0-3在运行时CPU 4-7是空闲的显示一片空白。
刚才我们使用 Systrace
埋点的 secionName
是 SystraceAppOnCreate
,可以在搜索栏查找,Systrace
会将它高亮显示出来:
其中,Wall Duration
和 CPU Duration
分别的对应CPU Profiler中的 Wall Clock Time
和 Thread Time
。这里显示 Wall Duration
即程序执行的耗时是63ms,而实际上 CPU Duration
即CPU执行的时间是48ms。
如果 Wall Duration
和 CPU Duration
差值较大,比如上图显示的数据,Wall Duration
执行了515ms,实际CPU执行时间只有175ms,这之间CPU都处于休眠的状态。遇到这种情况就需要考虑是否程序对CPU利用率不高,提高CPU利用率开启一些线程操作,或者分析是不是程序导致锁等待问题。
如果需要单独查看 SystraceAppOnCreate
的具体信息,可以在 Systrace
点击并按下 m
键开启或关闭高亮显示:
关于 Systrace
更详细的使用方式和相关原理,可以参考文章:Systrace基础知识和实战
3.3.3 Systrace的优点使用场景
相比 TraceView
,Systrace
有它的优点:
-
轻量级,开销小
-
直观反映CPU利用率。如上面看到的
Wall Duration
和CPU Duration
walltime
和 cputime
(即 Systrace
列出的 Wall Duration
和 CPU Duration
)的区别:
-
walltime是代码执行时间
-
cputime是代码消耗CPU的时间,它才是我们应该优化的重点指标,根据
Systrace
展示的信息分析让cputime跑满CPU
为什么 walltime
和 cputime
会不同呢?一个比较经典的案例是锁冲突问题。
程序执行到 a()
时它是一个 synchronized
方法需要拿到锁,而刚好锁被其他程序占用,这就会导致 a()
一直在等待锁,但其实 a()
执行并不耗时。这就导致 a()
的 walltime
耗时很长,但 cputime
实际却不耗时。
4 Application初始化的启动优化途径
很多时候为了能够在启动应用进入主界面时就可以使用一些功能,我们都会在Application的 onCreate()
初始化一些第三方库或其他组件,但在Application初始化的地方做太多繁重的事情是可能导致严重启动性能问题的元凶之一。Application里面的初始化操作不结束,其他任意的程序操作都无法进行。
其实很多组件是需要做区队对待的,有些可以做延迟加载,有些可以放到其他的地方做初始化操作,特别需要留意包含Disk IO操作、网络访问等严重耗时的任务,它们会严重阻塞程序的启动。
优化这些问题的解决方案是做延迟加载,可以在Application里面做延迟加载,也可以把一些初始化操作延迟到组件真正被调用到的时候再加载。
上面说明了优化方向,先简单总结下优化启动耗时的两种方式:
-
异步加载
-
延迟加载
接下来根据上面的两种处理方式提供对应的一些解决方案。
4.1 异步加载:子线程/线程池、TaskDispatcher
异步加载简单理解就是将一些初始化任务放到子线程异步执行,充分利用CPU由子线程或线程池分担主线程初始化任务。异步初始化的核心就是子线程分担主线程的任务,并行执行减少时间。
4.1.1 子线程/线程池
在java中创建子线程就是 Thread
或 Runnable
,但是我们一般都不会直接使用它们,而是使用线程池的方式统一管理。java同样提供了 Executors 线程池管理工具帮助我们管理线程。
在使用线程池之前,有必要了解线程池使用场景的任务类型:
-
IO密集型:IO密集型任务不消耗CPU,核心池可以很大
-
CPU密集型:核心池大小和CPU核心数相关
Executors
提供了四种线程池分别是 FixedThreadPool
、CacheThreadPool
、ScheduledThreadPool
和 SingleThreadPool
,让我们可以根据以上任务类型场景选择不同的线程池配置。如果上面的配置不能满足,也可以自定义 ThreadPool
,比如Android的 AsyncTask
就是CPU密集型任务类型,AsyncTask
内部自定义了线程池:
public abstract class AsyncTask<Params, Progress, Result> {
private static final int CPU_COUNT = Runtime.getRuntime().availableProcessors();
// We want at least 2 threads and at most 4 threads in the core pool,
// preferring to have 1 less than the CPU count to avoid saturating
// the CPU with background work
private static final int CORE_POOL_SIZE = Math.max(2, Math.min(CPU_COUNT - 1, 4));
private static final int MAXIMUM_POOL_SIZE = CPU_COUNT * 2 + 1;
private static final int KEEP_ALIVE_SECONDS = 30;
private static final ThreadFactory sThreadFactory = new ThreadFactory() {
private final AtomicInteger mCount = new AtomicInteger(1);
public Thread newThread(Runnable r) {
return new Thread(r, "AsyncTask #" + mCount.getAndIncrement());
}
};
private static final BlockingQueue<Runnable> sPoolWorkQueue =
new LinkedBlockingQueue<Runnable>(128);
static {
ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(
CORE_POOL_SIZE, MAXIMUM_POOL_SIZE, KEEP_ALIVE_SECONDS, TimeUnit.SECONDS,
sPoolWorkQueue, sThreadFactory);
threadPoolExecutor.allowCoreThreadTimeOut(true);
THREAD_POOL_EXECUTOR = threadPoolExecutor;
}
}
使用线程池的方式实现异步初始化的操作:
// 根据不同设备计算设置不同的核心线程数
private val CPU_COUNT = Runtime.getRuntime().availableProcessors()
private val CORE_POOL_SIZE = max(2, min(CPU_COUNT - 1, 4))
val threadPool = Executors.newFixedThreadPool(CORE_POOL_SIZE)
threadPool.submit {
// initialized
}
但在实际场景,异步初始化的第三方库可能进入首页后就要使用,这时候异步任务还没加载完第三方库可能会导致应用崩溃抛异常。可以使用 CountDownLatch
锁存器等待任务执行结束后再使用,同样的关于 CountDownLatch
参考博客:java并发
private val CPU_COUNT = Runtime.getRuntime().availableProcessors()
private val CORE_POOL_SIZE = max(2, min(CPU_COUNT - 1, 4))
private val mCountDownLatch = CountDownLatch(1)
val threadPool = Executors.newFixedThreadPool(CORE_POOL_SIZE)
threadPool.submit {
// initialized
...
mCountDownLatch.countDown()
}
// await()之前没有调用countDown()会一直阻塞
mCountDownLatch.await()
在使用线程池异步加载时需要考虑一些事情:
-
任务是否符合需要异步的要求:有些任务可能它就需要在主线程运行,那就需要考虑该任务放弃异步加载或者考虑任务优先级程度选择在主线程延迟加载
-
需要在某阶段完成:比如任务的数据要及时在闪屏页展示给用户,那么就考虑使用
CountDownLatch
(后续会用启动器的方式实现) -
线程数量控制:比如设备是8核的CPU,计算的设置的线程池核心数量是4,要根据设备的CPU数量动态计算核心线程数量。并且设置了4个核心线程,如果将任务全都放在一个
Runnable
运行也是不合理的,因为CPU线程没有得到有效的利用
4.1.2 异步启动器TaskDispatcher
使用子线程/线程池的方式也能实现异步初始化,如果需要等待加载完成再使用还可以使用 CountDownLatch
锁存器解决。
实际项目一般可能会有多个库,如果还是按照上面的写法一个个去写锁存器等待加载就会比较麻烦,而任务与任务之间如果存在依赖就很难处理。
针对上面的问题,目前比较好的方案是使用异步启动器 TaskDispatcher。
TaskDispatcher
的主体流程为上图中的中间区域,即主线程与并发两个区域块。
需要注意的是,在上图中的 head task
与 tail task
并不包含在启动器的主体流程中,它仅仅是用于处理启动前/启动后的一些通用任务,例如我们可以在 head task
中做一些获取通用信息的操作,在 tail task
可以做一些log输出、数据上报等操作。
看下 TaskDispatcher
是怎么使用的:
// 启动器初始化
TaskDispatcher.init(this);
// 创建启动器实例,createInstance()每次获取都是新对象
TaskDispatcher dispatcher = TaskDispatcher.createInstance();
// 启动器配置一系列(异步/非异步)初始化任务并启动启动器
dispatcher
.addTask(new TaskA())
.addTask(new TaskB())
.addTask(new TaskC()) // TaskC依赖TaskB,等待TaskB执行完再执行TaskC
.addTask(new TaskD()) // 继承MainTask,运行在主线程
.addTask(new TaskE()) // 运行在子线程且needWait()返回true
.start()
// 等待TaskE执行完后再往下执行,否则阻塞等待
dispatcher.await()
Log.i(TAG, "application onCreate execute finish");
Task
可以是执行在子线程的异步任务,也可以是执行在主线程的非异步任务:
// 继承自Task,运行在子线程
public class TaskA extends Task {
@Override
public void run() {
Log.i(TAG, "execute TaskA");
}
}
public class TaskB extends Task {
@Override
public void run() {
Log.i(TAG, "execute TaskB");
}
}
public class TaskC extends Task {
// 依赖TaskB,先执行TaskB后再执行TaskC
@Override
public List<Class<? extends Task>> dependsOn() {
List<Class<? extends Task>> dependsTasks = new ArrayList<>();
dependsTasks.add(TaskB.class);
return dependsTasks;
}
@Override
public void run() {
Log.i(TAG, "execute TaskC");
}
}
// 继承自MainTask,运行在主线程;如果继承Task也可以重写runOnMainThread()返回true
public class TaskD extends MainTask {
@Override
public void run() {
Log.i(TAG, "execute TaskD");
}
}
public class TaskE extends Task {
// 需要等待执行,TaskDispatcher需要调用await()
@Override
public boolean needWait() {
return true;
}
@Override
public void run() {
try {
// 为了方便查看是否等待阻塞,加个延时
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
Log.i(TAG, "execute TaskE");
}
}
输出:
execute TaskA // 子线程
execute TaskB // 子线程
execute TaskD // 主线程
execute TaskC // 子线程,依赖TaskB
execute TaskE // 子线程,needWait
application onCreate execute finish
TaskDispatcher
的核心思想是充分利用CPU多核,自动梳理任务顺序。
总结一下启动的核心流程:
-
任务Task化,启动逻辑抽象成Task(Task即对应一个个的初始化任务)
-
根据所有任务依赖关系排序生成一个有向无环图:例如上述说到的
TaskC
依赖于TaskB
,各个任务之间都可能存在依赖关系,所以将它们的依赖关系排序生成一个有向无环图能将并行效率最大化 -
多线程按照排序后的优先级依次执行
4.2 延迟加载:IdleHandler
延迟加载主要针对的是一些优先级不是很高的任务在某个适当的时机再初始化。
在Android中一般我们需要做延迟处理都会使用 Handler
的 .sendEmptyMessageDelay()
或者 postDelay()
:
Handler.postDelay({
// initialized
}, 3000)
但是使用这种方式会有比较明显的问题:
-
时机不容易控制。任务一般都会比较耗时,UI更新是在主线程,
handler.postDelay()
需要延迟多久不好控制 -
导致界面UI卡顿。延时时机不准确,UI在更新绘制过程如果执行了耗时任务就会导致UI卡顿
Android提供了 IdleHandler
,它在CPU空闲的时候会回调,我们可以在回调时分批执行任务初始化:
Looper.myQueue().addIdleHanddler {
// initialized
false
}
在异步加载介绍的异步启动器 TaskDispatcher
也支持 IdleHandler
:
public class DelayInitDispatcher {
private Queue<Task> mDelayTasks = new LinkedList<>();
private MessageQueue.IdleHandler mIdleHandler = new MessageQueue.IdleHandler() {
@Override
public boolean queueIdle() {
if (mDelayTasks.size() > 0) {
Task task = mDelayTasks.poll();
new DispatchRunnable(task).run();
}
return !mDelayTasks.isEmpty();
}
};
public DelayInitDispatcher addTask(Task task) {
mDelayTasks.add(task);
return this;
}
public void start() {
Looper.myQueue().addIdleHandler(mIdleHandler);
}
public void stop() {
Looper.myQueue().removeIdleHandler(mIdleHandler);
}
}
在合适的时机调用延迟初始化:
DelayInitDispatcher delayInitDispatcher = new DelayInitDispatcher();
delayInitDispatcher.addTask(new InitOtherTask()).start();
使用 IdleHandler
的优势:
-
执行时机明确,指定在CPU空闲时才执行回调
-
缓解界面UI卡顿,不会干扰UI更新绘制布局等过程
4.3 其他启动优化方案
-
提前加载
SharedPreferences
-
Multidex
之前加载,利用此阶段的CPU -
覆写
getApplicationContext()
返回this
-
-
启动阶段不启动子进程
-
子进程会共享CPU资源,导致主进程CPU紧张
-
注意启动顺序:Application的
onCreate()
之前是ContentProvider
启动调用onCreate()
-
-
类加载优化:提前异步类加载
-
Class.forName()
只加载类本身及其静态变量的引用类 -
如果是
new
类实例,可以额外加载类成员变量的引用类
-
-
启动阶段抑制GC
-
CPU锁频
5 UI的启动优化途径
按照上面的解决方案对组件做了区队对待处理为异步加载和延迟加载后,启动应用进程让UI更快的显示出来展示给用户也是启动优化其中一个优化途径。
提升Activity的创建速度是优化App启动速度的首要关注目标。从桌面点击App图标启动应用开始,程序会显示一个启动窗口等待Activity的创建加载完毕再进行显示。在Activity的创建加载过程中,会执行很多操作,例如设置页面主题、初始化页面的布局、加载图片、获取网络数据等等。
上述操作的任何一个环节出现性能问题都可能导致画面不能及时显示,影响了程序的启动速度。
那在UI层面怎么提升启动速度呢?
5.1 修改启动主题背景
上图是启动的过程,绝大多数步骤都是由系统控制的,一般不会出现什么问题我们也不需要干预。对于启动速度,我们能够控制优化的主要有三个地方:
-
Application:在
Application.onCreate()
通常会在这里做大量的通用组件初始化操作 -
Activity:在
Activity.onCreate()
通常会做界面初始化相关的操作,特别是UI布局和渲染操作,如果布局过于复杂很可能导致启动性能降低 -
闪屏页:部分App会提供自定义的启动窗口,在这个界面展示一些图片宣传等给用户提供一种程序已经启动的视觉效果
系统启动App前会加载显示一个空白的Window窗口,直到页面渲染加载完毕;如果应用程序启动速度够快,空白窗口停留显示时间则会很短,但是当程序启动速度偏慢时,等待时间过长就会降低用户体验甚至让用户放弃使用App。
所以目前大多数的App都会设置闪屏页,通过修改启动窗口主题替换系统默认的启动窗口,让用户在视觉效果上降低启动App等待的时间。但本质上并没有对启动速度做什么优化。
一般闪屏页Activity都是展示一张图片,修改主题非常简单,在 res/drawable/
提供一个主题背景图片,把这张图片通过设置主题的方式显示为启动闪屏,然后在入口Activity的 super.onCreate()
之前调用 setTheme()
替换回来:
- res/drawable/splash_bg.xml
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<item>
<bitmap
android:gravity="center"
android:src="@drawable/ic_launcher" />
</item>
</layer-list>
- res/values/styles.xml
<style name="SplashTheme" parent="Theme.AppCompat.Light.NoActionBar">
<item name="android:windowBackground">@drawable/splash_bg</item>
</style>
- AndroidManifest.xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.example.demo">
<application
android:name=".MyApplication"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme">
<activity android:name=".SplashActivity"
android:theme="@style/SplashTheme">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
- SplashActivity.java
class SplashActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
setTheme(R.style.AppTheme)
super.onCreate(savedInstanceState)
}
}
5.2 UI渲染布局优化
布局优化主要有两个优化方向:
-
减少过度绘制
-
减少布局层级
-
控件延迟加载
具体的优化方式可以参考文章:
Android性能优化系列:VSync、Choreographer和Render Thread
本文地址:https://blog.csdn.net/qq_31339141/article/details/105019455