阅读 929

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

零:前言

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 详情页就是这个问题,滑动时非常卡顿。那么为什么会发生这么不可思议的事呢?又该怎样解决呢?

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 方法栈情况如下:


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

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

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


3.解决方案

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

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

二、重绘范围 RepaintBoundary

1.绘制的上界

既然是范围,那必然会有上界下界。我们回想一下 Flutter 绘制探索 3 | 深入分析 CustomPainter 类 中,一个 RenderObject 对象被收录到待重绘列表中的情景。事情发生在 RenderObject#markNeedsPaint 。每个 RenderObject 对象都会有一个 isRepaintBoundary 的布尔属性,默认为 false ,其作用就是用于判断是否是绘制的边界。那么绘制的边界到底是什么意思呢?

下面代码可以看出:当一个 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 的可渲染对象。


2.绘制的下界

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

---->[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。就这么简单。

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 之间。


2._ScrollbarState

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


3._TextFieldState_CupertinoTextFieldState

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


4. _GlowingOverscrollIndicatorState

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


5. Sliver 相关

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

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


6. Flow 中

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


四、其他需要注意的组件

1. 水波纹系列

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

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


2.输入框系列

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

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 ~

文章分类
Android
文章标签