Flutter 探索系列:Widget 原理(一)

在Flutter中,一切都是由Widget组成,不管是按钮、文本、图像、列表、布局、手势、动画处理等都可以作为Widget,开发者通过组合、嵌套Widget构建UI界面。

这篇文章将探索 Flutter Widget 背后的设计思想,深入分析源码以弄清它的实现原理,从而让我们更好地使用 Widget 开发 UI 界面。

设计思想

Flutter 从 React 中吸取灵感,通过现代化框架创建出精美的组件。它的核心思想是用 widget 来构建你的 UI 界面。Widget 描述了在当前的配置和状态下视图所应该呈现的样子。当 widget 的状态改变时,它会重新构建其要展示的 UI,框架则会对比前后变化的不同,以确定底层渲染树从一个状态转换到下一个状态所需的最小更改。

这是官方对 Widget 的介绍,可以看出,Flutter 设计灵感来自于 React,React 的核心声明式和组件化编程,Flutter 都继承了下来,Widget 同样使用声明式和组件化编程范式。

编程范式,是一种编程风格,它提供了(同时决定了)程序员对程序执行的看法。例如,在面向对象编程中,程序员认为程序是一系列相互作用的对象,而在函数式编程中一个程序会被看作是一个无状态的函数计算的序列。

声明式编程

声明式是一种编程范式,描述 UI 是什么样的,而不是直接指导 UI 怎么一步步地构建。通常与声明式编程相对的是命令式编程,命令式编程需要用算法来明确的指出每一步该怎么做。

对于声明式编程,当我们要渲染界面时,无需编写操作视图命令的代码,而是修改数据,由框架完成数据到视图的转换。数据是组件的UI数据模型,开发者根据需要设计出合理的数据模型,框架根据数据来渲染出UI界面。这种方式让开发人员只需要管理和维护数据状态,大大减轻了开发人员的负担。

iOS 开发中的 UITableView 的使用与声明式编程较类似,我们作个比较来帮助理解声明式编程。通常先准备好数据源 dataSource,然后将 dataSource 中的 items 映射成一个个 cell,当数据源 dataSource 改变时,UITableView 就会相应的刷新。

我们再举个例子,对比说明命令式编程和声明式编程的不同

image.png

在命令式编程中,通常使用选择器 findViewById 或类似函数获取到 ViewB 的实例 b,并调用相关的方法使用其生效,如下

// 命令式风格
b.setColor(red)
b.clearChildren()
ViewC c3 = new ViewC(...)
b.add(c3)

而在声明式编程中,当UI需要改变时,我们在 StatefulWidgets 组件上调用 setState()改变数据,重建新的Widget树。

// 声明式网络
return ViewB(
  color: red,
  child: ViewC(...),
)

当按照上面的数据驱动视图的方式构建 UI,会出现一个问题,视图中的任一状态变化,都会重新渲染整个视图,导致不必要的刷新,那 React/Flutter 如何避免这个问题的?

在 React 中用 Component 描述界面,在 Flutter 中用 Widget 描述界面,Component 和 Widget 都是视图内容的“配置信息”,并不是真正渲染在屏幕的元素,这些配置对象的创建、销毁不会带来太大的性能损耗。而真正负责渲染绘制的对象重建代价是很高的,不会轻易重建。

当数据状态有变动时,框架重新计算生成新的组件树,并比较新、旧组件树的差异,找出有变化的组件进行重新渲染。这样就只渲染了有变化的组件,没变化的组件不刷新,避免了整体刷新带来的无谓性能损耗。

组件化

在 React/Flutter 的世界中,一切都是组件,组件是对一个 UI 元素的配置或描述,描述了在屏幕上展示的内容,可以说用户界面就是组件,组件嵌套组合构成了用户界面。

组件,可以看成是一个状态机,通过与用户的交互,改变不同状态,当组件处于某个状态时输出对应的UI,组件状态改变时,根据新的状态重新渲染视图,数据与视图始终保持一致。

组件是一个比较独立的个体,自身包含了逻辑/样式/布局,甚至是依赖的静态资源,相关代码都封装到一个单元中,尽可能地避免与其他代码产生纠葛。这种设计符合高内聚、低耦合,组件尽可能独立完成自己的功能,不依赖外部的代码。

一个个简单的组件通过嵌套、组合的方式构成大的组件,最终再构建成复杂的界面,这样搭建界面的方式易于理解、易于维护。

思考:React/Flutter 中的组件是 MVVM 吗

