Flutter之美

原文:https://zhuanlan.zhihu.com/p/428123586

本文旨在尽量避开具体的代码细节,从思想上去介绍flutter的各种技术实现,让已经在从事flutter开发的同学有更多的收获,同时对flutter感兴趣的观望者也能更好的了解这门技术

一、flutter能给我们带来什么?

跨越多个平台的能力

由于不依赖平台,使用独立渲染的方式,可以在多个平台上高效运行:

android、ios:

所有ui部分的开发都可以完全独立进行,同时具备与原生平台互调用的能力,通过制作插件的方式,可以完美使用现有两个移动平台的生态资源,大部分轮子不需要再重新制作

windows、mac、linux:

由于native的相似性,移动平台的插件大部分都可以兼容pc平台,把移动端代码迁移到桌面端非常容易,一套app的代码可以在桌面端运行,这成本真的太低了,太吸引人了

web:

要将dart代码生成js代码,打包体积比较大,生态资源也不如js,用到native特性的插件都不支持web,代码共用困难,目前很少使用

Fuchsia:

Google自研的物联网操作系统,基于Zircon微内核(非linux),未来2年内大概率面世。它将flutter作为框架层,相信未来大面积使用这个操作系统的时候,flutter技术也会迎来一个新的高潮

开发的时间成本大大降低

强大的跨平台能力,已经大大节约了我们开发的人力时间成本,但flutter的强大远不止如此,下面我来一一介绍

热重载:

同rn一样,拥有jit即时编译的能力,可以在调试时无需重新运行,修改代码后可以立马同步到界面。想想android开发中,特别大型的项目,编译超过3分钟的不少见,每天几十次的编译运行能节省出大概1小时以上的时间,可以早早打开下班啦

代码一键定位:

先来看看android开发者的一个痛点,在不熟悉代码的情况下,如果需要改个ui的话,可能需要以下几个步骤:

  1. 首先要定位是哪个activity,通过命令或者layout inspector工具。
  2. 知道是哪个 activity之后,打开对应的xml布局配置文件,如果design里不能明显看出来,就要通过id的命名去找
  3. 如果这个组件就是在activity里,那就可以直接找到id对应的组件了。如果这个组件不在activity里,比如在RecyclerView里,那就需要找到它对应的viewholder,继续在布局里找。
  4. 如果是代码生成的组件,那么就抱歉了,只能寄希望于代码量不是很大了…

好吧,真的是一言难尽

再来看看flutter的一键定位功能:

  1. 点击开启视图定位模式

2. 点击想要定位的ui组件

3. 成功定位到该组件的代码位置

是不是又可以提前下班的时间了!!!

高效的dart语言:

我个人认为dart语言可能是移动端开发最好的语言了,下面就说说它好在哪

  1. 拥有java一样的强类型特性,是一款类型安全的语言,并且支持dynamic类型,需要的话,可以像js一样灵活
  2. 支持函数式编程,代码更为简洁
  3. mixin方式实现多继承,比内部类更为优雅
  4. Flutter2.0开始支持空安全,不需要再到处判空了

基本上借鉴了java、js、kotlin的优点,开发效率会得到很大的提升

好了,现在我们知道了flutter相较于传统移动开发的强大之处,后面我们将详细介绍flutter的设计原理和机制,包括整体架构、线程模型、渲染过程等

二、Flutter框架全景

Dart 框架层(Framework)

上层框架,主要包括 dart 侧 Widget 管理、绘制、动画、手势等接口

C++ 引擎层(Engine)

虚拟机、线程模型、与平台的通信、绘制流程、系统事件、文字布局、帧渲染管线等

平台相关的嵌入层(Embeder)

渲染图层、平台线程和事件循环管理,Native Plugin 等

三、flutter的界面渲染过程

视图树的构建流程

flutter中的视图树借鉴了react的思想,也和android中的mvvm类似,核心思想就是ui和数据绑定,数据变化之后重新构建ui,来达到ui更新的目的。实现这种方式构建ui有两个必要条件,一是要比较两次view树变化的区域,这个只需要完成对应的diff算法就很容易解决。二是需要频繁创建、销毁view树的配置对象,需要在内存管理方面做到高效,后面会讲到dart虚拟机是如何应对这种频繁gc的情况。下面让我们来看看具体的ui构建过程

1. 声明式创建widget树:

widget树就是一份简单的、轻量级的配置信息,并不是真正的视图组件节点。它是不可变的,不可修改的,为什么呢?想想我们在android开发里,每一个ui组件可以在xml布局文件里创建,又可以在代码中随意修改,这样造成的结果就是如果你在设备上看到一个组件被修改了,那么在xml文件里不一定能找到修改的出处,同样在代码里也要去找很久,因为view的引用可以被随意传递,这实在太可怕了,这太不可控了,太不利于维护了。

Flutter怎么做的?

Flutter使用声明式构建ui,完全解决了这个痛点,widget不能修改,只能重新声明去更新它,这也是它为什么是轻量级的,重建的代价不大。如果被修改了,从声明处开始寻找,结合一键定位,可以快速找到修改它的出处。

