Android性能优化系列:启动优化

应用启动类型

应用的启动类型分为三种:

  • 冷启动

  • 温启动

  • 热启动

三种类型启动耗时时间从大到小排序:冷启动 > 温启动 > 热启动

冷启动

在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 的生命周期阶段,这是我们开发者能控制的时间,其他阶段都是系统做的

温启动

温启动包含在冷启动期间发生的一些操作,它的开销大于热启动。有许多可能的状态可以被认为是温启动,例如:

  • 用户退出应用到 Launcher,但随后重新启动应用。此时进程可能还在运行,但应用程序必须通过调用 onCreate() 重新创建Activity

  • 应用程序因为内存原因被系统强制退出,然后用户重新启动应用。进程和 Activity 需要被重新启动,但是保存的 Bundle 实例状态会被传递给 onCreate() 使用

简单理解温启动就是它会重新走 Activity 的一些生命周期,它不会重新走进程的创建、Application 的生命周期等。

热启动

热启动比冷启动简单得多且开销更低,在热启动时,系统会将 Activity 从后台切回到前台,如果应用的所有 Activity 仍旧驻留在内存中,那么应用可以避免重复对象初始化、布局加载和绘制

然而,如果应用响应了系统内存清理的通知清理了内存,比如回调 onTrimMemory(),那么这些被清理的对象在热启动就会被重新创建。

热启动和冷启动展示在屏幕的行为相同:系统进程展示一个空白屏幕直到应用绘制完成显示出 Activity。

查看启动耗时

在启动耗时分析之前,有必要了解怎么查看启动耗时。根据输出的启动耗时记录,我们就可以先记录优化前的冷启动耗时,然后再对比优化后的启动耗时时间。

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:重复次数

Logcat Displayed 查看启动耗时

在 Android 4.4(API 19)或以上版本,Android 提供了一个指标可以让我们在 Logcat 就可以查看打印出应用的启动时间。这个时间值从应用启动(创建应用进程)开始计算到完成视图的首帧绘制(即 Activity 内容对用户可见)为止。

在 Android Studio 的 Logcat 查看,过滤 tag 为 Displayed,勾选 No Filters:

在这里插入图片描述

手动记录启动耗时

在网上搜索其他博客可能会告诉你,要在 Application 的 attachBaseContext() 和 MainActivity 的 onWindowFocusChanged() 分别记录冷启动的开始和结束时间。那为什么选择在这两个地方记录启动耗时?在这里记录是否就是准确的?

Application.attachBaseContext()

选择在 Application 的 attachBaseContext() 记录冷启动开始时间,是因为在创建应用进程时,应用 Application 会被反射创建,并且跟随的是将 Application 对象 attach:

static public Application newApplication(Class<?> clazz, Context context) 
		throws InstantiationException, IllegalAccessException,
		ClassNotFoundException {
	Application app = (Application) clazz.newInstance();
	app.attach(context); 
	return app;
}

应用的冷启动记录开始是应用被创建时,所以选择 Application 的 attachBaseContext() 是一个不错的选择

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;
			}
		});
	}
}

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));
        }
    }	
}

启动耗时分析工具

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 的区别,否则有可能会误导我们的优化方向。具体为什么会有可能误导,下面会说明讲解。

TraceView

TraceView 是 Android 平台特有的数据采集和分析工具,它主要用于分析 Android 中应用程序的耗时热点。TraceView 本身只是一个数据分析工具,而数据的采集则需要使用 Android SDK 中的 Debug 类生成 .trace 文件再结合 CPU Profiler 分析。

TraceView 具备以下特点:

  • 图形的形式展示执行时间、调用栈等

  • 信息全面,包含所有线程

TraceView 的操作步骤

在代码中加入 Debug.startMethodTracing()Debug.stopMethodTracing() 开始和停止 CPU 记录,运行程序生成 .trace 文件

public class MyApplication extends Application {
	@Override
	public void onCreate() {
		// 在开始分析的地方调用,传入路径
		// 如果是放到外部路径,需要添加权限
		// 默认存储在/sdcard/Android/data/packagename/files
		Debug.startMethodTracing("App");		
		
		...
		
		// 在结束的地方调用
		Debug.stopMethodTracing();
	}
}

