likes
comments
collection
share

Flutter 绘制探索 5 | 深入分析重绘范围 RepaintBoundary | 七日打卡

作者站长头像
站长
· 阅读数 24

零:前言

1. 系列引言

可能说起 Flutter 绘制,大家第一反应就是用 CustomPaint 组件,自定义 CustomPainter 对象来画。Flutter 中所有可以看得到的组件,比如 Text、Image、Switch、Slider 等等,追其根源都是画出来的,但通过查看源码可以发现,Flutter 中绝大多数组件并不是使用 CustomPaint 组件来画的,其实 CustomPaint 组件是对框架底层绘制的一层封装。这个系列便是对 Flutter 绘制的探索,通过测试调试源码分析来给出一些在绘制时被忽略从未知晓的东西,而有些要点如果被忽略,就很可能出现问题。


2. shouldRepaint 无法控制的重绘

前面说过,由于 shouldRepaint 只会在 RenderCustomPaint 渲染对象重新设置画板时而触发。所以它控制画布刷新的场景仅限于上层 element#rebuild,最常见的场景是 State#setState。经过测试,发现仍存在一些莫名的 paint 被重绘的场景。本文就来深入探究一下这些情况,已及对应的解决方案。


一、滑动中的莫名重绘

1. 测试案例

如下,通过一个 SingleChildScrollView 包含一个自定义的画板组件。并在 ShapePainter#paint 中打印绘制日志,页面中并未涉及任何的刷新逻辑。可以发现,随着滑动,ShapePainter#paint 在一直执行。想当年 FlutterUnit 的 CustomPaint 详情页就是这个问题,滑动时非常卡顿。那么为什么会发生这么不可思议的事呢?又该怎样解决呢?

Flutter 绘制探索 5 | 深入分析重绘范围 RepaintBoundary | 七日打卡

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
        title: 'Flutter Demo',
        theme: ThemeData(
          primarySwatch: Colors.blue,
        ),
        home: HomePage());
  }
}

class HomePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(),
      body: SingleChildScrollView(
        child: Column(
          children: [
            Container(
                height: 150,
                width: MediaQuery.of(context).size.width,
                child: CustomPaint( painter: ShapePainter(color: Colors.red))) ),
            Container( height: 900, color: Colors.green,)
          ],
        ),
      ),
    );
  }
}

class ShapePainter extends CustomPainter {
  final Color color;
  ShapePainter({this.color});

  @override
  void paint(Canvas canvas, Size size) {
    print('-------paint----${color.value}---${DateTime.now()}---');
    Paint paint = Paint()..color = color;
    canvas.drawCircle(Offset(80, 80), 50, paint);
  }

  @override
  bool shouldRepaint(covariant ShapePainter oldDelegate) {
    return oldDelegate.color!=color;
  }
}

2. 案例调试

既然是触发了ShapePainter#paint,那么必然冤有头,债有主,肯定有哪里执行了 RenderCustomPaint#paint 。所以分析的最好方法就是打个断点,调试一下。从 RendererBinding.drawFrame 开始看,执行到 ShapePainter#paint 方法栈情况如下:

Flutter 绘制探索 5 | 深入分析重绘范围 RepaintBoundary | 七日打卡


目前待渲染列表中,只有 _RenderSingleChildViewport 。它是由 SingleChildScrollView 间接创建的,在它的绘制中,会触发绘制孩子。

Flutter 绘制探索 5 | 深入分析重绘范围 RepaintBoundary | 七日打卡

它的 child 属性是 RenderFlex ,是由 Colunm 创建的。

Flutter 绘制探索 5 | 深入分析重绘范围 RepaintBoundary | 七日打卡

最后在 PaintingContext.paintChildRenderCustomPaint 作为孩子被绘制。而引发 ShapePainter#paint 绘制的执行。

Flutter 绘制探索 5 | 深入分析重绘范围 RepaintBoundary | 七日打卡


3.解决方案

代码处理起来非常简单,在 CustomPaint 之上添加 RepaintBoundary 即可。这样滑动时,就不会触发 ShapePainter#paint 的重绘,这时,你的心里肯定会有一个大大的问号,Why? 下面就来一起探索吧。

Flutter 绘制探索 5 | 深入分析重绘范围 RepaintBoundary | 七日打卡

child: RepaintBoundary( <--- 添加 RepaintBoundary
  child: CustomPaint(
    painter: ShapePainter(color: Colors.red),
  ),
),

二、重绘范围 RepaintBoundary

1.绘制的上界

下面代码可以看出:当一个 RenderObject 对象执行 markNeedsPaint 时,如果自身 isRepaintBoundary 为 false,会向上寻找父级,直到有 isRepaintBoundary=true 为止。然后该父级节点被加入 _nodesNeedingPaint 列表中。

