心遇 Android 启动优化实践:将启动时间降低 50%

2年前 (2022) 程序员胖胖胖虎阿
205 0 0

图片来自:https://unsplash.com/photos/_...
本文作者:ZZG

前言

作为 APP 体验的重要环节,启动速度是各个技术团队关注的重点。几百毫秒启动耗时的增减都会影响用户的体验,并直接反应在留存上。心遇 APP 作为一款用于满足中青年市场用户社交诉求的应用,对各个性能层次的手机型号,都要求有良好的启动体验。因此,随着用户量快速增长,启动优化作为一个性能专项被提上了日程。

启动优化,顾名思义,就是优化用户从点击 icon 到首页完全可见这一过程的时长。为了能更好地对启动时长进行度量,我们将它分为启动阶段和首页首刷这两部分。启动阶段即是从点击 icon 到首页首帧展示为止。首页首刷阶段则是记录从首页首帧可见到首页完全可见的时长。

经过 5 个月的优化实践,心遇线上平均启动时长从 8 秒多降低到 4 秒左右,启动时长减幅超过 50%,其中启动阶段降低 3.7秒,首帧首刷时长降低了 0.4秒。启动优化作为性能优化项目的重要组成部分,已经成功达到了预期的基线目标。

优化实践

本文将介绍心遇团队在启动优化上所做的工作,以及在优化实践中所获得一些感悟。

应用有三种启动状态:冷启动,温启动和热启动。本文主要关注冷启动的耗时。首先我们要明白,启动优化优化的是哪几个步骤:

心遇 Android 启动优化实践:将启动时间降低 50%

在冷启动开始时,系统进程首先会执行一系列操作,并最终创建出应用进程,然后由应用进程执行主线程启动,页面创建等任务。这个流程其实涉及到的点有很多,但基于减少从启动到首页展示这段主链路的时长这个目标来讲,我们可以将工作聚焦于三个阶段: Application 创建,主线程任务, Activity 页面渲染。在后续的优化中,我们也是着重优化这三个阶段的耗时点。

为了能够更好地阐述各个优化措施的实现和带来的收益,我们以 oppo A5 手机为例进行说明。

这是优化前的 App 在 oppo A5上 的各个阶段的耗时。

心遇 Android 启动优化实践:将启动时间降低 50%

从点击 icon 到首页完全可交互,这个阶段的耗时达到了 19 秒。oppo A5 作为一款性能较差的手机,固然会对启动时长有一定影响,但是 App 启动过程中各种不合理的逻辑和代码实现才是整个冗长启动流程的主要原因。经过一系列的优化工作,App 各个启动流程的耗时如下所示:

心遇 Android 启动优化实践:将启动时间降低 50%

整个启动耗时缩短至 9 秒,优化工作收益在10秒左右。接下来,我们会分阶段说明这10秒的收益是如何实现的。

Application 优化

Application 阶段通常用于初始化比较核心的业务库。在应用开发早期,我们并没有对这个阶段的启动任务进行管控,导致这里往往会堆积有大量的强业务相关的代码。在接手这个优化项目前,整个 Application 中执行的任务有 90 多个。在后续的优化中,我们对整个任务流程进行了精简,基于的原则是:

  • Application 中的任务应当是全局基础任务
  • Application 创建时应当尽量减少网络请求操作
  • Application 创建时不允许有强业务相关的任务
  • Application 创建时尽量减少有 Json 解析处理和 IO 操作的工作

优化后,Application 中的启动任务被减少到了 60 多个,主要分为基础库初始化,功能配置和全局配置这三大类。基础类库主要是对网络库,日志库等基础库进行初始化配置,除了主进程外,其余的进程也依赖这些任务,移除它们会对全局的稳定性造成影响。它们也是启动任务中占比最大的,耗时最多的一类任务,因此降低它们的耗时也是后面持续优化的重点。功能配置主要是对一些全局相关的业务功能的前置配置,例如对业务缓存的预加载,特定业务前置等,移除它们会造成业务有损,在这种情况下,我们需要找到业务诉求和功能配置之间的平衡点。全局配置主要是对于全局 UI 配置,文件路径的处理操作,它们占比少,耗时少,是首页创建的前置任务,因此暂不处理。