实现原理

在 Flutter 中,Widget 的功能是对一个 UI 元素的配置或描述,并不是真正在设备上显示的元素。真正表示在设备显示元素的类是 Element,真正负责布局和绘制的类是 RenderObject。

我们从几个问题开始,理清 Widget 的实现原理
1,Widget、Element 和 RenderObject 是什么,它们各自负责什么功能?
2,Widget、Element 和 RenderObject,三者有什么关系?
3,Widget、Element 和 RenderObject,它们是如何生成的、如何建立关联?
4,页面中 Widget 更新时,视图如何重新渲染?
5,Element 树如何更新?

1,Widget、Element 和 RenderObject 是什么,它们各自负责什么功能?

Widget 是对一个 UI 元素的配置或描述,存放渲染内容、布局信息等。

对于 Widget,它是不可变的,一经创建便不能修改。当用户界面发生变化时,Flutter不会修改旧的Widget树,而是创建新的 Widget 树。由于 Widget 很轻量,只是一个“蓝图”,并不涉及实际的视图渲染,
频繁的销毁和重建也不会带来性能问题。

Element 是通过 Widget 生成的,是 Widget 的实例化对象,它们之间有一一对应关系。Element 同时持有 Widget 和 RenderObject,是连接配置信息到最终渲染的桥梁。

Element 被创建之后,将插入到 UI 树中。如果之后 Widget 发生变化,则将其与旧的 Widget 进行比较,并更新对应的 Element。

由于 Widget 的不可变性,当 Widget 树重建时,Element 树将对比新旧 Widget 树,找到有变化的节点,并同步到 RenderObject 树,最终只渲染有变化的部分节点,提高渲染效率。

RenderObject 具体负责布局和绘制工作。

2,Widget、Element 和 RenderObject,三者有什么关系?

Flutter 的 UI 系统中有三棵树:Widget 树、Element 树、渲染树,它们的关系是:Element 树根据 Widget 树生成,渲染树又根据 Element 生成。

当 Widget 树发生改变时,将重新构建对应的 Element 树,同时更新渲染树。

3,Widget、Element和RenderObject,它们是如何生成的、如何建立关联?

Flutter 程序的入口是 void runApp(Widget app) 方法,应用启动时调用。该方法传入要展示的第一个 rootWidget,然后创建 rootElement,并把 rootWidget 关联到 rootElement.widget 属性上。rootElement 创建完成后,调用自身的 mount 方法创建 rootRenderObject 对象,并把rootRenderObject 关联到 rootElement.renderObject 属性上。

rootElement 创建完成后,调用 buildScope 方法,进行 child widget 树的创建。widget 与 element 一一对应,child widget 调用 createElement 方法,以自身为参数创建 child element,然后 child element 将自身挂载到 rootElement 上,形成一棵树。

同时调用 widget.createRenderObject 创建 child renderObject,并挂载到 rootRenderObject 上。

其中 rootElement 和 renderView(RenderObject子类)是全局单例对象,只会创建一次。

image.png

4,页面中Widget更新时,视图如何重新渲染?

Widget 的子类 StatefullWidget 能够创建对应的 State 对象,通过调用 state.setState() 方法触发视图的刷新。

state.setState() 方法内部调用了 markNeedsBuild,标记该 StatefullWidget 对应的 Element 需要刷新

_element.markNeedsBuild();

当下一个周期的帧绘制 drawFrame 时,重新调用 performRebuild(),触发 Element 树更新,并使用最新的 Widget 树更新自身以及关联的 RenderObject 对象,之后进入行布局和绘制流程。

5,Element树如何更新?

当有 StatefullWidget 发生改变时,找到对应的 element 节点,设置它的 dirty 为 true。当下一次帧绘制 drawFrame 时,重新调用 performRebuild() 更新 UI

newWidget == null newWidget != null
child == null Returns null. Returns new [Element].
child != null Old child is removed, returns null. Old child updated if possible, returns child or new [Element].

如上所示,新 widget 与旧的 child 内的 widget 进行比较,有4种情形,

新 widget 为空、旧 widget 也为空,返回 null

新widget为空、旧widget不为空,则移除旧child,返回null

新widget不为空、旧widget为空,创建新的Element并调用mount嵌入到树上

新widget不为空、旧widget不为空,判断是否可更新旧child,可以则更新child。不可以则移除旧child,创建新的Element并返回

源码分析

我们从一个Hellow world的demo开始分析Widget源码。

程序的入口是 runApp 方法,它传入要显示的界面Widget。

void main() {
  runApp(
    Center(
      child: Text(
        'Hello, world!',
        textDirection: TextDirection.ltr,
      ),
    ),
  );
}

进入 runApp 方法,其中 WidgetsFlutterBinding 是一个桥接类,它是连接底层 Flutter engine SDK 的桥梁,用来接收处理 Flutter engine 传递过来的消息,Flutter engine 负责布局、绘制、平台消息、手势等功能。

WidgetsFlutterBinding 继承自 BindingBase,BindingBase mixin 了7个类:GestureBinding, ServicesBinding, SchedulerBinding, PaintingBinding, SemanticsBinding, RendererBinding, WidgetsBinding。mixin 类似于多继承,在 mixin 模式中,后面类的同名方法会覆盖前面类的方法。这些类组合到一块共同监听来的 Flutter engine 的各种消息。

WidgetsFlutterBinding 是一个单例类,ensureInitialized 方法负责初始化,返回单例对象

void runApp(Widget app) {
    WidgetsFlutterBinding.ensureInitialized()
        ..attachRootWidget(app)
        ..scheduleWarmUpFrame();
}

attachRootWidget 方法传入 rootWidget,将 rootWidget 和 renderView(RenderObject子类)包装到 RenderObjectToWidgetAdapter 中。renderView 是上面提到的RendererBinding 在初始化时创建,它是 RenderObject 的子类,负责实际的布局和绘制工作。

RenderObjectToWidgetAdapter 继承自 Widget,重写了 createElement 和 createRenderObject 方法,这两个方法在后面构建Element 和 RenderObject 会用到,createElement 返回的是RenderObjectToWidgetElement 类型对象,createRenderObject 返回的是 renderView。

renderViewElement 和 renderView 都是 WidgetsFlutterBinding 类的属性,而 WidgetsFlutterBinding 是单例模式,自然 renderViewElement 和 renderView 全局唯一。

Element get renderViewElement => _renderViewElement;
Element _renderViewElement;

void attachRootWidget(Widget rootWidget) {
    _renderViewElement = new RenderObjectToWidgetAdapter<RenderBox>(
        container: renderView,
        debugShortDescription: ‘[root]’,
        child: rootWidget
    ).attachToRenderTree(buildOwner, renderViewElement);
}

attachToRenderTree 方法创建并返回 RenderObjectToWidgetElement 对象,首次调用时创建新的 RenderObjectToWidgetElement 对象,再次调用复用已有的。

createElement 方法将 RenderObjectToWidgetAdapter 自身作为参数,初始化 RenderObjectToWidgetElement 对象,这样 RenderObjectToWidgetElement 就可以取到 rootWidget 和 renderView,从而让三者关联起来。

RenderObjectToWidgetElement<T> createElement() => RenderObjectToWidgetElement<T>(this);

RenderObjectToWidgetElement<T> attachToRenderTree(BuildOwner owner, [RenderObjectToWidgetElement<T> element]) {
    if (element == null) {
        owner.lockState(() {
            element = createElement();
            assert(element != null);
            element.assignOwner(owner);
        });
        owner.buildScope(element, () {
            element.mount(null, null);
        });
    } else {
        element._newWidget = this;
        element.markNeedsBuild();
    }
    return element;
}

随着 Element 的创建,RenderObject 也会被创建。在父类 RenderObjectElement的mount 方法中,调用 createRenderObject 得到RenderObject,这里的 widget 即是 RenderObjectToWidgetAdapter,_renderObject 是 RenderObjectToWidgetAdapter 持有的 renderView 对象

    void mount(Element parent, dynamic newSlot) {
      super.mount(parent, newSlot);  //[见小节2.5.5]
      _renderObject = widget.createRenderObject(this);
      attachRenderObject(newSlot); //将newSlot依附到RenderObject上
      _dirty = false;
    }

到这里,Element 和 RenderObject 都被创建出来。回到 RenderObjectToWidgetElement 的 mount 方法,它调用了 _rebuild 方法,_rebuild 又调用了 updateChild 方法

  void mount(Element parent, dynamic newSlot) {
    assert(parent == null);
    super.mount(parent, newSlot);
    _rebuild();
  }
  
  void _rebuild() {
  try {
    _child = updateChild(_child, widget.child, _rootChildSlot);
  } catch (exception, stack) {
    ...
  }
}