2. 生成element树

这就是真正的视图组件节点了,它和widget树一一对应,会比较新的widget树和原来widget树的变化,只更新变化的节点。

3. 根据element生成的RenderObject树进行渲染。

需要新创建的节点它会将配置信息解析出RenderObject并持有它,用来处理具体的布局和绘制。需要更新的节点则只需要修改RenderObject,不需要重新创建。由此实现了widget树变化后进行最小范围的处理,性能由此得到提升。RenderObject不和上面两棵树一一对应,它只是具体要渲染的节点,比如StatelessWidget只是组合了其他widget,不需要为他单独生成一个RenderObject

布局与绘制

1. 布局

先回想一下android中的布局:测量一般会进行2次,第一次进行模糊测量,第二次根据子view大小确定具体的测量值,然后布局。一旦其中一个view有了变化,又需要重新布局。它的缺点显而易见:多次测量,view变化后影响较大。来看看flutter是如何优化这两个不足之处的吧

每个节点都有一个布局约束,即maxWidth,minWidth,maxHeight,minHeight,这个约束是根据父节点的约束和自己本身的约束得到的。这样就只需要一次后续遍历便可以确定每一个view的大小和位置。如此,就实现了单次布局

使用RelayoutBoundary进行布局边界限制,边界内的组件发生变化,边界外不重新布局

2. 绘制

为了避免没必要的重绘,每一个RenderObject都有一个isRepaintBoundary属性,即绘制边界,通过这个边界来进行绘制区域的隔断

重绘标记

如图,节点4被标记为需要重新绘制,它的isRepaintBoundary=false,会向上查找,直到找到节点2的isRepaintBoundary=true,将节点2加入到重绘列表中,即真正进行重绘的节点

绘制

如图,节点2存在于重绘制列表中,会进行一个先序遍历2->3->4->5,依次绘制。为什么到5就结束了?因为5也是一个绘制边界,由此确定出最小的绘制区域。节点1和节点6都不进行重新绘制

为什么不是所有节点都使用RepaintBoundary?

RepaintBoundary强制使用新的图层进行绘制,可以避免无关自己的重复绘制。如果图层过多,也会使得渲染性能下降,所以只需要将无需重复绘制的部分使用RepaintBoundary就能做到最大的性能优化

RepaintBoundary应用

3. 合成和渲染

终端设备的页面越来越复杂,因此flutter的渲染树层级通常很多,直接交付给渲染引擎进行多图层渲染,可能会出现大量渲染内容的重复绘制,所以还需要先进行依次图层的合成,即将所有的图层根据大小、层级、透明度等规则计算出最终的显示效果,将相同的图层归类合并,简化渲染树,提高渲染效率。

四、Dart虚拟机原理

单线程模型

所谓单线程模型,就是将任务放在队列中轮询执行,以此来实现异步任务,dart线程中有两个任务队列:microtask queue优先级更高,如果它里面有未处理的事件,会优先从这里取出事件处理。event queue,一般的异步任务都是放在这里的。

为什么要用单线程模型呢?

  1. 前端开发大部分异步任务都是为了等待,比如网络请求的等待,数据库、文件数据读取的等待,IO密集型的任务是不消耗cpu的,为此使用多线程反而浪费资源,单线程模型更为合理
  2. 单线程模型里不需要多线程共享内存,就不必担心同步死锁这些问题,开发效率得到提升
  3. 无锁的内存分配是可以实现内存的线性分配的,不用查找可用内存空间,内存分配的效率得到提升

异步任务理解

如何使用异步任务

使用Future传入一个方法就会把一个任务放在Event Queue中了,当这个异步任务执行完毕后,会将Future.then()里的函数添加到MicroTask Queue中,由此可以更优先去处理异步任务的结果

void main() {
  Future(() {
    print("future1");
  });

  Future future2 = Future(() {});

  Future(() {
    print("future3-1");
  }).then((value) {
    print("future3-2");
    scheduleMicrotask(() {
      print("future3-microtask");
    });
  }).then((value) {
    print("future3-3");
  });

  Future(() {
    print("future4-1");
  }).then((value) {
    Future(() {
      print("future4-2");
    });
  }).then((value) {
    print("future4-3");
  });

  future2.then((value) {
    print("future2-1");
  });

  scheduleMicrotask(() {
    print("microtask 1");
  });

  print("main");
}

输出结果为:

