干货 | 携程酒店Flutter性能优化实践

2022-07-12 12:43:05 浏览数 (1)

作者简介

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并不会多次绘制。

代码语言:javascript复制
 @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,          );        },      );    }
代码语言:javascript复制

对于详情页头部沉浸式动画的例子,可以把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并触发绘制。

代码语言:javascript复制
 @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);    });  }
代码语言:javascript复制

帧的绘制状态可以从SchedulerBinding获得,同时建立队列保证一帧执行一个子widget绘制。

代码语言:javascript复制
// 等待当前帧结束时替换占位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工具

安装C

sudo apt-get install autoconf automake libtool curl make g unzip

安装Protobuf发行版

https://github.com/protocolbuffers/protobuf/releases

下载完成之后,解压,进到目录中执行下面命令编译安装

./configure

make

make check

sudo make install

sudo ldconfig # refresh shared library cache.

安装protoc-gen-dart插件

dart pub global activate protoc_plugin

在Terminal中执行protoc命令生成dart文件

protoc --dart_out=. <文件名>.proto

图13 生成的契约文件结构

b)  使用生成的dart契约文件

执行flutter pub add protobuf命令,修改项目的pubspec.yaml,在dependencies中加上: protobuf: ^2.0.1

编写如下测试代码:

图14 使用契约的样例代码

执行后可以得到如下结果:

图15 执行结果

其中,生成Person的类继承了Protobuf包里的GeneratedMessage类,序列化和反序列化由基类实现。但是这种方式不能根据需要定制化生成契约文件。因此,为了更好的兼容Json格式的数据,可以使用FreeMarker模板引擎定制化生成契约文件。

图16 使用FreeMarker生成契约的文件结构

3.3 使用FreeMarker定制化生成dart契约文件

FreeMarker是一款模板引擎:即一种基于模板和要改变的数据,并用来生成输出文本(HTML网页、电子邮件、配置文件、源代码等)的通用工具。它不是面向最终用户的,而是一个Java类库,是一款程序员可以嵌入他们所开发产品的组件。

下面介绍如何使用FreeMarker和protoc命令生成任意编程语言的契约文件

1)下载FreeMarker最新版jar包

https://freemarker.apache.org/freemarkerdownload.html

2)下载Protobuf对应版本的jar包

https://mvnrepository.com/artifact/com.google.protobuf/protobuf-java

3)在Java项目中导入对应jar包

图17 项目中导入工具方法

4)编写Java程序

图18 程序流程图

程序的流程如上图所示。首先使用protoc命令生成对应的描述文件,其次将描述文件转换成对应java对象,最后使用FreeMarker模板引擎生成任意语言的契约文件。

图19 程序的实现

由上图可知,模板引擎的输入是一个classModel对象。如下图实现了将描述文件转化成classModel对象的功能。

图20 程序的实现(续)

FTL模板文件如下图所示:

图21 模版文件

5)执行代码输出契约文件

图22 输出的契约文件

这样就可以实现了根据proto文件自定义生成任意编程语言的契约文件。

3.4 Json与Protobuf的性能对比

我们对比了相同报文情况下Json和Protobuf在序列化和反序列化上所花费的时间。从下图可知,Protobuf在序列化和反序列化相同大小报文时比Json花费的时间大大减少了,也大大提高了我们获取数据的速度。

图23 序列化、反序列化时间

四、内存泄漏治理

4.1 内存泄漏的常用监控手段

内存泄漏是一个比较严重的问题,如果出现,对App的稳定性和用户体验都有非常大影响。因此对这块的监控和治理也是我们非常关注的一块。

在监控方面Flutter现在比较通用的方法就是利用Expando中的弱引用去监控我们要检查是否有泄漏的对象,如果出现则从VM中获取其引用链接,从而分析其泄漏原因。我们的框架也利用此方法监控了我们app中的每个页面是否在退出时还存在泄漏。