在 updateChild 方法中,对新旧节点的 widget 进行对比,有4种情形,
新widget为空、旧widget也为空,返回null
新widget为空、旧widget不为空,则移除旧child,返回null
新widget不为空、旧widget为空,创建新的Element并调用mount嵌入到树上
新widget不为空、旧widget不为空,判断是否可更新旧child,可以则更新child。不可以则移除旧child,创建新的Element并返回

Element updateChild(Element child, Widget newWidget, dynamic newSlot) {
  if (newWidget == null) {
    if (child != null)
      deactivateChild(child); 
    return null;
  }
  if (child != null) {
    if (child.widget == newWidget) {
      if (child.slot != newSlot)
        updateSlotForChild(child, newSlot); 
      return child;
    }
    if (Widget.canUpdate(child.widget, newWidget)) {
      if (child.slot != newSlot)
        updateSlotForChild(child, newSlot);
      child.update(newWidget);
      return child;
    }
    deactivateChild(child);
  }
  return inflateWidget(newWidget, newSlot);
}

Element inflateWidget(Widget newWidget, dynamic newSlot) {
  final Key key = newWidget.key;
  if (key is GlobalKey) {
    final Element newChild = _retakeInactiveElement(key, newWidget);
    if (newChild != null) {
      newChild._activateWithParent(this, newSlot);
      final Element updatedChild = updateChild(newChild, newWidget, newSlot);
      return updatedChild;
    }
  }
  final Element newChild = newWidget.createElement();
  newChild.mount(this, newSlot);
  return newChild;
}

至此,app 从启动到首次渲染的过程就完成了,接下来我们看看,当页面 Widget 更新时,内部做了什么,我们从 setState() 方法开始分析

abstract class State<T extends StatefulWidget> extends Diagnosticable {
  StatefulElement _element;

  void setState(VoidCallback fn) {
    ...
    _element.markNeedsBuild(); 
  }
}

将 element.dirty 设为 true,即标记该 element 需要刷新,并将其放到脏元素数组中,等待下一个周期渲染时处理。

onBuildScheduled 是 WidgetsBinding 初始化时创建的,方法内部又调用了 ui.window.scheduleFrame(),通知底层 engine 重新刷新帧

abstract class Element extends DiagnosticableTree implements BuildContext {
  void markNeedsBuild() {
    if (!_active)
      return;
    if (dirty)
      return;
    _dirty = true;
    owner.scheduleBuildFor(this);
  }
}

void scheduleBuildFor(Element element) {
  if (element._inDirtyList) {
    _dirtyElementsNeedsResorting = true;
    return;
  }
  if (!_scheduledFlushDirtyElements && onBuildScheduled != null) {
    _scheduledFlushDirtyElements = true;
    onBuildScheduled(); 
  }
  _dirtyElements.add(element);
  element._inDirtyList = true;
}

Engine 通知页面刷新,最终调到 drawFram 方法内的 buildScope,buildScope 首先对脏元素数组排序,浅节点靠前,深节点靠后,避免子节点先重建,父节点重建后再次重建。

  void drawFrame() {
    try {
      buildOwner.buildScope(renderViewElement);
      ......
      buildOwner.finalizeTree();
    } finally {
    }
  }
  
  void buildScope(Element context, [VoidCallback callback]) {
    try {
        ...
      _dirtyElements.sort(Element._sort);//对脏元素排序
        ...
      int dirtyCount = _dirtyElements.length;
      int index = 0;
      //遍历脏元素,重建
      while (index < dirtyCount) {
        try {
          _dirtyElements[index].rebuild();
        } catch (e, stack) {
        }
        index += 1;
      }
    } finally {
      for (Element element in _dirtyElements) {
        element._inDirtyList = false;
      }
      _dirtyElements.clear();
        ...
    }
  }

Element 的 rebuild 方法最终会调用 performRebuild(), performRebuild 又调用了 updateChild 方法,此方法在前面有介绍,在 updateChild 方法中,对新旧节点的 widget 进行对比,有4种情形,只更新需要变更的节点。

参考资料

Flutter
(一):React的设计哲学 - 简单之美
帝国的纷争-FlutterUI绘制解析
深入理解setState更新机制
Flutter 在銘師堂的實踐

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,214评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,307评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,543评论 0 341
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,221评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,224评论 5 371
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,007评论 1 284
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,313评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,956评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,441评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,925评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,018评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,685评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,234评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,240评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,464评论 1 261
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,467评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,762评论 2 345

推荐阅读更多精彩内容