
无需加好友免费技术支持
携程酒店业务使用Flutter技术开发的时间快接近两年,这期间有列表页、详情页、相册页等页面使用了Flutter技术栈进行了跨平台整合,大大提高了研发效率。在开发过程中,也遇到了一些性能相关问题和用户反馈,比如长列表滚动卡顿、页面打开时间较长、页面打开后部分数据加载时间较长等问题。为解决这些问题,我们选用了多个性能指标监控业务运行状态,借助性能检测工具定位问题,并查阅源码、文档等资源解决问题,形成了这篇文章。
同时在不断的需求迭代和代码更新过程中,APP的性能稳定性持续受到挑战,为此我们建立了线上性能监控系统,通过量化,治理,监控三方面手段,持续改善APP性能和用户体验。目前页面的各种性能指标诸如FPS、TTI、内存等都达到了不错的效果,本文将介绍我们在优化过程中所遇到的问题和采取的主要优化方案。
对于客户端应用来说,流畅度是影响用户使用体验的关键因素。流畅度低主要有:低FPS、高TTI、卡顿。这些现象出现时,页面会出现不连续的动画,页面刷新会短暂停顿,打开新页面速度较慢,新页面出现白屏或者较长时间的加载动画,用户做点击滑动等交互时页面不响应。
用户操作 FPS 的定义是每秒传输帧数 (Frames Per Second),是图像领域的概念。对于手机客户端来说,主流显示屏的刷新率为60Hz,高端手机显示屏刷新率可以达到120Hz及以上。理想情况下,页面绘制的FPS和屏幕刷新率一致。屏幕画面刷新次数越多,屏幕可以展示的动态细节越多,所以数值越高越好。TTI的定义是从页面加载开始到页面处于完全可交互状态 (Time To Interactive),完全可交互状态指的是页面有内容呈现并且用户可以进行操作。
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耗时与对应执行的方法
图3 Widget树结构优化以减少build次数
b) 预构建widget (AnimatedBuilder)
图4 酒店详情页头部使用预构建减少build次数
上图是酒店详情页头部沉浸式动画的UI,头部展开的过程中,图片和图片上的蒙层需要重新绘制,图片上部SHA logo不需要重新绘制,图片下部tab栏不需要重新绘制,对于这个需求的做法是用AnimatedBuilder。
AnimatedBuilder提供了几个可选参数,animation是对动画的监听,builder是动画过程中需要重新绘制的部分,child是动画过程中不需要重新绘制的部分,child作为 参数会传入builder中。 下面的伪代码是一个例子,动画过程中Text并不会多次绘制。
对于详情页头部沉浸式动画的例子,可以把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并触发绘制。
帧的绘制状态可以从SchedulerBinding获得,同时建立队列保证一帧执行一个子widget绘制。
PB反序列化Response到JsonString的编码JsonString到MethodChannel(使用JsonMethodCodec编解码)传输 JsonString到Reponse的解码整个 过程链路长,数据传输量大,效率低,影响到页面加载性能,如下图所示
图11 优化前的业务服务请求数据流
改造后,通过服务返回的数据流,直接传输到Flutte r侧,在Flutter直接进行PB的反序列化,传输性能得到极大提升。
PB的数据流到MethodChannel(使用StandardMethodCodec编解码)传输PB反序列化到Response整 个过程链路短,数据传输量小,效率高,如下图所示:
图12 优化后的业务服务请求数据流
其中MethodChannel的编解码器由JsonMethodCodec换成了StandardMethodCodec。因为StandardMethodCodec可以避免转换JsonString的操作,能节省传输时间。
在flutter中使用Protobuf,首先需要将proto契约文件转化成dart文件,可以借助官方编译工具protoc进行编译。
a) 获取protoc工具
安装C++
sudo apt-get install autoconf automake libtool curl make g++ unzip
安装Protobuf发行版
下载完成之后,解压,进到目录中执行下面命令编译安装
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包
2)下载Protobuf对应版本的jar包
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 内存泄漏的常用监控手段
在监控方面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侧的调用:
Native的响应:
可以看到Native在接受到这个plugin调用时,对于result的调用返回不是一直都会做的,它需要等到满足条件才会做这件事情,而如果它不做这件事情,对应的flutter那边的闭包就会一直被保存在引擎中,这个引用链也会一直存在,从而造成这个引用链上的对象都泄漏了。
解决的方法:
我们的解决方式,就是对这种异步但不能确定回调是否一定完成的情况,换成用StreamSubscription去监听。然后当页面退出时做一下cancel的动作,这样就能避免泄漏的发生。
b) 一些观察者模式中的订阅者在页面退出时没有取消订阅
这种是大家比较熟悉的一种情况。常见的例子有例如像Timer,EventBusCenter.defaultBus和LifeCycleObserver等。这些订阅者如果在页面退出时不需要了,需要记得取消掉。否则也会造成内存泄漏,这种情况我们也应该避免。
五、小结
性能优化是一件不断持续,不断深入的事情。我们通过本文中所介绍 的改进措施 对页面性能实现了很大的优化,达到了不错的效果。后续也会在此基础之上对还可提高的地方继续加深,同时也会对已经验证实行有效的方案去做一些抽象,封装工作,后续提供通用的解决方案。
作者:Qifan
来源:微信公众号:携程技术
出处: