作者简介
Qifan,携程高级工程师,专注移动端开发;Yinuo,携程高级工程师,专注移动端开发;popeye,携程软件技术专家,关注移动端跨端技术,致力于快速,高性能地支撑业务开发。
一 、前言
携程酒店业务使用Flutter技术开发的时间快接近两年,这期间有列表页、详情页、相册页等页面使用了Flutter技术栈进行了跨平台整合,大大提高了研发效率。在开发过程中,也遇到了一些性能相关问题和用户反馈,比如长列表滚动卡顿、页面打开时间较长、页面打开后部分数据加载时间较长等问题。为解决这些问题,我们选用了多个性能指标监控业务运行状态,借助性能检测工具定位问题,并查阅源码、文档等资源解决问题,形成了这篇文章。
同时在不断的需求迭代和代码更新过程中,APP的性能稳定性持续受到挑战,为此我们建立了线上性能监控系统,通过量化,治理,监控三方面手段,持续改善APP性能和用户体验。目前页面的各种性能指标诸如FPS、TTI、内存等都达到了不错的效果,本文将介绍我们在优化过程中所遇到的问题和采取的主要优化方案。
二、FPS&TTI提升性能优化
2.1 常用性能指标和卡顿定义
对于客户端应用来说,流畅度是影响用户使用体验的关键因素。流畅度低主要有:低FPS、高TTI、卡顿。这些现象出现时,页面会出现不连续的动画,页面刷新会短暂停顿,打开新页面速度较慢,新页面出现白屏或者较长时间的加载动画,用户做点击滑动等交互时页面不响应。
用户操作 FPS 的定义是每秒传输帧数 (Frames Per Second),是图像领域的概念。对于手机客户端来说,主流显示屏的刷新率为60Hz,高端手机显示屏刷新率可以达到120Hz及以上。理想情况下,页面绘制的FPS和屏幕刷新率一致。屏幕画面刷新次数越多,屏幕可以展示的动态细节越多,所以数值越高越好。TTI的定义是从页面加载开始到页面处于完全可交互状态 (Time To Interactive),完全可交互状态指的是页面有内容呈现并且用户可以进行操作。
2.2 FPS优化的工具介绍
Flutter官方提供了三种应用编译选项,debug模式、release模式和profile模式。当我们需要做性能分析的时候,需要打包profile模式的应用,这个模式的性能接近release模式,并且有性能相关的信息分析。我们使用的工具是官方提供开发者工具中的Performance View,并选择了Enhance tracing模式。
图1 帧渲染时间柱状图
上图是帧渲染时间,横坐标是帧号,纵坐标是绘制时间,蓝色代表该帧满足60fps,橙色代表不满足60fps。从这张图可以快速定位到绘制时间较长的帧,而下图是选中某帧之后,UI绘制和光栅化时间,如果选择了Enhance tracing模式,可以看到耗时较长的方法、widget build。
前文已经介绍过FPS的定义,对于flutter绘制而言,每帧绘制耗时前三的是UI绘制时间、光栅化时间、vsync ahead。UI绘制时间主要是widget build、layout、paint,简单认为是CPU时间;光栅化时间可以简单认为是GPU时间;vsync ahead是vsync信号与widget build之间的延时。
图2 Widget build耗时与对应执行的方法
2.3 具体实践方案
a) 控制setState次数,使用Provider机制减小刷新范围
我们的业务开发是MVVM结构的,数据驱动UI更新。UI的绘制占了性能开销的很大部分,减少不必要的UI绘制、控制UI绘制的范围这两种方法能显著改善性能。
减少不必要的UI绘制是通过控制build次数实现的。widget build是通过setState方法或者builder方法触发的,在业务中,尽量减少非必要的setState,只有真正页面数据发生变化,页面状态变化时才调用setState方法。对于builder方法,可以实现shouldRebuild等接口,增加触发builder方法的限制。
控制UI绘制的范围是通过改变widget树层级实现的。MVVM中数据触发UI更新的方式有很多,我们的业务主要用到了Provider机制,这是一种观察者模式设计。如下图所示,对于左边的widget树,如果只需要更新Container容器配置和Icon图标配置,那么可以将selector拆分到这两个widget的双亲widget,实现了Text widget不刷新。对于widget树较大的业务,这样的改动能显著提升FPS。
图3 Widget树结构优化以减少build次数
b) 预构建widget (AnimatedBuilder)
图4 酒店详情页头部使用预构建减少build次数
上图是酒店详情页头部沉浸式动画的UI,头部展开的过程中,图片和图片上的蒙层需要重新绘制,图片上部SHA logo不需要重新绘制,图片下部tab栏不需要重新绘制,对于这个需求的做法是用AnimatedBuilder。
AnimatedBuilder提供了几个可选参数,animation是对动画的监听,builder是动画过程中需要重新绘制的部分,child是动画过程中不需要重新绘制的部分,child作为参数会传入builder中。下面的伪代码是一个例子,动画过程中Text并不会多次绘制。
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _controller,
child: Container(
width: 200.0,
height: 200.0,
color: Colors.green,
child: const Center(
child: Text('Text!'),
),
),
builder: (BuildContext context, Widget child) {
return Transform.rotate(
angle