作者简介|本文为联合撰稿,作者为携程火车票Flutter团队。
一、背景
携程火车票在十余个核心业务的列表页及主流程大规模进行了Flutter实践。经过一年多的开发、维护 ,总结了一套行之有效的性能优化方案。本文主要介绍结合性能分析工具,来识别、区分、定位一些性能问题,并且能够找到具体的方法和代码位置,帮助更快地解决问题。此外,也会分享我们做的一些性能优化案例和体验上的优化,希望能够给你带来一些启发。
二、渲染优化
Flutter 渲染性能问题主要可以分为 GPU 线程问题和 UI 线程(CPU)问题两种。通过Performance Overlay工具就能很清晰的分辨出来。UI 线程图表报红或者两个图表都报红,则表示 Dart 代码消耗了大量资源,需要优化代码执行时间。再结合火焰图, 分析CPU 的调用栈就能很轻松的找到哪个方法的耗时长,方法名是什么,渲染的层级有多深,而且还能做到性能优化前后的一个对比。 如果仅仅是GPU 线程图表报红的话,意味着渲染的图形太复杂,导致无法快速渲染。有时候Widget树的构建很简单,但是GPU线程的渲染却很耗时,就要考虑是否过度渲染,缺少组件缓存,涉及到Widget的裁剪、蒙层这类多视图叠加的渲染。
2.1 Selector控制刷新范围
在StatefulWidget中,很容易通过setState来进行渲染刷新界面,要尽量的控制刷新范围,避免不必要的界面组件重新渲染,使得GPU消耗过大,造成界面卡顿。举个例子如下所示:
在界面滚动的时候,我们需要监听CustomerScrollView,然后设置顶部悬浮组件的透明度去实现效果,代码如下:
/// 动画距离
int scrollHeight = 120;
_scrollController.addListener(() {
if (_scrollController.offset > scrollHeight && _titleAlpha != 255) {
setState(() {
_titleAlpha = 255;
});
}
if (_scrollController.offset <= 0 && _titleAlpha != 0) {
setState(() {
_titleAlpha = 0;
});
}
if (_scrollController.offset > 0 && _scrollController.offset < scrollHeight) {
setState(() {
_titleAlpha = _scrollController.offset * 255 ~/ scrollHeight;
});
}
});
根据滚动距离,设置透明度;但是setState会去刷新整个界面,整个界面的组件都会被重新渲染。通过Flutter Performance查看组件渲染次数,发现整个界面都在刷新,当我们多次滑动页面后,发现很多组件都渲染了多次,如下图所示:
通过DevTools,在滑动改变顶部的透明度时,发现FPS值很低,而且几乎每一帧都会超过16ms,火焰图很深,说明渲染的层级很深,整个界面的组件自上而下都重新渲染了,如图所示:
现在就能理解为什么在用户滑动界面的时候会造成卡顿了,主要是由于渲染消耗过大,没有控制好界面的刷新范围。当改变顶部悬浮组件的时候,只需要改变顶部组件状态,而没有必要刷新整棵树。改造策略是通过Provider的Selector进行控制刷新范围的,将透明度值存放在ChangeNotifier的子类中,当透明度发生改变时,通过notifyListeners()函数通知界面刷新。
监听代码如下:
void addScrollListenerForTopTitle(BuildContext context) {
var tabViewModel = Provider.of<TopTabStatusViewModel>(context, listen: false);
/// 动画距离
int scrollHeight = 120;
_scrollController.addListener(() {
///根据滚动距离来设置顶部titleBar的透明度
if (_scrollController.offset > scrollHeight && tabViewModel.titleAlpha != 255) {
tabViewModel.titleAlpha = 255;
}
if (_scrollController.offset <= 2 && tabViewModel.titleAlpha != 0) {
tabViewModel.titleAlpha = 0;
}
if (_scrollController.offset > 0 && _scrollController.offset < scrollHeight) {
tabViewModel.titleAlpha = _scrollController.offset * 255 ~/ scrollHeight;
}
});
}
透明度渐变组件:
Selector<TopTabStatusViewModel, int>(builder: (context, alpha, child) {
return Container(
color: Colors.white.withAlpha(tabViewModel.titleAlpha),
child: Column(
children: [
HotelDetailNavBar(tabViewModel.titleAlpha, widget.pageDeliverData, hotelDetail),
],
),
);
}, selector: (context , viewModel) => viewModel.titleAlpha);
改造之后,可以看到,当界面滑动的时候,只重新渲染了需要改变透明度的组件,组件重建状态如下图所示:
火焰图如下所示:
这样很大程度的减小了组件的重建范围,每次都只是按需加载,build层级明显减少,总耗时也明显降低。因此在界面渲染的时候,应尽量降低Widget Tree遍历的出发点,合理控制重建范围。
2.2 setState 降低刷新颗粒度
如图所示,有一个动态的轮播效果,需要每间隔2s进行轮播一次,实现的方式是使用一个Timer,每间隔2s进行setState一下文字,以实现轮播的效果。
但是发现这个时候,这整个View都会被重绘,导致了巨大的开销,造成不必要的渲染,当前需求只是修改一个文字,没有必要整棵Widget树都去重新载入。这里需要考虑到没有合理控制刷新的范围。改进策略是将这个具有轮播效果的组件进行独立封装,以同样的方式去实现轮播效果;
Widget build(BuildContext context) {
///使用Timer每间隔2s去修改texts的值
return Container(
alignment: Alignment.center,
child: Text(this.texts),
);
}
这样每次渲染的Widget就只有文本这个组件本身,如下图所示:
2.3 减少组件重绘的次数
开发过程中,很容易触发界面的重新渲染,大多数时候都是没有控制好组件的刷新次数,这样很容易导致内存消耗过大,或多次无效的网络加载,导致界面在滑动的时候出现卡顿,用户体验差等问题。如下图所示,借助 flutter_xlider三方组件实现区间选择效果:
在onDragCompleted回调方法中处理界面及数据刷新,代码如下:
Widget rangeSliderView() {
return FlutterSlider(
values: [0, 1000],
onDragCompleted: (handlerIndex, lowerValue, upperValue) {
if(lowerValue != startSortPrice || upperValue != endSortPrice) {
if (mounted) {
setState(() {
startSortPrice = lowerValue;
endSortPrice = upperValue;
});
}
/// 更新价格区间并刷新数据
refreshPriceText(lowerValue, upperValue);
}
},
);
}
如上图,这里存在一个问题,再次选同样的价格区间,也会触发界面和数据刷新,是完全无效的刷线操作。这里改进策略是添加条件限制避免重复的无效刷新。优化代码如下:
Widget rangeSliderView() {
return FlutterSlider(
values: [0, 1000],
onDragCompleted: (handlerIndex, lowerValue, upperValue) {
if(lowerValue != startSortPrice || upperValue != endSortPrice) {
if (mounted) {
setState(() {
startSortPrice = lowerValue;
endSortPrice = upperValue;
});
}
/// 更新价格区间并刷新数据
refreshPriceText(lowerValue, upperValue);
}
},
);
}
2.4 拆分ViewModel降低界面刷新几率
在开发Flutter的过程中,很多时候不会千篇一律的都使用setState去控制一个界面的状态,因为这样会使得界面过于零碎且难以控制。这时可以使用Provider进行管理界面的状态,使得界面的状态集中管理且界面渲染都在可控范围之内。
将存放状态的对象叫做ViewModel,针对一个大的界面,数据可能有多个来源,如果将所有的数据及状态值都存放在一个ViewModel中,就会使得 ViewModel过于冗余,当ViewModel中的数据发生变化时,可能会导致整个界面被触发重新渲染,这个显然是不合适的。因此可以将ViewModel进行拆分,尽量使得一个ViewModel只管理一个View,将ViewModel与View进行绑定,然后使用MultiProvider,将所有的Provider统一存放在界面的入口处,如下所示:
MultiProvider(
providers: [
ChangeNotifierProvider(
create: (context) => CalendarSelectorViewModel(),
),
ChangeNotifierProvider(
create: (context) => TopTabStatusViewModel(),
),
],
child: HotelDetailPageful(scriptDataEntity),
);
一个 ViewModel只对应界面中的一个UI,也就是说当数据变化的时候,只会控制对应的 View进行刷新,而不会刷新无关的View,从而降低无关View的刷新频率。
2.5 缓存高层级组件
复杂页面,页面级的每个模块都是独立的组件,每次刷新页面把所有的子组件都重新渲染一遍,性能开销非常大。尽量复用,避免不必要的视图创建。List 缓存高层级组件。
///存放界面所有的widgets,用以缓存
List<Widget> widgets = new List<Widget>();
///因为头部布局是静态的不刷新,使用变量控制是否复用以前的widgets
var refreshPage = true;
///获取界面布局所有的widgets
List<Widget> getPageWidgets(ScriptDataEntity data) {
if(widgets.isNotEmpty && !refreshPage) {
return widgets;
}
}
2.6 const 标识
当调用 setState(),Flutter 会 Rebuild 当前View中的每一个子组件,避免全部重新构建的方法就是用 const;特别是在一些有动画效果的组件上,更应该用const 修饰避免频繁构造。同时使用const 修饰还能减少垃圾回收。
2.7 RepaintBinary隔离
对于一些经常需要变动渲染的组件,比如Swiper、PageView、Lottie等,可以使用RepaintBoundary进行隔离。RepaintBoundary就是重绘的边界,用户重绘时独立于父布局。因为它会为经常发生显示变化的内容提供一个新的layer,新的layer paint不会影响到其他的layer。
RepaintBoundary(
child: Container(
child: Lottie.network(
InlandPicture.otaLottieJson,
),
),
)
2.8 尽量避免使用ClipPath组件
在开发过程中应尽量避免使用ClipPath,裁剪path是一个很昂贵的操作,在绘制小部件的时候,ClipPath会影响每个绘图指令,做相交操作,之外的部分裁剪掉,因此这是一个耗时操作。如果只是想裁剪圆角之类的组件,还是推荐使用Container的raidus进行去设置。
2.9 减少使用Opacity类型组件
减少Opacity Widget的使用,尤其是在动画中,因为它会导致widget的每一帧都会被重建,可以用AnimatedOpacity或者FadeInImage进行代替。
AnimatedOpacity(
opacity: showHeader ? 1.0 : 0.0,
duration: Duration(milliseconds: 200),
child: Container(
color: SmartColor.d_FFFFFF,
padding: EdgeInsets.fromLTRB(6, 0, 6, 0),
child: SmartTrainHeader(showHoverHeader: showHoverHeader,handlerCallBack: widget.handler)),
)
三、Root Isoate 优化
3.1 减少build中逻辑处理
尽量减少build中处理逻辑,因为widget在页面刷新的过程中会随时通过build重建,build调用频繁,应该只处理跟UI相关的逻辑,因此将一些不涉及每次渲染都必须的操作,存放在initState中,或者使用变量进行状态判断,避免每次界面元素刷新触发build重绘时都需要大量重复切不必要的计算,从而降低CPU的消耗。
3.2 耗时计算放到Isolate去执行(多线程)
针对UI线程存在的一些耗时操作,可以使用Isolate以”多线程“的方式去执行。
Isolate本质更接近于操作系统中的”进程“概念,Dart中不存在共享内存的并发机制,由于不用担心线程抢占的问题因此也不会造成死锁,Isolate是没有共享内存的,这是跟常见的其它多线程语言区别较大的地方。
创建一个线程会增加2MB左右的内存,尽可能还是避免滥用导致内存开销。
酒店详情页的头部header,跟随页面的滚动需要实时的计算当前的透明度,滑动到最顶部的时候全透明显示,滑动出头部图片显示区域的时候则完全显示出来,并且在界面滑动的过程中需要监听每个对应模块滑动的偏移量,以修改顶部悬浮Tab的状态;因此使用isolate将滑动实时计算透明度及偏移量的逻辑进行隔离操作,计算成功后将结果返回。这样就不会影响到UI主线程滚动页面的操作,可以提升页面的流畅性。
四、长列表滑动性能优化
4.1 ListView Item 复用
通过GlobalKey可以得到widget,包括获得组件的renderBox在内的各种element有关的信息,可以得到state里面的变量。在长列表分页加载时,数据变更会造成整个ListView重现构建,我们就可以利用 globalkey 获得 widget 的属性,来实现 Item 复用。从而解决分页加载成功后大量渲染引造成的页面卡顿问题。
Widget listItem(int index, dynamic model) {
if (listViewModel!.listItemKeys[index] == null) {
listViewModel!.listItemKeys[index] =RectGetter.createGlobalKey();
} else {
final rectGetter = listViewModel!.listItemKeys[index];
if (rectGetter is GlobalKey) {
final widget = rectGetter.currentWidget as RectGetter?;
if (widget != null) {
return widget;
}
}
}
使用GlobalKey不应该在每次build的时候重建GlobalKey,它应该是State拥有的长期存在的对象。
4.2 首页预加载
为了减少等待时间,能让用户进入列表页就能看到内容,在上个页面预加载列表的数据。预加载数据有几种情况,已加载成功直接带入加载数据结果,“在途请求”通过桥方法重新获取数据。代码如下:
_loadHotels() {
if (isFirstLoad && page == 1) {
// response首页携带已请求完毕的数据
if (response != null) {
// 处理展示列表页数据
return;
// 数据还在请求当中
} else if (isPreloading) {
// 首页数据加载完毕后回调,处理展示列表页数据
return;
}
}
// 正常加载数据
}
4.3 分页预加载
通常情况下当用户滑动到底部的时候才会去加载下一页的数据,这样用户要花费等待加载的时间,影响用户体验。可以采用剩余法预加载数据,当用户滑动到剩余一定数量的酒店时,开始加载下一页的数据,在网络良好的情况下,滑动场列表界面,界面基本不会存在等待加载的时间。
// getRectFromKey获取到scrollView的位置信息,遍历指定剩余数量的item,如果在当前屏幕中去加载一下页数据
if (!(itemRect.top > rect.bottom || itemRect.bottom < rect.top)) {
// 加载下一页数据
}
Rect? getRectFromKey(GlobalKey key) {
final renderObject = key.currentContext?.findRenderObject();
final translation = renderObject?.getTransformTo(null).getTranslation();
final size = renderObject?.semanticBounds.size;
if (translation != null && size != null) {
return Rect.fromLTWH(translation.x, translation.y, size.width, size.height);
}
return null;
}
4.4 取消在途网络请求
频繁做一些筛选等操作会在短时间内多次请求网络,如果网络较差或者服务端返回时间过长,会导致数据展示错乱的问题,在刷新列表时要取消掉还未返回数据的请求。
_loadHotels() {
if (isRefresh) {
// 通过标识符取消请求
cancelRequest(identifier);
}
identifier = 'QUERY_IDENTIFIER' + '时间戳';
// 列表数据请求
}
五、图片渲染性能和内存开销治理
图片加载是 APP 最常见也最基本的功能,也是影响用户体验的重要因素之一。在看似简单的图片加载背后却隐藏着很多技术细节,在接下来的章节,将主要介绍Flutter图片加载上做的一些优化尝试。
5.1 图片加载原理
以NetworkImage为例,我们看一下Flutter中图片的加载过程,首先通过ImageProvider的resolve获取相应的图片资源,得到ImageStream,通过底层进行解码,并生成纹理。ImageState接收到纹理对象绘制图片,上层获取图片纹理后会调用ImageState的SetState方法将纹理对象传给底层Render object,排版完成后图片就会绘制到屏幕。当上层Image Widget被销毁,Image Cache清空时,触发底层纹理的释放。
5.2 图片加载治理
在业务开发中,我们总希望页面内容可以尽可能快的展示给用户,给用户“直出”的用户体验。在酒店列表和详情页面中,都有较多的酒店和房型的图片,图片多,导致内存占用高,加载耗时,影响用户体验。
5.3 图片预加载
数据预加载:如果使用的图片资源是一些异步获取的数据,可以考虑是不是可以提前获取相关的数据,在要使用的时候,再拿过来使用。利用空闲资源,提前获取加载所需关键数据。
图片预加载机制:precacheImage,在合适的时机提前使用precacheImage对需要展示的图片数据进行预加载到内存中,这样在真正展示的时候,图片已经被加载到内存了,就可以在内容加载时达到“直出”的效果。
延时加载:在很多场景中,如酒店列表,酒店详情头部轮播图,第一次只需要加载首屏内的数据,就可以对非首屏的数据进行延迟加载,避免加载瞬时资源竞争,优先保证重要资源的加载,实现良好的加载体验。
5.4 图片资源优化
图片资源处理,图片压缩,图片格式建议优先使用webp格式,Flutter中原生支持webp图片格式。
CDN优化是另一个非常重要的方面,主要是在资源层面,最小化传输图片大小,最快响应图片请求,最优化图片选择,支持网络图片大小裁剪,根据实际的需要,加载对应的图片,比如大的头图和小的缩略图,根据具体的场景,加载裁剪之后的不同的图片资源。
5.5 图片内存优化
经过预加载和资源优化,已经可以比较流畅的加载相关业务了,但是过多的数据加载到内存,又会导致内存占用过高,怎么合理高效的利用内存就成为了接下来要解决的问题,一方面,Flutter图片管理能力较弱,缺乏本地存储能力;另一方面,在混合APP开发时,因为前面说的缓存不同,图片的重复下载,很容易造成内存过高,从而发生OOM(OutOfMemory)情况。在梳理 Flutter 原生图片方案之后,为了更稳定流畅的体验,是不是有机会在某个环节将 Flutter 图片和 Native 以原生的方式打通。
共享内存:打通Native内存数据,保证同样的数据在内存中只保留一份,避免重复加载造成的内存开销。使用磁盘缓存,这样既可以增大缓存的数据量,同时通过磁盘,Native和Flutter又可以共享一份数据,极大的减少了内存占用,保证了内存平稳运行。
图片加载:Flutter的图片加载有两种方式:一是默认方式不指定cacheWidth/cacheHeight,最终图片的加载使用的是原图分辨率,这就可能导致内存使用过大出现内存泄漏的情况;二是指定cacheWidth/cacheHeight,以此限制图片的加载分辨率,同时图片的key也会受此影响,即同一源的图片多次不同分辨率加载会多次占用内存,这既不方便也没有节约到内存。
因此针对以上情况,图片的内存缓存的命中和width/height、cacheWidth/cacheHeight等参数相关,这样从分根据图片的参数来设置缓存数据,更有效的保证缓存的真实有效性。在使用缓存时,发现一个问题,就是图片容易模糊,变形。比如在加载一个高清大图时,采样比例无法单纯的根据页面widget的宽高来计算,设置太小会模糊,设置大了,又不利于节省缓存。
六、总结
本文介绍了遇到Flutter页面渲染问题,结合Performance Overlay 性能分析工具来确定是 UI线程的性能问题,还是GPU 线程的性能问题。UI线程的性能问题可以通过火焰图来具体分析是哪个方法造成的。GPU 的线程问题可以通过查看渲染的次数,渲染的范围来确定。下面是我们常用的一些性能优化的方法:
UI 线程优化
- 拆分VieModel降低刷新几率
- Provider监听数据推荐使用Selector
- 减少在build中做耗时操作,放到Isolate去执行
- 缓存高层级组件
- 控制刷新范围、频次
- setState 刷新颗粒度在最低层
- const 修饰避免频繁构造
GPU 线程优化
- 使用RepaintBinary隔离 提别是轮播广告、动画
- 减少ClipPath的使用,简单圆角采用BoxDecoration实现
- 避免Opacity,可以通过切图实现。有动画效果的建议用AnimatedOpacity
- 避免使用带换行符的长文本
同时也介绍了Flutter 在长列表、图片加载上的一些体验优化措施,希望能在你做Flutter性能优化和用户体验时有一些帮助。