flutte3.0 性能优化系列|一文教你完全掌握ListView卡顿优化

976 阅读13分钟

1. 卡顿的原理

一秒 60 帧,也就意味着平均两帧之间的间隔为 16.7ms

检测build---layout---paint三个阶段的耗时 , 同时有4大线程

所以,我们做性能优化,关心DartUI,关心GPU两个线程,掉不掉帧,卡不卡的关键,

就看这两位了,而且在99%情况下,作为Flutter开发人员,我们我们基本上解决好,DartUI线程上的问题,就==解决了渲染性能问题。

原因: Flutter为什么会卡顿、帧率低?总的来说均为以下2个原因

1). UI线程慢了-->渲染指令出的慢

2). GPU线程慢了-->光栅化慢、图层合成慢、像素上屏慢

总之: 我们只需要在任意Flutter工程中,搜索drawFrame() 便可以得到答案。

2. 如何检测卡顿

我们经常在做性能调优的时候,会用到timeline工具,你会看到这样一幅图:

现在串起来了吗,4个线程build---layout---paint三个阶段是不是都一目了然,各发生在什么地方,什么阶段,谁先谁后。

所以,我们说 要解决卡顿掉帧的问题,就是要解决build,layout,paint这三个阶段各函数执行耗时的问题。 3. 卡顿检测的工具

检测工具的效果:流畅度检测工具 APP 以悬浮框的方式显示

3.1 FPS检测工具 fps_monitor (重点 )

使用方法; dependencies: fps_monitor: ^1.12.13-1, 接入工程

显示Fps界面

显示 Fps 的页面比较简单,直接通过 OverlayState 插入即可。如果你不太熟悉 Overlay 可以把它理解成浮窗

实现原理: 渲染调度SchedulerBinding

3.2. 卡顿排查:DevTools是官方的开发配套工具,非常实用

  1. Performance检测单帧CPU耗时(build、layout、paint)、GPU耗时、Widget Build次数
  2. CPUProfiler 检测方法耗时
  3. Flutter Inspector观察不合理布局
  4. Memory 监控Dart内存情况

devTools, devTools的启动姿势是:

flutter pub global activate devtools
devTools

结合DevTools的分析图,我们可以看出。在上面130ms的构建的主要耗时集中在Layout中调用的build方法

3.3. Flutter Performance&Inspector

首推官方性能分析工具并结合使用 profile 模式查看性能问题

www.sunmoonblog.com/2020/01/10/… (非常牛逼)

以AS为例,右侧会出现Flutter Performance和Inspector2个功能区。Performance功能区如下图:

3.4 其他工具:PerformanceOverLay 和 DoKit

4. 卡顿优化方案

4.1 小技巧

1)、尽量将setState放在叶子节点,好处是build时影响范围极小,简称局部刷新

Provider 中获取 Model 的方式会影响刷新范围。推荐使用 Selector 或 Consumer 来获取祖先

2). 缓存不变的Widget

缓存不变的Widget有2大好处。1.被缓存的Widget将无需重复创建, 虽然Flutter官方认为Widget是一种非常轻量级的对象,在实际业务中,Build耗时过高仍是一种常见现象。2.返回相同引用的Widget将使Flutter停止该子树后续遍历, 即Flutter认为该子树无变化无需更新。原理请看下图“Element.updateChild源码分析”

3). 减少不必要的build(setState)

直播Tab用到一个埋点曝光组件,经过DevTools检查,发现其在每一次进度回调中重新创建itemWidget,虽然这不会造成业务异常,但理论上itemWidget只需被创建一次,这块经排查是使用组件时误传了builder函数,而不是直接传itemWidget实例。

详情页的逻辑非常复杂,AppBar根据滚动距离实时计算透明度,这会导致高频的setState,实际上透明度变化前后应该满足一个差值后才应刷新一次状态, 为了性能考量,透明度应该只有少数几种值变更。

4). 避免频繁的triggerGC