任务排布的核心是处理好任务的前后依赖问题,这个需要开发者对业务逻辑有着比较深的理解,由于每个应用都不相同,因此这里不做展开。我们主要对心遇在任务排布和优化中一些细节进行介绍:

  • 基于进程进行任务排布。心遇在运行中会启动多个进程,它们用于实现特定的任务,方便模块隔离。很多进程,例如 IM 进程,通常只需要启动 Crash SDK 和网络 SDK 等极少数核心 SDK 初始化工作。对于这类进程,如果按照主进程的流程执行所有的主链路代码,会造成不必要的资源浪费。为此,我们会对 Application 的任务进行细致划分,将任务的运行精细到进程级,避免在非主进程中执行了不必要的任务。
  • 懒加载。这里主要是对一些基础任务进行改造,将任务初始化和任务启动拆分开来,将启动工作移出 Application 创建流程,同时对其进行精简,去除冗余逻辑。在创建对象时,可以延迟其成员对象的创建,灵活使用 by lazy 等关键字,使对象轻量化。
  • 进程收敛。多进程可以实现模块隔离,同时避免单个进程内存占比过高导致的内存上限限制,其劣势在于多进程会导致应用整体内存占用过多,触发低内存的概率更高。此外,如果应用启动时内存占比过高,可能会导致手机进行内存回收,占用大量的CPU资源,这反应在用户的体验上就是启动慢,应用卡。对比多进程的优劣,我们目前采用的策略是尽量延后主进程以外的进程启动,同时通过进程合并来减少进程的数量。通过这些策略,我们最终有效地将启动时的进程数降低至两个。结合任务排布工作,我们为每一个进程都筛选了最简的任务集,避免它们执行不必要的任务,造成资源浪费,这些工作最终使得进程启动占用的内存大大减少。
  • 线程收敛。对于多核CPU来说,适当的线程数量能够提升效率,但是如果线程泛滥则会导致 CPU 负载过重。多线程并发,本质上就是多个线程轮流获取 CPU 使用权的过程。在负载超重的情况下,过多的线程争抢时间片,除了降低启动速度外,也会导致主线程卡顿,影响用户体验。在做这方面优化时,需要确保全局使用统一的线程池。同时很多二方和三方 SDK 也是创建子线程的大户,这时候需要和相关的技术部门进行沟通,去除不合理的线程创建。另一方面,避免在启动阶段进行网络请求,也是减少线程数的关键所在。

Application 优化是整个启动流程的关键,合理的任务编排不仅能够降低 Application 的创建时长,对后续的首页创建也有非常大的优化效果。目前,在 oppo A5 手机上,心遇 Application 的创建时间从 5 秒降低到了 2.5 秒左右,并且还有很大的优化空间。

启动链路

执行完 Application 创建之后,应用进程的主要工作就是创建 Activity。这里需要注意的是,从 Application 到 Activity 里还藏有很多 post 到主线程中的任务,以及注册的 ActivityLifecycleCallbacks 的回调监听,它们会偷偷增加从 Application 到 Activity 的时间间隙。ActivityLifecycleCallbacks 注册通常和业务相关,它的注册比较隐蔽。在之前业务开发中,我们确实存在着一定的 ActivityLifecycleCallbacks 滥用的情况,这需要我们重视起来。

关于主线程消息耗时,我们在使用 Profiler 和 Systrace 对启动流程进行耗时定位时,就发现了很多这样的问题。因为各种原因,Application 中任务会将耗时的工作 post 到主线程中来,表面上看 Application 的创建时间缩短了,但是总体上启动时间却被扩大了。对于耗时点应该定位到根本原因进行解决,而不是一味地 post 出去,这样治标不治本。