在这里插入图片描述

.trace 文件导入 CPU Profiler 分析:

在这里插入图片描述

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() 必须在同一个线程中

TraceView 的缺点和使用场景

TraceView 收集的信息比较全面,比如上面演示 TraceView 的例子中只是在主线程加了埋点,它就会抓取所有的线程所有执行函数以及顺序。但也是这个工具太强大,所以它也带来了一些问题:

  • 使用 TraceView 时运行时开销严重:整体 App 的运行变慢,可能会导致无法区分是不是 TraceView 影响了启动耗时

  • 可能会带偏优化方向:就如上面提到的,TraceView 开销大影响了整体性能,可能方法 A 正常情况下执行时间并不耗时,但加上 TraceView 受影响可能就变得耗时

列出上面的问题并不是想表明 TraceView 就不能作为启动耗时工具分析使用,而是 要根据对应的分析场景使用

比如如果单纯的使用 CPU Profiler 基本不能抓取到准确的启动耗时的,但结合 TraceView 先在代码埋点之后,运行程序生成 .trace 文件再导入 CPU Profiler 分析就是一个很好的方式。

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); // 结束记录
            }
        }
        
        ...
    }
}

Systrace 环境安装

Systrace 工具存放在目录 platform-tools/systrace/systracce.py,在使用前需要安装相关环境:

1、在 33.0.1 版本 Android 已经在 platform-tools 已经删除了 systrace,如果要使用需要下载 33.0.1 之前的版本:33.0.0 platform-tools

2、安装 python 2.7:可以在官网 python 下载

3、电脑系统Win10和较高版本Android Studio在运行 Systrace 准备导出 trace.html 文件时可能会出现如下问题:

ImportError: No module named win32con

需要安装 pywin32(选择python 2.7版本,根据32位或64位系统区分下载)

4、在安装完 pywin32 后可能还有提示如下问题:

ImportError: No module named six

需要安装 six 库:

在这里插入图片描述

然后在解压后的目录使用 python 安装:

在这里插入图片描述

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基础知识和实战

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 实际却不耗时。

Application 初始化的启动优化途径

很多时候为了能够在启动应用进入主界面时就可以使用一些功能,我们都会在 Application 的 onCreate() 初始化一些第三方库或其他组件,但在 Application 初始化的地方做太多繁重的事情是可能导致严重启动性能问题的元凶之一。Application 里面的初始化操作不结束,其他任意的程序操作都无法进行。

其实很多组件是需要做区队对待的,有些可以做延迟加载,有些可以放到其他的地方做初始化操作,特别需要留意包含 Disk IO 操作、网络访问等严重耗时的任务,它们会严重阻塞程序的启动。

在这里插入图片描述

优化这些问题的解决方案是做延迟加载,可以在 Application 里面做延迟加载,也可以把一些初始化操作延迟到组件真正被调用到的时候再加载。

在这里插入图片描述

上面说明了优化方向,先简单总结下优化启动耗时的两种方式:

  • 异步加载

  • 延迟加载

接下来根据上面的两种处理方式提供对应的一些解决方案。

异步加载:子线程/线程池、TaskDispatcher

异步加载简单理解就是将一些初始化任务放到子线程异步执行,充分利用 CPU 由子线程或线程池分担主线程初始化任务。异步初始化的核心就是 子线程分担主线程的任务,并行执行减少时间

子线程/线程池

在 java 中创建子线程就是 Thread,但是我们一般都不会直接使用它们,而是使用线程池的方式统一管理。java 同样提供了 Executors 线程池管理工具帮助我们管理线程。

一般在做启动优化时我们都会自定义线程池,需要特别注意使用场景是 CPU 密集型还是 IO 密集型。

使用线程池的方式实现异步初始化的操作:

// 根据不同设备计算设置不同的核心线程数
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 锁存器等待任务执行结束后再使用。

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 线程没有得到有效的利用

异步启动器

不改变现有启动任务执行逻辑的前提下,启动优化本质上就是解决任务的依赖以及合理的、有序的调度问题。而依赖问题本质就是数据结构问题,合理调度解决的是并发问题