因为AliFlutter的关系,我们得以主动触发DartGC,但GC同样也是有消耗的,高频的GC更是如此。淘特之前因为iOS的内存压力,在列表滚动停止时ScrollEndNotification则会触发GC,ScrollEndNotification在每一次手Down->up事件后都会触发一次,如果用户多次触摸,则会较为频繁的触发GC,实测影响Y67 4帧左右的性能,这块增加页面不可见时GC 和在Y67等android低端机关闭滑动GC,提高滑动性能。

5). 优化 ClipPath 和 ClipRPath

5)、能不用 Opacity Widget,就尽量不要用,因为这货会粗发GPU一个saveLayer的指令,做Skia的大神说,这个指令相当耗时。

避免使用 Opacity widget,尤其是在动画中避免使用。请用 AnimatedOpacity 或 FadeInImage 进行代替

6)、多变图层与不变图层分离, 对于频繁更新的控件(比如倒计时,秒表, 就是动画等),使用RepaintBoundary隔离它,让他在一个独立的paint区域。

在日常开发中,会经常遇到页面中大部分元素不变,某个元素实时变化。如Gif,动画。这时我们就需要RepaintBoundary,不过独立图层合成也是有消耗,这块需实测把握。以淘特为例。

直播Feed中的Gif图是不断高频跳动,这会导致页面同一图层重新Paint。此时可以用RepaintBoundary包裹该多变的Gif组件,让其处在单独的图层,待最终再一块图层合成上屏。

同理, 秒杀倒计时也是电商常见场景, 该组件也适用于RepaintBoundary场景。

7)、使用const来修饰永远不需要变更的控件。

8)、优先使用StateLessWidget,而不是全部用StateFulWidget

9). 尽量减少或降级Clip、Opacity等组件的使用

Flutter中,Clip主要用于裁剪,裁矩形、圆角矩形、圆形。一旦调用,后续所有的绘图指令都会受其Clip影响。有些ClipRRect可以用ShapeDecoration代替,Opacitiy改用AnimatedOpacity, 针对图片的Clip裁切,可以走定制图片库Transform实现。

10)、使用Visibility控件替换if/else,有些小伙伴喜欢else时return一个 占位控件,须不知,这种效率是没有Visibility高效的。

11). 用 AnimatedBuilder 时,避免在不依赖于动画的 widget 的构造方法中构建 widget 树。动画的每次变动都会重建这个 widget 树。而应该构建子树的那一部分,并将其作为 child 传递给 AnimatedBuilder

12). 避免使用带换行符的长文本

4.2 小技巧如何提高UI线程性能:

如何提高build性能

    • 降低遍历出发点,降低setState的触发节
    • 停止树的遍历,不变的内容,返回同样的组件实例、Flutter将停止遍历该树(SlideTransition)
    • 减少非必要的build(setState)
  • 如何提高layout性能

    • layout暂时不太容易出问题
  • 如何提高paint性能

    • RepaintBoundary分离多变和不变的图层,如Gif、动画, 但多图层的合成也是有开销的
  • 其他

    • 耗时方法如大JSON解析用compute子线程化
    • 减少不必要的channel调用或批量合并
    • 减少动画
    • 减少Release时的log
    • 提高UI线程在Android/iOS的优先级
    • 列表组件支持局部build
    • 较小的cacheExtent值,减少渲染范围

如何提高GPU线程性能:

1). 谨慎saveLayer

2).尽量少ClipPath、一旦调用,后续所有绘图指令需与Path做相交。(ClipRect、ClipRRect等)

3).减少毛玻璃BackdropFilter、阴影boxShadow

4).减少Opacity使用,必要时用AnimatedOpacity

5. listview卡顿优化方案

5.1 Listiview卡顿的原因 :
listiview卡顿的原因 :在某一帧内,ListView构建多个复杂的item, 导致build方法耗时, 出现卡顿
5.2 istiview卡顿场景
1). 长列表懒加载
2). 首次进入多次的构建item,
3). 快速滑动,一帧内构建多个item
4). 一些分页列表上

Flutter中ListView采用懒加载机制。对于ListView里面的每一个item,并不会在build阶段全部进行构建。而是在layout阶段,根据屏幕当前的尺寸以及缓存区的范围,动态的构建每一个item