---->[RenderObject#markNeedsPaint]----
void markNeedsPaint() {
    if (_needsPaint)
      return;
    _needsPaint = true;
    if (isRepaintBoundary) {
      if (owner != null) {
        owner!._nodesNeedingPaint.add(this); //<--- 自己被加入 待渲染列表
        owner!.requestVisualUpdate();
      }
    } else if (parent is RenderObject) {
      final RenderObject parent = this.parent as RenderObject;
      parent.markNeedsPaint();
    } else {
      if (owner != null)
        owner!.requestVisualUpdate();
    }
  }

bool get isRepaintBoundary => false;

如下图,如果 4 节点执行了 markNeedsPaint ,由于它的 isRepaintBoundary=false ,就会执行 parent.markNeedsPaint,同理向上追溯发现 2 节点的 isRepaintBoundary=true 所以,就会将 2 加入_nodesNeedingPaint 列表中。 如果 3 执行 markNeedsPaint,也是 2 加入_nodesNeedingPaint 列表中。如果是 5 执行 markNeedsPaint,其本身是 isRepaintBoundary , 则 5 加入_nodesNeedingPaint 列表中。这也就是渲染对象的上界 需要是一个 isRepaintBoundary=true 的可渲染对象。

Flutter 绘制探索 5 | 深入分析重绘范围 RepaintBoundary | 七日打卡


2.绘制的下界

RenderObject#paintChild 中可以发现,只有当 child.isRepaintBoundary 成立时,才不会继续绘制绘制孩子,这就是说,如果 2 被加入 _nodesNeedingPaint 列表,在 2 节点触发绘制时,会绘制孩子,如果此时 5isRepaintBoundary,那么就不会向下绘制,这样 6 就不会绘制,这就是 绘制的下界

Flutter 绘制探索 5 | 深入分析重绘范围 RepaintBoundary | 七日打卡

---->[RenderObject#paintChild]----
void paintChild(RenderObject child, Offset offset) {
  if (child.isRepaintBoundary) {
    stopRecordingIfNeeded();
    _compositeChild(child, offset);
  } else {
    child._paintWithContext(this, offset);
  }
}

唯鹿 兄在 说说Flutter中的RepaintBoundary 也介绍过 RepaintBoundary,但感觉没有点出绘制上下界的概念。不过他可能是最早分享 RepaintBoundary 使用的人吧,很感谢他的分享。这里通过这个探索系列,相信大家能对此有一个更深刻的认识。


4.RepaintBoundary 组件的原理

其实原理超级简单,比如在旧版的里面,在 2 节点绘制时,会触发 5 的重绘。 想要不让 5 绘制,只要在 5 之前加个挡箭牌 就行了,RepaintBoundary 就是干这个事的,其创建的 RenderRepaintBoundary 对象的 isRepaintBoundarytrue。就这么简单。

Flutter 绘制探索 5 | 深入分析重绘范围 RepaintBoundary | 七日打卡

class RepaintBoundary extends SingleChildRenderObjectWidget {
  /// Creates a widget that isolates repaints.
  const RepaintBoundary({ Key key, Widget child }) : super(key: key, child: child);
  @override
  RenderRepaintBoundary createRenderObject(BuildContext context) =>  RenderRepaintBoundary();
	// 略...
}

class RenderRepaintBoundary extends RenderProxyBox {
  /// Creates a repaint boundary around [child].
  RenderRepaintBoundary({ RenderBox? child }) : super(child);
  @override
  bool get isRepaintBoundary => true;

5.为什么不全加 RepaintBoundary

有人也许有疑问,既然如此,所有节点都加 RepaintBoundary ,自己负责绘制自己,别牵连别人不好吗?我们来看一下,如果 isRepaintBoundary 成立,虽然之后的节点不会绘制,但会发生什么。

---->[RenderObject#paintChild]----
void paintChild(RenderObject child, Offset offset) {
  if (child.isRepaintBoundary) {
    stopRecordingIfNeeded();
    _compositeChild(child, offset); <--- 
  } else {
    child._paintWithContext(this, offset);
  }
}

会进行 _compositeChild ,最终将 child._layer 添加到 _containerLayer 中。如果 RepaintBoundary 非常多,就会导致非常多的 Layer。所以是药三分毒, RepaintBoundary 也不是来瞎用的。最常见的就是用于 滑动时,让自己绘制的复杂画板不频繁刷新。

void _compositeChild(RenderObject child, Offset offset) {
  if (child._needsPaint) {
    repaintCompositedChild(child, debugAlsoPaintedParent: true);
  } else {
  final OffsetLayer childOffsetLayer = child._layer as OffsetLayer;
  childOffsetLayer.offset = offset;
  appendLayer(child._layer!);
}
  
@protected
void appendLayer(Layer layer) {
  assert(!_isRecording);
  layer.remove();
  _containerLayer.append(layer);
}

三、盘点源码中 RepaintBoundary 的使用

俗话说,以史为镜,可正衣冠。 看源码是最正的,我们最信任的应该是源码,但也要保留一分质疑。下面就来看一下,源码中对于 RepaintBoundary 的使用,以此借鉴。


1. _CupertinoScrollbarState

这个组件是 CupertinoScrollbar,和滑动相关, 在使用 ScrollbarPainter 时,将 CustomPaint 夹在了两个 RepaintBoundary 之间。

Flutter 绘制探索 5 | 深入分析重绘范围 RepaintBoundary | 七日打卡


2._ScrollbarState

这个对于的组件是 Scrollbar,和滑动相关, 在使用 ScrollbarPainter 时,将 CustomPaint 夹在了两个 RepaintBoundary 之间。

Flutter 绘制探索 5 | 深入分析重绘范围 RepaintBoundary | 七日打卡


3._TextFieldState_CupertinoTextFieldState

分别是 TextFieldCupertinoTextField,由于输入框的游标频闪,使用需要加 RepaintBoundary 进行限制。

Flutter 绘制探索 5 | 深入分析重绘范围 RepaintBoundary | 七日打卡


4. _GlowingOverscrollIndicatorState

滑动到顶底的指示器,也是和滑动相关, 在使用 _GlowingOverscrollIndicatorPainter 时,将 CustomPaint 夹在了两个 RepaintBoundary 之间。

Flutter 绘制探索 5 | 深入分析重绘范围 RepaintBoundary | 七日打卡


5. Sliver 相关

ListViewGridView 的本质都是 Sliver 相关的组件。在 SliverChildBuilderDelegate 中都默认会套上 RepaintBoundary,因为 addRepaintBoundaries 默认为 true 。从这可以看出这是列表类滑动组件的默认行为,RepaintBoundary 并没有那么昂贵。

Flutter 绘制探索 5 | 深入分析重绘范围 RepaintBoundary | 七日打卡

你可以做一个测试,将 SingleChildScrollView 替换成 ListView 。这样在滑动时也不会触发画板的频繁绘制,原因就在于 SliverChildBuilderDelegate 中的 RepaintBoundary 处理。

Flutter 绘制探索 5 | 深入分析重绘范围 RepaintBoundary | 七日打卡


6. Flow 中

Flow 中,其传入的 children ,会通过 RepaintBoundary.wrapAll 对每个组件进行包裹。

Flutter 绘制探索 5 | 深入分析重绘范围 RepaintBoundary | 七日打卡

四、其他需要注意的组件

1. 水波纹系列

RawMaterialButton 系列的组件,底层都依赖于 InkWell ,在测试中发现水波纹效果会触发自定义画板的不断重绘。如下:

Flutter 绘制探索 5 | 深入分析重绘范围 RepaintBoundary | 七日打卡

class HomePage extends StatelessWidget{
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      floatingActionButton: FloatingActionButton(
        child: Icon(Icons.add),
        onPressed: () {
          // debugDumpRenderTree();
        },
      ),
      body: CustomPaint(
        painter: ShapePainter(color: Colors.red),
      ),
    );
  }
}
class ShapePainter extends CustomPainter {
  final Color color;
  ShapePainter({this.color});
  @override
  void paint(Canvas canvas, Size size) {
    print("----paint--------${DateTime.now()}-------");
    Paint paint = Paint()..color = color;
    canvas.drawCircle(Offset(100, 100), 50, paint);
  }
  @override
  bool shouldRepaint(covariant ShapePainter oldDelegate) {
    return oldDelegate.color != color;
  }
}

调试一下可以看到,上界如下,不知道是官方少加了 RepaintBoundary 下界,还是另有考虑。解决方案是在绘制的组件上套一个 RepaintBoundary

Flutter 绘制探索 5 | 深入分析重绘范围 RepaintBoundary | 七日打卡


2.输入框系列

在输入框收起打开时,会触发自定义画板的绘制,而且随着打开次数的增加,绘制越多,感觉像是 bug 。同样解决方案是在绘制的组件上套一个 RepaintBoundary ,就不会出现重绘现象。目前版本,最新稳定版 Flutter 1.22.5

Flutter 绘制探索 5 | 深入分析重绘范围 RepaintBoundary | 七日打卡

class HomePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          CustomPaint(
              size: Size(300,150),
              painter: ShapePainter(color: Colors.red),
            ),
          TextField(),
        ],
      ),
    );
  }
}

当你在通过 CustomPaint 组件自定义绘制时,需要注意这几类组件:1、滑动类型; 2、InkWell 相关 ;3、 输入框 。当然这些只是我遇到的,当你自定义的绘制出现卡顿或频繁重绘时,也要注意一下。


通过本文,你应该对 Flutter 中的绘制范围有了更深的认识。如果你的绘制中出现了频繁触发的异常重绘,那么 RepaintBoundary 一定会帮助你。本文就到这里,下一篇将会讲解另一个 shouldRepaint 无法控制的画板重绘,不过这个无法控制是我们的需求,那就是基于 repaint 对画板绘制的原理。前面虽然有所涉及,但我觉得有必要用一篇文章详述一下可监听对象与画板的关系,再对 CustomPaint 组件的其他属性进行探索。


@张风捷特烈 2021.01.15 未允禁转 我的公众号:编程之王 联系我--邮箱:1981462002@qq.com -- 微信:zdl1994328 ~ END ~