原来你是这样的Flutter

时间:2022-07-23
本文章向大家介绍原来你是这样的Flutter,主要内容包括其使用实例、应用技巧、基本知识点总结和需要注意事项,具有一定的参考价值,需要的朋友可以参考一下。

前面我们提到过Flutter其实就是个Dart编写的UI库,附带了自己的渲染引擎。我们通过Widget描述我们的view,然后Flutter会用它的渲染引擎根据我们的Widget树来绘制我们的界面。注意,是根据Widget树来绘制界面,而不是直接绘制Widget树,这是一个很重要的概念,咱们接下来慢慢来探讨。

绘制的到底是什么?

我们来看一张Flutter的架构图:

Flutter在我们跟渲染引擎之间提供了好几层抽象,我们日常开发主要接触到的就是那些个Widget库了,Rendering做了一些渲染相关的抽象,而dart:ui则是用Dart编写的最后一层代码,它实现了一些与底层的引擎交互的胶水代码,我们使用到的canvas API也是在这里定义的。

当我们组合好我们Widget树后,Flutter会从根节点向叶节点传递他们的约束或者说叫配置,约束限制了minHeight,minWidth,maxHeight,maxWidth等等。比如Center就向它的子Widget传递居中的约束,当访问到叶节点的时候,这时候Widget树上所有的Widget都知道了它们的约束,这时候他们就可以根据已有的约束自己确定它们实际要占有的大小跟位置,再一层层往上传递,只需要线性的时间复杂度,整个界面的上的元素绘制在哪个像素上就都确定下来了。

这是我从谷歌找到的一张图:

那屏幕上绘制的既然不是我们代码里写的Widget树,那到底是什么呢?我之前也说过了Flutter里面其实不只有Widget,还有其他的对象类型,只不过我们作为开发者日常开发任务中关心的只有Widget而已,所以Everything is Widget这句话也不能算错。我们这里要提到的其他对象类型就是RenderObject,这个类虽然也暴露给我们了,但是基本上只在Flutter框架内部使用,我们平常开发大多数不会碰到的。从名字可以猜到它们跟渲染相关,确实,RenderObject在Flutter里面负责实际在屏幕上的绘制,并且每一个Widget都有一个对应的RenderObject,也就是说,除了Widget树,我们还会有一个RenderObject树。

我们平时编写的Dart代码,组合的那些Widget,其实就是给RenderObject提供了草图,提供了对UI的描述信息,然后RenderObject根据这些信息去绘制我们的界面。RenderObject有一些方法诸如performLayoutpaint,这些方法负责在屏幕上绘制,我们使用的Widget的概念为我们在RenderObject上提供了很好的抽象,我们只需要声明我们想要什么东西就好了。那有些同学可能会想,其实我们也可以抛开Widget去直接绘制的呀?大部分人应该都不愿意直接跟底层绘制打交道,那样就要自己计算每个像素应该绘制的位置,工作量会大大增加,就像我们之前开发android app不会所有的界面都用OpenGL去绘制一样,而是使用各种View、ViewGroup,Widget跟View一样是框架提供给我们的编写界面的抽象。

RenderObject干了什么?

本质上,RenderObject是没有任何状态的,它也不包含任何业务逻辑,它们只知道一点点关于它们父RenderObject的信息,同时还有访问它们子RenderObject的能力。在整个app的层面上它们不会互相协作,也不能帮别人做决定,只会按照顺序在屏幕上绘制。

widget在他们的build方法里面会返回其它Widget,导致Widget树越来越庞大。在树的最下端最底下会遇到一个或多个RenderObjectWidget,就是这个类帮整个Widget树创建了RenderObject

我们前面提到过Widget拿到自己的约束后会决定自己的大小,其实这些约束拿到了之后是给了自己对应的RenderObject,它们会根据约束决定Widget在屏幕上的真实的物理大小。不同的RenderObject决定大小的方式也不同,主要就三大类:

•尽可能地占满空间,比如Center对应的RenderObject•跟子Widget保持一样大,比如Opacity对应的RenderObject•特定大小,比如Image对应的RenderObject

