货拉拉用户端体验优化--启动优化篇

11,274 阅读13分钟

作者简介
shirly,货拉拉资深客户端工程师,目前负责货拉拉App iOS端性能优化、APM相关工作

前言

过去几年,在货拉拉业务高速发展的同时,作为核心业务入口的用户端app,在以「快」为第一目标实现业务需求的同时,也积累了比较多的技术债,表现为各项技术指标与业界优秀的app相比都差强人意,并且线上经常会收到用户反馈app使用卡顿、闪退。在此背景下,我们开始了对app使用体验的优化。

那么我们在讲一个app用户体验好的时候,主要是指哪些方面做得好呢?

站在用户角度,去感受一个app的使用体验如何,一般以下几个指标是比较容易被感知到的:

  • 安装包大小
  • 启动速度
  • 稳定性
  • 耗损
  • 流畅度

0.一个体验优秀的APP.jpg

本篇主要介绍下货拉拉用户端在「启动速度」上的治理实践,其他几项可期待我们后续的系列博客。

启动是 App 给用户的第一印象,启动越慢用户流失的概率就越高,良好的启动速度是用户体验不可缺少的一环 ,同时当 App 中的业务模块越来越多、越来越复杂,集成了更多的三方库,App 启动也会越来越慢,因此我们希望能在业务扩张的同时,保持较优的启动速度,给用户带来良好的使用体验。

启动相关指标定义

根据场景的不同,启动可以分为两种:冷启动,热启动。

  • 冷启动:第一次打开app或app被杀死后重新打开的情况
  • 热启动:app在后台且存活的状态下,再次打开app的情况

热启动不会有启动流程的过程,即不走AppDelegate代理方法didFinishLaunchWithOptions,不属于正常启动流程。因此我们这里指的是 App 冷启动的优化。

  • 启动时长:启动结束的时间戳 减去 启动开始的时间戳;
  • 启动开始:进程创建的时间
  • 启动结束:不同APP对启动终点的定义略有不同,而我们货拉拉几个iOS端最终采用的启动终点为首页首屏渲染完成;在代码中的打点时机为:首页 viewController 的 viewDidAppear

监控先行

在明确了启动时长相关的指标定义后,我们首先做的对线上用户启动时长监控的搭建。

1.app启动时长监控系统.jpeg

监控体系的搭建主要从以下两步着手:

1、指标采集上报

核心指标:启动时长
设备信息:操作系统、app版本号等

2、监控面板设计

在设计监控面板时,我们要以目标为导向。启动时长的监控目标主要是想摸清线上用户启动时长区间分布,以及各个版本的数据对比;我们参考业界对启动优秀的时长标准,分了以下几个区间:3秒以内、3-4秒、4-5秒、5秒以上。

2.监控面板设计.png

监控上线后,数据证实了我们APP的启动时长确实有很大优化空间。

确定优化方向

iOS开发同学应该都知道,app的启动主要包括三个阶段:

  • main() 函数执行前:操作系统加载App可执行文件到内存,然后执行一系列的加载&链接等工作,最后执行至App的main()函数。
  • main() 函数执行后:从main()开始,到appDelegate的didFinishLaunchingWithOptions方法执行完毕.
  • 首屏渲染完成后: didFinishLaunchingWithOptions 方法作用域内执行首屏渲染之后的所有方法执行完成

对应以上三个阶段分别有一些常见的优化思路:

  • main() 函数执行前的优化项

    • 减少动态库加载。每个库本身都有依赖关系,苹果公司建议使用更少的动态库,并且建议在使用动态库的数量较多时,尽量将多个动态库进行合并。
    • 减少加载启动后不会去使用的类或者方法。
    • +load() 方法里的内容可以放到首屏渲染完成后再执行,或使用 +initialize() 方法替换掉。
  • 通过重排 Mach-O中的二进制,减少启动流程中的缺页中断次数(Page Fault) .
  • main() 函数执行后的优化项

    • 减少在主线程中执行IO读写操作.
    • 将各种SDK(二方、三方)初使化工作放到子线程处理.
    • 减少首屏渲染的大量计算.
  • 首屏渲染完成后