其次,缩短启动到首页的链路,是我们优化的重点。

心遇 Android 启动优化实践:将启动时间降低 50%

在原有的启动流程中,loading 页面作为心遇的启动页面,承担有路由和权限请求两个任务。

  1. 在通常情况下,用户启动 App,在 loading 页面判断是否登录,如果未登录,则进入登录页面
  2. 如果用户已经登录,则判断是否需要展示开屏页,如果需要则进入开屏页,等到开屏结束,就跳回 loading 界面,再进入首页。

从上可知,即使没有开屏页,用户启动 APP 到展示首页,最起码要经过两个 Activity 的启动。启动链路缩短的核心在于将 loading,main 和开屏页合并为一个页面。这样做不仅可以最起码减少一次 Activity 的启动,同时也可以在展示开屏页时并行处理其他的任务。

心遇 Android 启动优化实践:将启动时间降低 50%

这里的首页更像是一块画布,首页界面的创建和渲染是其中的一笔。

实现这个功能的代码逻辑比较简单,就是将 main 页面设置为启动页,首页和开屏页封装为两个 fragment,根据业务逻辑进行展示。用户点击 icon 进入到首页,如果判断已登录,则执行首页前置任务和首页 UI 渲染,同时判断是否加载开屏页 fragment。值得注意的是,我们并没有去除 loading 页,当判断用户未登录的情况下,就会进入 loading 页面,进行原有的打点和登录路由的工作。由于在绝大多数情况下,用户都是在已登录的情况下使用应用,所以这么做的收益最大并且修改成本最小。

为了实现这个流程,我们需要处理好首页实例和首页任务编排的问题:

首页实例

首页原有的 launchMode 是 singleTask,这么做的目的是为了确保全局只有一个首页实例。但是我们在改造后将首页设置为启动页,如果继续将首页设置为 singleTask,会造成业务 bug:当我们从二级页面退到后台,在点击图标时回到前台时,会跳到首页,而不是原先的二级页面。这里的原因可以简单理解为,点击图标时,系统会调用 launcher 属性的首页。由于栈中存在首页实例以及它的 singleTask 属性,导致系统会使用这个已有实例,并将它上面的 activity 都 pop 出栈,最后造成这个异常情况出现。解决的方案是选择 singleTop 作为首页的 launchMode。singleTop 并不能确保首页实例的全局唯一性。好在心遇 APP 实现了 router 跳转的功能,可以通过统一的 url 打开首页。我们在启动首页 Activity 的最后一步,在 intent 中增加 FLAG_ACTIVITY_NEW_TASK 和 FLAG_ACTIVITY_CLEAR_TOP 的 flag,以实现了 singleTask 的效果。这个方案基本可以满足我们的需求,但是也不排除在特定情况下,出现直接启动首页的操作。为此我们注册了 Application.ActivityLifecycleCallbacks,对栈里的 activity 实例进行监控,当栈中出现多个首页实例时,对新的首页实例进行清除,并进行提示。

任务编排

经过改造后的首页并不是一个传统意义上的页面 Activity,而是承载了一连串任务执行的容器。包括在后续的改造中,我们会将首页的数据请求和 UI 渲染抽离开来,此外一部分高优的业务任务也被从 Application 中抽出放到了首页中,为了有效管理这些任务的前后依赖关系,我们需要一个有向无环图对这些任务进行管理。

心遇 Android 启动优化实践:将启动时间降低 50%

任务编排的核心思想就是任务打散,错峰加载,将一个低优先级并且高耗时的任务延后,或者放在更往后的闲时工作流中去进行执行,同时也要保证任务之间的前后依赖关系,确保不要出错。合理地划分业务任务的颗粒度并且对它们进行排序是决定图运行速度的关键,也比较考验开发对业务的熟悉程度。目前业内已经开源的关于有向无环图的方案有很多,比如 alpha 等,为了适配心遇特定的业务需求,团队内部也开发一套启动框架用于实现对首页任务的编排。

