愿历尽千帆 归来仍少年

优化实践:从系统层面优化应用启动速度

字数统计: 2,460阅读时长: 9 min
2021/11/19

应用的启动表现特别是冷启动的速度,是用户对App的第一印象。很多时候也会被评测机构用来测试一个手机性能的依据,所以对启动速度的优化一直以来是App和手机厂商发力的重点

故事背景

最近,测试提了很多应用启动速度落后于竞品荣耀x20的问题单
以其中的支付宝为例, 如下是测试提供的支付宝冷启数据
(通过高速相机,以手指松开为起点,以首页内容加载出为结束点)

从高速相机均值数据上,我们的机器上冷启支付宝比竞品机慢了29%,另外通过实测同时打开,直观感受上发现绝大部分时候确实慢于竞品机。
顺便说一句:
测试冷启速度最佳方式是通过高速相机,这种方式最贴近用户实际感受,其次便是通过Systrace,但是Systrace的结束帧的确定是个技术活(没有源码情况下)。

写在前面

本文将以支付宝为例进行讨论,对于支付宝这种体积的App,在启动过程中会起来上百个业务操作,但是其冷启耗时却能够控制在几百毫秒,我觉得已经是非常之优秀了。
其实这种头部App会有专门的性能团队进行优化,相信支付宝已经做了很多的优化工作。
PS: 本文深受androidperformance系列文章启发

本文将致力于回答如下两个问题

  1. 同样的App版本,同样的配置,为何我们的机器启动速度会比竞品机慢?
  2. 如何优化提升我们机器上的应用启动速度?

大纲

  1. App和系统角度分别看启动速度优化
  2. CPU提频
  3. Dex2oat策略优化
  4. 抑制GC次数
  5. IO优化
  6. 优化后实测数据
  7. 写在最后
  8. 参考文献

App&系统角度看启动速度优化

应用角度看启动优化

对于应用启动优化手段比较常规,主要有如下几种:

  1. 启动页主题之类的替换,达到视觉上的速度加快,并非真的快了。
  2. 初始化的生命周期内一些繁重业务或者库加载尽可能的延迟加载,异步加载,用的时候再加载。
  3. 布局上的优化,尽可能降低布局的复杂度,在需要用的时再加载
  4. 主动dex2oat,需要注意的是在Android10+上谷歌限制了应用进程主动dex2oat,但是其实还是有办法触发,只是实现上会稍微麻烦一点,这里不展开讨论。

这些常规的优化步骤是最基础的,但是实际优化起来并不一定容易,特别是一些庞大的团队合作App。
之前在小米做过启动优化的工作,由于项目业务代码实在太繁杂,初始化代码里充斥了各种回调各种插件,还有很多锁等待,文件读写,反射hook,网络操作等耗时的操作,梳理优化的过程需要有足够的耐心,需要不少的沟通成本。
对于应用开发人员而言,分析应用自身的耗时分析手段比较常规,最常见的手段就是Systrace,traceview,simpleperf等,实在不行代码里进行插桩埋点,因为有源代码,一切都好办。
而对于一些头部App比如抖音支付宝等,这类App往往有专门的性能团队,可能会涉及到GC优化,dex2oat优化,文件重排列降低IO次数等门槛较高的方向。

系统全局角度看启动优化

对于系统工程师而言,需要关注从驱动上报input事件到首页第一帧画面合成的整个过程。
主要会涉及input事件传递,一帧的渲染,CPU&GPU的调度频率,Surfaceflinger的合成等等。

CPU提频

下图是启动过程中主线程的CPU频率

mtk默认已有实现在进程创建后boost持续10s,从上面Systrace可以看到主线程绘制running的片段是跑在大核上且频率已是最高。
但是在对比竞品机Systrace时,笔者发现竞品机surfaceflinger很多时候会跑在大核上,而我们的机器没有跑在大核的时候,其单次onMessageReceived耗时很多时候只有我们的一半。
这里先解释下onMessageReceived的作用,主要是处理两个Message

  1. MessageQueue::INVALIDATE — 主要是执行 handleMessageTransaction&handleMessageInvalidate
  2. MessageQueue::REFRESH — 主要是执行 handleMessageRefresh 方法
    可以简单的理解为这块的消息处理越快,合成的速度也会越快,画面能够提早显示出来,降低掉帧的概率。
    这个时候,你可能会有疑问了,竞品机是做了绑大核处理了吗?