这个阶段用户已经能够看到 App 的首页信息了,所以优化的优先级排在最后。但是,那些会卡住主线程的方法还是需要最优先处理的,不然还是会影响到用户后面的交互操作。

而我们货拉拉APP在此之前还没有系统性的进行过启动优化,所以可以说是大有可为。我们计划的节奏是先易后难,先从小投入大收益的动作开始,也就是先从 main() 函数执行后的优化项开始

工具介绍

工欲善其事,必先利其器。在分析启动任务过程中,我们使用了几个轻量级的工具,这里也展开介绍下。

工具1、XCode DYLD PRINT (测量Pre-main Time)

dyld(the dynamic link editor)是苹果的动态链接器,是苹果操作系统一个重要组成部分,在系统内核做好程序准备工作之后,交由dyld负责余下的工作.

dyld下载地址:opensource.apple.com/tarballs/dy…

通过在工程的scheme中添加环境变量DYLD_PRINT_STATISTICS,设置Value为true,App启动加载时就会有启动过程的日志输出.

3.工具1-XCode DYLD PRINT.png

Total pre-main time: 2.1 seconds (100.0%)
dylib loading time: 743.48 milliseconds (34.0%)
rebase/binding time: 163.35 milliseconds (7.4%)
ObjC setup time: 54.74 milliseconds (2.5%)
initializer time: 1.2 seconds (55.9%)
slowest intializers :
libSystem.B.dylib :  8.53 milliseconds (0.3%)
libglInterpose.dylib : 438.64 milliseconds (20.0%)
AFNetworking : 133.57 milliseconds (6.1%)
XXXKit : 228.21 milliseconds (10.4%)
XXXUIKit : 194.17 milliseconds (8.8%)
Huolala : 173.65 milliseconds (7.9%)

通过控制台输出的数据,可以看到: dylib loading time: 加载动态库用了743.48ms rebase/binding time: 指针重定位使用了163.35 ms, ObjC setup time: ObjC类初始化使用了54.74ms, initializer time: 其它初始化使用了1.2s

在「其它初始化使用的1.2s 」中,就可以看到用时最多的四个初始化是

  • libglInterpose.dylib (20.0%)
  • XXXKit (10.4%)
  • XXXUIKit (8.8%)
  • Huolala (7.9%)

通过控制台输出的数据,可以看到: dylib loading time: 加载动态库用了743.48ms rebase/binding time: 指针重定位使用了163.35 ms, ObjC setup time: ObjC类初始化使用了174.56ms, initializer time: 其它初始化使用了1.2s 在「其它初始化使用的1.2s 」中,就可以看到用时最多的四个初始化是

  • libglInterpose.dylib (20.0%)
  • XXXKit (10.4%)
  • XXXUIKit (8.8%)
  • Huolala (7.9%)

工具2、Instruments TimeProfiler

Time Profiler能够帮助我们分析代码中各个API的执行时间,找出导致程序变慢的原因,告诉我们“时间都去哪儿了?”

4.工具2-TimeProfiler.png

4.工具2-TimeProfiler-02.png

4.工具2-TimeProfiler-03.png

解释下每一个选项对左侧列表中数据的显示起了什么作用:

  • Separate by Thread:每个线程被单独考虑。这能让你知道哪一个线程占用CPU最多
  • Invert Call Tree:选中该选项后,调用栈会自上至下显示。这通常是你需要的,因为你想知道CPU花费时间的那个最深的方法
  • Hide System Libraries:选中该选项后,只有你自己app中出现的符号会被显示出来。通常选中该选项是有用的,因为你只关心CPU在你自己的代码中的哪一部分花费时间,你没法对系统库使用CPU做多少改变
  • Flatten Recursion:该选项将每一个调用栈中的递归函数(调用它们自身的函数)视作单一入口,而不是多入口
  • Top Functions:选上这一选项让Instruments将花费在一个函数中的总时间视作在该函数中直接花费的时间加上调用的其他函数花费的时间。所以如果函数A调用了函数B,那么函数A花费的总时间被记为A花费的时间加上B花费的时间。这一选项非常有用,因为它能让你在每次进入调用栈时找到花费最长的时间,瞄准你最耗时的方法

