1

Flutter在携程复杂业务的高性能之旅

 2 years ago
source link: https://www.51cto.com/article/705952.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

作者简介|本文为联合撰稿,作者为携程火车票Flutter团队。

携程火车票在十余个核心业务的列表页及主流程大规模进行了Flutter实践。经过一年多的开发、维护 ,总结了一套行之有效的性能优化方案。本文主要介绍结合性能分析工具,来识别、区分、定位一些性能问题,并且能够找到具体的方法和代码位置,帮助更快地解决问题。此外,也会分享我们做的一些性能优化案例和体验上的优化,希望能够给你带来一些启发。

二、渲染优化

Flutter 渲染性能问题主要可以分为 GPU 线程问题和 UI 线程(CPU)问题两种。通过Performance Overlay工具就能很清晰的分辨出来。UI 线程图表报红或者两个图表都报红,则表示 Dart 代码消耗了大量资源,需要优化代码执行时间。再结合火焰图, 分析CPU 的调用栈就能很轻松的找到哪个方法的耗时长,方法名是什么,渲染的层级有多深,而且还能做到性能优化前后的一个对比。 如果仅仅是GPU 线程图表报红的话,意味着渲染的图形太复杂,导致无法快速渲染。有时候Widget树的构建很简单,但是GPU线程的渲染却很耗时,就要考虑是否过度渲染,缺少组件缓存,涉及到Widget的裁剪、蒙层这类多视图叠加的渲染。

2.1 Selector控制刷新范围

在StatefulWidget中,很容易通过setState来进行渲染刷新界面,要尽量的控制刷新范围,避免不必要的界面组件重新渲染,使得GPU消耗过大,造成界面卡顿。举个例子如下所示:

65940784263aad59256420ca0d2273c48ea536.jpg

在界面滚动的时候,我们需要监听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查看组件渲染次数,发现整个界面都在刷新,当我们多次滑动页面后,发现很多组件都渲染了多次,如下图所示:

19bef03905bd6105f0c935d58342644081ecb5.png

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

87f709592a404e5e480571ffa17ccb070aee29.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);

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

c6986c109ef175dec94671aff382bf6ce371b5.png

火焰图如下所示:

891c19a5416c47d85f6019f0b1eeda315f80f9.png

这样很大程度的减小了组件的重建范围,每次都只是按需加载,build层级明显减少,总耗时也明显降低。因此在界面渲染的时候,应尽量降低Widget Tree遍历的出发点,合理控制重建范围。

2.2 setState 降低刷新颗粒度

如图所示,有一个动态的轮播效果,需要每间隔2s进行轮播一次,实现的方式是使用一个Timer,每间隔2s进行setState一下文字,以实现轮播的效果。

07616df35f72e1382f310419dafd3002388384.jpg

但是发现这个时候,这整个View都会被重绘,导致了巨大的开销,造成不必要的渲染,当前需求只是修改一个文字,没有必要整棵Widget树都去重新载入。这里需要考虑到没有合理控制刷新的范围。改进策略是将这个具有轮播效果的组件进行独立封装,以同样的方式去实现轮播效果;

Widget build(BuildContext context) {
  ///使用Timer每间隔2s去修改texts的值
  return Container(
    alignment: Alignment.center,
    child: Text(this.texts),
  );
}

这样每次渲染的Widget就只有文本这个组件本身,如下图所示:

7384ad70911ed9c4a8f572441d11ebc13081f6.png

2.3 减少组件重绘的次数

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

e4c063f36589536114843125a88f66615de316.jpg

在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中逻辑处理

0980a8517ce0d5f96cf3312a8ed6f580a90424.png

尽量减少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清空时,触发底层纹理的释放。

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

3797d181162c4cc25ee287fd222bf70df38400.png

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

UI 线程优化

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

GPU 线程优化

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

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


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK