干货 | 携程酒店Flutter性能优化实践
作者简介
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: _controller.value * 2.0 * math.pi,
child: child,
);
},
);
}
对于详情页头部沉浸式动画的例子,可以把widget树进行拆分,只有图片和图片蒙层放入builder方法中,其余的widget作为child传入builder,同时用Stack widget实现两部分UI的组合,这样改进之后,FPS在动画过程中有较大提升。
c) const widget
对于dart语法,需要分清楚final和const关键字的区别。关键字final的意思是一次赋值,不能改变;而关键字const的意思是常量,确定的值。这两者的区别是final变量在第一次使用时被初始化,而const 变量是一个编译时替换为常量值。同样的,对于const widget,这个widget在编译阶段就已经确定,不会有状态的变化和成员变量更新。const widget特别适合于标签、特殊Icon等可以复用的UI,性能开销较小。
d) 减少耗时计算,放到Isolate
Flutter应用中的Dart代码执行在UI Runner中,而Dart是单线程的,我们平时使用的异步任务Future都是在这个单线程的Event Queue之中,通过Event Loop来按顺序执行。需要避免将一些耗时计算放在UI线程,可以把耗时计算放到Isolate去执行。
e) 懒加载
能够实现懒加载的有ListView.builder、PageView.builder和GridView.builder,这些widget可以用户长列表或重复容器结构的UI,通过判断单个item是否在屏幕内或者将要进入屏幕位置而进行绘制。与之对应的是Column、Row等一次性绘制widget,对于重复结构的数据,尽量避免使用这些组件。
如下图中,酒店周边景点美食购物列表和附近同类型酒店列表都实现了按需加载。酒店周边景点美食购物列表的卡片数量超过20个,最初使用Row 组件构建时,第一次构建时间超过25ms,达不到60FPS的16ms绘制时间要求。当然,按需加载也有性能开销,出现在列表的滑动过程中。如果一次性全部构建了列表,滑动过程中不会触发新的构建,滑动流畅度体验更好,但是第一次构建时的卡顿感明显。
图5 酒店详情页周边内容运用懒加载减少构建次数
f) 分帧渲染
错峰加载方案使用分帧渲染,分帧渲染的原理是将一棵Widget树中的部分绘制时间较长的节点在第一帧时只占位不绘制,等到下一帧开始时,节点替换占位UI,单独使用一帧时间绘制。
在酒店详情头部信息绘制中运用了分帧渲染技术,下左图未使用分帧渲染,下右图对图片tab栏、酒店设施标签、点评模块、地址栏使用分帧渲染。从结果看,减少了3次卡顿和1次轻微卡顿,流畅帧占比超过90%。
图6 分帧渲染在详情页头部运用的效果
布局与绘制的基本单位是一棵widget树,分帧渲染的原理是将布局与绘制时间较长的子widget先用Container占位,再等下一帧开始时单独渲染。使用占位widget的伪代码如下,build方法返回占位widget,并在widget构建帧结束时替换占位widget并触发绘制。
@override
void initState() {
super.initState();
result = widget.placeHolder;
replaceWidget ();
}
@override
Widget build(BuildContext context) {
return result;
}
void replaceWidget() {
SchedulerBinding.instance.addPostFrameCallback((t) {
TaskQueue.instance.scheduleTask(() {
if (mounted)
setState(() {
result = widget.child;
});
}, Priority.animation, id: widget.index);
});
}
帧的绘制状态可以从SchedulerBinding获得,同时建立队列保证一帧执行一个子widget绘制。
// 等待当前帧结束时替换占位widget并触发绘制
await SchedulerBinding.instance.endOfFrame;
// 执行任务队列中的绘制任务
final TaskEntry<dynamic> entry = _taskQueue.first;
entry.run();
2.4 UI GPU 问题定位与优化
GPU 问题主要集中在底层渲染耗时上。有时候 Widget 树虽然构造起来容易,但在 GPU 线程下的渲染却很耗时。涉及 Widget 裁剪、蒙层这类多视图叠加渲染,或是由于缺少缓存导致静态图像的反复绘制,都会明显拖慢 GPU 的渲染速度。可以使用性能图层提供的两项参数,负责检查多视图叠加的视图渲染开关checkerboardOffscreenLayers和负责检查缓存的图像开关checkerboardRasterCacheImages来检查这种模块的存在。
a) checkerboardOffscreenLayers
多视图叠加通常会用到 Canvas 里的savaLayer 方法,这个方法在实现一些特定的效果(比如半透明)时非常有用,但由于其底层实现会在 GPU 渲染上涉及多图层的反复绘制,因此会带来较大的性能问题。对于 saveLayer 方法使用情况的检查,我们只要在 MaterialApp 的初始化方法中,将 checkerboardOffscreenLayers 开关设置为 true,分析工具就会自动帮我们检测多视图叠加的情况了,使用了 saveLayer 的 Widget 会自动显示为棋盘格式,并随着页面刷新而闪烁。
不过,saveLayer 是一个较为底层的绘制方法,因此我们一般不会直接使用它,而是会通过一些功能性 Widget,在涉及需要剪切或半透明蒙层的场景中间接地使用。所以一旦遇到这种情况,我们需要思考一下是否一定要这么做,能不能通过其他方式来实现。如下图所示,因为详情头部bar用到高斯模糊,同时使用ClipRRect裁切圆角,ClipRRect会调到savelayer接口,所以该部分产生闪烁。
图7 详情页头部图片标题栏中裁切样式应用
b) checkerboardRasterCacheImages
从资源的角度看,另一类非常消耗性能的操作是,渲染图像。这是因为图像的渲染涉及 I/O、GPU 存储,以及不同通道的数据格式转换,因此渲染过程的构建需要消耗大量资源。
为了缓解GPU 的压力,Flutter 提供了多层次的缓存快照,这样Widget 重建时就无需重新绘制静态图像了。与检查多视图叠加渲染的checkerboardOffscreenLayers 参数类似,Flutter 也提供了检查缓存图像的开关 checkerboardRasterCacheImages,来检测在界面重绘时频繁闪烁的图像(即没有静态缓存)。
我们可以把需要静态缓存的图像加到 RepaintBoundary 中,RepaintBoundary 可以确定 Widget 树的重绘边界,如果图像足够复杂,Flutter 引擎会自动将其缓存,避免重复刷新。当然,因为缓存资源有限,如果引擎认为图像不够复杂,也可能会忽RepaintBoundary。
2.5 页面预加载提升TTI
网页应用的主要流程有三步,通过链接打开页面,发送服务请求获得页面数据,将页面数据展示在页面上。对客户端应用来说,页面之间跳转是相对确定的,数据在页面之间存在共享的可能,预加载的工作是在打开页面之间预先获得页面的数据,从而减少打开页面到页面展示的时间。
预加载数据有三种常见方法,第二个页面的数据在第一个页面的服务结果中获得;第二个页面的数据在客户端其它页面中预先获得并缓存;第二个页面的服务请求在打开页面之前发送。
a) 预加载页面数据
页面数据预获取的方案,实现方法是在上一个页面提前获取服务数据,在用户跳转到当前页面时,直接从缓存获取,节省了数据的网络传输时间,达到快速展示当前页面内容的效果。目前在酒店核心预订流程,都运用了数据预加载技术,如下图所示。
图8 酒店业务预加载页面数据的应用
结合酒店业务特点,数据预加载需要考虑几个方面问题,第一,酒店预订流程页面PV量都很高,酒店列表和详情页PV都是千万级别,所以需要考虑数据预加载的时机,避免服务的资源浪费。第二,酒店列表,详情,填单页都有价格信息,价格信息对用户来说是动态信息,实时都有变价可能,所以需要考虑数据预加载的缓存策略,避免因为价格的前后不一致造成用户误解。
在实现全流程预加载方案之后,我们酒店预订流程页面的慢加载率从初始值的42.90%降低至现阶段的8.05%。
b) 预加载ViewModel
与数据预获取的方案相比,预加载ViewModel更进一步,将预获取的数据处理成ViewModel形式,在打开页面时直接用ViewModel进行展示。这种方案减少了业务对数据处理的时间。
图9 酒店详情页预加载ViewModel技术的应用
上图是杭州绿城尊蓝钱江豪华精选酒店在酒店列表页和酒店详情页头部的UI对比。可以看出,酒店详情页头部的信息主要是酒店名称、星级、榜单、特色设施、点评、开业装修时间等信息,这些信息和列表页酒店卡片信息存在重合。如果用户浏览的轨迹为从酒店列表页到酒店详情页,那么可以直接将列表页的数据带入酒店详情页作为头部展示。
图10 酒店详情页预加载ViewModel的数据流
上图为详情页头部预加载的主要流程。我们的flutter业务代码采用MVVM的结构,将服务请求的结果处理完的数据放入ViewModel中,ViewModel的数据更新通过Provider机制触发页面UI更新。
图中可以开到,详情页头部ViewModel的数据有两个来源,分别是列表页服务请求的结果和详情页服务请求的结果。这两个服务请求结果到ViewModel的业务流程不一样,列表页的服务结果数据通过URL参数的方式传入详情页,而详情页服务结果可以直接生成详情页头部的ViewModel。
图中还有一个重要模块是列表页服务结果和详情页服务结果之间的通用缓存DataCache,它的功能是实现页面之间数据的一致性。页面上的数据可以由服务更新,也可以由用户交互更新。业务的ViewModel依赖这个通用缓存,数据更新会触发页面UI更新。
三、Flutter服务通道优化
3.1 背景
因为我们APP采用的私有服务协议,目前发服务的动作还是在Native代码上,而酒店的核心页面已经转到了Flutter上。通过Flutter框架提供的通道技术Native到Flutter的数据传输通道需要对数据做一次额外的序列化及反序列化的传输,同时传输的过程比较耗时,会阻塞UI的渲染主线程,对页面的加载会造成明显的影响。我们检测到这个环节之后和框架一起对Flutter的底层框架进行了改造,可以实现数据流直接的透传,同时不阻塞UI主线程,性能得到了极大的提升。
优化前,通过服务返回的数据流传递到flutter使用,整个过程要经历以下4步:
PB反序列化
Response到JsonString的编码
JsonString到MethodChannel(使用JsonMethodCodec编解码)
传输JsonString到Reponse的解码
整个过程链路长,数据传输量大,效率低,影响到页面加载性能,如下图所示
图11 优化前的业务服务请求数据流
改造后,通过服务返回的数据流,直接传输到Flutter侧,在Flutter直接进行PB的反序列化,传输性能得到极大提升。
PB的数据流到MethodChannel(使用StandardMethodCodec编解码)传输
PB反序列化到Response
整个过程链路短,数据传输量小,效率高,如下图所示:
图12 优化后的业务服务请求数据流
其中MethodChannel的编解码器由JsonMethodCodec换成了StandardMethodCodec。因为StandardMethodCodec可以避免转换JsonString的操作,能节省传输时间。
3.2 Flutter中使用Protobuf
在flutter中使用Protobuf,首先需要将proto契约文件转化成dart文件,可以借助官方编译工具protoc进行编译。
a) 获取protoc工具
b) 使用生成的dart契约文件
图14 使用契约的样例代码
图15 执行结果
图17 项目中导入工具方法
4)编写Java程序
图20 程序的实现(续)
5)执行代码输出契约文件
图23 序列化、反序列化时间
void callNative() {
FlutterBridge.callNative("method", map).then((value) {
do some thing;});
}
override fun flutterPluginAction ( result: MethodChannel.Result){
if (condition) {
result.success(ret)
} else {
do something;
}
}
void callNative () {
Future future = FlutterBridge. callNative ("method");
_streamSubscription?.cancel();
_streamSubscription = future?.asStream()?.listen((value)
{
do something;
});
}
void onPageDestroy() {
_ streamSubscription?.cancel();
}
“携程技术”公众号
分享,交流,成长