下面我们看下两者cpuset的差异

可以看到竞品机器对于surfaceflinger设置的是前台进程组,我们的机器遵循的是原生System-background,这也意味着我们机器上surfaceflinger只能跑在0-5核上。
那么谷歌设计限制在System-background上基于什么考虑呢?猜测是因为绝大部分时候,这个设计都可以满足需求,占用大核的话可能会导致某些情况下加剧大核队列的负载。

Dex2oat策略优化

我们知道dex2oat在优化时,会根据需要优化一定量的method。也就是说并不是优化的method都会被翻译成oat模式。根据优化的method的量的多少,可以分为几种常见的模式:
verify、quicken、space-profile、space、speed-profile、speed、everything
字面上比较好理解, 越后面的类型编译时间越长,占用的空间也越大,运行时打开速度也越快,典型空间换时间思路的体现。

下面是我们的机器和对比机上参数的对比

参数是一致的,其实这也是平台默认的配置, 这些参数中有一项是install时的选择的模式: speed-profile.
这里说下speed和speed-profile的区别,speed-profile是对profile中的热点函数进行编译,可以简单理解为是局部编译,而speed是全编。
对于dex2oat的调优,其实策略比较常规,根据不同的场景和负载,选择不同的模式。除了原生的idle模式下优化,新增了热点应用的周期性dex2oat。

降低GC的频率

之前处理过的性能案例中,有不少是GC引起的性能问题。
将友商oppo的修改合入:
大意是在进程状态变化触发的GC转换中,如果当前堆增长(自上次GC以来)大小未达到可增长的1/4,则跳过本次GC,这样可以降低启动过程中GC的频率。
另外,谷歌在Android R上的一笔GC优化修改,也是降低启动过程中的GC频率

大意就是应用启动过程中等进程fork结束之后,将堆上限阈值调整到最大,因为我们知道GC的触发时机主要取决于堆实际增长是否触及上限值,这样一来的话,进程启动过程中GC的概率就会降低,等两秒钟过后,再将堆上限阈值恢复回来。
这些修改的目的都是为了降低GC对启动速度的影响,其实GC的影响不光光在启动上,对于整机的流畅度都起到了很重要影响。

IO优化

当内核发起一个读请求时(例如进程发起 read() 请求),首先会检查请求的数据是否缓存到了 pagecache 中。如果有,那么直接从内存中读取,不需要访问磁盘,这被称为 cache命中(cache hit)。如果 cache 中没有请求的数据,即 cache 未命中(cache miss),就必须从磁盘中读取数据。
然后内核将读取的数据缓存到 cache 中,这样后续的读请求就可以命中 cache 了。Page 可以只缓存一个文件部分的内容,不需要把整个文件都缓存进来。对磁盘的数据进行缓存从而提高性能主要是基于两个因素:

  1. 磁盘访问的速度比内存慢好几个数量级(毫秒和纳秒的差距)。
  2. 被访问过的数据,有很大概率会被再次访问。

对于一些头部App比如支付宝就对Apk中的文件进行了重排优化,大意就是:
通过文件重布局,将启动阶段需要用到的文件在 APK 文件中排布在一起,尽可能的利用 pagecache 机制,用最少的磁盘 IO 次数,读取尽可能多的启动阶段需要的文件,减少 IO 开销,从而达到提升启动性能的目的

上面是App中的IO优化策略,从系统角度的优化策略比较复杂,需要对文件系统比较熟悉, 后续会单独成文记录IO这块的内容

最终优化效果

当前采用了如下三种方式对启动性能进行了优化

  1. 对SurfaceFlinger占据的CPU资源调整
  2. 针对不同场景下的dex2oat进行了编译模式调优
  3. 启动过程中降低GC次数
    影响其实最大的是dex2oat

在优化后的版本上,我们再次使用高速相机对支付宝进行测试

参考资料

https://developer.android.google.cn/topic/performance/vitals/launch-time
https://source.android.com/devices/tech/dalvik/configure

CATALOG
  1. 1. 故事背景
  2. 2. 写在前面
  3. 3. 大纲
  4. 4. App&系统角度看启动速度优化
    1. 4.1. 应用角度看启动优化
    2. 4.2. 系统全局角度看启动优化
  5. 5. CPU提频
  6. 6. Dex2oat策略优化
  7. 7. 降低GC的频率
  8. 8. IO优化
  9. 9. 最终优化效果
  10. 10. 参考资料