Flutter 渲染浅析

1,974 阅读10分钟

Flutter 是 Google 在 2015 年开源的 UI 框架,能帮助开发者通过一套代码库高效地构建出 iOS 和 Android 上高质量的原生应用,还支持 Web、桌面和嵌入式平台开发。

渲染方式

常见的跨平台开发有如下三种渲染方式:

image.png

  1. WebView渲染:依赖 WebView 进行渲染,在功能和性能上有所妥协,如微信小程序等。
  2. 原生渲染:通过中间层把前端框架转化为原生控件来渲染 UI 界面,例如 React Native、Weex等,这种方案多了一层转译层性能上有损耗,数据通信也有性能瓶颈。
  3. 自建渲染:自建渲染框架自行实现一套渲染框架,底层使用 Skia 等图形库进行渲染,而不用依赖于原生控件,这类的例子有 Flutter、Unity。其中,Flutter 使用 Skia 将界面渲染到平台提供的画布上,不需调整即可迁移到其他平台。

那么 Flutter 究竟是如何渲染的呢?

首先,这个流程主要涉及到三棵树

image.png

  • Widget 树: Widget 树是程序员用 Dart 语言编写的,描述页面和逻辑等信息的树,类似于 web 开发里的 HTML 文档,它提供了 UI 界面的配置信息,就像是绘制的蓝图
  • Element 树: Flutter 会根据 Widget 树,生成相应的 Element 树和 RenderObject 树,Element 有点儿类似于 web 里的 DOM,用来描述节点信息,还有做 diff,它主要负责管理生命周期,也是连接 Widget 树和 RenderObject 树的桥梁
  • RenderObject 树: 也就是渲染树,负责布局(计算位置和大小等信息)和绘制

正如上面所述,其实 Flutter 的这几树跟 Web 里的渲染是有相似之处的。RenderObject 的概念也不是 Flutter 独创的,Web 里也有 RenderObject 的概念,浏览器会将根据 HTML 和 CSS 输入构建的 DOM 树和 CSSOM 树合并成一个“渲染树”,然后进行渲染。

上面简单了解了它们分别是什么以及各自的作用,那么 Flutter 又是为什么会生成这三棵树呢? 下文会从 Flutter 架构入手简单讲解一下 Flutter 对渲染的抽象思路。

架构

image.png

这是一个简单版的 Flutter 架构图,Flutter 架构最核心的便是 Framework(框架)层和 Engine(引擎)层: 这里只包含了 Framework 层和 Engine 层其实还有一个平台嵌入层,详细的架构图可以到官网查看

我们可以从下往上看:

  • Engine 层(渲染引擎层):Flutter 引擎毫无疑问是 Flutter 的核心,它主要使用 C++ 编写,提供了 Flutter 核心 API 的底层实现,包括图形、文本布局、文件及网络 IO 等,还提供了 Dart 运行环境。

    其中,渲染采用的是开源的 2D 图形渲染库 Skia,提供了能适用于多种软硬件平台的通用 API。当需要绘制新一帧的内容时,引擎将负责对合成好的场景进行栅格化(将绘图指令转化为二维像素),然后交给 GPU 去进行渲染。

  • Framework 层:Framework 层是我们日常开发打交道最多的一层,它是用 Dart 编写的,封装了整个 Flutter 架构的核心功能,包括 Widget、动画、绘制、手势等功能,还封装了有 Material(Android 风格 )和 Cupertino(iOS 风格 )的 UI 组件库来让程序员轻松实现复杂的 UI 布局。

    • 首先,我们来看看 dart:ui 层,它是对 engine 层的一层很薄的封装,简单来说就是把 engine 层可以做的事儿对 framework 层暴露接口。例如,提供了 pictureRecorder 和 canvas 等可以用来画图。

    • 再上一层是 rendering 渲染层,主要是对底层布局和渲染的抽象,这也是第一个把布局、绘制和渲染管道抽象了的层次。有了渲染层,你可以构建一棵 RenderObject 树,在你动态更新这些对象时,渲染树也会自动根据你的变更来更新布局。

    • 再上一层是 widget 层,它是一种对组合的抽象。每一个渲染层中的渲染对象,都在 widget 层中有一个对应的类。而且,widget 层让你可以自由组合你需要复用的各种类,来实现复杂的 UI 界面。此外,响应式编程模型也是在该层级中被引入的。

