通过一系列文章记录一下学习 Flutter 的过程,总结一下相关知识点。
- 学习Flutter -- 框架总览
- 学习Flutter -- 启动过程做了什么
- 学习Flutter -- Widget 的组成
- 学习Flutter -- Element 的作用
- 学习Flutter -- RenderObject 布局过程
- 学习Flutter -- RenderObject 绘制过程
概述
本篇将会介绍一下 Flutter 渲染流水线的最后一步:绘制(paint),Flutter 会遍历 RenderObject 的子树来逐一绘制,最终呈现到屏幕上面的其实是由不同的图层(layers)组合(composite)而成的。并且这些图层也是以树形结构组织起来的,也就是Flutter 中的第四棵树:layer tree。
下面是 Flutter 渲染机制的示意图:
绿色背景部分
UI 线程,就是 Flutter 渲染流水线运行的地方,通过 GPU 的 Vsync 信号驱动,渲染完成后会输出一个 layer tree,之后会被送入 engine。
蓝色背景部分
GPU 线程,engine 将 layer tree 在 GPU 线程进行合成(composite),然后由 Skia 渲染引擎渲染后交给 GPU 显示。
接下来,我们将针对渲染机制中的部分流程详细展开介绍。为方便理解,先介绍几个常用的类及其概念。
基本概念
RepaintBoundary (绘制边界)
RepaintBoundary 是Flutter 自带的一个组件,作用就是向组件树中插入一个绘制边界节点。
什么是绘制边界节点?
将 isRepaintBoundary 属性值为 true 的 RenderObject 节点称为绘制边界节点
绘制边界节点(后续简称:边界节点),在重绘时,使 RenderObject 可独立于父结点进行绘制,切断了绘制请求向父结点传播,目的还是为了优化性能,避免不必要的重绘。
Layer
源码:
abstract class Layer extends AbstractNode with DiagnosticableTreeMixin {
@override
ContainerLayer get parent => super.parent;
Layer get previousSibling => _previousSibling;
Layer _previousSibling;
Layer get nextSibling => _nextSibling;
Layer _nextSibling;
//...
}
Layer ,是 Flutter 中的图层,继承自 AbstractNode,表明它也是个树形结构,是构成 layer tree 的节点。其中 parent 指向父结点,previousSibling 和 nextSibling 表示同一图层的前一个和后一个兄弟节点,图层的孩子节点们是用双向链表的结构存储的。
Layer 的种类
具体的 Layer 可分为两大类:
-
容器类 Layer (ContainerLayer)
用于管理一组 Layers,是唯一可以拥有 child layer 的 Layer。
它的作用和具体使用场景是什么呢?
-
Flutter 通过容器 Layer 生成 layer tree
父 Layer 可以包含多个子 Layer,子 Layer又可以包含多个子 Layer。
-
可以对多个 Layer 整体应用一些变换效果
容器 Layer 可以对其子 Layer 整体做一些变化效果,如裁剪效果(ClipRectLayer)、矩阵变换(TransformLayer)、透明变换(OpacityLayer)等等。
-
-
绘制类 Layer
没有 child layer,在 layer tree 中作为叶子节点,是真正用于承载渲染结果的 layer。如PictureLayer 承载图片的渲染结果,TextureLayer 承载纹理的渲染结果。
Canvas
Canvas 类,封装了 Flutter Skia 的各种绘制指令,比如:drawLine、drawCircle、drawRect 等绘制相关的基础接口,RenderObject 正是通过 Canvas 中的这些基础接口完成绘制任务的。
Canvas 是 Framework(Dart) 层与 Engine(C++) 层的桥接,真正的功能在 Engine 层实现。
源码:
Canvas(PictureRecorder recorder, [ Rect? cullRect ]) : assert(recorder != null)
如上,在 Canvas 初始化时,需要传入 PictureRecorder 对象,用于记录所有“绘制操作指令”。
除了正常的绘制操作(draw),Canvas 还支持矩阵变换(transform matrix)、区域裁剪(clip),下面列举了部分方法:
void scale(double sx, [double sy]);
void rotate(double radians) native;
void transform(Float64List matrix4);
void clipRect(Rect rect, { ClipOp clipOp = ClipOp.intersect, bool doAntiAlias = true });
void clipPath(Path path, {bool doAntiAlias = true});
void drawColor(Color color, BlendMode blendMode);
void drawLine(Offset p1, Offset p2, Paint paint);
void drawRect(Rect rect, Paint paint);
PictureRecorder
用于记录 Canvas 上执行的所有 “绘制操作指令”,最终通过 endRecording 方法生成绘制结果 Picture。
Picture
是一系列“绘制操作指令”的集合,通过 toImage 方法将记录的所有操作进行光栅化处理,最后生成 Image 对象。
Scene
是一系列 Picture、Texture 合成的结果。在渲染流水线中,经过 build、layout、paint等操作最终生成 Scene。通过 window.render 将 Scene 送入Engine层,最后经过GPU 光栅化后显示到屏幕上。
举个🌰
下面通过一个例子来看一下这些类是如何使用的。
void main() {
// 初始化 Canvas,并传入指令记录器 PictureRecorder
PictureRecorder recorder = PictureRecorder();
Canvas canvas = Canvas(recorder);
Paint paint= Paint();
paint.color = Colors.yellowAccent;
// 调用 Canvas 的绘制接口,画一个矩形
var rect = const Rect.fromLTWH(100, 100, 300,300);
canvas.drawRect(rect, paint);
// 绘制结束,生成Picture
Picture picture = recorder.endRecording();
// 创建 SceneBuilder ,并传入 picture
SceneBuilder sceneBuilder = SceneBuilder();
sceneBuilder.addPicture(const Offset(0, 0), picture);
sceneBuilder.pop();
// 生成 Scene
Scene scene = sceneBuilder.build();
window.onDrawFrame = () {
// 将 scene 送入 Engine 层进行渲染显示
window.render(scene);
};
window.scheduleFrame();
}
运行之后,在屏幕上画出了一个黄色的矩形。
以上只是为了演示,通过直接操作 Canvas 的接口进行图形绘制,日常开发中并不需要直接操作这些基础的 API。
方法解析
markNeedsRepaint - (RenderObject)
RenderObject 对象(节点)是通过该方法发起重绘请求的,当一个节点需要重绘时,首先需要找到距离它最近的父级的边界节点,然后让该边界节点重绘即可,源码:
void markNeedsPaint() {
if (_needsPaint) return;
_needsPaint = true;
if (isRepaintBoundary) { // 当前节点是边界节点
//将当前节点添加到需要重新绘制的列表中。
owner!._nodesNeedingPaint.add(this);
// 请求新的frame,该方法最终会调用scheduleFrame()
owner!.requestVisualUpdate();
} else if (parent is RenderObject) { // 若不是边界节点且存在父节点
final RenderObject parent = this.parent! as RenderObject;
// 递归调用父节点的 markNeedsPaint
parent.markNeedsPaint();
} else {
// 如果是根节点,直接请求新的 frame 即可
if (owner != null)
owner!.requestVisualUpdate();
}
}
当一个节点调用了 markNeedsPaint 后,具体流程是:
-
从当前节点一直递归的向父级节点查找,直到找到一个边界节点时才终止,然后将该边界节点添加到 PiplineOwner 的 _nodesNeedingPaint 列表中(后续重绘时会遍历该列表)。
-
在向上查找过程中,会将当前节点到边界节点整个路径上的所有节点的 _needsPaint 都标记为 true,表示需要重绘。
-
最后,请求新的 frame,执行重绘流程。
drawFrame - (RenderBinding)
当请求新的 frame 后,下一个 Vsync 信号到来后,就会触发 drawFrame 方法,即:渲染流水线。
void drawFrame() {
pipelineOwner.flushLayout(); //1.布局
pipelineOwner.flushCompositingBits(); //图层合成相关
pipelineOwner.flushPaint(); //绘制相关
renderView.compositeFrame(); // this sends the bits to the GPU
//...省略
}
其中重绘的流程主要是从 flushPaint 方法开始,我们先重点看下这个方法。
flushPaint - (PipelineOwner)
从上文知,当某个 RenderObject 节点需要重绘时,会调用 markNeedsPaint 方法,将其最近的父级边界节点加入到 PiplineOwner 的 _nodesNeedingPaint 列表中。此方法,正是对之前通过markNeedsPaint 方法加入的那些节点进行重绘。
void flushPaint() {
final List<RenderObject> dirtyNodes = _nodesNeedingPaint;
//深度优先排序,从下到上遍历
for (RenderObject node in dirtyNodes..sort((RenderObject a, RenderObject b) => b.depth - a.depth)) {
if (node._layer.attached) {
PaintingContext.repaintCompositedChild(node);
//...
}
}
在处理需要重绘的节点时,首先会按节点深度排序,越深的先进行重绘。只有当 _layer.attached 为 true 时(表示已经在layer tree 上),会调用 PaintingContext.repaintCompositedChild(node) 进行绘制。
repaintCompositedChild - (PaintingContext)
这个方法比较重要,为各种重绘操作进行图层的创建,以及与绘制上下文之间的绑定。从代码中的 asset 断言可以看出,进入该方法的节点都是绘制边界节点。源码
static void repaintCompositedChild( RenderObject child, PaintingContext? childContext) {
assert(child.isRepaintBoundary);
OffsetLayer? childLayer = child.layer;
if (childLayer == null) {
final OffsetLayer layer = OffsetLayer();
child.layer = childLayer = layer;
} else { //(之前绘制时已经为其创建过layer了),则清空其子节点。
childLayer.removeAllChildren();
}
//创建新的 PaintingContext
paintingContext ??= PaintingContext(childLayer, child.paintBounds);
//_paintWithContext 方法内部逻辑简单,主要是调用 paint 方法
child._paintWithContext(childContext, Offset.zero);
//结束
childContext.stopRecordingIfNeeded();
}
- 图层的创建
该方法会先检查 RenderObject 节点的 layer 属性,为 null 则会创建一个 OffsetLayer 给它;若图层已经存在,则清空其所有子节点。
- PaintingContext 的创建
随后通过该 OffsetLayer 构建一个 PaintingContext 对象,即二者绑定。这意味着通过同一个paintingContext 的canvas 绘制的产物属于同一个 layer。先看下 PaintingContext 的部分源码
class PaintingContext extends ClipContext {
@protected
PaintingContext(this._containerLayer, this.estimatedBounds)
@override
Canvas get canvas {
if (_canvas == null)
_startRecording();
return _canvas;
}
//创建PictureLayer和canvas
void _startRecording() {
_currentLayer = PictureLayer(estimatedBounds);
_recorder = ui.PictureRecorder();
_canvas = Canvas(_recorder);
//将pictureLayer添加到_containerLayer(是绘制边界节点的Layer)中
_containerLayer.append(_currentLayer);
}
void stopRecordingIfNeeded() {
_currentLayer!.picture = _recorder!.endRecording();
_currentLayer = null;
_recorder = null;
_canvas = null;
}
//... 省略
从源码可以看出,当调用 context 的 canvas 时,会创建一个 PictureLayer,并将该 layer 添加到了 _containerLayer 中(即构造函数传入的边界节点)。从 stopRecordingIfNeeded 方法可以看出,当绘制结束之后,会将 Canvas 的绘制产物保存在 PictureLayer 中,因此通过同一个 context 的 canvas 绘制的产物属于同一个 PictureLayer。
paint - (RenderObject)
RenderObject 中的 paint 是个空方法, 需要子类自己实现,用于绘制自身。不同类型的节点,绘制算法也不同。
- 非容器组件:绘制自身,如 Image Widget (对应:RenderImage),直接通过 paintImage 进行自身相关 image 的绘制。
void paint(PaintingContext context, Offset offset) {
paintImage(
canvas: context.canvas,
rect: offset & size,
image: _image,
...
);
}
- 容器类的组件:要绘制自身和子节点。自身若不需要绘制,则只绘制子节点,如 Center Widget (对应:RenderShiftedBox),通过 paintChild 对子节点进行绘制。
void paint(PaintingContext context, Offset offset) {
final RenderBox? child = this.child;
if (child != null) {
final BoxParentData childParentData = child.parentData! as BoxParentData;
context.paintChild(child, childParentData.offset + offset);
}
}
paintChild - (PaintingContext)
该方法会递归的绘制子节点,源码:
void paintChild(RenderObject child, Offset offset) {
if (child.isRepaintBoundary) {
stopRecordingIfNeeded();
_compositeChild(child, offset);
} else if (child._wasRepaintBoundary) {
child._layerHandle.layer = null;
child._paintWithContext(this, offset);
} else {
//不是边界节点,则绘制自身
//_paintWithContext 方法内部逻辑简单,主要是调用 paint 方法
child._paintWithContext(this, offset);
}
}
//
void _compositeChild(RenderObject child, Offset offset) {
if (child._needsPaint || !child._wasRepaintBoundary) {
repaintCompositedChild(child, debugAlsoPaintedParent: true);
} else {
if (child._needsCompositedLayerUpdate) {
updateLayerProperties(child);
}
}
//将孩子节点的layer添加到Layer树中,
final OffsetLayer childOffsetLayer = child._layerHandle.layer! as OffsetLayer;
//将当前边界节点的layer添加到父边界节点的layer中.
childOffsetLayer.offset = offset;
appendLayer(childOffsetLayer);
}
从源码得知,如果子节点是边界节点且需要绘制,则递归调用 repaintCompositedChild(上面分析过),然后将当前节点的 layer 添加到父边界节点的 layer 中。如果子节点不是边界节点,则调用 paint 方法进行绘制。
注意:绘制子节点时,如果字节点是边界节点但不需要绘制(即_needsPaint 为 false)时,会直接复用将该节点的 layer,无需重绘。
stopRecordingIfNeeded - (PaintingContext)
结束绘制指令的记录,该方法是在上述的 repaintCompositedChild 方法最后调用的。
void stopRecordingIfNeeded() {
_currentLayer!.picture = _recorder!.endRecording();// 将canvas绘制产物保存在 PictureLayer中
_currentLayer = null;
_recorder = null;
_canvas = null;
}
//void _startRecording() {
//...
//_containerLayer.append(_currentLayer)
//}
在结束绘制时,会将绘制产物保存在 _currentLayer (PictureLayer) 中,然后会将所有变量都置空。
注意:此时只是将 _currentLayer 的引用置空,在最开始的 _startRecording 中已经将 _currentLayer 添加到了边界节点的 _containerLayer (即 PaintContext 初始化时候传入的边界节点的 OffsetLayer)中了。
小结
通过以上分析,将主要的方法调用通过流程图串起来,如图所示:
按照上述流程执行完毕后,最终所有边界节点的 layer 连接在一起就组成了一颗 layer tree。
第四棵树的生成 - Layer Tree
边界节点的特点
通过以上代码分析得知,绘制边界节点的主要特点有:
- 每个边界节点在绘制时,都有一个独属于自己的 OffsetLayer (继承自 ContainerLayer),其自身及子孙节点的绘制结果都将 attach 到该 OffsetLayer 为根节点的字数上;
- 每个边界节点在绘制时,都有一个独属于自己的 PaintingContext (及其 Canvas、PictureLayer),从而将绘制过程与父结点完全隔离开。
layer tree 绘制过程
通过以上方法的分析,layer tree 的生成过程大概是这样的:当 Flutter 第一次绘制时,会从上到下递归的绘制子节点。当遇到一个边界节点时,判断其layer 属性(ContainerLayer)是否为 null,为空的话则会创建一个 OffsetLayer 给它;不为空则直接使用。然后将边界节点的 layer 传递给子节点,当绘制子节点的逻辑是:
- 如果子节点不是边界节点,且需要绘制
-
- 当第一次绘制时:
- 创建一个 Canvas 和 PictureLayer 对象,后续通过 Canvas 的绘制结果都会绑定在 PictureLayer 上;
- 并将这个 PictureLayer 添加到边界节点的 layer 中。
-
- 不是第一次绘制,则复用已有的 PictureLayer 和 Canvas 对象
- 子节点是边界节点,则对子节点递归上述过程。当子树递归完成后,则将子节点的 layer 添加到父级 layer 中。
以上整个流程执行完成之后就生成了一颗 layer 树。下面通过一个例子来理解一下,如图:左侧是一颗 RenderObect Tree,右侧是一颗 Layer Tree。
如上图:
- 根节点
RenderView 是根节点,从它开始绘制。同时也是边界节点,所以会为它 OffsetLayer。
- R1 、R4、R5
-
- 子节点 R1 是边界节点,会为它 OffsetLayer。同时添加到父级的边界节点中。
- 子节点R4不是边界节点且需要绘制,此时是第一次绘制,会创建对应的 PictureLayer 来承载绘制结果。并添加到边界节点的 OffsetLayer 中。
- 子节点R5 不是边界节点也需要绘制,但此时不是第一次绘制,因此会复用之前绘制 R4 的PictureLayer。
- R2、R6、R8
-
- 字节点 R2 的绘制会有一个新的PictureLayer 来承载绘制结果。
- R6 是边界节点,会将它的 OffsetLayer 添加到父级的OffsetLayer 中。
- R8 同理会创建新的 PictureLayer。
- R3、R7
-
- R3 与 R2 是兄弟节点,且都不是边界节点。但是 R2 的子节点是边界节点,所以R2 绘制完成后会将 Canvas 置空。所以绘制 R3 的时候新建 PictureLayer。
- 同理R7 (同 R5)与 R3 绘制在同一个 PictureLayer 上。
📢:Repaint Boundary 并不是越多越好,太多反而会增加引擎的负担,只适用那些需要频繁重绘的场景(如视频),或特殊布局场景(如 Stack)。
开天辟地第一帧
上文提到,Flutter 渲染流水线的运行是通过 GPU 的 Vsync 信号驱动的,那么 Flutter 的第一帧的渲染也需要等待 Vsync 信号吗?
runApp
我们再回到 Flutter App 的入口函数 runApp 中,之前的文章 2.学习Flutter -- 启动过程做了什么 中已经介绍过启动过程每一步骤具体的作用。
void main() {
runApp(MyApp());
}
void runApp(Widget app) {
WidgetsFlutterBinding.ensureInitialized()
..scheduleAttachRootWidget(app)
..scheduleWarmUpFrame(); //预热第一帧
}
这里在简要说明一下每一步的作用,从而将整个思路串起来。
第一步:根节点的创建
WidgetsFlutterBinding 混入了多个 Binding,所以在初始化过程中又调用了各个 Binding 的相关初始化方法。其中涉及到绘制相关就是 RendeBinding,
RendererBinding 的初始化
在 RendererBinding 创建了根 RenderObject 节点:renderView,并且直接对该节点调度了绘制相关操作,摘取了部分源码:
//初始换根节点
void initRenderView() {
renderView = RenderView(configuration: createViewConfiguration(), window: window);
renderView.prepareInitialFrame();
}
//根 RenderObject 保存在了 PipelineOwner 中,作为渲染树的根节点
set renderView(RenderView value) {
_pipelineOwner.rootNode = value;
}
// RenderView
class RenderView extends RenderObject {
//...
void prepareInitialFrame() {
scheduleInitialLayout();
scheduleInitialPaint(_updateMatricesAndCreateNewRootLayer());
}
TransformLayer _updateMatricesAndCreateNewRootLayer() {
_rootTransform = configuration.toMatrix();
final TransformLayer rootLayer = TransformLayer(transform: _rootTransform);
rootLayer.attach(this);
return rootLayer;
}
void scheduleInitialPaint(ContainerLayer rootLayer) {
_layerHandle.layer = rootLayer;
owner!._nodesNeedingPaint.add(this);
}
//根节点是 绘制边界节点
bool get isRepaintBoundary => true;
//...
}
主要流程:
- 调度初始化布局 scheduleInitialLayout
- 调度初始化绘制 scheduleInitialPaint ,将根节点加入到 PipelineOwner 的 _nodesNeedingPaint 列表中,后续进行绘制的时候就会对该列表中的节点进行绘制(上述 flushPaint 方法内)。
- 创建根 layer (TransformLayer),并赋值给了 renderView 的 layer。
注意:根节点也是绘制边界节点。
第二步:生成完整的 RenderObject 树
runApp 方法的第二步 scheduleAttachRootWidget 将所有子节点 RenderObject 对象的关联到根 RenderObject 上,生成了完成的 RenderObject 树。
第三步:触发渲染
这一步的就是要将准备好的 RenderObject 树,进行绘制并显示到屏幕上。
void scheduleWarmUpFrame() {
...
handleBeginFrame(null);
handleDrawFrame();
...
}
正如方法名,预热第一帧,在该方法内部直接调用了 handleBeginFrame()和handleDrawFrame(),强制渲染,从而触发渲染流水线。因此,Flutter 第一帧的绘制不需要等待 Vsync 信号驱动。
以上通过绘制流程的方法分析,又反过来总结了一下 Flutter App 中根节点对第一帧的绘制调度过程。
小结
上屏显示
绘制完 layer 后,接下来就要上屏显示了。这一过程是由 compositeFrame() 方法来完成,再回到 drawFrame 中,该方法是通过根节点 renderView 来调用的,源码:
void drawFrame() {
//...
renderView.compositeFrame(); // this sends the bits to the GPU
//...
}
compositeFrame
通过 renderView 的 layer 来构建 scene,将整棵layer tree 转换成 scene 对象,从这里开始就都是对图层的操作了。
void compositeFrame() {
final ui.SceneBuilder builder = ui.SceneBuilder();
final ui.Scene scene = layer.buildScene(builder);
//
_window.render(scene);
}
//构建 Scene
ui.Scene buildScene(ui.SceneBuilder builder) {
updateSubtreeNeedsAddToScene();
addToScene(builder);
final ui.Scene scene = builder.build();
return scene;
}
最后当所有的图层处理完之后,再通过 window.render 将 scene 送入 engine 去渲染显示。
总结
- 整个绘制过程就是对 RenderObject Tree 进行深度遍历,输出一颗 Layer Tree。
- 重绘时,与布局过程的 markNeedsLayout 相似,当 RenderObject 需要重绘时,会通过 markNeedsPaint 方法标记 dirty 节点存在PipelineOwner 中。
- 重绘边界 Repaint Boundary 与 布局边界 Relayout Boundary不同的是,不仅是为了重绘时的性能优化,也能通过图层的隔离实现不同的布局效果。