哈啰出行 iOS App 首屏秒开优化
哈啰出行目前已经覆盖了出行相关领域许多业务场景。App 首页作为哈啰用户第一个被用户感知的页面,几乎承载了所有核心业务的流量入口。App 首屏渲染的快慢,对 App 整体用户体验至关重要。
本文主要介绍哈啰出行 App 在首屏启动渲染所面临的挑战,如何进行问题定位分析,并如何进行针对解决。
APP 首屏渲染时间定义
启动的定义在不同产品中有不同的标准,对于哈啰出行来说,首页启动加载完成的定义为:
从用户感知侧,我们希望优化用户真正点击 APP icon 到首页首屏渲染加载完成的时间。
优化阶段
产品快速迭代在解决快速业务发展的同时也带来了大量的技术债堆积,如果没有良好的规范和监控流程会使项目在稳定和体验上存在较多的隐患和挑战。正如启动阶段的启动项和业务逻辑堆积。
目前影响用户感知到的首页加载速度主要分以下三个阶段分析定位:
- 启动/前置任务项
- 首页框架/业务逻辑
- 业务模块加载性能
优化路径
- 数据收集:收集启动阶段、首页加载、各业务模块加载性能数据
- 问题分析:确认并定位分析目前各阶段加载存在或依赖的问题
- 优化解决:启动项问题、首页逻辑处理问题、业务模块问题解决
- 持续监控:持续优化&监控首页的加载渲染和模块性能问题&可视化数据
一、数据收集
数据收集目前主要关注启动加载全链路的各阶段耗时,包括首页的页面加载时间、模块加载时间。
1、 APP 启动渲染全链路数据收集
从用户感知侧,我们希望收集到用户真正点 APP Icon 到首页首屏渲染加载的时间,以此来优化首页用户真实场景的体验。计划收集以下节点阶段数据分析:
- 初始化耗时:DidFinishLaunching - App Process Init
- 前置任务耗时:DidFinishLaunching - Homepage Init
- 首页加载耗时:Homepage Init - Homepage Did Appear
目前该阶段收集的时间方案为“首页生命周期初始节点”到“加载首页所以缓存模块完成“并且首屏已对用户可见。
点击打开 APP:获取应用进程开始时间
+ (BOOL)processInfoForPID:(int)pid procInfo:(struct kinfo_proc*)procInfo {
2、启动项任务耗时收集( CocoaService
)
CocoaService
是团队内用于快速高质量开发iOS App
的一套开发环境,主要提供模块管理、App 启动流程任务管理、Service 容器等功能。以及日志、监控、注解等工具 API。实现快速构建 iOS 模块化 App。
目前启动项任务的耗时收集主要依赖于 CocoaService
框架对于启动项任务的注册依赖和加载调度的管理策略。目前调度策略分四个阶段,每个阶段加载注册依赖的任务列表,并收集任务的耗时上报。
- 任务加载阶段定义
- AppInitialize:App 初始化前执行(初始化前、用来替代+load 方法)。
- CoreModuleMountedAfter:核心模块加载完成后(即时模块还未加载)。
- InstantModulesMountedAfter:即时模块加载完成(所有同步启动项加载完成)。
- AppLaunchedAfter :App 启动之后(App 启动完成,UI 已展示)。
任务加载时序图:
3、模块加载时间收集
目前首页模块加载数据收集分为两个阶段:加载数据阶段和UI 渲染阶段。
*Adapter 为首页模块化的抽象实体对象
二、问题分析
1、App 整体性能分析:Instrument
使用 Instrument Time Profiler 性能工具分析
Time Profiler 是 iOSer 日常性能分析中用的比较多的工具,通常会选择一个时间段,然后聚合分析调用栈的耗时。
但Time Profiler 其实只适合粗粒度的分析,我们来看下它的实现原理:
默认 Time Profiler 会 1ms 采样一次,只采集在运行线程的调用栈,最后以统计学的方式汇总。比如下图中的 5 次采样中,method3 都没有采样到,所以最后聚合到的栈里就看不到 method3。所以 Time Profiler 中的看到的时间,并不是代码实际执行的时间,而是栈在采样统计中出现的时间。对于我们分析头部耗时问题的任务会有一定的帮助。
Time Profiler 支持一些额外的配置,如果统计出来的时间和实际的时间相差比较多,可以尝试开启:
- High Frequency,降低采样的时间间隔
- Record Kernel Callstacks,记录内核的调用栈
- Record Waiting Thread,记录被 block 的线程
环境:Xcode11 , iOS 13,iPhone 双核测试机
结论:
仅从截取部分的图中可以看到启动到首页加载阶段 CPU 都处于满负荷的状态,特别是在启动项加载和首页加载的阶段有异常的波峰。不完全统计启动项高达 69+个(三方 SDK、二方 SDK、业务线 Task),线程数 30+,同时工具分析出来的业务前置耗时代码逻辑也大量存在。
- 启动阶段启动项 Task、业务 Task 堆积处理占用 CPU 资源,造成资源抢占,存在性能瓶颈。
- 首页阶段加载业务过多,包括依赖首页加载的异步启动项对 CPU 占用明显过高,需要重点优化。
- 启动项的合理化管理、线程管理需要重点关注。
2、首页阶段性能分析:排除法-错峰加载首页分析启动依赖影响
首页作为项目中第一个页面,在运行加载环境上强耦合启动阶段的 SDK 启动项、业务前置 Task。所以明确各阶段本身的问题痛点更有助于我们解决问题。
以下是我们分场景采样多次,进行场景排除法来缩小问题范围,下图是各场景采样后分别求平局数和中位数:
错峰加载:指将首页初始化加载延后,避开 APP 启动任务加载 CPU 使用高峰。
样本设备信息:系统:iOS 12.4.1,型号:iPhone 6
结论:
- 异步启动项任务对首页加载有明显的影响,应该着重处理。
- 从错峰加载可以看出启动阶段的各 SDK 和业务 Task 对首页有严重影响
3、具体业务场景分析:首屏模块加载耗时分析
主要通过以下三个方式进行分析:
- Instruments(Timeprofile/System Trace)性能工具分析
- 排除法:简单直接有效,适合场景独立,环境稳定的情况下进行初步确认/缩小问题范围。
- 数据分析:Debug 时的分析可能因样本数据不够,那线上收集到足够的样本数据分析后就可以帮助我们精准的确定耗时模块。
结论:
通过收集数据中定义的模块加载的两个阶段。各模块加载和 UI 渲染的耗时数据,存在大量的主线程耗时逻辑和复杂 UI 绘制的情况。
三、解决方案
1、启动项优化
目前项目存在的现状,优化点主在于业务侧。主要问题在于前置阶段启动项/业务逻辑堆积,无规范和流程管控。
- 性能分析:启动项性能排查分析,明确问题启动项。
通过针对性启动项性能排查分析优化解决或直接下线问题启动项。
- 规范方案:建立整套合理启动任务管理方案,合理处理启动项场景和时机。
通过团队内同学开发的以注解的方式 CocoasService 得以在 APP 启动生命周期各阶段规范注册分发启动项加载时机,统一收口可监控。
- 调度策略:处理任务集中问题,降低 CPU 峰值。
通过调度策略对启动项分阶段处理,降级集中处理的任务数量、延迟低优先级任务处理时机等等策略降低 CPU 峰值。
- 数据监控:监控启动项增长和各启动项性能数据。
依赖于整体的启动项管控方案,基础架构同学目前在 APM 上增加了关于各启动项的初步性能数据收集可视化,并持续丰富数据、优化方案和改进策略。
- 二方、三方 SDK 影响
在实际的 Timprofile 和 debug 分析中,SDK 本身大多为基础库或组件能力库,在实际的开发中问题主要集中在以下几个方面:
- 作为基础组件库在单一 Demo 环境下性能相对可控,对性能感知不明显,在接入大型项目后复杂的运行环境和基础接口的设计不合理,在启动场景下存在较严重的性能影响。
- 滥用 Runtime、重写系统类方法,导致影响到业务逻辑。
- 接入 SDK 缺少完整的性能报告分析支撑。
- 不严谨的多线程处理方式,导致偶现的卡顿、崩溃等问题。
后相关启动项都按照场景进行下线、延迟加载、异步加载等其他策略进行优化,并加入 CocaService 任务调度方案进行统一管理。
- CocoaService 启动项后置任务处理策略问题
目前依赖 CocoaService 管理启动项策略流程,初步收集了各启动项的性能数据。但也在优化首页启动时从中排查发现并优化了一些问题:
- 几十个启动项分为同步和异步,异步启动项在初始化阶段同步并非初始化,导致 CPU 峰值过高。后改为异步串行并进行部分延迟策略进行初始化加载。
- 后置任务启动项定义的初始化节点为首页
ViewController
的-ViewDidApper
时机,进行同步加载部分启动项任务,造成首页首屏展示卡顿和延迟。后改为异步串行并进行部分延迟策略进行初始化加载。
*因篇幅问题,启动项问题不再此一一列举。
2、业务问题优化
- Lottie 框架
Lottie 是一个 iOS,Android 和 React Native 库,可实时渲染 After Effects 动画,从而使应用程序可以像使用静态图像一样轻松地使用动画。
Lottie 的特点:
- 设计即所见: 设计师用 AE 设计好动画后直接导出 Json 文件,Lottie 解析 Json 文件后调 Core Animation 的 API 绘制渲染。还原度更好,开发成本更低。
- 跨平台: 一份 json 描述文件多端使用。支持 iOS、Android、React Native。
- 性能:Lottie 对于从 AE 导出的 Json 文件,用 Core Animation 做矢量动画, 性能较佳。Lottie 对解析后的数据模型有内存缓存。但是对多关键帧图片帧、图层混合较多的动画性能比较差。
- 支持动画属性丰富:比起脸书的 Keyframes,Lottie 支持了更多 AE 动画属性,比如 Mask, Trim Paths,Stroke (shape layer)等。
- 包大小:相比动辄上百 K 的帧动画,Json 文件包大小较小。有图片资源的情况下,同一张图片也可以被多个图层复用,而且运行时内存中只有一个 UIImage 对象(iOS)。
在项目中我们多处使用了 Lottie 的动画解决方案。但同时因为 Lottie 官方已不再维护 Object-C 版本,在实际使用和维护中我们也遇到了一些问题:
- 设计师在 AE 上出图时多加了很多无用图层忘记删掉,导出后这些无用图层在解析时缺少了 LayerId,在 iOS 框架层解析异常导致崩溃。
- Lottie 仅支持 Memory cache,缺少二级缓存。每次新的 APP 生命周期内都需要重新下载。
内存缓存可以快速的支持渲染和多次的动画执行效率。但缺少磁盘缓存,当资源相对较大、网络环境较差/网络抖动错误时每次重新加载的等待是一个非常糟糕的体验。
所以支持磁盘缓存,减少资源下载、减少等待快速加载展示是我们要解决的问题。参考 SDWebImage 的设计方式,我们在 Lottie 上扩展了对应的二级缓存:
- Lottie 框架在同步处理转码时会根据关键帧图片的大小有不同的性能问题。
通过 AE 的 bodymoving 插件导出的常用文件格式有:json + zip(图片资源) 或 纯 json(图片转为 base64)。纯 json 描述文件的输出中关键帧图片一般会被处理成 base64 格式,需要本地转码生成位图渲染。
- (void)_setImageForAsset:(LOTAsset *)asset {
可以看到源码中对于 data:
类型资源和获取路径资源都是在主线程进行的,对于关键帧多张或者单张较大的图片会阻塞线程。对于这种情况目前会有两种场景处理方式:
- 创建异步线程队列处理关键帧过大和关键帧较多的情况,并将处理进度回调到外部感知进行合适的加载渲染时机处理。
- 减少关键帧的大小和数量,在一些场景下 Lottie Animation 必须更快速的加载渲染到屏幕,在这种场景下关键帧的大小可以通过图层混合、矢量绘制或设计输出规范等方式减少关键帧资源达到能快速加载的目的。
- 图片资源加载渲染
首页加载渲染时会有较多的图片资源加载,目前的图片资源加载方案,为了快速读取资源解码位图并渲染到屏幕,都会有二级缓存策略如下时序图(SDWebImage 框架对于图片资源加载流程)。
在通常图片资源有缓存的情况下。首页冷启动时,内存中都没有对应的图片资源,需要进行 async 读取磁盘中的缓存图片进行解码渲染。当启动阶段 CPU 处理任务过多近乎满载的状态下,图片资源读取到渲染出来会被延到下一个 runloop 中,通常会看到宫格会有灰色占位图加载再到图片完全渲染出来的情况(如 GIF 图 2),体验较差。
在通过对宫格模块的图片预加载同步解码,将图片预先从磁盘中读取到内存中解码,渲染时同步加载内存中解码后的图片直接渲染,和刷新机制的优化使宫格框架和图片可以在同一个 runloop 中渲染出来(如 GIF 图 2)。提升了加载体验效果。
- Runtime Hook Method
+load 除了方法本身的耗时,还会引起大量 Page In,另外 +load 的存在对 App 稳定性也是冲击,因为 Crash 了捕获不到。
众所周知Object - C是一门动态语言,依赖于 runtime 的消息转发机制使我们可以在运行时做一些特殊的处理(比如 AOP)。同时也在不规范开发中带来了很多隐患,如:
- +load 方法的滥用,在项目中有大量的逻辑代码存在于+load 方法中,对启动加载有较大影响。
- 在交换首页的生命周期方法后在一些内部逻辑处理后没有交换回来或没能及时交换回来。
- 对系统基础类大量的方法交换,进行一些逻辑处理。性能不可控,链路难以追踪
通过对以上场景的处理首屏启动加载有比较可观的耗时优化。
- First Frame Render
一般会用 Root Controller 的 viewDidApper 作为渲染的终点,我们目前收集数据的策略也是以首页的 viewDidApper 为首屏的渲染节点。
Apple 在 MetricsKit 里对启动终点定义是第一个 CA::Transaction::commit()
。什么是 CATransaction 呢?我们先来看一下渲染的大致流程:
iOS 的渲染是在一个单独的进程 RenderServer 做的,App 会把 Render Tree 编码打包给 RenderServer,RenderServer 再调用渲染框架(Metal/OpenGL ES)来生成 bitmap,放到帧缓冲区里,硬件根据时钟信号读取帧缓冲区内容,完成屏幕刷新。CATransaction 就是把一组 UI 上的修改,合并成一个事务,通过 commit 提交。
渲染可以分为四个步骤:
- Layout:Root Layer 调用[CALayer layoutSubLayers],这时候 UIViewController 的 viewDidLoad 和 LayoutSubViews 会调用,autolayout 也是在这一步生效。
- Display:Root Layer 调用[CALayer display],如果 View 实现了 drawRect 方法,会在这个阶段调用。
- Prepare:这个过程中会完成图片的解码。
- Commit:打包 Render Tree 通过 XPC 的方式发给 Render Server。
XPC — XPC 是 OS X 下的一种 IPC (进程间通信) 技术, 它实现了权限隔离, 使得 App Sandbox 更加完备。
由于首页是粗粒度模块的容器化页面开发,各业务的特性功能由对应的业务团队同学实现,在处理各自数据和 UI 渲染逻辑时会存在一些问题,影响首屏的加载:
- Layout 阶段:部分业务模块层级过于复杂,复杂的嵌套和不同状态的 Layout 更新对 AutoLayout 的性能有较大的影响。特别是在 iOS 12 以下 Apple 未对 AutoLayout 算法进行优化。可以评估 ROI 决定要不要改成 frame。
- Display 阶段:部分业务模块 drawRect 实现了一些 UI 状态逻辑处理,重复处理 subViews。建议 Lazy 初始化 View,不要先创建设置成 hidden,不要在 drawRect 中处理 UI 状态逻辑。
优化成果
优化前后效果对比
优化数据对比
线上采集数据情况:
- 优化前:首屏渲染时间都大于 1 秒。
- 优化后:90%的用户首屏渲染时间在 1 秒内,其中大部分用户在 0.5 秒内。
- 整体对比,首屏渲染性能提升 40%。极大的提升了用户打开使用 App 的体验,同时也支撑了用户快速触达业务的响应。
规划和展望
不积跬步,无以至千里;不积小流,无以成江海,持续提升用户体验是我们孜孜不倦的追求,未来我们会针对首页架构建设全链路监控和云端一体化容器,提升监控能力和动态化能力。
如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。
绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论