三层实现对比

接下来我们看一下分别用 dart:ui 层,rendering 渲染层和 widget 层这三层的内容来实现一个非常简单的例子分别是如何做的。

这个例子就是大名鼎鼎的 Hello World,需求是要在屏幕的正中间显示“hello, world.”的文字,就是这么简单,主要目的对比不同抽象层次它们的实现方式。

先看一下最终渲染出来的界面效果如下图所示: image.png

用dart:ui实现

下面就是只用 dart:ui 层提供的方法来实现上面的例子,大家先别被这些代码吓到,我们平时开发的时候是不需要写这么底层的代码的。

import 'dart:ui' as ui;

void beginFrame(Duration timeStamp) {
  // 获取设备像素比
  final double devicePixelRatio = ui.window.devicePixelRatio;
  // 计算出逻辑像素大小
  final ui.Size logicalSize = ui.window.physicalSize / devicePixelRatio;

  // 用 ParagraphBuilder 生成我们想要的文字
  final ui.ParagraphBuilder paragraphBuilder = ui.ParagraphBuilder(
    ui.ParagraphStyle(textDirection: ui.TextDirection.ltr),
  )..addText('Hello, world.');
  final ui.Paragraph paragraph = paragraphBuilder.build()
    ..layout(ui.ParagraphConstraints(width: logicalSize.width));

  final ui.Rect physicalBounds =
      ui.Offset.zero & (logicalSize * devicePixelRatio);
  final ui.PictureRecorder recorder = ui.PictureRecorder();
  final ui.Canvas canvas = ui.Canvas(recorder, physicalBounds);
  canvas.scale(devicePixelRatio, devicePixelRatio);
  
  // 调用 canvas 中的 drawParagraph 在屏幕中央画出我们要的文字
  canvas.drawParagraph(
      paragraph,
      ui.Offset(
        (logicalSize.width - paragraph.maxIntrinsicWidth) / 2.0,
        (logicalSize.height - paragraph.height) / 2.0,
      ));
  final ui.Picture picture = recorder.endRecording();

  final ui.SceneBuilder sceneBuilder = ui.SceneBuilder()
    ..pushClipRect(physicalBounds)
    ..addPicture(ui.Offset.zero, picture)
    ..pop();

  // 调用 window 上的 render 方法最终渲染出页面
  ui.window.render(sceneBuilder.build());
}

// main方法是程序的主入口,Flutter引擎会在加载代码后立即执行main方法
void main() {
  // 每当要生成新的一帧的时候,引擎就会调用 onBeginFrame
  ui.window.onBeginFrame = beginFrame;
  // 整个过程是由我们通知引擎安排新的一帧开始的,当准备好开始生成新的一帧时,引擎最终将调用 onBeginFrame 来告诉 framework 层
  ui.window.scheduleFrame();
}

从上面的例子可以看到,这种方式提供给我们的就是一块画布,我们需要知道怎么去画以及在哪个位置画。而且,这种通过直接调用 API 绘制图像的方式更像是命令式编程方式。比如,我们要自己去计算需要渲染的文字在页面上的位置,然后告诉 canvas 在正确的位置绘制出 Hello World 的文字内容。

这个例子只是实现一个很简单的需求就需要写这么多的代码是很繁琐的,更难以想象要用这种方式去实现复杂多变的真正的用户界面了,而且上面的例子还不涉及到 UI 界面的更新等。

Rendering层实现

接下来看到的是用 rendering 渲染层的 RenderObject 实现的同样的 hello world 的需求。

import 'package:flutter/rendering.dart';