工具3、Messier

Messier 可以用来跟踪iOS应用的Objective-C方法调用,完美的解决了Time Profiler 把调用方法都合并了起来,失去了时序的表现。首先要说明的是,目前Messier只支持arm64,因此只能使用arm64的真机; 下面简单讲下 messier的使用教程

第一步:

安装macOS 客户端,下载链接:github.com/messier-app…

第二步:

将messier.framework集成到工程中

  1. 拖拽 messier.framework 到 Xcode Targets -> Build Phases -> Link Binary With Libraries.
  2. 点击 New Copy Files Phase 添加 Copy Files 步骤, 拖拽 messier.framework 进去 ,配置 Destination 为 Frameworks
  3. 如果需要进行一些参数配置,可以在 Project Scheme -> Run -> Arguments, 设置 Environment Variables
// 是否开启系统方法检测
MessierInlineHook: true | false
// 是否开启preMain前方法加载检测
MessierEnableOnAppBoot: true | false
// 是否仅检测主线程方法
MessierMainThreadMethodsOnly : true | false

PS: MessierEnableOnAppBoot 和MessierInlineHook 如设置为true,有可能会导致APP无法运行.建议三个默认值都为false.

5.工具3-Messier.png

5.工具3-Messier-02.png

第三步:trace

5.工具3-Messier-03.png 第四步:查看结果

  1. 打开Chrome浏览器(或者Chromium系列),进入chrome://tracing
  2. 将第三步trace.json拖拽到chrome://tracing网页中,奇妙的结果诞生了

5.工具3-Messier-04.png

这个图也叫火焰图,从左到右是时间序列,从上到下是某个函数生命周期的调用链;火焰越宽,说明函数耗时越长;火焰越尖,说明函数调用链越长

优化实践

结合确定的优化方向,以及我们用工具Messier拿到的启动流程相关API方法耗时,我们前期主要的优化动作有以下几个:

1、启动过程中网络请求,异步延迟加载并缓存

在梳理app启动流程时,我们首先识别到的是,app在启动时先会请求一个全局配置接口和一个AB相关的接口,并且这两个接口是串行阻塞式,也就是说在app没拿到接口数据时,是不会进行下一步流程。在跟业务方沟通确认后,明确这两个接口:1-数据不会经常变动 2-业务实时性并没有强烈诉求;在此前提条件下,我们把接口请求改为异步加载,并对接口数据做了缓存

2、设计启动任务管理器,统一管理启动任务

这里的核心思路是全面梳理启动时的所有任务,把任务原子化拆分成一个个独立的task并归类;然后根据task的依赖关系、并发关系、优先级,分别创建对应的队列来执行task

6.启动器任务图.png

以一个稍大型的APP为例,可以做如下拆分和归类:

  • 必须主线程同步执行的任务:神策SDK初始化、百度地图定位SDK初始化,设置rootViewController等
  • 必须主线程但可异步延时执行的任务:微信支付SDK初始化、个推SDK初始化
  • 高优先级-子线程并发队列:实时日志服务,crash采集上报服务(bugly)等
  • 普通优先级-子线程并发队列:日志上报、umengSDK初始化等

这里要注意: 各种二方库、三方库初始化是否可以放子线程,是否可以延迟初始化,一定要认真阅读SDK的开发文档,或者咨询对应的技术支持

代码示例:

7.启动任务管理-代码示例.png