关于首页任务的加载,我们初步提出了工作流的概念。我们将启动任务划分为了三个工作流阶段,包括基础工作流,核心工作流和闲时工作流。整个启动流程中的业务逻辑都被我们拆成了相对独立的任务,并按照优先级和依赖关系分配到了这三个工作流中。

  • 基础工作流:这个阶段主要是执行 Application 的创建,用于放置网络库,监控等基础 SDK。 这个工作流的任务要求尽量得少,并且都是后续工作流的前置任务。
  • 核心工作流:在这个阶段中,会放置核心的业务工作,除了一些核心业务的初始化工作之外,还包括首页 UI 的渲染,业务数据的请求以及开屏页的展示。这些任务根据有向无环图进行排列和管理,从首页创建时开始执行。由于这个阶段已经进入到了首页,因此为了让用户能尽早看到第一帧,我们需要尽可能地将业务数据获取和首页渲染的任务提前。
  • 闲时工作流:这个阶段主要适用于放置一些优先低,耗时长并且对完成时间不做要求的任务。关于对闲时时机的判断有好几种方法,心遇这边做了简单处理,即在核心工作流结束 10 秒后,在 IdleHandler 中进行执行的。如果希望比较精确地判定闲时时机,可以通过往主线程中 post 消息,统计主线程中消息间隔时长和应用内存水位监控相结合的方案进行判定。

使用启动框架来管理启动任务的好处在于可以使得核心业务提前加载完成,同时也可以将任务细粒度化。例如为了使得首页更快地展示,我们将首页的数据请求和 UI 渲染相剥离。将首页的数据请求提前到了 Application 中。这个低端机上优化效果显著,数据请求的 call 对象创建和 Json 解析在低端机上耗时严重,通过利用 Application 和 Activity 创建的时间同时进行接口请求操作,使得在低端机上,首页的 loading 时间从原有的 3 秒缩短到了 1 秒以内。

在后续的工作中,任务编排始终是我们启动优化的重点方向。尤其是通过对每个任务的耗时点定位和对整个任务流程的梳理,从而将启动时长降低到极致,是我们启动优化的长期目标之一。

启动优化做到了现在这个地步,整个启动流程的代码也是大翻新,但是线下评测数据显示,整个启动耗时仅仅缩短了 3 秒左右,甚至于首刷时长还有一定程度的劣化。这是因为启动流程是一个整体,启动和首页不能割裂开来。前面对于启动的任务编排也势必会影响到首页的创建。此外,我们的优化工作还不够细腻,对一些细节把握还不足,尤其是锁的处理方面,后面会对这个进行介绍。

首页优化

处理完启动链路的梳理,接下来,我们需要将目光转向首页。首页是整个 APP 中最核心的页面。它的业务逻辑繁复并且 UI 层级复杂。

懒加载

经过前面的改造,我们的首页大致如图所示:
心遇 Android 启动优化实践:将启动时间降低 50%
在打开首页后,APP 会加载首页的五个 TabFragment,这个是极为耗时的。

我们测算了 App在 oppo A5 上各个 fragment 的创建时长,大致数据如下:

心遇 Android 启动优化实践:将启动时间降低 50%

如果能够延迟动态等另外四个 fragment 的创建和加载,理论上可以减少 2 秒左右的启动耗时。

考虑到首页展示时只有第一个 fragment 是可见的。为此我们对首页实现了懒加载。首页使用的是通用的 ViewPager2+tabLayout 的架构形式。ViewPager2 天然支持懒加载的操作,为了避免在页面切换时,已有的fragment 被回收,我们增大了 viewPager2 内部的 recyclerView 的缓存池大小。

 ((RecyclerView)mViewPager.getChildAt(0)).setItemViewCacheSize(mFragments.size());

虽然这个方案能极大地加快首页的渲染速度,但是本质上它是将其他页面的创建和渲染推迟到了切换时,如果页面比较重并且手机性能比较差的话,在切换时会有明显的卡顿和白屏情况,这也是无法接受的。

为此我们对首页和各个fragment进行了改造。

页面插件化

View 的创建是首页渲染耗时的大户。通常情况下,我们使用 LayoutInflater 去加载 xml 文件,这里面涉及到了 xml 解析,然后进行反射生成实例的过程,总体上是比较耗时的。我们对其中比较简单的 xml,使用了代码进行构建,但是对于复杂的布局文件,使用代码构建耗时巨大,并且不可维护。

为了让首页"轻"起来,我们基于业务角度对 view 进行组件化切割。这里核心的一个思路就是让用户看到最基础的界面,使用到最核心的功能。举个例子,比如一款视频播放应用,用户第一眼想看到的是它的播放界面,想使用的是视频播放功能。至于其他的顶部图标等等,却是不会在乎的。基于这个思想,我们应当首先将播放组件和播放功能优先创建,并展示出来,而其他的业务模块可以通过 ViewStub 的形式延后加载。

那么心遇的核心页面是什么?是首页的缘分列表,而核心功能则是对缘分列表的操作。明白了这一点,首页复杂的逻辑整个就清晰了起来,我们明白了用户最核心的需求是什么。

这里介绍一下 Plugin,Plugin 是团队内部沉淀出的一套 UI 组件化方案,本质上是一套升级版的 ViewStub,但是又具备 fragment 的能力,天然适配 mvvm。Plugin 是一个功能强大的组件库,关于 Plugin 的具体实现,我们在经过细细打磨之后,有机会会在后续文章里进行介绍,这里暂且不做展开。通过 Plugin, 我们将复杂的 view 基于业务层次切割成一块块独立的业务功能组件,按照优先级进行加载。这样可以确保用户可以更快地看到首页,并使用到最核心的功能。

心遇 Android 启动优化实践:将启动时间降低 50%

缘分的 plugin 在首页创建时就优先展示,而其余的plugin可以等到缘分 plugin 完全展示,并且相关的数据返回时再进行渲染和加载,这样大大减轻了首页的负载。

Json解析处理

Json 解析操作也是需要进行优化的点。优化前,根据测试同学的测试数据,在低端手机上,主接口的 Json 解析时间高达 3 秒,这个时长是无法被接受的。

Json 解析耗时的原因本质上是在解析时,从 Json 数据到对象的创建是通过反射操作进行对象生成和赋值的,对象越复杂,那么耗时就越长。对于首页的主接口来说,返回对象的解析在低端机上的耗时已经超过 UI 的渲染的耗时,是我们必须要克服的点。

我们目前采取的方案是将首页的数据对象使用 Kotlin 进行重构,并对相关对象使用 @JsonClass(generateAdapter = true) 进行标注,它会在编译期间对标注的对象生成对应的解析适配器,从而缩短解析时间。

XML 解析优化

测试数据显示,在性能较差的手机上,xml inflate 的时间在 200 到 500 毫秒之间。自定义控件和较深的 UI 层级会加重这个解析耗时。

为了降低 xml 解析的时间。我们对首页的各个 UI 模块的 xml 进行了优化,尽量减小 xml 层级,并且避免不必要的自定义控件的使用。在心遇 App 上,对强业务相关的自定义控件一定程度的滥用也是导致 xml 加载耗时的重要原因。

除此之外,我们也考虑过其他方案来降低解析的时间,比如将 xml 解析的操作放在子线程中,并提前到 Application 中进行执行。这个方案简单且有效,并取得了一定的收益。

一个具体的例子是,首页缘分页面的 item 的 xml 解析耗时在 200ms 左右,我们将它放置于子线程中并提前预处理,解析成功后的 view 存入缓存中,在进入到首页创建 item 时,从缓存中获取 view 进行渲染。结果是成功将 item 创建的时长降低到了 50ms 以内。

