App启动优化最佳实践
优化启动的意义
启动流程是用户对我们App的第一体验,打开应用后才能去使用其提供的强大功能,就算我们应用的内部界面设计的再精美,功能再强大,如果启动速度过慢,用户第一印象就会很差。更有甚者,如果用户点击App后,半天都打不开,用户就可能失去耐心卸载应用。一款App,启动流程承载的不止是用户体验的第一场景,更是一款产品技术形象的初始印象。
我们遇到的问题
借款App很早就开始重视并着手对启动流程做优化,早期借款App的启动项散落在整个启动流程各处,启动项之间又有依赖关系,如需增删启动项,或者调整启动项顺序,就会牵一发而动全身,整个启动流程稳定性欠佳,启动速度慢,测试回归成本高,启动卡住的情况偶有发生。启动流程治理已经迫在眉睫,自此PPDFrame1.0诞生,1.0版本将启动任务与业务流程做了分离,并对启动流程做了物理隔离,将整个启动流程分为Application,Splash闪屏页,首页三个区域。以不影响启动速度为前提,将各启动任务梳理清楚后分别放置在对应区域的各生命周期模块里,后续的迭代过程如需修改启动项,经评估后决定该启动项应该放在哪里。相当长的一段时间里PPDFrame1.0版本有效的保证了启动流程的健壮与稳定。然而随着业务的快速迭代,启动项迅速从之前的20个左右增加到近40个,加之合规需求对启动流程的不断冲击,整个启动流程又出现了新的问题:
1:闪屏页跳转至首页导致的割裂感,用户体验不连贯。
2:首页加载慢,用户已经到了首页,内容还没有渲染完成
3:启动速度慢,白屏时间长。
为了将上述问题一网打尽,是时候祭出PPDFrame2.0了。2.0完全抛弃了1.0的思路与实现,针对目前的问题做通盘考虑,各个击破。
闪屏页跳转至首页导致的割裂感,用户体验不连贯。
因为闪屏页是个Activity,即SplashActivity,首页也是Activity,Activity之间的跳转在App开发中其实是比较重的操作,给用户的感觉就是割裂,不连贯。同时Activity的创建销毁也都是有开销的,基于此首先想到的是抛弃闪屏Activity,应用直接启动首页Activity,闪屏页作为Fragment(SplashFragment)挂载到首页。而实际情况是因为借款App已经迭代多年,即便业务需求可以平移至SplashFragment中,但SplashActivity作为push,deeplink的入口,轻易改动影响的业务方较多,其次SplashActivity还牵扯到复杂的Activity栈管理。基于此方案上仍然保留SplashActivity,但它是个空白闪屏不承载业务,也没有UI渲染,只作为一个跳板存在,创建即跳转首页并销毁。流程图大致如下:
基于上述修改,闪屏页摇身一变成为了首页的Fragment,闪屏页的倒计时3...2...1...结束后,首页的视图早已利用倒计时的间隙在闪屏页下默默的渲染好,用户视感受验连贯不割裂,体验得到了提升。
首页加载慢
虽然调整完启动流程,用户体验着实有了改善,但假如在闪屏页用户直接点了跳过,这个时候留给首页准备的时间还是捉襟见肘。首页是采用Hybrid实现,上半部分是Native,下半部分是H5,H5时常来不及渲染,首页的统一弹框有时候出来也比较慢。所以我们又启动了关于首页优化的项目,Native侧的优化措施有:
1.首页接口请求BFF聚合,减轻客户端网络请求压力。
首页因为业务较复杂,一打开光Native就请求了七八个接口,客户端的网络请求压力可想而知,所以我们考虑对还款卡片,消息通知,品牌信息,滚动栏,异形广告位这五个接口做BFF聚合,这样就避免了因为网络请求队列阻塞而导致接口请求慢引起的白屏问题。
2.首页布局采用渐进式加载策略,提升首屏展示速度。
首页的布局本身就比较复杂,加之新的优化策略中把SplashFragment也挂载到了首页,首页的复杂度进一步提升,最开始首页是一次性加载的,这就导致首页的渲染耗时达到了近500ms,所以我们又对首页的加载策略做了优化,采用渐进式加载,先加载SplashFragment,SplashFragment展示后再去加载真正的首页,这里的具体实现细节不表。经过渐进式加载优化后首页的渲染时间降低了一半。
3.优化首页布局层级,提升首页渲染速度。
虽然经过渐进式加载优化后首页渲染速度已经快了不少,但是首页原本的布局复杂,嵌套层级较深,影响了渲染速度,最后又硬生生的把首页的布局层级减少了2层,布局压力得到缓解。
4.预加载统一弹窗,提升弹窗速度。
针对首页的弹窗弹出比较慢的情况,也对弹窗做了预加载操作。首页的统一弹窗本质上是一个H5页面,以往的方案都是首页加载完成后再去请求url,然后loadurl,预加载的思路也比较容易理解,即在首页创建之前先去请求url并load。
经过上诉的优化后首页视觉以及体验上连贯丝滑,用户体验大幅提升。然而首页下半部分的H5部分因为架构设计以及业务牵扯问题,目前还没有去做预加载操作,但整体效果已经好了不少。
启动速度优化
从用户手指点击桌面上的应用图标到屏幕上显示出应用主 Activity 界面而完成应用启动,快的话往往不到一秒,但是这整个过程却是十分复杂的,其中涉及了 Android 系统的几乎所有核心知识点。同时应用的启动速度也绝对是系统的核心用户体验指标之一,多年来,无论是谷歌或是手机系统厂商们还是各Android应用开发者,都在为实现应用打开速度更快一点的目标而不断努力。但是要想真正做好应用启动速度优化这件事情,须要对应用启动的整个流程有充分的认识和理解。首先应用的启动类型分为三种:
1:冷启动
2:热启动
3:温启动
其中冷启动是指从点击应用图标到UI界面完全显示且用户可操作的全部过程,可以简单的理解为一个应用从未被启动到完全启动的整个流程。
热启动是当我们按了Home键或其它情况app被切换到后台,再次启动app的过程。
热启动时,系统将activity带回前台。如果应用程序的所有activity存在内存中,则应用程序可以避免重复对象初始化、渲染、绘制操作。
如果由于内存不足导致对象被回收,则需要在热启动时重建对象,此时与冷启动时将界面显示到手机屏幕上一样。
温启动包含了冷启动的一些操作,由于app进程依然在,温启动只会重走Activity的生命周期,而不会重走进程的创建,Application的创建与生命周期等,这代表着它比热启动有更多的开销。
温启动有很多场景,例如:
用户按连续按返回退出了app,然后重新启动app;
由于系统收回了app的内存,然后重新启动app。
限于内容与篇幅的问题,本文则主要聚焦在冷启动流程的优化上,本文所述启动也皆指冷启动。
冷启动流程
启动流程:
1:点击桌面App图标,Launcher进程采用Binder IPC向system_server进程发起startActivity请求;
2:system_server进程接收到请求后,向zygote进程发送创建进程的请求;
3:Zygote进程fork出新的子进程,即App进程;
4:App进程,通过Binder IPC向sytem_server进程发起attachApplication请求;
5:system_server进程在收到请求后,进行一系列准备工作后,再通过binder IPC向App进程发送scheduleLaunchActivity请求;
6:App进程的binder线程(ApplicationThread)在收到请求后,通过handler向主线程发送LAUNCH_ACTIVITY消息;
7:主线程在收到Message后,通过发射机制创建目标Activity,并回调Activity.onCreate()等方法。
8:到此,App便正式启动,开始进入Activity生命周期,执行完onCreate/onStart/onResume方法,UI渲染结束后便可以看到App的主界面。
通常到了界面首帧绘制完成后,我们就可以认为启动已经结束了。然而这些都是系统行为,一般情况下我们是无法直接干预的。我们对启动速度的优化方向就是 Application和Activity的生命周期,因为这个阶段的时机对于我们来说是可控的。如下图是早期借款App对于启动项的管理,基本上是完全堆砌在主线程上的,随着业务的快速迭代,启动项也逐渐增多,启动速度濒于失控。我们对启动速度的优化,对启动流程的治理,很大一部分工作就是对启动任务做高效的管理。
启动速度优化
说到启动任务管理,第一时间可能会想到异步加载。将耗时任务放到子线程加载,等到所有加载任务加载完成之后,再进入首页。多线程异步加载方案确实是ok 的。但如果遇到前后依赖的关系呢。比如任务2 依赖于任务 1,这时候要怎么解决呢。最简单的方案是将任务1 丢到主线程加载,然后再启动多线程异步加载。
如果遇到更复杂的依赖呢?任务3 依赖于任务 2, 任务 2 依赖于任务 1 呢,这时候你要怎么解决。更复杂的依赖关系呢?总不能将任务 2,任务 3 都放到主线程加载吧,这样多线程加载的意义就不大了。有没有更好的方案呢?答案肯定是有的,使用有向无环图。它可以完美解决先后依赖关系。
有向无环图(Directed Acyclic Graph, DAG)是有向图的一种,字面意思的理解就是图中没有环。常常被用来表示事件之间的驱动依赖关系,管理任务之间的调度。有向无环图的原来以及具体实现过程,本文不再赘述。
如上图,想要执行任务5,必须是3和4都执行过,同理1执行完才能执行2和4,4执行完才能执行3,那么上述这个依赖关系的正确执行顺序即为12435。
前置任务:任务3依赖于任务 0,1,那么任务3的前置任务是任务 0,1。
子任务:任务0执行完之后,任务3才能执行,那么称呼任务3为任务0的子任务。
多线程中,任务的执行是随机的,那如何保证任务被依赖的任务先于任务执行呢?
首先我们要解决一个问题,它有哪些前置任务,这个可以用队列存储,代表它依赖的任务队列。当它所依赖的任务队列没有执行完毕,当前任务需要等待。当前依赖的任务队列为空,即代表改任务无前置任务或前置任务全都执行完毕,可以立刻执行。
具体实现
首先我们定义一个ITask的接口,再实现一个Task的任务包装类,将启动的任务项全都包装成Task
public interface ITask {
@Priority
int getPriority();
ThreadType threadType();
void run(@NonNull Remote remote) throws Exception;
void onStart();
void onError(Throwable throwable);
void onComplete();
interface LifecycleListener {
void onComplete(Task task);
void onStart(Task task);
void onError(Task task, Throwable throwable);
}
}
task流转与调度上,
private void doPromoteRunner() {
if (!startFlag.get() /*|| runningTask.size() >= concurrency*/) {
return;
}
synchronized (this) {
if (startTime == 0) {
startTime = System.currentTimeMillis();
}
if (completed()) {
startTime = 0;
notifyRunCompleteTask();
} else {
for (Iterator<TaskWrapper> i = queue.iterator(); i.hasNext(); ) {
TaskWrapper call = i.next();
if (!call.isReady()) {
continue;
}
i.remove();
if (call.getRealTask().isExecutable()) {
runningTask.add(call); findExecutorByThreadType(call.threadType()).execute(call);
}
}
}
}
}
做了上面的实现,已经可以保证任务之间的依赖关系以及执行顺序不会出错,然而
Java线程调度是抢占式的,线程优先级比较重要,需要区分。没有区分IO和CPU密集型任务,有可能导致主线程抢不到CPU于是在线程池设计上,我们提供了3个线程池:主线程,iO密集型线程池,CPU密集型线程池。我们在把启动相关做task包装的时候就要知道并定义好当前任务需要进入的线程池类型。
IO密集型任务:IO密集型任务不消耗CPU,核心池可以很大。常见的IO密集型任务如文件读取、写入,网络请求等等。
CPU密集型任务:核心池大小和CPU核心数相关。常见的CPU密集型任务如比较复杂的计算操作,此时需要使用大量的CPU计算单元。
为了更好的监视启动任务的执行状况,我们还做了个小工具可以直观的展示启动任务的执行情况。通过对任务执行情况的分析灵活调整任务的调度流程,对于比较耗时的任务也做了专项的优化。通过一些列优化后,debug情况下,启动主线程耗时从1.5s降低到了300ms,启动速度得到了大幅提升。
启动任务执行情况见下图:
优化前借款App线上的平均启动速度近4s。
优化后线上的平均启动速度降低到1.3s。
未来规划
以上就是借款App在启动流程上的主要优化工作。虽然现在的启动速度比较快了,启动流程的体验也好了很多,但是我们追求极致性能与体验的脚步并没有停止,我们的启动速度目标是1s以内,也即所谓的秒开。当然我们也还有一些规划与手段还没来得及落地,比如前文提到的首页的webview还没有做预加载,空闪屏页也因为历史负债问题没有去掉,我们还可以对首页接口预请求,对布局做预渲染。当然我们还可以从系统层面考虑做MultiDex优化等等。