性能专题>大厂前端敲门砖——Flutter的应用实践>
2
2

大厂前端敲门砖——Flutter的应用实践分享专题

实战篇: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查看组件渲染次数,发现整个界面都在刷新,当我们多次滑动页面后,发现很多组件都渲染了多次,如下图所示:
 

328C4421-D9AE-4618-83E4-685525C79745.png

通过DevTools,在滑动改变顶部的透明度时,发现FPS值很低,而且几乎每一帧都会超过16ms,火焰图很深,说明渲染的层级很深,整个界面的组件自上而下都重新渲染了,如图所示:

4A4FA89E-13C6-43E7-A316-1430A34BC1BD.png

现在就能理解为什么在用户滑动界面的时候会造成卡顿了,主要是由于渲染消耗过大,没有控制好界面的刷新范围。当改变顶部悬浮组件的时候,只需要改变顶部组件状态,而没有必要刷新整棵树。改造策略是通过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);

改造之后,可以看到,当界面滑动的时候,只重新渲染了需要改变透明度的组件,组件重建状态如下图所示:
 

DFD341F7-7F01-449D-BA53-3DDF23671836.png

        
火焰图如下所示:

75FDFEBD-3A25-46E7-8709-9625422CDC53.png


这样很大程度的减小了组件的重建范围,每次都只是按需加载,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就只有文本这个组件本身,如下图所示:

A1F4BD86-942C-491B-874E-4F4DD029A636.png

  
2.3 减少组件重绘的次数

开发过程中,很容易触发界面的重新渲染,大多数时候都是没有控制好组件的刷新次数,这样很容易导致内存消耗过大,或多次无效的网络加载,导致界面在滑动的时候出现卡顿,用户体验差等问题。我们可以借助 flutter_xlider三方组件实现区间选择效果:


在onDragCompleted回调方法中处理界面及数据刷新,代码如下:

Widget rangeSliderView() {
  return FlutterSlider(
    values: [0, 1000],
    onDragCompleted: (handlerIndex, lowerValue, upperValue) {
      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<Weight> 缓存高层级组件。

///存放界面所有的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中逻辑处理

0403CF59-BB3D-4AE4-95A0-49252CEDDF20.png

尽量减少build中处理逻辑,因为widget在页面刷新的过程中会随时通过build重建,build调用频繁,应该只处理跟UI相关的逻辑,因此将一些不涉及每次渲染都必须的操作,存放在initState中,或者使用变量进行状态判断,避免每次界面元素刷新触发build重绘时都需要大量重复切不必要的计算,从而降低CPU的消耗。

3.2 耗时计算放到Isolate去执行(多线程)

针对UI线程存在的一些耗时操作,可以使用Isolate以”多线程“的方式去执行。

Isolate本质更接近于操作系统中的”进程“概念,Dart中不存在共享内存的并发机制,由于不用担心线程抢占的问题因此也不会造成死锁,Isolate是没有共享内存的,这是跟常见的其它多线程语言区别较大的地方。

创建一个线程会增加2MB左右的内存,尽可能还是避免滥用导致内存开销。

D9C868CB-FC29-444C-AA0C-F4ECF8F43F2B.png

    

酒店详情页的头部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清空时,触发底层纹理的释放。

953267CE-674D-463C-9954-EE18627B73CE.png

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的宽高来计算,设置太小会模糊,设置大了,又不利于节省缓存。

91D739B1-F591-47D1-B88A-0F2D28D93108.png

      
六、总结

本文介绍了遇到Flutter页面渲染问题,结合Performance Overlay 性能分析工具来确定是 UI线程的性能问题,还是GPU 线程的性能问题。UI线程的性能问题可以通过火焰图来具体分析是哪个方法造成的。GPU 的线程问题可以通过查看渲染的次数,渲染的范围来确定。下面是我们常用的一些性能优化的方法:

UI 线程优化

拆分VieModel降低刷新几率
Provider监听数据推荐使用Selector
减少在build中做耗时操作,放到Isolate去执行
缓存高层级组件
控制刷新范围、频次
setState 刷新颗粒度在最低层
const 修饰避免频繁构造

GPU 线程优化

使用RepaintBinary隔离 提别是轮播广告、动画
减少ClipPath的使用,简单圆角采用BoxDecoration实现
避免Opacity,可以通过切图实现。有动画效果的建议用AnimatedOpacity
避免使用带换行符的长文本

同时也介绍了Flutter 在长列表、图片加载上的一些体验优化措施,希望能在你做Flutter性能优化和用户体验时有一些帮助。

更多Flutter的内容推荐大家阅读本篇

美团FlutterWeb性能优化探索与实践

最后感谢携程技术大佬的文章分享~

 

请先登录,感受更多精彩内容
快去登录吧,你将获得
  • 浏览更多精彩评论
  • 和开发者讨论交流,共同进步