Flutter 之美 | 开发者说·DTalk
Posted 谷歌开发者
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Flutter 之美 | 开发者说·DTalk相关的知识,希望对你有一定的参考价值。
本文原作者: 搜狐焦点-鲍立志,原文发布于: 搜狐技术产品。
本文旨在尽量避开具体的代码细节,从思想上去介绍 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 的话,可能需要以下几个步骤:
首先要定位是哪个 activity,通过命令或者 layout inspector 工具。
知道是哪个 activity 之后,打开对应的 xml 布局配置文件,如果 design 里不能明显看出来,就要通过 id 的命名去找。
如果这个组件就是在 activity 里,那就可以直接找到 id 对应的组件了。如果这个组件不在 activity 里,比如在 RecyclerView 里,那就需要找到它对应的 viewholder,继续在布局里找。
如果是代码生成的组件,那么就抱歉了,只能寄希望于代码量不是很大了...
好吧,真的是一言难尽。
再来看看 Flutter 的一键定位功能:
1. 点击开启视图定位模式
2. 点击想要定位的 ui 组件
3. 成功定位到该组件的代码位置
是不是又可以提前下班的时间了!!!
高效的 dart 语言:
我个人认为 dart 语言可能是移动端开发最好的语言了,下面就说说它好在哪:
拥有 java 一样的强类型特性,是一款类型安全的语言,并且支持 dynamic 类型,需要的话,可以像 js 一样灵活;
支持函数式编程,代码更为简洁;
mixin 方式实现多继承,比内部类更为优雅;
Flutter 2.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 构建过程。
声明式创建 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 应用
最典型的应用就是CustomScrollView,在滑动过程中,会不断的重绘子组件,如果我们的子组件中有较为复杂的绘制逻辑,就
3. 合成和渲染
终端设备的页面越来越复杂,因此 Flutter 的渲染树层级通常很多,直接交付给渲染引擎进行多图层渲染,可能会出现大量渲染内容的重复绘制,所以还需要先进行依次图层的合成,即将所有的图层根据大小、层级、透明度等规则计算出最终的显示效果,将相同的图层归类合并,简化渲染树,提高渲染效率。
四、Dart 虚拟机原理
单线程模型
所谓单线程模型,就是将任务放在队列中轮询执行,以此来实现异步任务,dart 线程中有两个任务队列: microtask queue 优先级更高,如果它里面有未处理的事件,会优先从这里取出事件处理。event queue,一般的异步任务都是放在这里的。
为什么要用单线程模型呢?
前端开发大部分异步任务都是为了等待,比如网络请求的等待,数据库、文件数据读取的等待,IO 密集型的任务是不消耗 cpu 的,为此使用多线程反而浪费资源,单线程模型更为合理;
单线程模型里不需要多线程共享内存,就不必担心同步死锁这些问题,开发效率得到提升;
无锁的内存分配是可以实现内存的线性分配的,不用查找可用内存空间,内存分配的效率得到提升。
异步任务理解
如何使用异步任务
使用 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 之间的逻辑
Root Isolate 需要创建或重新渲染一个 frame 时,通知 engine;
engine 通过 Platform Runner 监听来自 GPU Runner 的 vsync 信号;
Platform Runner 收到 vsync 信号通知 engine,engine 通知 Root Isolate 进行 Widget Tree 的 build 以及布局、绘制、合成 Layer Tree;
Root Isolate 将 Layer Tree 交给 engine,engine 发送给 GPU Runner,GPU Runner 配置好资源,将 Layer Tree 生成 GPU 指令交给 GPU 进行最后的渲染。
六.总结
相信您已经看到了 Flutter 的强大之处,跨平台能力、高效的开发体验、先进的前端框架设计思想。可能唯一的不足就是它实在是太年轻了,但是也能看到在短短不到三年的时间里它所取得的成就,这足够让人兴奋。
现在已经到了 Flutter 2.8 版本,每隔几个月就会有一次大的版本更新,每次的升级都会带来不同的惊喜,Google 确实是对它寄予了厚望,可能也是想尽快为 Fuchsia 把路铺的更平吧。
Flutter 的未来值得期待!
引用
1.https://juejin.cn/post/6844903901641048077
2.https://juejin.cn/post/6974363413942108197
3.https://juejin.cn/post/6890951845729009671
长按右侧二维码
查看更多开发者精彩分享
"开发者说·DTalk" 面向中国开发者们征集 Google 移动应用 (apps & games) 相关的产品/技术内容。欢迎大家前来分享您对移动应用的行业洞察或见解、移动开发过程中的心得或新发现、以及应用出海的实战经验总结和相关产品的使用反馈等。我们由衷地希望可以给这些出众的中国开发者们提供更好展现自己、充分发挥自己特长的平台。我们将通过大家的技术内容着重选出优秀案例进行谷歌开发技术专家 (GDE) 的推荐。
点击屏末 | 阅读原文 | 即刻报名参与 "开发者说·DTalk"
以上是关于Flutter 之美 | 开发者说·DTalk的主要内容,如果未能解决你的问题,请参考以下文章
探索 Flutter 模拟事件触发 | 开发者说·DTalk
已开源!Flutter 流畅度优化组件 Keframe | 开发者说·DTalk
已开源!Flutter 流畅度优化组件 Keframe | 开发者说·DTalk