关于Flutter自带的RenderObject就这三点比较重要,一般我们也不会去自定义RenderObject

我还有个兄弟:Element

再来看看我们开头那张Flutter架构图。我们Widget层抽象出了一个Widget树,我们dart:ui负责实际绘制,抽象出了一个RenderObject树,中间的一层Rendering干了啥?它其实也抽象出来了一个树:Element树。

当一个Widgetbuild方法被调用时,Flutter会调用Widget.createElement(this)创建一个Element,这个Widget就是此Element一开始的配置,这个Element会持有它的引用。值得一提的是我们的StatefulWidget关联的State对象其实也是由Element管理的,因为State一般都存活的比较长,widget却可能频繁build。对应的,ElementWidget就有一个显著的不同,它会更新,当build方法再被调用时,它会更新它的引用指向新的Widget。我们之前说过了在屏幕绘制的不是Widget树,现在可以说绘制的到底是什么东西了,是Element树。Element树代表着app的实际结构,是app的骨架,是实际绘制在屏幕上的东西。Element会通过引用查询Widget携带的信息,在一系列的判断后交给RenderObject去绘制。(主要判断有木有修改,要不要重绘)

现在就很明朗了:

Element持有WidgetRenderObject的引用,RederObject负责把上层描述转换成可以跟底层渲染引擎通信的东西,而Element则是WidgetRenderObject之间的胶水代码。

为什么有三兄弟?

那到底为什么要设计出这三层呢,直接绘制不好吗?为什么要增加这样的复杂度呢?我们知道Flutter是一个响应式的框架,所有的Widget也都是immutable的,任何修改都会导致重新build,也就是会重新构建它的Widget树,一个app每天build界面几百万次不过分吧?而RenderObject是开销比较大的对象,因为负责底层的绘制,比较expensive,这样它也频繁地销毁重建的话肯定会影响性能,大多数时候界面上仅有一小部分被修改,比如在一个动画中,一帧可能就改变一点点,可能只改个某部分的颜色,其它的都不变,那么随便我们的Widget树怎么变,我们的app骨架也就是我们的Element树结构完全不需要重新构建,只需要把改变的那部分重新绘制就好了。Widget只是配置文件,比较轻量,想怎么变你就怎么变,我们实际绘制在屏幕上的是Element,只要想办法判断它指向的Widget有没有改变就好了,变了就重新绘制,没变就不管,这样虽然我们可能频繁地通过setState之类的手段去频繁通知重绘,Widget树也频繁地重新build,Flutter的性能并不会受到影响。我们在享受了immutable带给我的便利的同时也复用了那些个实际在屏幕上做绘制的对象。

Flutter的复用机制

之前我们说过build方法被调用后Element会更新引用,然后判断要不要重绘。具体的判断标准就是运行时类型有木有改变,或者说如果一个Widget有key的话,key有木有变等等。这么说听起来也有点抽象,我们就来实际写一点代码来感受一下Flutter的这个机制。

还是用昨天的那个app为例,这次我们希望我们点击重置那个FAB的时候,可以交换加减两个按钮的位置。可能大家没看我之前的文章,有的人还不熟悉Flutter开发,我这里先带大家定义一个按钮叫做FancyButton,看完大家就知道Flutter代码怎么写了:

class FancyButton extends StatefulWidget {
  final Widget child;
  final VoidCallback callback;

  const FancyButton({Key key, this.child, this.callback}) : super(key: key);

  @override
  FancyButtonState createState() {
    return FancyButtonState();
  }
}

因为它是一个StatefulWidget,它的核心逻辑都在它对应的State里面,StatelessWidget更简单,它包含了一个类似的build方法,这里就不带大家写了,后面直接看源代码就好了:

class FancyButtonState extends State<FancyButton> {
  @override
  Widget build(BuildContext context) {
    return Container(
      child: RaisedButton(
        color: _getColors(),
        child: widget.child,
        onPressed: widget.callback,
      ),
    );
  }

  Color _getColors() {
    return _buttonColors.putIfAbsent(this, () => colors[next(0, 5)]);
  }
}