所以引起卡顿的原因非常明显主要由于,在某一帧内,ListView构建多个复杂的item。例如分析图中,在Layout阶段同时build了多个item,一个item的构建耗时已经接近10ms,同时构建自然超过了16ms。

5.3 如何优化ListView卡顿?

1). 长列表滑动性能优化

ListView等长列表在滚动的过程中是Lazy Loading机制,按需加载滑窗范围内的items,但如果items的高度是没有显性的指定的时候,将会有严重的性能问题

提供一个新的属性itemExtentBuilder,有了它,我们可以为每一个item指定高度,同时有着丝滑的性能体验。

2)、分帧上屏

卡顿的本质原因是在一帧内,模块的运行时间过长,这不光是ListView的问题,所有有复杂元素的页面都一样。那么我们有没有一种通用的方案解决这个问题?其实答案很简单,我们可以从两条路去思考:第一种 优化模块时间(例如安卓上的布局优化等) 这个需要我们具体问题具体分析,因为导致模块卡顿的原因是多样的,有可能是Widget太复杂,没有合适的局部刷新,或者 UI isolate进行了大量计算等。第二条思路是在不优化模块的情况下,对时间进行分片,提升流畅度 也就是俗称的分帧运行

假设,我们屏幕能显示4个item,每个item构建耗时是10ms。在现有的ListView布局过程中,会在第一帧的时候,同时构建这四个item,总共40ms。

采用分帧之后,在页面的第一帧我们先通过构建简单的占位item,占位的item可以是个简单的Container。由于其构建基本不耗时,在第一帧的时候构建四个Container不会导致卡顿。 之后将实际的四个item,分别延迟到后面四帧进行渲染。这样对于每个16.7ms而言,都没有发生超时渲染,整个流程不会发生卡顿

3)、Element复用?

闲鱼在一文中还提到了一点:element的复用。这个优化点在和lwlizhe交流之后,我个人认为可能效果没那么明显。因为如果从Native的角度出发以ViewHolder为例,他的复用本质是对于同类型的item减少创建view和解析xml的时间,其中有个关键的方法:onBindViewHolder将数据绑定到View上。

但是对于Flutter而言,即使item的类型相同,对于不同数据的item而言,并没有一个数据绑定Widget的方法。所以仅仅只能做建立一个缓存池来保存element,创建的时候优先从缓存获取。但这样问题就来了,其实官方本来就有一个cacheExtent缓存区的设计,缓存在cacheExtent内的的Element。个人认为没多大必要额外在做一个缓存。

最简单的,将cacheExtent设置大一点就行

4)、LoadMore增量更新

上面我们提到了,item的构建是由ListView的layout驱动,所以如果是增量更新的情况,我们只要修改itemCount之后,标记ListView进行layout即可。闲鱼在文中提到了这个在layout之前需要做Widget缓存的更新,但是实际上在1.22之后,因为这个缓存几乎没有任何优化作用,官方已经去掉了这个Widget缓存,所以这个过程变得更加简单。

1)、加载更多的更新问题
2)、Element被回收后的复用问题

其中核心的updateChild方法的第一个参数传递的是index对应的element对象,而第二个参数变成了null,在原来我一直在错误的使用 setState()?中提到过,在第二个参数为null的时候,那么之前的element对象会被卸载unmount()。这样在二次创建的时候,该index对应的element对象又会被再次创建。所以这里可以通过建立一个element缓存池,在创建的时候优先从缓冲池获取;

  1. .按需加载滑窗范围内的items,但如果items的高度是没有显性的指定的时候,将会有严重的性能问题

ListView可以通过ListView.itemExtent或者ListView.prototypeItem设置高度来提高Lazy Loading过程中的耗

  1. . 跳转到某个item, 没法做跳转到某个index的原因。

**5.4 ListView具体的优化措施&&**建议

5.3.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' + '时间戳';
    // 列表数据请求
}

4.5)、使用ListView.builder()而不是直接使用ListView()来构建列表。

4.6). 列表 Item 高度可知的情况下,推荐设置 itemExtent,减少滑动中频繁计算列表高度

👀关注公众号:Android老皮!!!欢迎大家来找我探讨交流👀