异步解析的方案看起来很有效果,但是如果指望将所有 xml 都通过异步解析的方案进行预处理,那么注定是要失望的。因为它其实有很大的局限性。

首先要注意的 view 的锁的问题,它会使 xml 的解析从异步变成同步,导致解析反而变慢。关于锁的讲解和处理,我们会在下一章进行详细说明。在上述优化例子中,我们通过复制 LayoutInflater 实例的方案来部分绕过了锁的限制。

   LayoutInflater inflater = LayoutInflater.from(context).cloneInContext(context);
   View view = inflater.inflate(R.layout.item_view, null, false);

view的锁事实上并不只LayoutInflater一处,在resource,assets内部也存在有锁,因此上述的方案并不能完全达到同步的效果。

第二点,子线程优先级低,尤其在负载重的情况下,子线程解析 xml 会导致整个解析流程拉长。这很容易出现在实际需要用到 view 时,xml 解析还没执行完毕,导致走降级方案,重新主线程解析 xml,这反而导致了资源浪费,最终使得渲染时长变长。因此异步解析 xml 只能用于极少的核心 xml 的预处理,并且 xml 的层级不能太复杂。

关于 xml 的解析优化一直是我们探索的重点。目前,我们尝试使用 compose 的方案来作为解决 xml 解析耗时问题的主要方向,现在还在实验和沉淀阶段,相信不久就会有符合预期的成果。

首页优化的工作带来了很大的收益,线下评测显示,在 oppo A5 手机上,不仅首帧展示的时长相比于优化前降低了 3 秒左右,整个首页渲染的时间也达到了 1 秒以内。首页的数据能够更快地展示,用户的使用体验也会大大提升。

锁对启动优化时带来的麻烦是如此让人印象深刻,以至于我们需要单独开一节来讲。

在进行启动优化时,如果遇到耗时的任务,我们通常会将它置于子线程中处理,理论上讲如果资源足够,这个耗时任务的时间会被完全优化掉。但是事实往往并不是如此,这种操作可能效果不佳,甚至反而会恶化。这里面的原因就是锁。这里我们挑几个有代表性的锁来讲一讲。

Retrofit

我们都知道 Retrofit 是通过动态代理的方式生成请求时的 Call 实例,但是我们往往忽视了其中锁的存在。

心遇 Android 启动优化实践:将启动时间降低 50%

如果在首页出现这种大量接口同时发起请求的情况,多个接口创建竞争这把锁,这会无形中把接口请求,从并行变成了串行,这在性能较差的手机上尤为明显。

所以在实际启动过程中,我们可以看到,一个 api 请求的耗时,往往是请求本身只需要 300ms,但等 Retrofit 的这把锁就要 200ms。

心遇 Android 启动优化实践:将启动时间降低 50%

此外分析 Retrofit 通用的写法,我们可以看到这部分耗时是在执行 create 时产生的。糟糕的是,这种写法常常会让我们误以为,这只是创建了一个对象,并不是一个耗时的操作,从而将它轻易地暴露在主线程中。

   GitHubService service = retrofit.create(GitHubService.class);

我们通过对首页的代码进行整改,通过切换线程的形式,将这部分代码切到子线程中。对于锁的问题,通过排查,基本上是由于解析接口返回数据时耗时过长所导致的。这部分涉及到 Json 解析优化的问题,可以看上文解决方案。

反射

我们知道反射是耗时操作,尤其对于 Kotlin 的反射而言。因为 Kotlin 各种语法糖的缘故,反射操作需要从 Metadata 中读取类信息,所以 Kotlin 的反射效率本身就比 Java 低不少。