main
microtask 1
future1
future2-1
future3-1
future3-2
future3-3
future3-microtask
future4-1
future4-3
future4-2
  • Event Loop 优先执行 main 方法同步任务,再执行微任务,最后执行 Event Queue 的异步任务。所以 main先执行
  • 同理微任务 microtask 1 执行
  • 其次,Event Queue FIFO,future1 被执行
  • future2 内部为空,所以 then 里的内容被加到微任务队列中去,微任务优先级最高,所以 future2-1 被执行
  • 其次,future3-1 被执行。由于存在2个 then,先执行第一个 then 中的 future3-2,然后遇到微任务,所以 future3-microtask 被添加到微任务队列中去,等待下一次 Event Loop 到来时触发。接着执行第二个 then 中的 future3-3。随着下一次 Event Loop 到来,future3-microtask 被执行
  • 其次,future4-1 被执行。随后的第一个 then 中的任务又是被 Future 包装成一个异步任务,被添加到 Event Queue 中,第二个 then 中的内容也被添加到 Event Queue 中。
  • 接着,执行 future4-3。本次事件循环结束
  • 等下一轮事件循环到来,打印队列中的 future4-2

async和await

用async修饰函数,表示异步方法,但如果async方法中没有出现await,它仍然是一个同步方法

执行顺序为:a

b

c

main

只有当遇到await时,才会将之后的内容打包成一个Future放入event queue中

执行顺序为:a

main

b

c

Future的其他用法

  • Future.catcheError() 用于处理异常
  • Future.whenComplete() 无论是否发生异常都会进行回调
  • Future.wait() 接收一个Future数组,等待所有异步任务完成
  • Completer 它持有一个future,可以通过Completer.complete()来自己控制future完成,相当于一个一次性使用的listener注册

多线程

单线程模型不是为了替代多线程而存在的,只是为了在大量io密集型场景下进行高效开发所设计的,如果我们遇到了算法密集型任务,继续使用单线程,那么就会导致我们的ui线程卡顿了,所以在算法密集型任务里使用多线程来最大化cpu的利用率是必不可少的。

dart里的线程叫做Isolate,意思为隔离,和他的名字类似,两个Isolate之间是不能共享内存的,是独立的,更像是进程的感觉。前面讲到单线程模型的好处,不必操心同步死锁问题,不用查找可用内存进行无锁的内存分配,所以即便使用多线程,也就不能够进行共享内存了。两个线程之间的通信可以通过管道实现

start() async {
  ReceivePort receivePort = ReceivePort(); // 创建管道
  Isolate isolate = await Isolate.spawn(coding, receivePort.sendPort); // 创建 Isolate,并传递发送管道作为参数
  // 监听消息
  receivePort.listen((message) {

  });
}

内存分配和垃圾回收

内存分配

DartVM的内存分配策略非常简单,创建对象时只需要在现有堆上移动指针,内存增长始终是线性的,省去了查找可用内存段的过程 每个Isolate之间是无法共享内存的,所以这种分配策略可以让Dart实现无锁的快速分配。

垃圾回收

Dart的垃圾回收也采用了多生代算法,新生代在回收内存时采用了 “半空间” 算法,触发垃圾回收时Dart会将当前半空间中的“活跃”对象拷贝到备用空间,然后整体释放当前空间的所有内存 整个过程中Dart只需要操作少量的“活跃”对象,大量的没有引用的“死亡”对象则被忽略,这种多生代无锁垃圾回收器,专门为UI框架中常见的大量Widgets对象创建和销毁优化,非常适合Flutter框架中大量Widget重建的场景.

五、理解Runner

什么是Runner?

通过全景图中可以看到,dart VM中的isolate其实也是被Embedder所分配和管理的,所以Root Isolate也只是flutter运行时的其中一个线程,还有其他的一些线程由engine进行管理,称为Runner

  • UI runner:

负责处理root isolate 中的代码执行、界面布局、绘制、生成 layer tree 等

  • GPU runner:

负责将 layer tree 信息转为 GPU 指令,配置绘制所需资源

  • IO runner:

配合 GPU runner,主要负责读取图片、解码,上传到 GPU 等耗时操作

  • Platform runner:

负责处理 Engine 与外部的所有交互 ,同时处理平台相关的所有事件,即平台主线程。平台主线程与flutter 的ui线程相互独立,平台线程卡顿不会影响flutter ui界面。

渲染过程中各个Runner之间的逻辑

  1. Root Isolate 需要创建或重新渲染一个frame时,通知engine
  2. engine通过Platform Runner 监听来自GPU Runner的 vsync信号
  3. Platform Runner 收到vsync信号 通知engine,engine通知Root Isolate进行Widget Tree的build以及布局、绘制、合成Layer Tree
  4. Root Isolate 将Layer Tree 交给engine,engine发送给GPU Runner,GPU Runner配置好资源,将Layer Tree生成GPU指令交给GPU进行最后的渲染

六.总结

相信你已经看到了flutter的强大之处,跨平台能力、高效的开发体验、先进的前端框架设计思想。可能唯一的不足就是它实在是太年轻了,但是也能看到在短短不到三年的时间里它所取得的成就,这足够让人兴奋。

现在已经到了flutter 2.5版本,每隔几个月就会有一次大的版本更新,每次的升级都会带来不同的惊喜,Google爸爸确实是对它寄予了厚望,可能也是想尽快为Fuchsia把路铺的更平吧。

Flutter的未来值得期待!

引用

1.juejin.cn/post/68449039

2.juejin.cn/post/69743634

3.juejin.cn/post/68909518

 

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注