以上两个优化上线后,我们APP 3秒内启动时长占比提升了大概13p.p,大盘平均启动时长已经接近1秒,优化效果显著;

但是我们深入分析后发现低端机型比如Iphone6还是会有最高3秒的耗时,长尾数据明显,所以接下来我们就针对低端机继续分析和优化。

3、长尾治理

对低端机的启动时长进行治理,首先我们需要知道低端机对比高端机耗时大头在哪里。

这里有个简单的方法,就是把启动过程中所有函数进行耗时打点,取一个高端和低端的机型进行本地调试,输出详细的函数耗时数据

8.长尾治理数据示例.png 左边为低端机数据,右边为高端机数据

可以看到耗时长的主要是业务相关的一些API,这里只是提供个数据对比的思路,具体我们做了什么优化,因为是业务相关的,就不展开讲了。

防劣化建设

在进行了三个阶段的持续优化后,我们的APP基本是秒开状态,完成了既定目标。后面就是通过一些流程和工具防止app在迭代过程中数据腐化。

防劣化的思路也是从影响app启动时长的因素着手:

1、新SDK接入评估

接入必要性评估、初始化耗时测算、初始化时机推荐:使用时加载-> 闲时加载 -> 异步加载-> 延迟加载 -> 启动流程中主线程加载

2、启动任务管理

维护好启动任务管理器,如有新增启动任务,严格按照任务依赖性优先级编排任务

3、发版前进行启动时长性能测试

这里要感谢测试部门同学的大力支持,顺便提一下我们的移动测试平台-MTC,具备功能、性能、兼容、稳定性、埋点等自动化测试服务。

app每个版本集成回归时,测试同学会在MTC跑一遍性能测试并输出测试报告。

报告包含了我们定义的核心场景下,app的内存、CPU、启动时长等性能指标数据及版本对比波动值。

在允许的波动范围内,比如启动时长波动<100ms,那么就认为测试通过,否则就认为数据有恶化趋势,测试不通过,需要研发排查优化,直到测试通过。

9.MTC测试报告.png

(性能测试报告--启动时长报告)

优化成果和规划

1、优化成果

  • 21年8月份和21年12月份app 启动时长 录屏对比👇

middle_img_v2_dfd26ace-c547-429a-92e0-3243389f2d9g.gif

  • 21年8月份和21年12月份app启动时长 监控数据对比👇

10.优化后数据-01.png

10.优化后数据-02.png

2、后续规划

  • 监控报警

目前我们的启动时长虽然搭建了监控系统,但是还没有配置报警能力;APP新版本上架后,是人工每天去看一眼监控数据是否有波动和恶化;后续计划增加报警能力,在每个启动区间配置报警阈值,预警波动,释放人力。

  • 增加TTFD指标

先解释下两组概念:

  • 完全显示所用时间 (Time-To-Full-Display, TTFD)

应用完成渲染并可供用户交互和使用时所需的时间,包括显示本地存储或来自网络上的内容所需的时间

  • 初步显示所用时间 (Time-To-Initial-Display, TTID)

应用完成初步绘制所需的时间,也就是业界默认定义的启动时长

通过这两组概念可以看到,一般各个技术团队开始着手app启动优化时,都是定义启动结束为 启动图完全消失的第一帧,也就是TTID。而TTFD更接近用户感知,并且在优化TTID时,有可能因为把一些耗时操作延迟,而导致TTFD变长;所以我们下一步的计划就是完善TTFD的采集监控与优化。

总结

本次主要从优化方向、监控搭建、工具使用、优化动作、防劣化几个方面介绍了货拉拉iOS用户端在启动优化方面的实践。而其中有一些思路是大多数技术优化项目通用的,也总结给大家参考下:

  • 治理原则: 先易后难,抓大放小
  • 监控先行:从黑盒到白盒,知道数据分布,衡量优化效果
  • 防劣化:避免业务快速迭代过程中,治理的速度赶不上数据恶化的速度