上面讲解使用子线程/线程池的方式也能实现异步初始化,如果需要等待加载完成再使用,还可以使用 CountDownLatch 锁存器解决。

实际项目一般可能会有多个库,如果还是按照上面的写法一个个去写锁存器等待加载就会比较麻烦,而任务与任务之间如果存在依赖就很难处理。

任务间的依赖关系适合的数据结构是有向无环图,即一个有向图无法从某个顶点出发经过若干条边重新回到该顶点,就是有向无环图,简称 DAG 图

DAG 常被用来表示事件之间的驱动依赖关系,管理任务之间的调度。并且处理任务之间的顺序执行,还需要完成拓扑排序,拓扑排序是对一个有向图构造拓扑序列的过程

在这里插入图片描述

有向无环图的比喻:学习 OkHttp,需要先学 Java,然后再学 Socket 或设计模式,学完 Socket 后再学 Http 协议,最后才学 OkHttp。

图的概念:

  • 顶点:每个任务 Task 代表的就是顶点

  • 边:任务之间连接的线就是边

  • 出度:从顶点发出的边的数量。例如 Java 有两条边分别连接 Socket 和设计模式,Java 的出度就是 2

  • 入度:连接到顶点的边的数量。例如 Socket 有一条边从 Java 连接的边,Socket 的入度是 1,Java 入度是 0

将图经过拓扑排序为有向无环图的步骤(实际上排序是为了准备两张表:入度表和任务依赖表):

  • 先找出入度为 0 的顶点,然后从图中删除入度为 0 的顶点【第一个入度为 0 的顶点是 Java】

  • 继续找入度为 0 的顶点【Java 被删除后,另外入度为 0 的顶点就是 Socket 和设计模式】

  • 继续找入度为 0 的顶点【Socket 和设计模式被删除后,另外入度为 0 的顶点是 Http 协议,OkHttp 的入度为 1】

  • 继续找入度为 0 的顶点【Http 协议被删除,另外入度为 0 的顶点是 OkHttp】

在这里插入图片描述

经过拓扑排序后入度表和依赖任务表如下:

在这里插入图片描述

具体的代码实现如下:

package com.example.startup.launch

import java.util.ArrayDeque

object TopologySort {