另外通过Flutter的Dev tool中的内存监控工具也能实现对泄漏对象的发现。比如对于酒店详情页面,反复进入和退出此页面,如果有泄漏会发现,在内存监控工具中出现此页面多个的对象存活,此时基本可以判断出此页面出现了泄漏了。下图的第一列是类名,第二、三列是实例数量,第四、五列是对应分配的字节数。

图24 酒店详情的内存泄漏监控

4.2 内存泄漏的治理

下面介绍一下,我们在我们页面的内存泄漏治理中发现的一些导致泄漏的原因和解决的办法。

a) 调用Native的Plugin时,对Future的Then设置的闭包没有关闭

在调用Native的Plugin接口时,有时会设置一个Then的闭包,期望在这个闭包里去处理这个Plugin的返回结果。这个闭包会注册到引擎的全局变量里面,如果Native调用了result的listener,这个Then的闭包会走到,然后会被清除掉。如果某些case,Native没有调用,则这个闭包会泄露,如果这个闭包所属的Model能引用到页面对象的话,则会造成整个页面的泄露。

比如下面这个例子,我们进入flutter页面时会调这个plugin,但是native对应的result则必须在某些case情况下才会回调。而大部分情况下,是不会回调的,从而造成整个页面的泄露。解决方法是把future转换成stream,然后我们在页面退出时cancel掉,就能避免闭包的泄漏。

例子:调用Native的Plugin时出现泄漏的情况

Flutter侧的调用:

代码语言:javascript复制
void callNative() {FlutterBridge.callNative("method", map).then((value) {     do some thing;});}

Native的响应:

代码语言:javascript复制
override fun flutterPluginAction ( result: MethodChannel.Result){if (condition) {        result.success(ret)    } else {        do something;    }}

可以看到Native在接受到这个plugin调用时,对于result的调用返回不是一直都会做的,它需要等到满足条件才会做这件事情,而如果它不做这件事情,对应的flutter那边的闭包就会一直被保存在引擎中,这个引用链也会一直存在,从而造成这个引用链上的对象都泄漏了。

解决的方法:

代码语言:javascript复制
void callNative () {Future future = FlutterBridge. callNative ("method");    _streamSubscription?.cancel();    _streamSubscription = future?.asStream()?.listen((value){        do something;});}

我们的解决方式,就是对这种异步但不能确定回调是否一定完成的情况,换成用StreamSubscription去监听。然后当页面退出时做一下cancel的动作,这样就能避免泄漏的发生。

代码语言:javascript复制
void onPageDestroy() {    _ streamSubscription?.cancel();}

这种等待对异步调用的回调监听其实都可能存在类似问题,只不过如果是单纯在Dart中的异步调用一般不会存在这种不回调的情况。但是对于plugin这种跟native的交互的地方,我们在初期接触flutter时没有关注到这块,有可能会造成遗漏。

b) 一些观察者模式中的订阅者在页面退出时没有取消订阅

这种是大家比较熟悉的一种情况。常见的例子有例如像Timer,EventBusCenter.defaultBus和LifeCycleObserver等。这些订阅者如果在页面退出时不需要了,需要记得取消掉。否则也会造成内存泄漏,这种情况我们也应该避免。

五、小结

性能优化是一件不断持续,不断深入的事情。我们通过本文中所介绍的改进措施对页面性能实现了很大的优化,达到了不错的效果。后续也会在此基础之上对还可提高的地方继续加深,同时也会对已经验证实行有效的方案去做一些抽象,封装工作,后续提供通用的解决方案。

【推荐阅读】

  • 携程机票 App KMM 跨端 KV 存储库 MMKV-Kotlin
  • 携程机票iOS Widget实践
  • Trip.com APP QUIC应用和优化实践
  • Flutter在携程复杂业务的高性能之旅

 “携程技术”公众号

  分享,交流,成长

0 人点赞