Flutter 原理初探

3,716 阅读9分钟

一、Flutter 框架全景图

上图是 Flutter 官网上的一张全景图。从结构上看,Flutter 主要分为三个层次:

  • Dart 框架层(Framework)
    • 上层框架,主要包括 dart 侧 Widget 管理、绘制、动画、手势等接口
  • C++ 引擎层(Engine)
    • 虚拟机、线程模型、与平台的通信、绘制流程、系统事件、文字布局、帧渲染管线等
  • 平台相关的嵌入层(Embeder)
    • 渲染图层、平台线程和事件循环管理,Native Plugin 等

今天主要阐述 Engine 层和 Framework 的一部分关键模块。

二、线程模型

在 Flutter 的设计中,各个不同层级中的独立执行单元分别对应一个专有的概念: DartVM 中的独立执行单元叫做 Isolate,Engine 中叫做 Runner,Embedder 中才对应平台真正的线程。Isolate、Runner 等这些概念对线程进行了封装,施加了特定的约束,既实现了线程安全性又保证了平台无关性。

下图主要展示了 Flutter 各层中执行单元与线程的对应关系:

其具体约束和行为特点总结如下:

  • Dart Isolate:

    • 各 Isolate 间不共享内存,无需 lock
    • 各 Isolate 间通过 port 发消息,调用皆异步
    • 除 Root Isolate 以外都跑在 dart 自己维护的线程池上
  • Engine 从 Embedder 得到多个 Task runner:

    • 每个 runner 一般都对应一个独立的线程
    • Runner 所对应的线程在 Engine 的生命周期内保持不变
  • Engine 中的各 runner 的职责:

    • UI runner:负责处理 Dart root isolate 中的逻辑、界面布局、生成 layer tree 等
    • GPU runner:负责将 layer tree 信息转为 GPU 指令,配置绘制所需资源
    • IO runner:配合 GPU runner,主要负责读取图片、解码,上传到 GPU 等耗时操作
    • Platform runner:负责处理 Engine 与外部的所有交互
      • Platform messages
      • API 调用

      Platform Runner 上的任务大多由 Embedder 提供

下面的流程图展示了 Flutter 程序渲染过程中各 runner 的基本职责:

  1. Root Isolate 将待渲染的 frame 发给 Engine

  2. Engine 让 Platform runner 监听 vsync 信号

  3. Platform runner 收到 vsync 信号,通知 Engine

  4. Engine 通知 Root Isolate 执行以下操作:

    • 更新动画 interpolator
    • rebuild 相关的 widget
    • 布局、渲染图层树
    • 发送辅助操作(Accessibility)相关信息
  5. 图层树从 Root Isolate 发送到 GPU runner

  6. GPU runner执行以下操作:

    • 配置绘制所需资源
    • 准备好 frame buffer
    • 管理绘制 surface 生命周期
    • 纹理检查

    最后再将 GPU 指令发送给 GPU 设备。

    GPU runner 可能会由于负载过重导致与 UI runner 不能同步,这时它会影响 frame-scheduling 的机制,进而延缓 UI runner 发送新 frame 的速度,导致显示性能问题。

三、Flutter 界面逻辑处理流程

注:此部分内容主要出自参考资料中的视频,系 Flutter 项目两个创始人于 2016 年做的分享,其内部细节不确定有没有发生变化,如读者有发现本文描述有误或真实细节与视频描述不符的,欢迎留言更正。

下图是一个从用户操作开始到最后渲染的总体流程,主要包括以下阶段:

  • 用户输入
  • 动画处理
  • Build Widgets
  • 布局
  • 绘制
  • 组装
  • 光栅化

1. Flutter 界面框架的分层架构

从界面构建、渲染的实现上,Flutter 提供了由高级到低级的几个不同层次的接口(API):

  • Material
  • Widgets
  • Rendering
  • dart:ui

下面从低级到高级一一举例说明:

1.1 dart:ui 层

dart:ui 这一层基本没有提供任何封装和抽象,开发者注册一个屏幕重绘回调函数 —— render()函数,系统会在每一帧绘制时调用此方法。

开发者需要在 render() 函数的实现中操作屏幕绝对坐标,调用相关子元素的绘制方法等。

具体的 render 函数实现细节示例:

可以看出实现一个较简单的界面也需要复杂的计算和逻辑,实现和维护都比较麻烦。

另外,这种实现方式对各子元素坐标等没有做任何缓存,每次屏幕重绘时都需要重新计算所有坐标,性能较差。

1.2 Rendering 层

Rendering 层比 dart:ui 层高级一些,封装了被渲染的对象和其父子关系,并且缓存了相关对象的坐标,省去了不必要的重复布局计算。

例:main() 函数中以声明的形式指定绘制结构:

render() 函数中调用 runApp() 绘制相应的 Rendering 对象:

其中,Rendering 对象是可变对象,应用程序需要先创建 Render object 树,然后在后续状态有变化时更新这棵树(结构和属性),管理起来比较麻烦。

Rendering Object 的设计对应于 iOS 中的 UIView/UILayer 等对象

1.3 Widget 层

Widget 层提供了完整、高效的封装:

  • Widget 是不可变的,每次有状态变化都是重新生成 Widget 树(省去了维护可变树的复杂性)
    • 可以将 Widget 理解为一个不可变的配置(config),很轻量
  • runApp() 会生成 Element,并调用 Elment 来生成 Render objects
    • Element 用来管理 RenderObject 的生命周期,轻量,不会随着 Widget 每次都重新生成
    • RenderObject 是真正渲染的对象