    /**
     * 有向无环图拓扑排序的目的:
     * 其实就是被依赖的任务(即入度为 0 的任务)排在执行队列前面先启动
     * 等这些被依赖的任务启动完了,就让依赖的任务入队启动,直到结束。
     *
     * 目的就是充足利用 CPU 资源尽可能有效的启动任务执行
     *
     * 举个例子:
     * 假设 task1 是入度为 0 的任务,被 task2 和 task3 依赖
     * task1 因为入度为 0 会先入队启动,等 task1 启动完后,找到 task2 和 task3 依次启动
     */
    fun sort(startupList: List<Startup<*>>): StartupSortStore {
        // 入度表
        // 入度表的作用:按列表顺序记录入度为0的任务
        val inDegreeMap = mutableMapOf<Class<out Startup<*>>, Int>()
        // 入度为0的任务队列,任务执行队列
        val zeroDeque = ArrayDeque<Class<out Startup<*>>>()

        val startupMap = mutableMapOf<Class<out Startup<*>>, Startup<*>>()
        // 任务依赖表
        // 任务依赖表的作用:用于任务查询
        // 假设 task1 是入度为 0 的任务,被 task2 和 task3 依赖
        // 入度为 0 的 task1 被启动后,可以通过任务依赖表查询到 task2 和 task3
        // 将它们的入度数-1,此时 task2 和 task3 的入度就为 0,则放入 zeroDeque 执行队列启动执行
        val startupChildMap = mutableMapOf<Class<out Startup<*>>, MutableList<Class<out Startup<*>>>>()

        // 找出图中入度为0的顶点
        startupList.forEach { startup ->
            // 记录所有的任务
            startupMap[startup.javaClass] = startup

            // 构建入度表
            // 记录每个任务的入度数(依赖的任务数)
            val dependenciesCount = startup.getDependenciesCount()
            inDegreeMap[startup.javaClass] = dependenciesCount

            // 记录入度数(依赖的任务数)为 0 的任务
            // 入度为 0 的任务先进入排在执行队列前面
            if (dependenciesCount == 0) {
                zeroDeque.offer(startup.javaClass)
            } else {
                // 构建任务依赖表
                // 遍历本任务的依赖(父)任务列表
                startup.dependencies().forEach { parentTask ->
                    var childTasks = startupChildMap[parentTask]
                    if (childTasks == null) {
                        childTasks = mutableListOf()
                        startupChildMap[parentTask] = childTasks
                    }
                    childTasks.add(startup.javaClass)
                }
            }
        }

        val result = mutableListOf<Startup<*>>()
        val mainStartupList = mutableListOf<Startup<*>>()
        val threadStartupList = mutableListOf<Startup<*>>()
        // 依次在图中删除顶点
        while (!zeroDeque.isEmpty()) {
            val parentTask = zeroDeque.poll()
            val startup = startupMap[parentTask]!!
            // 将入度为 0 的任务添加到列表
            if (startup.callCreateOnMainThread()) {
                mainStartupList.add(startup)
            } else {
                threadStartupList.add(startup)
            }

            // 如果启动执行的任务没有子任务,重新进入循环去执行队列拿下一个任务执行

            // 如果启动执行的任务有子任务
            if (startupChildMap.containsKey(parentTask)) {
                // 任务依赖表查找父任务是否有依赖的子任务
                val childTasks = startupChildMap[parentTask]
                childTasks?.forEach { childTask ->
                    val num = inDegreeMap[childTask]!!
                    inDegreeMap[childTask] = num - 1 // 将子任务的入度数-1
                    if (num - 1 == 0) {
                        zeroDeque.offer(childTask) // 子任务入度为 0,添加到执行队列
                    }
                }
            }
        }

        result.apply {
            // 先添加子线程的任务,再添加主线程任务
            // 避免要运行在主线程的同步任务有阻塞任务,任务先执行导致阻塞下一个要被执行的异步任务启动
            addAll(threadStartupList)
            addAll(mainStartupList)
        }

        return StartupSortStore(result, startupMap, startupChildMap)
    }
}

具体代码可以参考 demo 项目 startup

延迟加载:IdleHandler

延迟加载主要针对的是一些优先级不是很高的任务在某个适当的时机再初始化。

在 Android 中一般我们需要做延迟处理都会使用 handler.sendEmptyMessageDelay()/postDelay()

Handler.postDelay({
	// initialized
}, 3000)

但是使用这种方式会有比较明显的问题:

  • 时机不容易控制。任务一般都会比较耗时,UI 更新是在主线程,handler.postDelay() 需要延迟多久不好控制

  • 导致界面 UI 卡顿。延时时机不准确,UI 在更新绘制过程如果执行了耗时任务就会导致 UI 卡顿

Android 提供了 IdleHandler,它在 Handler 消息处理完的时候会回调,我们可以在回调时分批执行任务初始化:

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更新绘制布局等过程

UI 的启动优化途径

按照上面的解决方案对组件做了区队对待处理为异步加载和延迟加载后,启动应用进程让 UI 更快的显示出来展示给用户也是启动优化其中一个优化途径。

提升 Activity 的创建速度是优化 App 启动速度的首要关注目标。从桌面点击 App 图标启动应用开始,程序会显示一个启动窗口等待 Activity 的创建加载完毕再进行显示。在 Activity 的创建加载过程中,会执行很多操作,例如设置页面主题、初始化页面的布局、加载图片、获取网络数据等等。

在这里插入图片描述

上述操作的任何一个环节出现性能问题都可能导致画面不能及时显示,影响了程序的启动速度。

那在UI层面怎么提升启动速度呢?

修改启动主题背景

在这里插入图片描述

上图是启动的过程,绝大多数步骤都是由系统控制的,一般不会出现什么问题我们也不需要干预。对于启动速度,我们能够控制优化的主要有三个地方:

  • 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)
    }
}

UI 渲染布局优化

布局优化主要有两个优化方向:

  • 减少过度绘制

  • 减少布局层级

  • 控件延迟加载