同时,因为 kotlin_builtins 的存在,Kotlin 中的很多内建信息(比如基础类型 Int, String, Enum, Annotation, Collection 都是以文件的形式保存在 apk 中的,同时也包括协程、多平台等 Kotlin 会用到的信息),在反射过程中会隐式触发类加载和对这些文件的 IO 操作。

   static {
        Iterator<ModuleVisibilityHelper> iterator = ServiceLoader.load(ModuleVisibilityHelper.class, ModuleVisibilityHelper.class.getClassLoader()).iterator();
        MODULE_VISIBILITY_HELPER = iterator.hasNext() ? iterator.next() : ModuleVisibilityHelper.EMPTY.INSTANCE;
        }

对于类加载而言,本身就是有锁 + IO 操作,所以线上经常会有 ANR 出现。

而 IO 操作更不必说,受限于系统整体文件系统的负载,IO 操作本身的耗时就是不可控的,同时在锁内 IO 又无形中加剧了这种耗时。

所以,反射操作尤其是 Kotlin 的反射可以简单理解成一个潜在的带锁 IO 操作(尤其是在 APP 启动过程中)。

这个可能会导致各种奇怪的问题。在优化中我们就曾碰到过这么一个例子,我们本身希望通过本地缓存的方式让用户更早得看到 UI,但是由于各种锁的加持,最终不仅拖慢了各个 api 请求的速度,还蝴蝶效应般的把这部分预加载的耗时又转换回了 UI 线程,导致线程卡死。我们在通过一系列的排查工作之后,最终定位到原因是在启动过程中,Kotlin 首次加载 buildins 带来的耗时。我们可以在必要时手动触发第一次反射,来规避掉这个问题。

View的锁

前面说到了 view 的创建是耗时大户,正向优化会比较困难,我们也会想到,是不是可以把一部分 UI 扔到 IO 线程中去 inflate。上文中也有异步解析 xml 的方案。同时我们也提到了 view 的 inflate 部分步骤也是带锁的。

心遇 Android 启动优化实践:将启动时间降低 50%

这把锁是跟着 LayoutInflater 实例走的,换言之一般情况下是跟 Context 走的。在我们遇到的例子中,view 的加载放在子线程中,由于锁的存在,导致其他的 view 的加载时间被拖长了,并且由于 CPU 负载高,IO 线程优先级低,这一系列原因反而导致启动流程恶化。

Moshi

Moshi 深度遍历一个 Class 并生成 JsonAdapter 涉及大量反射,耗时时间不可控。不过比较科学的是,Moshi 内部缓存虽然也有用到锁,但借助 ThreadLocal 可以把耗时操作放在了锁外,后续类似场景也可以参考这种写法。

心遇 Android 启动优化实践:将启动时间降低 50%

最佳实践

通过对上述一系列问题的分析,我们可以总结出以下最佳实践:

  1. 不要在主线程进行任何 Moshi 解析;
  2. 通过 Moshi 解析 Kotlin 类时,要使用 JsonClass 注解;
  3. 不要在主线程进行任何 Retrofit 相关的操作;
  4. 异步 inflate xml 需要注意多线程竞争的问题。

实际情况上,问题千变万化,硬套公式是不行的,最佳实践更不是银弹。在遇到耗时的任务时,不想着去查找原因,而是粗暴地将它放在子线程中,不管是因为同步锁还是其他机制,被消耗的 CPU 时间总会以另一种形式影响 UI 线程的工作效率。

防劣化

启动优化是一个长期的优化项目,其时效之长可以说是贯穿一款产品的生命周期。所以并不是说在一段时间重点攻坚之后,启动时长降下来了就万事大吉了。如果没有防劣化措施,在经过几个迭代之后,启动时长又将回升,尤其是当启动优化来到了深水区之后,各方面的改动都会对启动速度有着千丝万缕的影响。所以启动优化不仅是一个攻坚战,更是一个长时间的拉锯战。

因此,我们需要线上和线下的监控数据作为我们启动优化工作的指导。

线上数据

核心的节点如下:

心遇 Android 启动优化实践:将启动时间降低 50%

