突破App启动时间的极限

(这篇文章由 Mikhail Nakhimovich 发布。Mikhail Nakhimovich 白天在屡获殊荣的纽约时报安卓 APP 团队担任架构师,晚上则撰写安卓 APP 开发相关的文章,以及在 Friendly Robot 团队帮助开发者开发高性能、用户体验优良的 APP。 )

开发一个高性能的小规模 APP 很简单,一旦涉及到数据缓存、JSON 解析、不可变对象、依赖注入以及一系列安卓 support 库时,保持 APP 的高性能就变得很有挑战性了。

幸运的是保持 APP 的高性能依然是有可能的,即便我们使用了 RxJava 进行响应式编程,不可变数据对象, Guava 集合,Optional,以及用 Dagger 进行依赖注入。关键是如何减少阻塞代码反射,以及从 apk 中加载资源的相关代码。如果我们能够做到上述几点,我们依然能够让常见的 APP 启动速度和 hello world APP 保持一致。

根据我的经验,如果使用了未对安卓平台进行优化的第三方库,仅仅几次的调用就会严重拖慢 APP 的启动和运行。虽然这听起来很沉闷,其实这是天大的好消息!这意味着打造和 Google 官方 APP 同样高性能的 APP 并非痴人说梦。只需要遵循一些最佳实践,尤其是选择外部依赖时,你也可以把一款卡顿的 APP 改造成高性能的 APP。

在本文中,我们将一起开发一款在 RecyclerView 中显示 200kb 数据的 APP,它能离线工作,而且没有超过 65536 方法数限制,在 Nexus 5 上启动速度在 0.8 秒左右。

首先,我们 fork 一下 Android Boilerplate project 库。然后我们引入 Guava, Immutables, SqlDelight 以及一些安卓 support 库,使得方法数增加起来。

下面是我们的这个 APP 在安装之后首次启动时执行的工作:

  1. 从 reddit.com 下载 200 kb 左右的 JSON 数据;

  2. 在网络层缓存数据;

  3. 在内存中缓存数据;

  4. 把数据保存到 SQLite 中;

  5. 从 SQLite 加载数据;

  6. 在 RecyclerView 中显示数据,并使用 MVP 模式;

后续再次启动之后它会执行以下工作:

  1. 从 SQLite 加载数据;

  2. 在 RecyclerView 中显示数据,并使用 MVP 模式;

上述的缓存行为确实有些冗余,在多个层次都进行了缓存,不过这一行为也可以用于模拟真实 APP 中启动之后需要执行的复杂操作。首先我们需要优化 JSON 的序列化和反序列化。我们将使用 Gson 作为 JSON 解析库,因为它非常轻量快速。默认情况下 Gson 会使用反射进行 JSON 解析,反射性能较差,我们可以通过手动注册 type adapter 来避免对反射的使用。因为我们使用 Immutables 库,所以我们将使用在编译期自动生成的 type adapter

new GsonBuilder()
       .serializeNulls()
       .registerTypeAdapterFactory(new GsonAdaptersEntities())
       .create();

type adapter 设置好之后,我们就把 Gson 实例注册到 Retrofit 中。

new Retrofit.Builder()
       .baseUrl(changeableBaseUrl)
       .client(okHttpClient)
       .addConverterFactory(GsonConverterFactory.create(gson))
       .addCallAdapterFactory(RxJavaCallAdapterFactory.create())
       .build()
       .create(Api.class);

在这里我们还使用了 RxJavaCallAdapterFactory 来把网络请求的结果转化为 Observable,以方便异步处理和事件流操作。使用了我们自定义的 Gson 实例之后,Retrofit 可以在解析网络请求数据时完全不使用反射。下面是对https://www.reddit.com/r/cheetahPics.json 的一次调用示例,这次 API 调用返回了大约 200kb 的 JSON 数据。我们可以看到,整个网络请求耗时 91ms,而用于数据解析的只有 10ms(请看 RxCachedThreadScheduler-1 线程的火焰图)。

上面我们做的最后一件事是使用了一个自定义的 OkHttp 实例,我们注册了一个自定义的 interceptor。它可以让我们几乎零成本地缓存网络数据。由于 Square 团队优秀的设计,Retrofit 可以在网络请求过程中进行并发地缓存和数据解析。我们的自定义 interceptor 会给没有 Cache Control HTTP 头的网络请求在本地加上一个 Cache Control 头,用于进行网络缓存。

最后我们还加上了一个在 Friendly Robot 很常用的中间层,用于自定义数据存储,它将在 Guava 缓存(内存缓存)中进一步缓存数据。

尽管把网络请求的数据完全缓存在内存和磁盘中对于 APP 的重启非常有用,但我们有时只需要在首次加载时获取一少部分数据。例如一个博客 APP 可能会需要在加载完整博客数据之前先加载博客计数。接下来我们数据流的一环,就是把数据保存在数据库(SQLite)中,以便于在后续的使用中可以无需请求网络而直接使用。

这里我们将使用 Square 团队的另一个开源库 SqlDelight,它能生成 SQL 映射以及解析代码,为我们提供抽象的 cursor 接口。之所以使用 SqlDelight 而不是 ORM,主要有两个原因,首先 ORM 框架都无法和 Immutables 以及 AutoValue 一起工作,其次,ORM 框架通常都会使用运行时反射来完成工作。避免这些问题之后,就让我们使用 SqlDelight 时的开销几乎为零了。

当数据被保存之后,后续的所有启动都将使用数据库中的数据了(利用 SQLBrite 提供响应式的 API)。这使得 APP 的启动速度更快,用户体验也更棒。

最后,我们运用 MVP 模式在 RecyclerView 中展示下载的数据。由于我们的数据都是从数据库读取的,屏幕旋转也不会带来任何问题,因为我们的界面元素都是无状态的。

把上面的各个部分串联起来之后,我们达到了我们的目标:我们创建了一个 APP 架构,能够在启动 0.8 秒之内完全加载数据。点此查看完整的调用栈

这里是另一个每次启动都强行从网络请求数据的版本,它的行为和上面的 APP 初次安装时的性能相近。通常你只需要在每次冷启动时才会从数据库读取数据,点此查看完整的调用栈

可以看到,在每次冷启动时发起网络请求,带来了 150 毫秒的开销。

希望本文的样例工程可以帮助社区朋友来决定新引入的第三方库是否是符合性能标准的(通过 NimbleDroid 的测试)。样例工程源码在此

原文出处:http://blog.nimbledroid.com/2016/04/20/pushing-limits-of-app-startup-time-zh.html