具体的优化方式可以参考文章:

Android性能优化系列:VSync、Choreographer和Render Thread

Android性能优化系列:渲染及布局优化

Android一些你需要知道的布局优化技巧

异步 inflate

在实际的项目代码中,你可能已经处理了上面的所有操作,但是启动速度还是没能达到要求,比如启动进入的首页是 ViewPager 需要加载多个 Fragment,而某个或多个 Fragment 在执行 onCreateView() 使用 LayoutInflater 创建的 View,即使已经做到布局扁平化和某些控件懒加载,但因为控件数量过多导致了耗时过长,onCreateView() 的时机我们是不可干预的。

首先需要明白 inflate 它本质上是 IO 操作,当我们调用 layoutInflater.inflate(),该方法主要会有两个操作:

  • 使用递归的方式解析 xml

  • 根据 xml 解析使用反射的方式创建 View

控件越多,递归 xml 解析的 IO 过程就越久,反射创建 View 越多也越耗时。既然是 IO 操作,那能否将它放在子线程处理呢?答案是可以的。

Google 已经为我们提供了一个异步 inflate 的 API:AsyncLayoutInflater,使用 AsyncLayoutInflater 可以将 inflate 操作放到子线程处理,如果子线程 inflate 失败,就会在主线程执行 inflate,当 inflate 结束后会通过 callback 回调到主线程。

使用方式非常简单:

AsyncLayoutInflater asyncLayoutInflater = new AsyncLayoutInflater(this);

asyncLayoutInflater.inflate(R.layout.test_layout, null, new AsyncLayoutInflater.OnInflateFinishedListener() {
    @Override
    public void onInflateFinished(@NonNull View view, int resid, @androidx.annotation.Nullable ViewGroup parent) {
        // ...
    }
});

在我负责的项目中也是使用了异步 inflate,但是我没有使用 AsyncLayoutInflater,原因主要有两点:

  • AsyncLayoutInflater 是不能向下兼容的:高版本向下兼容是通过 AppCompat 进行的,而 AsyncLayoutInflater 的 BasicInflater 没有向下兼容:
private static class BasicInflater extends LayoutInflater {
	private static final String[] sClassPrefixList = {
		"android.widget.",
		"android.webkit.",
		"android.app."
	};
	
	BasicInflater(Context context) {
		super(context);
	}
}
  • 不能控制子线程的优先级:在冷启动时如果子线程还没有执行完 inflate 操作,还是会在主线程按正常流程 inflate,可能会有失效的问题

有上述两种问题,在了解了实现思路的情况下,我们完全可以自己写一个类似的功能。下面提供了一个自己写的工具类,仅作为参考:

public class AsyncInflateManager {
    private static final String TAG = "AsyncInflateManager";

    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 ThreadPoolExecutor mExecutors;

    public static final int TAG_TEST1 = 1;
    public static final int TAG_TEST2 = 2;
    public static final int TAG_TEST3 = 3;

    private SparseArray<View> mInflateFinishedViews = new SparseArray<>();
    private SparseBooleanArray mMainInflatedTags = new SparseBooleanArray();

    private static final class AsyncInflateManagerHolder {
        private static final AsyncInflateManager sInstance = new AsyncInflateManager();
    }

    private AsyncInflateManager() {
        mExecutors = new ThreadPoolExecutor(CORE_POOL_SIZE, CORE_POOL_SIZE, 8, TimeUnit.SECONDS,
                new LinkedBlockingQueue<Runnable>(), new ThreadFactory() {
            private AtomicInteger mCount = new AtomicInteger();

            @Override
            public Thread newThread(Runnable r) {
                Thread thread = new Thread(r, "AsyncInflate-Thread-" + mCount.incrementAndGet());
                thread.setPriority(Thread.MAX_PRIORITY); // 设置线程优先级,根据实际需求场景需要设置,仅参考
                return thread;
            }
        });
        mExecutors.allowCoreThreadTimeOut(true); // 如果仅用于冷启动,可以使用完后也将核心线程池回收
    }

    public static AsyncInflateManager getInstance() {
        return AsyncInflateManagerHolder.sInstance;
    }

