雪球 Android App 秒开实践
一、背景
启动速度可以说是一个 APP 的门面,对用户体验至关重要。随着业务不断增加,需要初始化的任务也越来越多,如果放任不管,启动时长会逐步增加,为此雪球客户端针对应用启动时长做了大量优化工作。本文从应用启动基本原理出发,总结了雪球客户端启动优化的思路和遇到的问题。主要包括启动原理介绍、优化方案和线上验证等三方面内容。
二、启动原理
根据 Google 官方文档,应用启动分为以下三种类型:
冷启动
热启动
温启动
冷启动
冷启动是指 APP 进程被杀死(系统回收、用户手动关闭等),启动 APP 需要系统重新创建应用进程,从用户点击应用桌面图标到第一个页面加载完成的全部过程。冷启动是启动类型中耗时最长的一种,也是启动优化最关键的优化点,下面我们来看一下冷启动的启动过程。
从上图可以看出 APP 冷启动可以分为以下三个过程:
用户点击桌面 APP 图标,调用 Launcher.startActivity ,由 Binder 通知 system_server 进程,system_server 内部使用 ActivityManagerService 通知 zygote 创建应用子进程
应用进程创建完成后,会加载 ActivityThread 并调用 ActivityThread.main 方法,用来实例化 ApplicationThread 、Lopper 和 Handler
ActivityThread 内部调用 attach 方法进行 Binder 通信回到 system_server 进程,执行 ActivityManagerService.attachApplication 完成 Application 的创建,同时启动第一个 Activity
我们可以换一种通俗易懂的描述:
想象一下把 Launcher 比做手机桌面,桌面里面很多 APP 可以理解成 Launcher 的孩子,zygote 就是一个进程,system_server 好比服务大管家,ActivityThread 好比每个 APP 进程自己的管家。
启动 APP 首先要通知服务大管家 (system_server),服务大管家 (system_server)收到通知后,会跟它的第一对接人 zygote 进程联系,请求 zygote 创建一个属于孩子的家,也就是 APP 自己的进程,进程创建完成后,接下来是属于孩子自己的工作,它开始使用自己的管家 ActivityThread 布置自己的家,可以简单把 Application 比做是大门,把 Activity 比作是卧室,AMS 是装修团队,ActivityThread 会不断和 AMS 交互,直到 Application 和 Activity 创建完毕,至此一个 APP 就启动完成了。
热启动
热启动是指应用程序从后台被唤起,此时应用进程仍然存在,应用启动无需创建子进程,但是可能会重新执行 Activity 的生命周期,在热启动中,系统的所有工作就是将您的 Activity 带到前台,只要应用的所有 Activity 仍驻留在内存中,应用就不必重复执行对象初始化、布局和绘制,例如用户按下 back 或者 home 键回到后台。
温启动
温启动包含了在冷启动期间发生的部分操作,同时它的开销要比热启动高,例如用户在退出应用后又重新启动应用,此时应用进程仍然存在,但应用必须通过调用 onCreate() 从头开始重新创建 Activity
冷启动是三种启动状态中最耗时的一种,启动优化也是在冷启动的基础上进行优化,热启动和温启动相对耗时较少,暂不考虑优化。
三、问题归因
工欲善其事必先利其器,要想优化启动时长,首先必须知道应用启动过程中发生了什么,以及耗时方法是哪些,下图列举了一些 APP 常用的性能检测工具:
adb shell
获取应用启动总时长 adb 命令:adb shell am start -W [packageName]/[packageName.xActivity]
详细使用可参考文档:developer.android.google.cn/studio/comm…
参数说明:
Activity:应用启动的第一个Activity
TotalTime:应用启动总时长,包括应用进程创建、Application 创建和第一个 Activity 创建并绘制完成到显示的所有过程,冷启动的情况下我们只需要关注 TotalTime 即可
Displayed
displayed 使用比较简单,我们只需要在 Logcat 中过滤关键字 displayed 即可看到应用启动的总时长,如下图所示,displayed 打印的时长跟 adb shell 几乎相同,也就是一次冷启动的总时长。
adb shell 和 displayed 都可以帮助我们快速获取应用启动时长,但是无法获取具体耗时方法的堆栈信息,应用启动的具体信息我们可以使用 Systrace 和 Traceview 来获取。
Systrace
Systrace 是 Android 平台自带的命令行工具,可记录短时间内的设备活动,并保存在压缩的文本文件中,该工具会生成一份报告,其中汇总了 Android 内核中的数据,例如 CPU 调度程序、磁盘活动和应用线程。
Systrace 工具默认在 Android SDK 里面,路径一般为 Android/sdk/platform-tools/systrace
使用 systrace 生成应用冷启动具体信息
如果没有配置环境变量,先切到 systrace 目录下 cd ~/Library/Android/sdk/platform-tools/systrace
执行 systrace.py -t 10 -o /Users/liuyakui/trace.html -a com.xueqiu.fund
或者直接用绝对路径执行 systrace
详细使用可参考文档:developer.android.google.cn/topic/perfo…
python ~/Library/Android/sdk/platform-tools/systrace/systrace.py -t 10 -o /Users/liuyakui/trace.html -a com.xueqiu.fund
systrace 报告如下图所示,这里仅摘取了启动优化所需要的主要信息:
区域1代表 CPU 使用率,柱子越高,越密集代表 CPU 使用率越高
区域2代表 CPU 编号,该设备是8核处理器,编号0-7,点击 CPU 编号区域,可以查看当前正在运行的任务
区域3代表所有线程和方法具体耗时情况,可以帮助我们定位具体耗时方法
从上图可以看出在0-3秒内,CPU 平均利用率较低,特别是1-3秒这段时间,CPU 几乎处于闲置状态,提高 CPU 利用率,充分发挥 CPU 的性能,是我们主要的优化方向。
上述三部分区域所提供的信息,基本上可以帮助我们定位启动耗时问题,它提供了 CPU 使用情况以及每个线程工作情况,但它不能告诉我们具体的问题代码在哪里,我们要确定具体的耗时代码,可以使用 Traceview 工具。
Traceview
Traceview 能够以图形化的形式展示线程的工作状态,以及方法的调用堆栈和调用链,我们以 application onCreate 为例,统计 onCreate() 内部详细的方法调用,并生成 trace 报表。
详细使用可参考文档:developer.android.google.cn/studio/prof…
@Override
public void onCreate() {
super.onCreate();
Debug.startMethodTracing("app_trace");
//初始化代码...
//...
Debug.stopMethodTracing();
}
应用启动完成后,会在 /sdcard/Android/data/com.xueqiu.fund/files 路径下生成一个 app_trace.trace 文件,直接用 AndroidStudio 打开即可,如下图所示:
trace 文件详细展示了每个线程的工作情况,以及每个线程内部具体的方法调用情况,下面简单介绍一下trace 报表中最重要的三块区域:
区域1代表 CPU 使用情况,可以拖拽选择时间段
区域2代表当前线程工作信息,截图所示为当前主线程在0-5s内所有的方法调用情况
区域3代表当前线程内部的方法调用堆栈,以及方法耗时等信息,使用 Top Down 和 Bottom Up 可以对方法正反排序
trace 报表清晰的展示了每个线程对应的所有方法的调用链和耗时情况,很好的帮助我们定位启动过程中具体问题所在,为优化方案提供了重要的参考依据。
四、优化方案
经过上述分析,APP 启动问题主要集中在以下两个阶段:
Application 创建
闪屏页绘制
因此下面主要是针对这两方面进行优化
Application 创建优化
从上述 Traceview 报表可以看出,影响 Application 创建的代码主要集中在 initThirdLibs 内部,我们来看一下 initThirdLibs 内部初始化代码执行流程。
initThirdLibs 内部包含了雪球客户端所有的初始化项,这些初始化任务不分主次和优先级都在主线程顺序执行,中间任意一个任务阻塞,都会影响 Application 的创建,而且随着业务不断迭代,初始化任务越来越多,Application 的创建时长也会继续累加。
因此梳理 initThirdLibs 内部任务的优先级,通过合理的方式统一调度,并对各个任务进行延迟初始化是优化 Application 创建的重要内容,延迟初始化主要实现的目标分为以下三点:
提高 CPU 利用率,充分发挥 CPU 性能
初始化任务 Task 处理,降低维护成本和提高任务调度的灵活性
多线程处理,梳理各个 Task 的优先级,形成一个有向无环图
Task 任务流程图如下:
关于启动器实现核心逻辑为,自定义线程池,根据设备 CPU 情况动态计算线程数量,保证所有 Task 任务并发执行,并且相互独立,所有 Task 执行完毕后会最后执行 Final Task 用来做一些收尾的工作,或者有强依赖的任务,也可以放到 Final Task 中进行,这里推荐以下两种实现方式:
CountDownLatch
自定义线程池
启动器伪代码如下:
//这里只是一段伪代码,帮助大家理解启动器的基本实现原理
TaskManager manager = new TaskManager();
ExecutorService service = createThreadPool();
final Task1 task1 = new Task1(1);
final Task2 task2 = new Task2(2);
final Task3 task3 = new Task3(3);
final Task4 task4 = new Task4(4);
for (int i = 0; i < n; i++) {
Runnable runnable = new Runnable() {
@Override
public void run() {
manager.get(i).start();
}
};
service.execute(runnable);
}
Task 调度完成后,将不依赖主线程的初始化任务,移动到并发 Task 中进行延迟初始化,进行统一管理并且避免阻塞主线程,提高 CPU 利用率。
闪屏页绘制优化
目前闪屏页主要承载的是业务广告,通过优化广告加载的逻辑可以间接调整页面的布局结构。
布局结构
闪屏页会预加载广告数据存到本地,每次应用启动从本地读取广告数据,这里我们可以优化无广告页面展示的逻辑,目前闪屏页无广告的时候仍然会加载布局文件,并设置默认的页面停留时长,理论上如果页面无广告,闪屏页创建完成后可以直接进入首页,不用加载页面的布局文件从而减少页面绘制时间,调整后页面广告加载逻辑核心代码如下:
private void prepareSplashAd() {
//读取广告数据
String jsonString = PublicSetting.getInstance().getSplashAd();
if (TextUtils.isEmpty(jsonString)) {
//无广告,关闭页面,进入首页
exitDelay();
return;
}
//加载布局文件
View parentView = inflateView();
setContentView(parentView);
//显示广告
AD todayAd = ads.get(0);
showSplashAd(todayAd.imgUrl, todayAd.linkUrl);
}
优化结果
经过多个版本的线上数据采样,启动时长明显下降,以华为 Mate 30E Pro 为例,效果对比如下:
优化前
优化后
从上面对比中可以看到,在5年以内的旗舰机型上,启动时长从原来的 1.9s - 2.5s 降低到 0.75s - 1.2s ,整体降低60%左右,可以达到秒开的效果!CPU 活动转为密集型,充分发挥 CPU 的性能,提高了 CPU 的利用率。
五、总结
本文先介绍了应用启动的基本原理,以及如何通过各种检测工具定位影响启动速度的原因,最后重点阐述 Application 创建和闪屏页绘制两个阶段的优化方案。同时它也代表一组最佳实践,在后续的性能优化中,都是不错的选择。
其实启动优化的方案还有很多,但我们除了要关注启动优化本身,更需要制定长远的规划,设计适合自己的方案,为后续业务迭代做好铺垫,避免再次出现启动时长逐步增加的问题。
作者:雪球工程师团队
来源:juejin.cn/post/7081606242212413447