在线上数据中,我们主要采集了 Application 的 attachBaseContext 方法作为启动的起始点,首页的 onWindowFocusChanged 作为首页可见的节点,onViewAttachedToWindow 作为首页数据上屏的节点。

心遇目前使用的监控节点更偏向于作为横向比较,如果期望更加精确的测量数据,启动的起始点可以使用进程的创建时间,首帧数据采集可以定位到 dispatchDraw 时调用。但是考虑到易用性,并且期望不受业务影响,心遇使用了目前的监控方案,主要是用于对比历史版本的优化和劣化。

值得一提的是,线上数据采集需要关注噪音的影响。部分机型会在后台杀死进程并重启,但是在重启过程中因为省电策略又会强制停止重启过程,导致这次计时异常,出现噪音。

心遇这边采用的方案是对比启动时长和线程的启动时长的对比,如果相差超过阈值,则舍弃这次记录。

   val intervalTime =
        abs(System.currentTimeMillis() - attachStartTime - SystemClock.currentThreadTimeMillis())

根据实践,20 秒是比较合适的阈值数字。

线下数据

通过打点进行数据统计,虽然能够在一定意义上反映出当前的启动状态,但是总归和用户实际的体验会有区别。因此在线下数据采集时,我们比较推荐使用各个性能层级的手机对应用进行启动录屏,最后测量出启动耗时。

措施

关于防劣化的工作,我们目前还在摸索阶段。目前在每个版本发布之后,测试同学会给出一份当前版本的性能测评报告,我们会结合线上的启动数据进行综合分析,判断当前版本的启动时长是否劣化。如果数据劣化,我们会对整个启动流程进行分析,找出异常点并修正。

这种方案比较低效,因为很多情况下劣化程度低,不易在数据上展示出来。在等到能从数据上反应出来时,可能已经累计有很多的异常点了。

为此,我们对Application和首页的代码改动会进行特别的 Code Review。我们不鼓励在 Application 中添加代码,如果需要添加代码,那么会对其必要性进行评估。此外,我们对启动框架设置了启动耗时报警,如果启动耗时超过阈值,则会在开发阶段就提醒开发者代码可能有异常。我们认为所有的优化工作归根到底都要靠开发者本身,因此每个团队成员都要有这方面的优化意识才是最重要的。我们目前也在计划制定相关的规范措施来规范团队成员在这方面的开发工作。

总结

启动优化做到现在,心遇的启动速度和首屏渲染时长都已进入到基线。但是正如上文中说的,启动优化是一个需要长期关注的专项,我们对于心遇的启动时长的优化也不会仅仅限于此。在这次优化项目中,我们遇到过很多问题,也总结出了很多的最佳实践方案,其中最大的收获就是深刻明白了一点:没有无缘无故的耗时,如果有,那么就肯定是哪里出问题了。面对耗时,不想着去解决,只是将它放在子线程中,然后不予理睬,这个问题必然会在下个路口等你。我们有时也考虑过使用一些黑科技去优化启动速度,但是往往效果不尽人意,后来想想,其实大道至简,往往最简单的方案才是最好的,对症下药,才能拿到最佳效果。一味追求高大上的技术去优化,往往陷入了大炮打蚊子的窘境。

在后续的工作中,我们依然会对启动进行持续的迭代和打磨。相比于之前的工作,我们会更加精细化,由浅入深,结合具体业务进行技术方案定制,实现速度提升,在摸索出一套比较合适的方案后,再对方案进行泛化,应用到其他业务中。然后回过头来看看,这样由外入内,再由内入外,我们可能会对整个启动流程有崭新的认识。

参考资料

  • 应用启动时间
  • 抖音 Android 性能优化系列:启动优化实践

本文发布自网易云音乐技术团队,文章未经授权禁止任何形式的转载。我们常年招收各类技术岗位,如果你准备换工作,又恰好喜欢云音乐,那就加入我们 grp.music-fe(at)corp.netease.com!

相关文章

暂无评论

暂无评论...