    /**
     * 批量异步inflate布局
     */
    public void batchAsyncInflate(Context context, List<AsyncInflateInfo> asyncInflateInfoList) {
        if (CollectionUtils.isEmpty(asyncInflateInfoList)) return;

        // 每个inflate都在一个单独线程异步并行执行
        for (AsyncInflateInfo asyncInflateInfo : asyncInflateInfoList) {
            if (isTagInvalid(asyncInflateInfo.tag)) continue;

            asyncInflate(context, asyncInflateInfo);
        }
    }

    /**
     * 将耗时的布局inflate切换到子线程执行并临时存储
     */
    public void asyncInflate(Context context, AsyncInflateInfo asyncInflateInfo) {
        mExecutors.submit(new AsyncInflateRunnable(LayoutInflater.from(context), asyncInflateInfo));
    }

    private class AsyncInflateRunnable implements Runnable {
        private LayoutInflater inflater;
        private AsyncInflateInfo asyncInflateInfo;

        AsyncInflateRunnable(LayoutInflater inflater, AsyncInflateInfo asyncInflateInfo) {
            this.inflater = inflater;
            this.asyncInflateInfo = asyncInflateInfo;
        }

        @Override
        public void run() {
            doAsyncInflate(inflater, asyncInflateInfo);
        }
    }

    private void doAsyncInflate(@NonNull LayoutInflater inflater, AsyncInflateInfo asyncInflateInfo) {
        View inflatedView = = inflater.inflate(asyncInflateInfo.layoutResId, null, false);
        // 如果主线程已经创建了,就不需要再存储进列表了
        if (!mMainInflatedTags.get(asyncInflateInfo.tag)) {
            mInflateFinishedViews.put(asyncInflateInfo.tag, inflatedView);
        }
    }

    public View getInflatedView(int tag) {
        if (isTagInvalid(tag)) return null;

        View inflatedView = mInflateFinishedViews.get(tag);
        // 异步inflate预加载的view获取后就移除
        if (inflatedView != null) {
            removeInflatedView(tag);
        } else {
            putTagIfMainInflated(tag);
        }
        return inflatedView;
    }

    private void putTagIfMainInflated(int tag) {
        if (isTagInvalid(tag)) return;

        mMainInflatedTags.put(tag, true);
    }

    public void removeInflatedView(int tag) {
        if (isTagInvalid(tag)) return;

        mInflateFinishedViews.remove(tag);
    }

    private boolean isTagInvalid(int tag) {
        return tag <= 0;
    }

    public static class AsyncInflateInfo {
        @LayoutRes int layoutResId;
        int tag;

        public AsyncInflateInfo(@LayoutRes int layoutResId, int tag) {
            this(layoutResId, tag);
        }
    }
}

public class MyApplication extends Application {
	
	@Override
	public void onCreate() {
		super.onCreate();
		List<AsyncInflateManager.AsyncInflateInfo> asyncInflateInfoList = new ArrayList<>();
		asyncInflateInfoList.add(new AsyncInflateManager.AsyncInflateInfo(R.layout.test_layout1, AsyncInflateManager.TAG_TEST1));
		asyncInflateInfoList.add(new AsyncInflateManager.AsyncInflateInfo(R.layout.test_layout2, AsyncInflateManager.TAG_TEST2	));
		asyncInflateInfoList.add(new AsyncInflateManager.AsyncInflateInfo(R.layout.test_layout3, AsyncInflateManager.TAG_TEST3));
		AsyncInflateManager.getInstance().batchAsyncInflate(this, asyncInflateInfoList);
	}
}

public abstract class BaseAsyncInflateFragment extends Fragment {
	
	@Nullable
	@Override
	public View onCreateView(@NonNull final LayoutInflate inflater, @Nullable final ViewGroup container, @Nullable Bundle savedInstanceState) {
		View inflatedView = AsyncInflateManager.getInstance().getInflatedView(getAsyncInflatedTag());
		// 子线程拿不到,就从主线程创建
		if (inflatedVIew == null) {
			inflatedView = inflater.inflate(getLayoutId(), container, false);
		}
		return inflatedView;
	}

	protected abstract int getAsyncInflatedTag();
}
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值