void main() {
  // 使用 RenderingFlutterBinding 把 render tree 连接到 window 对象
  RenderingFlutterBinding(
    // render tree的根节点是一个 RenderPositionedBox,它可以把 child 放在水平和垂直方向的中间
    root: RenderPositionedBox(
      alignment: Alignment.center,
      // 使用 RenderParagraph 来显示文字'Hello, world.',不带任何样式
      child: RenderParagraph(
        const TextSpan(text: 'Hello, world.'),
        // 因为是英文,所以需要设置文字的方向是从左到右(如果是阿拉伯语之类的就需要设置成从右到左)
        textDirection: TextDirection.ltr,
      ),
    ),
  );
}

可以看到代码量已经比 dart:ui 层的少了许多,这是因为 RenderOject 封装了 layout 和 paint 的逻辑,让我们不用关心怎么去画每一个地方,而可以把注意力放在在怎么用 RenderObject 去组装成我们想要的页面效果。

RenderObject 对底层进行了抽象,让我们不需要手动计算大小、位置等信息,还添加了一个父子关系,构建了一个布局模型来通过父节点向下传递布局约束、子节点向上传递大小信息,然后父节点决定子节点的位置来管理计算布局。

听起来 RenderObject 已经很好了,那为什么还需要 widgets 这一层呢?为什么不直接用 RenderObject 来构建页面呢?

因为直接用 RenderObject 会有性能问题,RenderObject 是非常重的,它上面保存了很多布局和绘制的信息,它还有很多缓存的数据被其它许多地方引用,我们并不想每一帧都重新构建整个 RenderObject 树,而是希望尽可能地复用 RenderObject。

然后,widget 层就被引入了,widget 层通过引入了响应式编程解决了上面的问题。

widgets层的实现

那么,最后来看看我们最熟悉的 widgets 层来实现的方式。

import 'package:flutter/widgets.dart';

void main() => runApp(const Center(
    child: Text('Hello, world!',
        key: Key('title'), textDirection: TextDirection.ltr)));

可以看到代码量进一步减少了很多,就只是用到了两个简单的 widget(Center 和 Text)就实现了hello world 的需求。

可以看出 widget 层的抽象是非常高的,不过我们能很容易地看懂上面的代码,因为代码看起来非常简洁明了。

而且,这种编写方式是声明式的编程方式,我们只需要申明我们的界面由哪些 widget 组成,Flutter 框架会处理好其余部分(包括布局和绘制等)。

可以看到从 dart:ui 层到 rendering 层再到 widget 层,随着抽象层次越来越高,我们写的代码是越来越少的,代码的可读性和可维护性也逐渐提高。这是因为 Flutter 为我们做了很多抽象和封装,把脏活累活都包了,让我们只用关注自己的业务逻辑和界面展示,而不用关心底层是怎么去渲染和更新界面的。

为什么需要 widget,element 和 renderObject?

image.png

关于 widget 有一个很重要的特点是,widget 是 immutable 的,也就是不可变的。

widget 有一个很重要的目标是实现响应式编程,让我们不用手动去做更新。我们想达到的效果是重新构建 widget 树,然后界面就自动更新了,像魔法一样。

上面我们说了 RenderObject 重建的问题就是它太重了,那么要解决这个问题 widget 就得很轻,很容易重建。Flutter 设计的 widget 里面只有数据,它不保存任何对其它 widget 或 RenderObject 的引用,这就使得它很容易被重新构建。

现在一方面我们有了 widget 来保存我们想要构建的页面的配置数据,但是没有任何布局和绘制信息,可以很容易地销毁重建;另一方面我们有 RenderObject 来保存布局和绘制的信息,希望能尽可能地复用,那么怎么把这二者连接起来呢?

image.png

这就轮到 element 出场了,element 负责将 widget 和 RenderObject 关联起来,element 保存了对 widget 和 renderObject 的引用,用于管理 widget 和 renderObject 的整个生命周期(包括新建、更新、重建和销毁等)。比如,RenderObject 布局计算时需要的数据是在 widget 里申明的,所以要从 widget 中获取这些数据,而 element 就是这个沟通的桥梁。

总结

以上内容简单介绍了 Flutter 渲染相关的架构以及为什么需要三棵树,更多的内容有待下回分解。