下面的例子显示了 Widget 与 Element 以及 Render object 的对应关系:

  • Widget(左):Rectangle 为父 Widget,Circle 为子 Widget
  • Element(中):调用 runApp() 时,会生成这两个 Widget 对应的 Element
  • RenderObject(右):Element 会调用相关方法生成自己的 RenderObject
    • 父 Element 生成 RenderRectanble,子 Element 生成 RenderCircle

当 Widget 颜色发生改变(Rectangle 颜色绿变黄,Circle 颜色蓝变红)时,应用程序会生成新的 Widget(Rectangle-yellow 和 Circle-red)。

框架如果发现 Widget 类型没变,之前的 Element 仍可复用,则新 Widget 指向之前的 Element。Element 会调用相关方法,将对应的 RenderObject 对象的属性改为新值(RenderObject 也复用)。

旧的 Widget 则被销毁。

如果新的 Widget 不能复用之前的 Element(比如说 Widget 类型不同,下图中子结点的形状由之前的圆形变成了三角形),则将销毁原有的 Element 和 RenderObject 并重新生成新的 Element 和 RenderObject:

1.4 Material 层

主要是提供了一些成型的界面风格控件,此处略去。

2. 渲染流水线

渲染流水线主要包括以下三个阶段:

  • 布局
  • 绘制
  • 组合

设计原则:Simple is fast

  • 一次遍历,线性时间的布局和绘制
  • 简单的盒模型布局约束可以实现复杂的布局
  • 使用组合使绘制对象结构化,实现局部重绘

Flutter 支持的布局约束比 iOS 的 Autolayout 简单得多,但也有足够强大的表达能力。

2.1 布局

  • RenderObject:布局的对象

    • Owner
    • Parent:父结点
    • Layout():布局方法
    • Paint():绘制方法
    • parentData: BoxParentData
      • offset
        • 子控件在父容器中的位置(父容器坐标系)

        子控件只决定自己的大小,父容器可将它放置于任何位置

    • visitChildren()方法
      • 不存子结点,只提供了访问子结点的方式
  • RenderBox(一个 RenderObject 的具体实现)

    • size
    • getMinInstrinsicWidth
    • getMaxIntrinsicWidth
    • getMinInstrinsicHeight
    • getMaxIntrinsicHeight
    • getDistanceToBaseline
    • hitTest
  • 布局的数据流:

    • 先从父结点向子结点传递约束信息
    • 然后各子结点向父结点传递布局后的大小信息

  • 例:Flex 布局
    • 输入:minWidth, maxWidth, minHeight, maxHeight 约束

    • 输出:

      • 总体的 width, height
      • 每个子控件的大小和位置
    • 主要步骤如下:

      • 布局非变长子结点
      • 计算剩余空间
      • 计算各变长子结点宽度
      • 布局变长子结点

      注:图中 +Inf(无穷大)表示当前结点不知道自己应该是什么值,由框架帮它决定。

  • 重布局边界(Relayout boundary):
    • 边界以内的结点布局变化不会导致其父结点重新布局

2.2 绘制

  • 深度遍历 RenderObject 树,根据布局阶段计算出的 offset 绘制相应的结点

  • 问题:应用程序可能有多个图层,Flutter 框架需要决定哪些元素绘制到哪个 layer 上?

    例:下图中黄色的图层是一个视频播放器,目标是要将 6 个 widget 画在 3 个 layer 上。

下图中 1、2、3、4、5 是渲染顺序,深度遍历 Render object 树,因为 4 需要一个单独的 layer,4 以后的都在红色的图层上,所以 5、6 都是红色。

注:其中 2 和 5 的区别是:2 是此结点在其子结点绘制前绘制的, 5 是此结点在其子结点绘制之后绘制的,在目前这个 case 下,2 和 5 会被绘制到不同的图层上。

  • 绘制数据流:
    • 父结点 -> 子结点:将你自己画到这个 offset 上
    • 子结点 -> 父结点:绘制完成,从这里(图层)接着绘制(类似 continuation passing)

注意区别 Flutter 复用 layer 的机制与别的框架的差异:在 Cocoa 中,UIView 和 CALayer 是一一对应的。

  • 重绘边界(Repaint boundary):用来分隔兄弟结点的绘制图层,否则 5 的重绘会导致 6 的重绘

    • 为了决定应该在哪里设置重绘边界,应该回答以下问题:
      • 如果程序中的这部分元素重绘了,其他哪些元素是一定也需要跟着一起重绘的?

布局顺序 Vs. 渲染顺序:

  • 依据各自的规则,二者顺序可能不同

2.3 图层组合

  • 渲染列表时,为了防止上下滚动时视窗中的所有元素都重绘,列表中的每一项单独使用一个图层(layer)
    • 单独使用一个图层通过设置重绘边界实现
    • 向上滚动时,只需要绘制新露出来的一行
    • 其他行只移动图层,不重绘

让 GPU 渲染一个 frame 有两种方式:

  • GPU 绘制指令
  • 纹理(Texture)+ blit(位图操作)
  • Flutter:绘制时首先使用 GPU 指令,画三次后发现三次的 command 都一样,就会切换成使用 Texture
  • iOS:所有元素都是使用 Texture(要求足够的 GPU 显存)
  • Android:所有元素都是绘制指令绘制的

参考资料