Map<FancyButtonState, Color> _buttonColors = {};
final _random = Random();

int next(int min, int max) => min + _random.nextInt(max - min);
List<Color> colors = [
  Colors.blue,
  Colors.green,
  Colors.orange,
  Colors.purple,
  Colors.amber,
  Colors.lightBlue,
];

其实我们也只是包装了RaisedButton并提供了颜色而已,其它的还是要上游去配置的。

接下来,我们就可以把这按钮添加到主页面去了:

@override Widget build(BuildContext context) {
  final incrementButton =
      FancyButton(child: Text("增加"), callback: _incrementCounter);
  final decrementButton =
      FancyButton(child: Text("减少"), callback: _decrementCounter);
  List<Widget> _buttons = [incrementButton, decrementButton];
  if (_reversed) {
    _buttons = _buttons.reversed.toList();
  }

  return Scaffold(
    appBar: AppBar(
      title: Text(widget.title),
    ),
    body: Center(
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: <Widget>[
          Container(
            decoration: BoxDecoration(
                borderRadius: BorderRadius.circular(4.0),
                color: Colors.green.withOpacity(0.3)),
            child: Image.asset("qrcode.jpg"),
            margin: EdgeInsets.all(4.0),
            padding: EdgeInsets.only(bottom: 4.0),
          ),
          Text(
            'You have pushed the button this many times:',
          ),
          Text(
            '$_counter',
            style: Theme.of(context).textTheme.display1,
          ),
          Row(
              mainAxisAlignment: MainAxisAlignment.spaceAround,
              children: _buttons),
        ],
      ),
    ),
    floatingActionButton: FloatingActionButton(
      onPressed: _resetCounter,
      tooltip: 'Increment',
      child: Icon(Icons.refresh),
    ),
  );
}

其中交换按钮位置的逻辑就很简单了:

void _swap() {
  setState(() {
    _reversed = !_reversed;
  });
}

好,可以运行代码了。

一切都如我们期望的那样,按钮交换过来了并且点击事件也都正常...等等!怎么按钮的颜色没动!

这就是我们前面提到的判断逻辑,复用机制了!原来,当重新build的时候,Element还是指向它原来位置对应的Widget,我们的Widget并没有key,那它只根据运行时类型来判断是否有改变,我们这儿俩个类型都是一样的,都是FancyButton,我们本来期望Flutter能发现两个按钮的颜色不一样从而去重新绘制。但是颜色是在State里面定义的,State并没有被销毁,因此只根据运行时类型Element最终会认为没有修改,所以我们看到颜色没有更新,那为什么文字跟点击事件变了呢,那是因为这俩是从外部传递过来的,外部重新创建了呀。解决这个问题也很简单,我们只要根据规则给这两个按钮加上key就好了,这样Flutter根据key就知道我们的Widget不一样了:

List<UniqueKey> _buttonKeys = [UniqueKey(), UniqueKey()];

...

final incrementButton = FancyButton(
    key: _buttonKeys.first, child: Text("增加"), callback: _incrementCounter);
final decrementButton = FancyButton(
    key: _buttonKeys.last, child: Text("减少"), callback: _decrementCounter);

Key的类型有好几种,不过不是今天的重点我们暂且不讨论。这下Flutter再也不会认为没有改变啦,再次运行项目,这下按钮切换的同时背景色也会跟着改变了。

好啦,到了这儿,Flutter的基本工作流程我们算是搞明白了,怪不得它频繁build却不卡顿!想深入了解的朋友们也可以看看Flutter团队的这个视频:Flutter渲染过程[1]。今天的信息量确实很大,好在我们日常开发不用直接跟它们打交道。大家也不用强迫自己一下子明白,尤其是刚入门的朋友们,不要急,虽然懂得原理会帮助我们处理一些问题,目前知道有这么个东西有个印象就好,时间长了自然就懂啦。

代码地址:counter[2]

References

[1] Flutter渲染过程: https://www.youtube.com/watch?v=UUfXWzp0-DU [2] counter: https://github.com/zongyunfeng/counter