This commit is contained in:
louzefeng
2024-07-11 05:50:32 +00:00
parent bf99793fd0
commit d3828a7aee
6071 changed files with 0 additions and 0 deletions

View File

@@ -0,0 +1,165 @@
<audio id="audio" title="09 | Widget构建Flutter界面的基石" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/7f/7d/7f99eeb00ede7b844bccc4513a4e177d.mp3"></audio>
你好,我是陈航。
在前面的Flutter开发起步和Dart基础模块中我和你一起学习了Flutter框架的整体架构与基本原理分析了Flutter的项目结构和运行机制并从Flutter开发角度介绍了Dart语言的基本设计思路也通过和其他高级语言的类比深入认识了Dart的语法特性。
这些内容是我们接下来系统学习构建Flutter应用的基础可以帮助我们更好地掌握Flutter的核心概念和技术。
在第4篇文章“[Flutter区别于其他方案的关键技术是什么](https://time.geekbang.org/column/article/105703)”中我和你分享了一张来自Flutter官方的架构图不难看出Widget是整个视图描述的基础。这张架构图很重要所以我在这里又放了一次。
<img src="https://static001.geekbang.org/resource/image/ac/2f/ac7d1cec200f7ea7cb6cbab04eda252f.png" alt="">
备注:此图引自[Flutter System Overview](https://flutter.dev/docs/resources/technical-overview)
那么Widget到底是什么呢
Widget是Flutter功能的抽象描述是视图的配置信息同样也是数据的映射是Flutter开发框架中最基本的概念。前端框架中常见的名词比如视图View、视图控制器View Controller、活动Activity、应用Application、布局Layout在Flutter中都是Widget。
事实上,**Flutter的核心设计思想便是“一切皆Widget”**。所以我们学习Flutter首先得从学会使用Widget开始。
那么在今天的这篇文章中我会带着你一起学习Widget在Flutter中的设计思路和基本原理以帮助你深入理解Flutter的视图构建过程。
## Widget渲染过程
在进行App开发时我们往往会关注的一个问题是如何结构化地组织视图数据提供给渲染引擎最终完成界面显示。
通常情况下不同的UI框架中会以不同的方式去处理这一问题但无一例外地都会用到视图树View Tree的概念。而Flutter将视图树的概念进行了扩展把视图数据的组织和渲染抽象为三部分即WidgetElement和 RenderObject。
这三部分之间的关系,如下所示:
<img src="https://static001.geekbang.org/resource/image/b4/c9/b4ae98fe5b4c9a7a784c916fd140bbc9.png" alt="">
### Widget
Widget是Flutter世界里对视图的一种结构化描述你可以把它看作是前端中的“控件”或“组件”。Widget是控件实现的基本逻辑单位里面存储的是有关视图渲染的配置信息包括布局、渲染属性、事件响应信息等。
在页面渲染上,**Flutter将“Simple is best”这一理念做到了极致**。为什么这么说呢Flutter将Widget设计成不可变的所以当视图渲染的配置信息发生变化时Flutter会选择重建Widget树的方式进行数据更新以数据驱动UI构建的方式简单高效。
这样做的缺点是因为涉及到大量对象的销毁和重建所以会对垃圾回收造成压力。不过Widget本身并不涉及实际渲染位图所以它只是一份轻量级的数据结构重建的成本很低。
另外由于Widget的不可变性可以以较低成本进行渲染节点复用因此在一个真实的渲染树中可能存在不同的Widget对应同一个渲染节点的情况这无疑又降低了重建UI的成本。
### Element
Element是Widget的一个实例化对象它承载了视图构建的上下文数据是连接结构化的配置信息到完成最终渲染的桥梁。
Flutter渲染过程可以分为这么三步
- 首先通过Widget树生成对应的Element树
- 然后创建相应的RenderObject并关联到Element.renderObject属性上
- 最后构建成RenderObject树以完成最终的渲染。
可以看到Element同时持有Widget和RenderObject。而无论是Widget还是Element其实都不负责最后的渲染只负责发号施令真正去干活儿的只有RenderObject。那你可能会问**既然都是发号施令那为什么需要增加中间的这层Element树呢直接由Widget命令RenderObject去干活儿不好吗**
答案是,可以,但这样做会极大地增加渲染带来的性能损耗。
因为Widget具有不可变性但Element却是可变的。实际上Element树这一层将Widget树的变化类似React 虚拟DOM diff做了抽象可以只将真正需要修改的部分同步到真实的RenderObject树中最大程度降低对真实渲染视图的修改提高渲染效率而不是销毁整个渲染视图树重建。
就是Element树存在的意义。
### RenderObject
从其名字我们就可以很直观地知道RenderObject是主要负责实现视图渲染的对象。
在前面的第4篇文章“[Flutter区别于其他方案的关键技术是什么](https://time.geekbang.org/column/article/105703)”中我们提到Flutter通过控件树Widget树中的每个控件Widget创建不同类型的渲染对象组成渲染对象树。
而渲染对象树在Flutter的展示过程分为四个阶段即布局、绘制、合成和渲染。 其中布局和绘制在RenderObject中完成Flutter采用深度优先机制遍历渲染对象树确定树中各个对象的位置和尺寸并把它们绘制到不同的图层上。绘制完毕后合成和渲染的工作则交给Skia搞定。
Flutter通过引入Widget、Element与RenderObject这三个概念把原本从视图数据到视图渲染的复杂构建过程拆分得更简单、直接在易于集中治理的同时保证了较高的渲染效率。
## RenderObjectWidget介绍
通过第5篇文章“[从标准模板入手体会Flutter代码是如何运行在原生系统上的](https://time.geekbang.org/column/article/106199)”的介绍你应该已经知道如何使用StatelessWidget和StatefulWidget了。
不过StatelessWidget和StatefulWidget只是用来组装控件的容器并不负责组件最后的布局和绘制。在Flutter中布局和绘制工作实际上是在Widget的另一个子类RenderObjectWidget内完成的。
所以在今天这篇文章的最后我们再来看一下RenderObjectWidget的源码来看看如何使用Element和RenderObject完成图形渲染工作。
```
abstract class RenderObjectWidget extends Widget {
@override
RenderObjectElement createElement();
@protected
RenderObject createRenderObject(BuildContext context);
@protected
void updateRenderObject(BuildContext context, covariant RenderObject renderObject) { }
...
}
```
RenderObjectWidget是一个抽象类。我们通过源码可以看到这个类中同时拥有创建Element、RenderObject以及更新RenderObject的方法。
但实际上,**RenderObjectWidget本身并不负责这些对象的创建与更新**。
对于Element的创建Flutter会在遍历Widget树时调用createElement去同步Widget自身配置从而生成对应节点的Element对象。而对于RenderObject的创建与更新其实是在RenderObjectElement类中完成的。
```
abstract class RenderObjectElement extends Element {
RenderObject _renderObject;
@override
void mount(Element parent, dynamic newSlot) {
super.mount(parent, newSlot);
_renderObject = widget.createRenderObject(this);
attachRenderObject(newSlot);
_dirty = false;
}
@override
void update(covariant RenderObjectWidget newWidget) {
super.update(newWidget);
widget.updateRenderObject(this, renderObject);
_dirty = false;
}
...
}
```
在Element创建完毕后Flutter会调用Element的mount方法。在这个方法里会完成与之关联的RenderObject对象的创建以及与渲染树的插入工作插入到渲染树后的Element就可以显示到屏幕中了。
如果Widget的配置数据发生了改变那么持有该Widget的Element节点也会被标记为dirty。在下一个周期的绘制时Flutter就会触发Element树的更新并使用最新的Widget数据更新自身以及关联的RenderObject对象接下来便会进入Layout和Paint的流程。而真正的绘制和布局过程则完全交由RenderObject完成
```
abstract class RenderObject extends AbstractNode with DiagnosticableTreeMixin implements HitTestTarget {
...
void layout(Constraints constraints, { bool parentUsesSize = false }) {...}
void paint(PaintingContext context, Offset offset) { }
}
```
布局和绘制完成后接下来的事情就交给Skia了。在VSync信号同步时直接从渲染树合成Bitmap然后提交给GPU。这部分内容我已经在之前的“[Flutter区别于其他方案的关键技术是什么](https://time.geekbang.org/column/article/105703)?”中与你介绍过了,这里就不再赘述了。
接下来我以下面的界面示例为例与你说明Widget、Element与RenderObject在渲染过程中的关系。在下面的例子中一个Row容器放置了4个子Widget左边是Image而右边则是一个Column容器下排布的两个Text。
<img src="https://static001.geekbang.org/resource/image/f4/b9/f4d2ac98728cf4f24b0237958d0ce0b9.png" alt="">
那么在Flutter遍历完Widget树创建了各个子Widget对应的Element的同时也创建了与之关联的、负责实际布局和绘制的RenderObject。
<img src="https://static001.geekbang.org/resource/image/35/6d/3536bd7bc00b42b220ce18ba86c2a26d.png" alt="">
## 总结
好了今天关于Widget的设计思路和基本原理的介绍我们就先进行到这里。接下来我们一起回顾下今天的主要内容吧。
首先我与你介绍了Widget渲染过程学习了在Flutter中视图数据的组织和渲染抽象的三个核心概念即Widget、 Element和RenderObject。
其中Widget是Flutter世界里对视图的一种结构化描述里面存储的是有关视图渲染的配置信息Element则是Widget的一个实例化对象将Widget树的变化做了抽象能够做到只将真正需要修改的部分同步到真实的Render Object树中最大程度地优化了从结构化的配置信息到完成最终渲染的过程而RenderObject则负责实现视图的最终呈现通过布局、绘制完成界面的展示。
最后在对Flutter Widget渲染过程有了一定认识后我带你阅读了RenderObjectWidget的代码理解Widget、Element与RenderObject这三个对象之间是如何互相配合实现图形渲染工作的。
熟悉了Widget、Element与RenderObject这三个概念相信你已经对组件的渲染过程有了一个清晰而完整的认识。这样我们后续再学习常用的组件和布局时就能够从不同的视角去思考框架设计的合理性了。
不过在日常开发学习中绝大多数情况下我们只需要了解各种Widget特性及使用方法而无需关心Element及RenderObject。因为Flutter已经帮我们做了大量优化工作因此我们只需要在上层代码完成各类Widget的组装配置其他的事情完全交给Flutter就可以了。
## 思考题
你是如何理解Widget、Element和RenderObject这三个概念的它们之间是一一对应的吗你能否在Android/iOS/Web中找到对应的概念呢
欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,209 @@
<audio id="audio" title="10 | Widget中的State到底是什么" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/8b/39/8b13514c4f5385b637d254b5cddb0939.mp3"></audio>
你好,我是陈航。
通过上一篇文章我们已经深入理解了Widget是Flutter构建界面的基石也认识了Widget、Element、RenderObject是如何互相配合实现图形渲染工作的。Flutter在底层做了大量的渲染优化工作使得我们只需要通过组合、嵌套不同类型的Widget就可以构建出任意功能、任意复杂度的界面。
同时我们通过前面的学习也已经了解到Widget有StatelessWidget和StatefulWidget两种类型。StatefulWidget应对有交互、需要动态变化视觉效果的场景而StatelessWidget则用于处理静态的、无状态的视图展示。StatefulWidget的场景已经完全覆盖了StatelessWidget因此我们在构建界面时往往会大量使用StatefulWidget来处理静态的视图展示需求看起来似乎也没什么问题。
那么StatelessWidget存在的必要性在哪里StatefulWidget是否是Flutter中的万金油在今天这篇文章中我将着重和你介绍这两种类型的区别从而帮你更好地理解Widget掌握不同类型Widget的正确使用时机。
## UI编程范式
要想理解StatelessWidget与StatefulWidget的使用场景我们首先需要了解在Flutter中如何调整一个控件Widget的展示样式即UI编程范式。
如果你有过原生系统Android、iOS或原生JavaScript开发经验的话应该知道视图开发是命令式的需要精确地告诉操作系统或浏览器用何种方式去做事情。比如如果我们想要变更界面的某个文案则需要找到具体的文本控件并调用它的控件方法命令才能完成文字变更。
下述代码分别展示了在Android、iOS及原生Javascript中如何将一个文本控件的展示文案更改为Hello World
```
// Android设置某文本控件展示文案为Hello World
TextView textView = (TextView) findViewById(R.id.txt);
textView.setText(&quot;Hello World&quot;);
// iOS设置某文本控件展示文案为Hello World
UILabel *label = (UILabel *)[self.view viewWithTag:1234];
label.text = @&quot;Hello World&quot;;
// 原生JavaScript设置某文本控件展示文案为Hello World
document.querySelector(&quot;#demo&quot;).innerHTML = &quot;Hello World!&quot;;
```
与此不同的是,**Flutter的视图开发是声明式的其核心设计思想就是将视图和数据分离这与React的设计思路完全一致**。
对我们来说如果要实现同样的需求则要稍微麻烦点除了设计好Widget布局方案之外还需要提前维护一套文案数据集并为需要变化的Widget绑定数据集中的数据使Widget根据这个数据集完成渲染。
但是当需要变更界面的文案时我们只要改变数据集中的文案数据并通知Flutter框架触发Widget的重新渲染即可。这样一来开发者将无需再精确关注UI编程中的各个过程细节只要维护好数据集即可。比起命令式的视图开发方式需要挨个设置不同组件Widget的视觉属性这种方式要便捷得多。
**总结来说,命令式编程强调精确控制过程细节;而声明式编程强调通过意图输出结果整体。**对应到Flutter中意图是绑定了组件状态的State结果则是重新渲染后的组件。在Widget的生命周期内应用到State中的任何更改都将强制Widget重新构建。
其中对于组件完成创建后就无需变更的场景状态的绑定是可选项。这里“可选”就区分出了Widget的两种类型StatelessWidget不带绑定状态而StatefulWidget带绑定状态。**当你所要构建的用户界面不随任何状态信息的变化而变化时需要选择使用StatelessWidget反之则选用StatefulWidget。**前者一般用于静态内容的展示,而后者则用于存在交互反馈的内容呈现中。
接下来我分别和你介绍StatelessWidget和StatefulWidget从源码分析它们的区别并总结一些关于Widget选型的基本原则。
## StatelessWidget
在Flutter中Widget采用由父到子、自顶向下的方式进行构建父Widget控制着子Widget的显示样式其样式配置由父Widget在构建时提供。
用这种方式构建出的Widget有些比如Text、Container、Row、Column等在创建时除了这些配置参数之外不依赖于任何其他信息换句话说它们一旦创建成功就不再关心、也不响应任何数据变化进行重绘。在Flutter中**这样的Widget被称为StatelessWidget无状态组件**。
这里有一张StatelessWidget的示意图如下所示
<img src="https://static001.geekbang.org/resource/image/3e/cc/3ec97a9f584132c2bcdbca60fd2888cc.png" alt="">
接下来我以Text的部分源码为例和你说明StatelessWidget的构建过程。
```
class Text extends StatelessWidget {
//构造方法及属性声明部分
const Text(this.data, {
Key key,
this.textAlign,
this.textDirection,
//其他参数
...
}) : assert(data != null),
textSpan = null,
super(key: key);
final String data;
final TextAlign textAlign;
final TextDirection textDirection;
//其他属性
...
@override
Widget build(BuildContext context) {
...
Widget result = RichText(
//初始化配置
...
)
);
...
return result;
}
}
```
可以看到在构造方法将其属性列表赋值后build方法随即将子组件RichText通过其属性列表如文本data、对齐方式textAlign、文本展示方向textDirection等初始化后返回之后Text内部不再响应外部数据的变化。
那么什么场景下应该使用StatelessWidget呢
这里,我有一个简单的判断规则:**父Widget是否能通过初始化参数完全控制其UI展示效果**如果能那么我们就可以使用StatelessWidget来设计构造函数接口了。
我准备了两个简单的小例子,来帮助你理解这个判断规则。
第一个小例子是我需要创建一个自定义的弹窗控件把使用App过程中出现的一些错误信息提示给用户。这个组件的父Widget能够完全在子Widget初始化时将组件所需要的样式信息和错误提示信息传递给它也就意味着父Widget通过初始化参数就能完全控制其展示效果。所以我可以采用继承StatelessWidget的方式来进行组件自定义。
第二个小例子是我需要定义一个计数器按钮用户每次点击按钮后按钮颜色都会随之加深。可以看到这个组件的父Widget只能控制子Widget初始的样式展示效果而无法控制在交互过程中发生的颜色变化。所以我无法通过继承StatelessWidget的方式来自定义组件。那么这个时候就轮到StatefulWidget出场了。
## StatefulWidget
与StatelessWidget相对应的有一些Widget比如Image、Checkbox的展示除了父Widget初始化时传入的静态配置之外还需要处理用户的交互比如用户点击按钮或其内部数据的变化比如网络数据回包并体现在UI上。
换句话说这些Widget创建完成后还需要关心和响应数据变化来进行重绘。在Flutter中**这一类Widget被称为StatefulWidget有状态组件**。这里有一张StatefulWidget的示意图如下所示
<img src="https://static001.geekbang.org/resource/image/8a/f6/8ae7bf36f618a999da8847cbb4da4bf6.png" alt="">
看到这里你可能有点困惑了。因为我在上一篇文章“Widget构建Flutter界面的基石”中和你分享到Widget是不可变的发生变化时需要销毁重建所以谈不上状态。那么这到底是怎么回事呢
其实StatefulWidget是以State类代理Widget构建的设计方式实现的。接下来我就以Image的部分源码为例和你说明StatefulWidget的构建过程来帮助你理解这个知识点。
和上面提到的Text一样Image类的构造函数会接收要被这个类使用的属性参数。然而不同的是Image类并没有build方法来创建视图而是通过createState方法创建了一个类型为_ImageState的state对象然后由这个对象负责视图的构建。
这个state对象持有并处理了Image类中的状态变化所以我就以_imageInfo属性为例来和你展开说明。
_imageInfo属性用来给Widget加载真实的图片一旦State对象通过_handleImageChanged方法监听到_imageInfo属性发生了变化就会立即调用_ImageState类的setState方法通知Flutter框架“我这儿的数据变啦请使用更新后的_imageInfo数据重新加载图片”。而Flutter框架则会标记视图状态更新UI。
```
class Image extends StatefulWidget {
//构造方法及属性声明部分
const Image({
Key key,
@required this.image,
//其他参数
}) : assert(image != null),
super(key: key);
final ImageProvider image;
//其他属性
...
@override
_ImageState createState() =&gt; _ImageState();
...
}
class _ImageState extends State&lt;Image&gt; {
ImageInfo _imageInfo;
//其他属性
...
void _handleImageChanged(ImageInfo imageInfo, bool synchronousCall) {
setState(() {
_imageInfo = imageInfo;
});
}
...
@override
Widget build(BuildContext context) {
final RawImage image = RawImage(
image: _imageInfo?.image,
//其他初始化配置
...
);
return image;
}
...
}
```
可以看到在这个例子中Image以一种动态的方式运行监听变化更新视图。与StatelessWidget通过父Widget完全控制UI展示不同StatefulWidget的父Widget仅定义了它的初始化状态而其自身视图运行的状态则需要自己处理并根据处理情况即时更新UI展示。
好了至此我们已经通过StatelessWidget与StatefulWidget的源码理解了这两种类型的Widget。这时你可能会问既然StatefulWidget不仅可以响应状态变化又能展示静态UI那么StatelessWidget这种只能展示静态UI的Widget还有存在的必要吗
## StatefulWidget不是万金油要慎用
对于UI框架而言同样的展示效果一般可以通过多种控件实现。从定义来看StatefulWidget仿佛是万能的替代StatelessWidget看起来合情合理。于是StatefulWidget的滥用也容易因此变得顺理成章难以避免。
但事实是StatefulWidget的滥用会直接影响Flutter应用的渲染性能。
接下来在今天这篇文章的最后我就再带你回顾一下Widget的更新机制来帮你意识到完全使用StatefulWidget的代价
>
Widget是不可变的更新则意味着销毁+重建build。StatelessWidget是静态的一旦创建则无需更新而对于StatefulWidget来说在State类中调用setState方法更新数据会触发视图的销毁和重建也将间接地触发其每个子Widget的销毁和重建。
那么,这意味着什么呢?
如果我们的根布局是一个StatefulWidget在其State中每调用一次更新UI都将是一整个页面所有Widget的销毁和重建。
在上一篇文章中我们了解到虽然Flutter内部通过Element层可以最大程度地降低对真实渲染视图的修改提高渲染效率而不是销毁整个RenderObject树重建。但大量Widget对象的销毁重建是无法避免的。如果某个子Widget的重建涉及到一些耗时操作那页面的渲染性能将会急剧下降。
因此,**正确评估你的视图展示需求避免无谓的StatefulWidget使用是提高Flutter应用渲染性能最简单也是最直接的手段**。
在接下来的第29篇文章“为什么需要做状态管理怎么做”中我会继续带你学习StatefulWidget常见的几种状态管理方法与你更为具体地介绍在不同场景中该选用何种Widget的基本原则。这些原则你都可以根据实际需要应用到后续工作中。
## 总结
好了今天关于StatelessWidget与StatefulWidget的介绍我们就到这里了。我们一起来回顾下今天的主要知识点。
首先我带你了解了Flutter基于声明式的UI编程范式并通过阅读两个典型WidgetText与Image源码的方式与你一起学习了StatelessWidget与StatefulWidget的基本设计思路。
由于Widget采用由父到子、自顶向下的方式进行构建因此在自定义组件时我们可以根据父Widget是否能通过初始化参数完全控制其UI展示效果的基本原则来判断究竟是继承StatelessWidget还是StatefulWidget。
然后针对StatefulWidget的“万金油”误区我带你重新回顾了Widget的UI更新机制。尽管Flutter会通过Element层去最大程度降低对真实渲染视图的修改但大量的Widget销毁重建无法避免因此避免StatefulWidget的滥用是最简单、直接地提升应用渲染性能的手段。
需要注意的是除了我们主动地通过State刷新UI之外在一些特殊场景下Widget的build方法有可能会执行多次。因此我们不应该在这个方法内部放置太多有耗时的操作。而关于这个build方法在哪些场景下会执行以及为什么会执行多次我会在下一篇文章“提到生命周期我们是在说什么”中与你一起详细分析。
## 思考题
Flutter工程应用模板是计数器示例应用Demo这个Demo的根节点是一个StatelessWidget。请在保持原有功能的情况下将这个Demo改造为根节点为StatefulWidget的App。你能通过数据打点得出这两种方式的性能差异吗
欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,207 @@
<audio id="audio" title="11 | 提到生命周期,我们是在说什么?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/fe/55/fe2ed4c88658fc76c93410596642b455.mp3"></audio>
你好我是陈航。今天我要和你分享的主题是Flutter中的生命周期是什么。
在上一篇文章中我们从常见的StatefulWidget的“万金油”误区出发一起回顾了Widget的UI更新机制。
通过父Widget初始化时传入的静态配置StatelessWidget就能完全控制其静态展示。而StatefulWidget还需要借助于State对象在特定的阶段来处理用户的交互或其内部数据的变化并体现在UI上。这些特定的阶段就涵盖了一个组件从加载到卸载的全过程即生命周期。与iOS的ViewController、Android的Activity一样Flutter中的Widget也存在生命周期并且通过State来体现。
而App则是一个特殊的Widget。除了需要处理视图显示的各个阶段即视图的生命周期之外还需要应对应用从启动到退出所经历的各个状态App的生命周期
对于开发者来说无论是普通Widget的State还是App框架都给我们提供了生命周期的回调可以让我们选择恰当的时机做正确的事儿。所以在对生命周期有了深入理解之后我们可以写出更加连贯流畅、体验优良的程序。
那么今天我就分别从Widget的State和App这两个维度与你介绍它们的生命周期。
## State生命周期
State的生命周期指的是在用户参与的情况下其关联的Widget所经历的从创建到显示再到更新最后到停止直至销毁等各个过程阶段。
这些不同的阶段涉及到特定的任务处理因此为了写出一个体验和性能良好的控件正确理解State的生命周期至关重要。
State的生命周期流程如图1所示
<img src="https://static001.geekbang.org/resource/image/bb/84/bba88ebb44b7fdd6735f3ddb41106784.png" alt="">
可以看到State的生命周期可以分为3个阶段创建插入视图树、更新在视图树中存在、销毁从视图树中移除。接下来我们一起看看每一个阶段的具体流程。
### 创建
State初始化时会依次执行 :构造方法 -&gt; initState -&gt; didChangeDependencies -&gt; build随后完成页面渲染。
我们来看一下初始化过程中每个方法的意义。
- 构造方法是State生命周期的起点Flutter会通过调用StatefulWidget.createState() 来创建一个State。我们可以通过构造方法来接收父Widget传递的初始化UI配置数据。这些配置数据决定了Widget最初的呈现效果。
- initState会在State对象被插入视图树的时候调用。这个函数在State的生命周期中只会被调用一次所以我们可以在这里做一些初始化工作比如为状态变量设定默认值。
- didChangeDependencies则用来专门处理State对象依赖关系变化会在initState() 调用结束后被Flutter调用。
- build作用是构建视图。经过以上步骤Framework认为State已经准备好了于是调用build。我们需要在这个函数中根据父Widget传递过来的初始化配置数据以及State的当前状态创建一个Widget然后返回。
### 更新
Widget的状态更新主要由3个方法触发setState、didchangeDependencies与didUpdateWidget。
接下来,我和你分析下这三个方法分别会在什么场景下调用。
- setState我们最熟悉的方法之一。当状态数据发生变化时我们总是通过调用这个方法告诉Flutter“我这儿的数据变啦请使用更新后的数据重建UI
- didChangeDependenciesState对象的依赖关系发生变化后Flutter会回调这个方法随后触发组件构建。哪些情况下State对象的依赖关系会发生变化呢典型的场景是系统语言Locale或应用主题改变时系统会通知State执行didChangeDependencies回调方法。
- didUpdateWidget当Widget的配置发生变化时比如父Widget触发重建即父Widget的状态发生变化时热重载时系统会调用这个函数。
一旦这三个方法被调用Flutter随后就会销毁老Widget并调用build方法重建Widget。
### 销毁
组件销毁相对比较简单。比如组件被移除或是页面销毁的时候系统会调用deactivate和dispose这两个方法来移除或销毁组件。
接下来,我们一起看一下它们的具体调用机制:
- 当组件的可见状态发生变化时deactivate函数会被调用这时State会被暂时从视图树中移除。值得注意的是页面切换时由于State对象在视图树中的位置发生了变化需要先暂时移除后再重新添加重新触发组件构建因此这个函数也会被调用。
- 当State被永久地从视图树中移除时Flutter会调用dispose函数。而一旦到这个阶段组件就要被销毁了所以我们可以在这里进行最终的资源释放、移除监听、清理环境等等。
如图2所示左边部分展示了当父Widget状态发生变化时父子双方共同的生命周期而中间和右边部分则描述了页面切换时两个关联的Widget的生命周期函数是如何响应的。
<img src="https://static001.geekbang.org/resource/image/72/d8/72e066a4981e0e2381b1dab6e61307d8.png" alt="">
我准备了一张表格,从功能,调用时机和调用次数的维度总结了这些方法,帮助你去理解、记忆。
<img src="https://static001.geekbang.org/resource/image/aa/bc/aacfcfdb80038874251aa8ad93930abc.png" alt="">
另外我强烈建议你打开自己的IDE在应用模板中增加以上回调函数并添加打印代码多运行几次看看各个函数的执行顺序从而加深对State生命周期的印象。毕竟实践出真知。
## App生命周期
视图的生命周期定义了视图的加载到构建的全过程其回调机制能够确保我们可以根据视图的状态选择合适的时机做恰当的事情。而App的生命周期则定义了App从启动到退出的全过程。
在原生Android、iOS开发中有时我们需要在对应的App生命周期事件中做相应处理比如App从后台进入前台、从前台退到后台或是在UI绘制完成后做一些处理。
这样的需求在原生开发中我们可以通过重写Activity、ViewController生命周期回调方法或是注册应用程序的相关通知来监听App的生命周期并做相应的处理。而在Flutter中我们可以利用**WidgetsBindingObserver**类,来实现同样的需求。
接下来,我们就来看看具体如何实现这样的需求。
首先我们来看看WidgetsBindingObserver中具体有哪些回调函数
```
abstract class WidgetsBindingObserver {
//页面pop
Future&lt;bool&gt; didPopRoute() =&gt; Future&lt;bool&gt;.value(false);
//页面push
Future&lt;bool&gt; didPushRoute(String route) =&gt; Future&lt;bool&gt;.value(false);
//系统窗口相关改变回调,如旋转
void didChangeMetrics() { }
//文本缩放系数变化
void didChangeTextScaleFactor() { }
//系统亮度变化
void didChangePlatformBrightness() { }
//本地化语言变化
void didChangeLocales(List&lt;Locale&gt; locale) { }
//App生命周期变化
void didChangeAppLifecycleState(AppLifecycleState state) { }
//内存警告回调
void didHaveMemoryPressure() { }
//Accessibility相关特性回调
void didChangeAccessibilityFeatures() {}
}
```
可以看到WidgetsBindingObserver这个类提供的回调函数非常丰富常见的屏幕旋转、屏幕亮度、语言变化、内存警告都可以通过这个实现进行回调。我们通过给WidgetsBinding的单例对象设置监听器就可以监听对应的回调方法。
考虑到其他的回调相对简单,你可以参考[官方文档](https://api.flutter.dev/flutter/widgets/WidgetsBindingObserver-class.html)对照着进行练习。因此我今天主要和你分享App生命周期的回调didChangeAppLifecycleState和帧绘制回调addPostFrameCallback与addPersistentFrameCallback。
### 生命周期回调
didChangeAppLifecycleState回调函数中有一个参数类型为AppLifecycleState的枚举类这个枚举类是Flutter对App生命周期状态的封装。它的常用状态包括resumed、inactive、paused这三个。
- resumed可见的并能响应用户的输入。
- inactive处在不活动状态无法处理用户响应。
- paused不可见并不能响应用户的输入但是在后台继续活动中。
这里,我来和你分享一个实际案例。
在下面的代码中我们在initState时注册了监听器在didChangeAppLifecycleState回调方法中打印了当前的App状态最后在dispose时把监听器移除
```
class _MyHomePageState extends State&lt;MyHomePage&gt; with WidgetsBindingObserver{//这里你可以再回顾下第7篇文章“函数、类与运算符Dart是如何处理信息的”中关于Mixin的内容
...
@override
@mustCallSuper
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);//注册监听器
}
@override
@mustCallSuper
void dispose(){
super.dispose();
WidgetsBinding.instance.removeObserver(this);//移除监听器
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) async {
print(&quot;$state&quot;);
if (state == AppLifecycleState.resumed) {
//do sth
}
}
}
```
我们试着切换一下前、后台观察控制台输出的App状态可以发现
- 从后台切入前台控制台打印的App生命周期变化如下: AppLifecycleState.paused-&gt;AppLifecycleState.inactive-&gt;AppLifecycleState.resumed
- 从前台退回后台控制台打印的App生命周期变化则变成了AppLifecycleState.resumed-&gt;AppLifecycleState.inactive-&gt;AppLifecycleState.paused。
可以看到App前后台切换过程中打印出的状态是完全符合预期的。
<img src="https://static001.geekbang.org/resource/image/28/e6/2880ffdbe3c5df3552c0b22c34157ae6.png" alt="">
### 帧绘制回调
除了需要监听App的生命周期回调做相应的处理之外有时候我们还需要在组件渲染之后做一些与显示安全相关的操作。
在iOS开发中我们可以通过dispatch_async(dispatch_get_main_queue(),^{…})方法让操作在下一个RunLoop执行而在Android开发中我们可以通过View.post()插入消息队列,来保证在组件渲染后进行相关操作。
其实,**在Flutter中实现同样的需求会更简单**依然使用万能的WidgetsBinding来实现。
WidgetsBinding提供了单次Frame绘制回调以及实时Frame绘制回调两种机制来分别满足不同的需求
- 单次Frame绘制回调通过addPostFrameCallback实现。它会在当前Frame绘制完成后进行进行回调并且只会回调一次如果要再次监听则需要再设置一次。
```
WidgetsBinding.instance.addPostFrameCallback((_){
print(&quot;单次Frame绘制回调&quot;);//只回调一次
});
```
- 实时Frame绘制回调则通过addPersistentFrameCallback实现。这个函数会在每次绘制Frame结束后进行回调可以用做FPS监测。
```
WidgetsBinding.instance.addPersistentFrameCallback((_){
print(&quot;实时Frame绘制回调&quot;);//每帧都回调
});
```
## 总结
在今天这篇文章中我和你介绍了State和App的生命周期这是Flutter给我们提供的感知Widget和应用在不同阶段状态变化的回调。
首先我带你重新认识了Widget生命周期的实际承载者State。我将State的生命周期划分为了创建插入视图树、更新在视图树中存在、销毁从视图树种移除这3个阶段并为你介绍了每个阶段中涉及的关键方法希望你能够深刻理解Flutter组件从加载到卸载的完整周期。
然后通过与原生Android、iOS平台能力的对比以及查看WidgetsBindingObserver源码的方式我与你讲述了Flutter常用的生命周期状态切换机制。希望你能掌握Flutter的App生命周期监听方法并理解Flutter常用的生命周期状态切换机制。
最后我和你一起学习了Flutter帧绘制回调机制理解了单次Frame绘制回调与实时Frame绘制回调的异同与使用场景。
为了能够精确地控制WidgetFlutter提供了很多状态回调所以今天这一篇文章涉及到的方法有些多。但**只要你分别记住创建、更新与销毁这三条主线的调用规则,就一定能把这些方法的调用顺序串起来,并能在实际开发中运用正确的方法去感知状态变更,写出合理的组件。**
我把今天分享所涉及的全部知识点打包成了一个[小项目](https://github.com/cyndibaby905/11_Flutter_lifecycle),你可以下载后在工程中实际运行,并对照着今天的课程学习,体会在不同场景下这些函数的调用时机。
## 思考题
最后,请你思考下这两个问题:
1. 构造方法与initState函数在State的生命周期中都只会被调用一次也大都用于完成一些初始化的工作。根据我们今天的学习你能否举出例子比如哪些操作适合放在构造方法哪些操作适合放在initState而哪些操作必须放在initState。
1. 通过didChangeDependencies触发Widget重建时父子Widget之间的生命周期函数调用时序是怎样的
欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,204 @@
<audio id="audio" title="12 | 经典控件文本、图片和按钮在Flutter中怎么用" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/b1/e4/b198d8fe89eac6745e60ae78ba78dfe4.mp3"></audio>
你好,我是陈航。
在上一篇文章中我与你介绍了Widget生命周期的实际承载者State并详细介绍了初始化、状态更新与控件销毁这3个不同阶段所涉及的关键方法调用顺序。深入理解视图从加载到构建再到销毁的过程可以帮助你理解如何根据视图的状态在合适的时机做恰当的事情。
前面几次分享我们讲了很多关于Flutter框架视图渲染的基础知识和原理。但有些同学可能会觉得这些基础知识和原理在实践中并不常用所以在学习时会选择忽视这些内容。
但其实像视图数据流转机制、底层渲染方案、视图更新策略等知识都是构成一个UI框架的根本看似枯燥却往往具有最长久的生命力。新框架每年层出不穷可是扒下那层炫酷的“外衣”里面其实还是那些最基础的知识和原理。
因此,**只有把这些最基础的知识弄明白了,修炼好了内功,才能触类旁通,由点及面形成自己的知识体系,也能够在框架之上思考应用层构建视图实现的合理性。**
在对视图的基础知识有了整体印象后我们再来学习Flutter视图系统所提供的UI控件就会事半功倍了。而作为一个UI框架与Android、iOS和React类似的Flutter自然也提供了很多UI控件。而文本、图片和按钮则是这些不同的UI框架中构建视图都要用到的三个最基本的控件。因此在今天这篇文章中我就与你一起学习在Flutter中该如何使用它们。
## 文本控件
文本是视图系统中的常见控件用来显示一段特定样式的字符串就比如Android里的TextView、iOS中的UILabel。而在Flutter中文本展示是通过Text控件实现的。
Text支持两种类型的文本展示一个是默认的展示单一样式的文本Text另一个是支持多种混合样式的富文本Text.rich。
我们先来看看**如何使用单一样式的文本Text**。
单一样式文本Text的初始化是要传入需要展示的字符串。而这个字符串的具体展示效果受构造函数中的其他参数控制。这些参数大致可以分为两类
- **控制整体文本布局的参数**如文本对齐方式textAlign、文本排版方向textDirection文本显示最大行数maxLines、文本截断规则overflow等等这些都是构造函数中的参数
- **控制文本展示样式的参数**如字体名称fontFamily、字体大小fontSize、文本颜色color、文本阴影shadows等等这些参数被统一封装到了构造函数中的参数style中。
接下来我们以一个具体的例子来看看Text控件的使用方法。如下所示我在代码中定义了一段居中布局、20号红色粗体展示样式的字符串
```
Text(
'文本是视图系统中的常见控件用来显示一段特定样式的字符串就比如Android里的TextView或是iOS中的UILabel。',
textAlign: TextAlign.center,//居中显示
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 20, color: Colors.red),//20号红色粗体展示
);
```
运行效果如下图所示:
<img src="https://static001.geekbang.org/resource/image/92/9e/926f77c8259fbd3ef914bf1b6039939e.png" alt="">
理解了展示单一样式的文本Text的使用方法后我们再来看看**如何在一段字符串中支持多种混合展示样式**。
**混合展示样式与单一样式的关键区别在于分片**即如何把一段字符串分为几个片段来管理给每个片段单独设置样式。面对这样的需求在Android中我们使用SpannableString来实现在iOS中我们使用NSAttributedString来实现而在Flutter中也有类似的概念即TextSpan。
TextSpan定义了一个字符串片段该如何控制其展示样式而将这些有着独立展示样式的字符串组装在一起则可以支持混合样式的富文本展示。
如下方代码所示我们分别定义了黑色与红色两种展示样式随后把一段字符串分成了4个片段并设置了不同的展示样式
```
TextStyle blackStyle = TextStyle(fontWeight: FontWeight.normal, fontSize: 20, color: Colors.black); //黑色样式
TextStyle redStyle = TextStyle(fontWeight: FontWeight.bold, fontSize: 20, color: Colors.red); //红色样式
Text.rich(
TextSpan(
children: &lt;TextSpan&gt;[
TextSpan(text:'文本是视图系统中常见的控件,它用来显示一段特定样式的字符串,类似', style: redStyle), //第1个片段红色样式
TextSpan(text:'Android', style: blackStyle), //第1个片段黑色样式
TextSpan(text:'中的', style:redStyle), //第1个片段红色样式
TextSpan(text:'TextView', style: blackStyle) //第1个片段黑色样式
]),
textAlign: TextAlign.center,
);
```
运行效果,如下图所示:
<img src="https://static001.geekbang.org/resource/image/a9/a2/a97d5ee7941585a5185cdb56b4303fa2.png" alt="">
接下来我们再看看Flutter中的图片控件Image。
## 图片
使用Image可以让我们向用户展示一张图片。图片的显示方式有很多比如资源图片、网络图片、文件图片等图片格式也各不相同因此在Flutter中也有多种方式用来加载不同形式、支持不同格式的图片
- 加载本地资源图片如Image.asset(images/logo.png)
- 加载本地File文件图片如Image.file(new File(/storage/xxx/xxx/test.jpg))
- 加载网络图片如Image.network(`'http://xxx/xxx/test.gif'`) 。
除了可以根据图片的显示方式设置不同的图片源之外图片的构造方法还提供了填充模式fit、拉伸模式centerSlice、重复模式repeat等属性可以针对图片与目标区域的宽高比差异制定排版模式。
和Android中ImageView、iOS里的UIImageView的属性都是类似的。因此我在这里就不再过多展开了。你可以参考官方文档中的[Image的构造函数](https://api.flutter.dev/flutter/widgets/Image/Image.html)部分去查看Image控件的具体使用方法。
关于图片展示我还要和你分享下Flutter中的**FadeInImage**控件。在加载网络图片的时候为了提升用户的等待体验我们往往会加入占位图、加载动画等元素但是默认的Image.network构造方法并不支持这些高级功能这时候FadeInImage控件就派上用场了。
FadeInImage控件提供了图片占位的功能并且支持在图片加载完成时淡入淡出的视觉效果。此外由于Image支持gif格式我们甚至还可以将一些炫酷的加载动画作为占位图。
下述代码展示了这样的场景。我们在加载大图片时将一张loading的gif作为占位图展示给用户
```
FadeInImage.assetNetwork(
placeholder: 'assets/loading.gif', //gif占位
image: 'https://xxx/xxx/xxx.jpg',
fit: BoxFit.cover, //图片拉伸模式
width: 200,
height: 200,
)
```
<img src="https://static001.geekbang.org/resource/image/54/e7/547b9bf0bce3dd0cc1c39cbbbe79d2e7.gif" alt="">
Image控件需要根据图片资源异步加载的情况决定自身的显示效果因此是一个StatefulWidget。图片加载过程由ImageProvider触发而ImageProvider表示异步获取图片数据的操作可以从资源、文件和网络等不同的渠道获取图片。
首先ImageProvider根据_ImageState中传递的图片配置生成对应的图片缓存key然后去ImageCache中查找是否有对应的图片缓存如果有则通知_ImageState刷新UI如果没有则启动ImageStream开始异步加载加载完毕后更新缓存最后通知_ImageState刷新UI。
图片展示的流程,可以用以下流程图表示:
<img src="https://static001.geekbang.org/resource/image/e8/0c/e84155b756a7c995821a209e1cd9120c.png" alt="">
值得注意的是ImageCache使用LRULeast Recently Used最近最少使用算法进行缓存更新策略并且默认最多存储 1000张图片最大缓存限制为100MB当限定的空间已存满数据时把最久没有被访问到的图片清除。图片**缓存只会在运行期间生效,也就是只缓存在内存中**。如果想要支持缓存到文件系统,可以使用第三方的[CachedNetworkImage](https://pub.dev/packages/cached_network_image/)控件。
CachedNetworkImage的使用方法与Image类似除了支持图片缓存外还提供了比FadeInImage更为强大的加载过程占位与加载错误占位可以支持比用图片占位更灵活的自定义控件占位。
在下面的代码中我们在加载图片时不仅给用户展示了作为占位的转圈loading还提供了一个错误图兜底以备图片加载出错
```
CachedNetworkImage(
imageUrl: &quot;http://xxx/xxx/jpg&quot;,
placeholder: (context, url) =&gt; CircularProgressIndicator(),
errorWidget: (context, url, error) =&gt; Icon(Icons.error),
)
```
最后我们再来看看Flutter中的按钮控件。
## 按钮
通过按钮我们可以响应用户的交互事件。Flutter提供了三个基本的按钮控件即FloatingActionButton、FlatButton和RaisedButton。
- FloatingActionButton一个圆形的按钮一般出现在屏幕内容的前面用来处理界面中最常用、最基础的用户动作。在之前的第5篇文章“[从标准模板入手体会Flutter代码是如何运行在原生系统上的](https://time.geekbang.org/column/article/106199)”中,计数器示例的“+”悬浮按钮就是一个FloatingActionButton。
- RaisedButton凸起的按钮默认带有灰色背景被点击后灰色背景会加深。
- FlatButton扁平化的按钮默认透明背景被点击后会呈现灰色背景。
这三个按钮控件的使用方法类似,唯一的区别只是默认样式不同而已。
下述代码中我分别定义了FloatingActionButton、FlatButton与RaisedButton它们的功能完全一样在点击时打印一段文字
```
FloatingActionButton(onPressed: () =&gt; print('FloatingActionButton pressed'),child: Text('Btn'),);
FlatButton(onPressed: () =&gt; print('FlatButton pressed'),child: Text('Btn'),);
RaisedButton(onPressed: () =&gt; print('RaisedButton pressed'),child: Text('Btn'),);
```
<img src="https://static001.geekbang.org/resource/image/fb/ad/fbd51429fd339ebf715a0e0248270cad.png" alt="">
既然是按钮,因此除了控制基本样式之外,还需要响应用户点击行为。这就对应着按钮控件中的两个最重要的参数了:
- onPressed参数用于设置点击回调告诉Flutter在按钮被点击时通知我们。如果onPressed参数为空则按钮会处于禁用状态不响应用户点击。
- child参数用于设置按钮的内容告诉Flutter控件应该长成什么样也就是控制着按钮控件的基本样式。child可以接收任意的Widget比如我们在上面的例子中传入的Text除此之外我们还可以传入Image等控件。
虽然我们可以通过child参数来控制按钮控件的基本样式但是系统默认的样式还是太单调了。因此通常情况下我们还是会进行控件样式定制。
与Text控件类似按钮控件也提供了丰富的样式定制功能比如背景颜色color、按钮形状shape、主题颜色colorBrightness等等。
接下来我就以FlatButton为例与你介绍按钮的样式定制
```
FlatButton(
color: Colors.yellow, //设置背景色为黄色
shape:BeveledRectangleBorder(borderRadius: BorderRadius.circular(20.0)), //设置斜角矩形边框
colorBrightness: Brightness.light, //确保文字按钮为深色
onPressed: () =&gt; print('FlatButton pressed'),
child: Row(children: &lt;Widget&gt;[Icon(Icons.add), Text(&quot;Add&quot;)],)
)
```
可以看到我们将一个加号Icon与文本组合定义了按钮的基本外观随后通过shape来指定其外形为一个斜角矩形边框并将按钮的背景色设置为黄色。
因为按钮背景颜色是浅色的为避免按钮文字看不清楚我们通过设置按钮主题colorBrightness为Brightness.light保证按钮文字颜色为深色。
展示效果如下:
<img src="https://static001.geekbang.org/resource/image/9a/04/9ad900d3aaa384237aea4fc3205ca404.png" alt="">
## 总结
UI控件是构建一个视图的基本元素而文本、图片和按钮则是其中最经典的控件。
接下来,我们简单回顾一下今天的内容,以便加深理解与记忆。
首先我们认识了支持单一样式和混合样式两种类型的文本展示控件Text。其中通过TextStyle控制字符串的展示样式其他参数控制文本布局可以实现单一样式的文本展示而通过TextSpan将字符串分割为若干片段对每个片段单独设置样式后组装可以实现支持混合样式的富文本展示。
然后我带你学习了支持多种图片源加载方式的图片控件Image。Image内部通过ImageProvider根据缓存状态触发异步加载流程通知_ImageState刷新UI。不过由于图片缓存是内存缓存因此只在运行期间生效。如果要支持缓存到文件系统可以使用第三方的CachedNetworkImage。
最后我们学习了按钮控件。Flutter提供了多种按钮控件而它们的使用方法也都类似。其中控件初始化的child参数用于设置按钮长什么样而onPressed参数则用于设置点击回调。与Text类似按钮内部也有丰富的UI定制接口可以满足开发者的需求。
通过今天的学习我们可以发现在UI基本信息的表达上Flutter的经典控件与原生Android、iOS系统提供的控件没有什么本质区别。但是在自定义控件样式上Flutter的这些经典控件提供了强大而简洁的扩展能力使得我们可以快速开发出功能复杂、样式丰富的页面。
## 思考题
最后,我给你留下一道思考题吧。
请你打开IDE阅读Flutter SDK中Text、Image、FadeInImage以及按钮控件FloatingActionButton、FlatButton与RaisedButton的源码在build函数中找出在内部真正承载其视觉功能的控件。请和我分享下你在这一过程中发现了什么现象
欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,297 @@
<audio id="audio" title="13 | 经典控件UITableView/ListView在Flutter中是什么" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/76/4c/760c496fca059dda4e3941d02329614c.mp3"></audio>
你好,我是陈航。
在上一篇文章中我和你一起学习了文本、图片和按钮这3大经典组件在Flutter中的使用方法以及如何在实际开发中根据不同的场景去自定义展示样式。
文本、图片和按钮这些基本元素需要进行排列组合才能构成我们看到的UI视图。那么当这些基本元素的排列布局超过屏幕显示尺寸即超过一屏我们就需要引入列表控件来展示视图的完整内容并根据元素的多少进行自适应滚动展示。
这样的需求在Android中是由ListView或RecyclerView实现的在iOS中是用UITableView实现的而在Flutter中实现这种需求的则是列表控件ListView。
## ListView
在Flutter中ListView可以沿一个方向垂直或水平方向来排列其所有子Widget因此常被用于需要展示一组连续视图元素的场景比如通信录、优惠券、商家列表等。
我们先来看看ListView怎么用。**ListView提供了一个默认构造函数ListView**我们可以通过设置它的children参数很方便地将所有的子Widget包含到ListView中。
不过这种创建方式要求提前将所有子Widget一次性创建好而不是等到它们真正在屏幕上需要显示时才创建所以有一个很明显的缺点就是性能不好。因此**这种方式仅适用于列表中含有少量元素的场景**。
如下所示我定义了一组列表项组件并将它们放在了垂直滚动的ListView中
```
ListView(
children: &lt;Widget&gt;[
//设置ListTile组件的标题与图标
ListTile(leading: Icon(Icons.map), title: Text('Map')),
ListTile(leading: Icon(Icons.mail), title: Text('Mail')),
ListTile(leading: Icon(Icons.message), title: Text('Message')),
]);
```
>
<p>备注ListTile是Flutter提供的用于快速构建列表项元素的一个小组件单元用于1~3行leading、title、subtitle展示文本、图标等视图元素的场景通常与ListView配合使用。<br>
上面这段代码中用到ListTile是为了演示ListView的能力。关于ListTile的具体使用细节并不是本篇文章的重点如果你想深入了解的话可以参考[官方文档](https://api.flutter.dev/flutter/material/ListTile-class.html)。</p>
运行效果,如下图所示:
<img src="https://static001.geekbang.org/resource/image/b1/01/b152f47246c851c3c1878564f07de101.png" alt="">
除了默认的垂直方向布局外ListView还可以通过设置scrollDirection参数支持水平方向布局。如下所示我定义了一组不同颜色背景的组件将它们的宽度设置为140并包在了水平布局的ListView中让它们可以横向滚动
```
ListView(
scrollDirection: Axis.horizontal,
itemExtent: 140, //item延展尺寸(宽度)
children: &lt;Widget&gt;[
Container(color: Colors.black),
Container(color: Colors.red),
Container(color: Colors.blue),
Container(color: Colors.green),
Container(color: Colors.yellow),
Container(color: Colors.orange),
]);
```
运行效果,如下图所示:
<img src="https://static001.geekbang.org/resource/image/df/ac/df382224daeca7067d3a9c5acc5febac.gif" alt="">
在这个例子中我们一次性创建了6个子Widget。但从图2的运行效果可以看到由于屏幕的宽高有限同一时间用户只能看到3个Widget。也就是说是否一次性提前构建出所有要展示的子Widget与用户而言并没有什么视觉上的差异。
所以考虑到创建子Widget产生的性能问题更好的方法是抽象出创建子Widget的方法交由ListView统一管理在真正需要展示该子Widget时再去创建。
**ListView的另一个构造函数ListView.builder则适用于子Widget比较多的场景**。这个构造函数有两个关键参数:
- itemBuilder是列表项的创建方法。当列表滚动到相应位置时ListView会调用该方法创建对应的子Widget。
- itemCount表示列表项的数量如果为空则表示ListView为无限列表。
同样地我通过一个案例与你说明itemBuilder与itemCount这两个参数的具体用法。
我定义了一个拥有100个列表元素的ListView在列表项的创建方法中分别将index的值设置为ListTile的标题与子标题。比如第一行列表项会展示title 0 body 0
```
ListView.builder(
itemCount: 100, //元素个数
itemExtent: 50.0, //列表项高度
itemBuilder: (BuildContext context, int index) =&gt; ListTile(title: Text(&quot;title $index&quot;), subtitle: Text(&quot;body $index&quot;))
);
```
这里需要注意的是,**itemExtent并不是一个必填参数。但对于定高的列表项元素我强烈建议你提前设置好这个参数的值。**
因为如果这个参数为nullListView会动态地根据子Widget创建完成的结果决定自身的视图高度以及子Widget在ListView中的相对位置。在滚动发生变化而列表项又很多时这样的计算就会非常频繁。
但如果提前设置好itemExtentListView则可以提前计算好每一个列表项元素的相对位置以及自身的视图高度省去了无谓的计算。
因此在ListView中指定itemExtent比让子Widget自己决定自身高度会更高效。
运行这个示例,效果如下所示:
<img src="https://static001.geekbang.org/resource/image/d6/5a/d654c5a28056afc017fe3f085230745a.png" alt="">
可能你已经发现了我们的列表还缺少分割线。在ListView中有两种方式支持分割线
- 一种是在itemBuilder中根据index的值动态创建分割线也就是将分割线视为列表项的一部分
- 另一种是使用ListView的另一个构造方法ListView.separated单独设置分割线的样式。
第一种方式实际上是视图的组合,之前的分享中我们已经多次提及,对你来说应该已经比较熟悉了,这里我就不再过多地介绍了。接下来,我和你演示一下**如何使用ListView.separated设置分割线。**
与ListView.builder抽离出了子Widget的构建方法类似ListView.separated抽离出了分割线的创建方法separatorBuilder以便根据index设置不同样式的分割线。
如下所示我针对index为偶数的场景创建了绿色的分割线而针对index为奇数的场景创建了红色的分割线
```
//使用ListView.separated设置分割线
ListView.separated(
itemCount: 100,
separatorBuilder: (BuildContext context, int index) =&gt; index %2 ==0? Divider(color: Colors.green) : Divider(color: Colors.red),//index为偶数创建绿色分割线index为奇数则创建红色分割线
itemBuilder: (BuildContext context, int index) =&gt; ListTile(title: Text(&quot;title $index&quot;), subtitle: Text(&quot;body $index&quot;))//创建子Widget
)
```
运行效果,如下所示:
<img src="https://static001.geekbang.org/resource/image/5e/3b/5e1ef0977150346fa95f23232d628e3b.png" alt="">
好了我已经与你分享完了ListView的常见构造函数。接下来我准备了一张表格总结了ListView常见的构造方法及其适用场景供你参考以便理解与记忆
<img src="https://static001.geekbang.org/resource/image/00/18/00e6c9f8724fcf50757b4a76fa4c9b18.png" alt="">
## CustomScrollView
好了ListView实现了单一视图下可滚动Widget的交互模型同时也包含了UI显示相关的控制逻辑和布局模型。但是对于某些特殊交互场景比如多个效果联动、嵌套滚动、精细滑动、视图跟随手势操作等还需要嵌套多个ListView来实现。这时各自视图的滚动和布局模型就是相互独立、分离的就很难保证整个页面统一一致的滑动效果。
那么,**Flutter是如何解决多ListView嵌套时页面滑动效果不一致的问题的呢**
在Flutter中有一个专门的控件CustomScrollView用来处理多个需要自定义滚动效果的Widget。在CustomScrollView中**这些彼此独立的、可滚动的Widget被统称为Sliver**。
比如ListView的Sliver实现为SliverListAppBar的Sliver实现为SliverAppBar。这些Sliver不再维护各自的滚动状态而是交由CustomScrollView统一管理最终实现滑动效果的一致性。
接下来我通过一个滚动视差的例子与你演示CustomScrollView的使用方法。
**视差滚动**是指让多层背景以不同的速度移动,在形成立体滚动效果的同时,还能保证良好的视觉体验。 作为移动应用交互设计的热点趋势,越来越多的移动应用使用了这项技术。
以一个有着封面头图的列表为例,我们希望封面头图和列表这两层视图的滚动联动起来,当用户滚动列表时,头图会根据用户的滚动手势,进行缩小和展开。
经分析得出要实现这样的需求我们需要两个Sliver作为头图的SliverAppBar与作为列表的SliverList。具体的实现思路是
- 在创建SliverAppBar时把flexibleSpace参数设置为悬浮头图背景。flexibleSpace可以让背景图显示在AppBar下方高度和SliverAppBar一样
- 而在创建SliverList时通过SliverChildBuilderDelegate参数实现列表项元素的创建
- 最后将它们一并交由CustomScrollView的slivers参数统一管理。
具体的示例代码如下所示:
```
CustomScrollView(
slivers: &lt;Widget&gt;[
SliverAppBar(//SliverAppBar作为头图控件
title: Text('CustomScrollView Demo'),//标题
floating: true,//设置悬浮样式
flexibleSpace: Image.network(&quot;https://xx.jpg&quot;,fit:BoxFit.cover),//设置悬浮头图背景
expandedHeight: 300,//头图控件高度
),
SliverList(//SliverList作为列表控件
delegate: SliverChildBuilderDelegate(
(context, index) =&gt; ListTile(title: Text('Item #$index')),//列表项创建方法
childCount: 100,//列表元素个数
),
),
]);
```
运行一下,视差滚动效果如下所示:
<img src="https://static001.geekbang.org/resource/image/dc/21/dcf89204e537f828d197dc3b916ca321.gif" alt="">
## ScrollController与ScrollNotification
现在,你应该已经知道如何实现滚动视图的视觉和交互效果了。接下来,我再与你分享一个更为复杂的问题:在某些情况下,我们希望获取视图的滚动信息,并进行相应的控制。比如,列表是否已经滑到底(顶)了?如何快速回到列表顶部?列表滚动是否已经开始,或者是否已经停下来了?
对于前两个问题我们可以使用ScrollController进行滚动信息的监听以及相应的滚动控制而最后一个问题则需要接收ScrollNotification通知进行滚动事件的获取。下面我将分别与你介绍。
在Flutter中因为Widget并不是渲染到屏幕的最终视觉元素RenderObject才是所以我们无法像原生的Android或iOS系统那样向持有的Widget对象获取或设置最终渲染相关的视觉信息而必须通过对应的组件控制器才能实现。
ListView的组件控制器则是ScrollControler我们可以通过它来获取视图的滚动信息更新视图的滚动位置。
一般而言获取视图的滚动信息往往是为了进行界面的状态控制因此ScrollController的初始化、监听及销毁需要与StatefulWidget的状态保持同步。
如下代码所示我们声明了一个有着100个元素的列表项当滚动视图到特定位置后用户可以点击按钮返回列表顶部
- 首先我们在State的初始化方法里创建了ScrollController并通过_controller.addListener注册了滚动监听方法回调根据当前视图的滚动位置判断当前是否需要展示“Top”按钮。
- 随后在视图构建方法build中我们将ScrollController对象与ListView进行了关联并且在RaisedButton中注册了对应的回调方法可以在点击按钮时通过_controller.animateTo方法返回列表顶部。
- 最后在State的销毁方法中我们对ScrollController进行了资源释放。
```
class MyAPPState extends State&lt;MyApp&gt; {
ScrollController _controller;//ListView控制器
bool isToTop = false;//标示目前是否需要启用&quot;Top&quot;按钮
@override
void initState() {
_controller = ScrollController();
_controller.addListener(() {//为控制器注册滚动监听方法
if(_controller.offset &gt; 1000) {//如果ListView已经向下滚动了1000则启用Top按钮
setState(() {isToTop = true;});
} else if(_controller.offset &lt; 300) {//如果ListView向下滚动距离不足300则禁用Top按钮
setState(() {isToTop = false;});
}
});
super.initState();
}
Widget build(BuildContext context) {
return MaterialApp(
...
//顶部Top按钮根据isToTop变量判断是否需要注册滚动到顶部的方法
RaisedButton(onPressed: (isToTop ? () {
if(isToTop) {
_controller.animateTo(.0,
duration: Duration(milliseconds: 200),
curve: Curves.ease
);//做一个滚动到顶部的动画
}
}:null),child: Text(&quot;Top&quot;),)
...
ListView.builder(
controller: _controller,//初始化传入控制器
itemCount: 100,//列表元素总数
itemBuilder: (context, index) =&gt; ListTile(title: Text(&quot;Index : $index&quot;)),//列表项构造方法
)
...
);
@override
void dispose() {
_controller.dispose(); //销毁控制器
super.dispose();
}
}
```
ScrollController的运行效果如下所示
<img src="https://static001.geekbang.org/resource/image/61/1b/61533dc0e445bd529879698ad3491b1b.gif" alt="">
介绍完了如何通过ScrollController来监听ListView滚动信息以及怎样进行滚动控制之后接下来我们再看看**如何获取ScrollNotification通知从而感知ListView的各类滚动事件**。
在Flutter中ScrollNotification通知的获取是通过NotificationListener来实现的。与ScrollController不同的是NotificationListener是一个Widget为了监听滚动类型的事件我们需要将NotificationListener添加为ListView的父容器从而捕获ListView中的通知。而这些通知需要通过onNotification回调函数实现监听逻辑
```
Widget build(BuildContext context) {
return MaterialApp(
title: 'ScrollController Demo',
home: Scaffold(
appBar: AppBar(title: Text('ScrollController Demo')),
body: NotificationListener&lt;ScrollNotification&gt;(//添加NotificationListener作为父容器
onNotification: (scrollNotification) {//注册通知回调
if (scrollNotification is ScrollStartNotification) {//滚动开始
print('Scroll Start');
} else if (scrollNotification is ScrollUpdateNotification) {//滚动位置更新
print('Scroll Update');
} else if (scrollNotification is ScrollEndNotification) {//滚动结束
print('Scroll End');
}
},
child: ListView.builder(
itemCount: 30,//列表元素个数
itemBuilder: (context, index) =&gt; ListTile(title: Text(&quot;Index : $index&quot;)),//列表项创建方法
),
)
)
);
}
```
相比于ScrollController只能和具体的ListView关联后才可以监听到滚动信息通过NotificationListener则可以监听其子Widget中的任意ListView不仅可以得到这些ListView的当前滚动位置信息还可以获取当前的滚动事件信息 。
## 总结
在处理用于展示一组连续、可滚动的视图元素的场景Flutter提供了比原生Android、iOS系统更加强大的列表组件ListView与CustomScrollView不仅可以支持单一视图下可滚动Widget的交互模型及UI控制模型对于某些特殊交互需要嵌套多重可滚动Widget的场景也提供了统一管理的机制最终实现体验一致的滑动效果。这些强大的组件使得我们不仅可以开发出样式丰富的界面更可以实现复杂的交互。
接下来,我们简单回顾一下今天的内容,以便加深你的理解与记忆。
首先我们认识了ListView组件。它同时支持垂直方向和水平方向滚动不仅提供了少量一次性创建子视图的默认构造方式也提供了大量按需创建子视图的ListView.builder机制并且支持自定义分割线。为了节省性能对于定高的列表项视图提前指定itemExtent比让子Widget自己决定要更高效。
随后我带你学习了CustomScrollView组件。它引入了Sliver的概念将多重嵌套的可滚动视图的交互与布局进行统一接管使得像视差滚动这样的高级交互变得更加容易。
最后我们学习了ScrollController与NotificationListener前者与ListView绑定进行滚动信息的监听进行相应的滚动控制而后者通过将ListView纳入子Widget实现滚动事件的获取。
我把今天分享讲的三个例子视差、ScrollController、ScrollNotification放到了[GitHub](https://github.com/cyndibaby905/13_listview_demo)上你可以下载后在工程中实际运行并对照着今天的知识点进行学习体会ListView的一些高级用法。
## 思考题
最后,我给你留下两个小作业吧:
1. 在ListView.builder方法中ListView根据Widget是否将要出现在可视区域内按需创建。对于一些场景为了避免Widget渲染时间过长比如图片下载我们需要提前将可视区域上下一定区域内的Widget提前创建好。那么在Flutter中如何才能实现呢
1. 请你使用NotificationListener来实现图7 ScrollController示例中同样的功能。
欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,249 @@
<audio id="audio" title="14 | 经典布局:如何定义子控件在父容器中排版的位置?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/c1/47/c1c75807f99f8ce3e716aefd54dd2247.mp3"></audio>
你好,我是陈航。
在前面两篇文章中我们一起学习了构建视图的基本元素文本、图片和按钮用于展示一组连续视图元素的ListView以及处理多重嵌套的可滚动视图的CustomScrollView。
在Flutter中一个完整的界面通常就是由这些小型、单用途的基本控件元素依据特定的布局规则堆砌而成的。那么今天我就带你一起学习一下在Flutter中搭建出一个漂亮的布局我们需要了解哪些布局规则以及这些规则与其他平台类似概念的差别在哪里。希望这样的设计可以帮助你站在已有经验的基础上去高效学习Flutter的布局规则。
我们已经知道在Flutter中一切皆Widget那么布局也不例外。但与基本控件元素不同布局类的Widget并不会直接呈现视觉内容而是作为承载其他子Widget的容器。
这些布局类的Widget内部都会包含一个或多个子控件并且都提供了摆放子控件的不同布局方式可以实现子控件的对齐、嵌套、层叠和缩放等。而我们要做的就是通过各种定制化的参数将其内部的子Widget依照自己的布局规则放置在特定的位置上最终形成一个漂亮的布局。
Flutter提供了31种[布局Widget](https://flutter.dev/docs/development/ui/widgets/layout)对布局控件的划分非常详细一些相同或相似的视觉效果可以通过多种布局控件实现因此布局类型相比原生Android、iOS平台多了不少。比如Android布局一般就只有FrameLayout、LinearLayout、RelativeLayout、GridLayout和TableLayout这5种而iOS的布局更少只有Frame布局和自动布局两种。
为了帮你建立起对布局类Widget的认知了解基本布局类Widget的布局特点和用法从而学以致用快速上手开发在今天的这篇文章中我特意挑选了几类在开发Flutter应用时最常用也最有代表性的布局Widget包括单子Widget布局、多子Widget布局、层叠Widget布局与你展开介绍。
掌握了这些典型的Widget你也就基本掌握了构建一个界面精美的App所需要的全部布局方式了。接下来我们就先从单子Widget布局聊起吧。
## 单子Widget布局Container、Padding与Center
单子Widget布局类容器比较简单一般用来对其唯一的子Widget进行样式包装比如限制大小、添加背景色样式、内间距、旋转变换等。这一类布局Widget包括Container、Padding与Center三种。
Container是一种允许在其内部添加其他控件的控件也是UI框架中的一个常见概念。
在Flutter中Container本身可以单独作为控件存在比如单独设置背景色、宽高也可以作为其他控件的父级存在Container可以定义布局过程中子Widget如何摆放以及如何展示。与其他框架不同的是**Flutter的Container仅能包含一个子Widget**。
所以对于多个子Widget的布局场景我们通常会这样处理先用一个根Widget去包装这些子Widget然后把这个根Widget放到Container中再由Container设置它的对齐alignment、边距padding等基础属性和样式属性。
接下来我通过一个示例与你演示如何定义一个Container。
在这个示例中我将一段较长的文字包装在一个红色背景、圆角边框的、固定宽高的Container中并分别设置了Container的外边距距离其父Widget的边距和内边距距离其子Widget的边距
```
Container(
child: Text('Container容器在UI框架中是一个很常见的概念Flutter也不例外。'),
padding: EdgeInsets.all(18.0), // 内边距
margin: EdgeInsets.all(44.0), // 外边距
width: 180.0,
height:240,
alignment: Alignment.center, // 子Widget居中对齐
decoration: BoxDecoration( //Container样式
color: Colors.red, // 背景色
borderRadius: BorderRadius.circular(10.0), // 圆角边框
),
)
```
<img src="https://static001.geekbang.org/resource/image/fa/f7/fad72eb6917be0062df5a46a104f3ff7.png" alt="">
如果我们只需要将子Widget设定间距则可以使用另一个单子容器控件Padding进行内容填充
```
Padding(
padding: EdgeInsets.all(44.0),
child: Text('Container容器在UI框架中是一个很常见的概念Flutter也不例外。'),
);
```
<img src="https://static001.geekbang.org/resource/image/24/ae/24a18be98054d93ddf8989aac1a5a5ae.png" alt="">
在需要设置内容间距时我们可以通过EdgeInsets的不同构造函数分别制定四个方向的不同补白方式如均使用同样数值留白、只设置左留白或对称方向留白等。如果你想更深入地了解这部分内容可以参考这个[API文档](https://api.flutter.dev/flutter/painting/EdgeInsets-class.html#constructors)。
接下来我们再来看看单子Widget布局容器中另一个常用的容器Center。正如它的名字一样Center会将其子Widget居中排列。
比如我们可以把一个Text包在Center里实现居中展示
```
Scaffold(
body: Center(child: Text(&quot;Hello&quot;)) // This trailing comma makes auto-formatting nicer for build methods.
);
```
<img src="https://static001.geekbang.org/resource/image/c5/65/c500408d7df10249494a2a90cb815a65.png" alt="">
需要注意的是为了实现居中布局Center所占据的空间一定要比其子Widget要大才行这也是显而易见的如果Center和其子Widget一样大自然就不需要居中也没空间居中了。因此Center通常会结合Container一起使用。
现在我们结合Container一起看看Center的具体使用方法吧。
```
Container(
child: Center(child: Text('Container容器在UI框架中是一个很常见的概念Flutter也不例外。')),
padding: EdgeInsets.all(18.0), // 内边距
margin: EdgeInsets.all(44.0), // 外边距
width: 180.0,
height:240,
decoration: BoxDecoration( //Container样式
color: Colors.red, // 背景色
borderRadius: BorderRadius.circular(10.0), // 圆角边框
),
);
```
可以看到我们通过Center容器实现了Container容器中**alignment: Alignment.center**的效果。
事实上为了达到这一效果Container容器与Center容器底层都依赖了同一个容器Align通过它实现子Widget的对齐方式。Align的使用也比较简单如果你想深入了解的话可以参考[官方文档](https://api.flutter.dev/flutter/widgets/Align-class.html),这里我就不再过多介绍了。
接下来我们再看看多子Widget布局的三种方式即Row、Column与Expanded。
## 多子Widget布局Row、Column与Expanded
对于拥有多个子Widget的布局类容器而言其布局行为无非就是两种规则的抽象水平方向上应该如何布局、垂直方向上应该如何布局。
如同Android的LinearLayout、前端的Flex布局一样Flutter中也有类似的概念即将子Widget按行水平排列的Row按列垂直排列的Column以及负责分配这些子Widget在布局方向行/列中剩余空间的Expanded。
Row与Column的使用方法很简单我们只需要将各个子Widget按序加入到children数组即可。在下面的代码中我们把4个分别设置了不同的颜色和宽高的Container加到Row与Column中
```
//Row的用法示范
Row(
children: &lt;Widget&gt;[
Container(color: Colors.yellow, width: 60, height: 80,),
Container(color: Colors.red, width: 100, height: 180,),
Container(color: Colors.black, width: 60, height: 80,),
Container(color: Colors.green, width: 60, height: 80,),
],
);
//Column的用法示范
Column(
children: &lt;Widget&gt;[
Container(color: Colors.yellow, width: 60, height: 80,),
Container(color: Colors.red, width: 100, height: 180,),
Container(color: Colors.black, width: 60, height: 80,),
Container(color: Colors.green, width: 60, height: 80,),
],
);
```
<img src="https://static001.geekbang.org/resource/image/90/72/909ad17a65cad573bca0c84c09b7fc72.png" alt="">
<img src="https://static001.geekbang.org/resource/image/9a/86/9a1bd0067d1bbb03d5ab74f411afae86.png" alt="">
可以看到单纯使用Row和Column控件在子Widget的尺寸较小时无法将容器填满视觉样式比较难看。对于这样的场景我们可以通过Expanded控件来制定分配规则填满容器的剩余空间。
比如我们希望Row组件或Column组件中的绿色容器与黄色容器均分剩下的空间于是就可以设置它们的弹性系数参数flex都为1这两个Expanded会按照其flex的比例即11来分割剩余的Row横向Column纵向空间
```
Row(
children: &lt;Widget&gt;[
Expanded(flex: 1, child: Container(color: Colors.yellow, height: 60)), //设置了flex=1因此宽度由Expanded来分配
Container(color: Colors.red, width: 100, height: 180,),
Container(color: Colors.black, width: 60, height: 80,),
Expanded(flex: 1, child: Container(color: Colors.green,height: 60),)/设置了flex=1因此宽度由Expanded来分配
],
);
```
<img src="https://static001.geekbang.org/resource/image/0e/04/0ed3fba81215150607edddaafbe9b304.png" alt="">
于Row与Column而言Flutter提供了依据坐标轴的布局对齐行为即根据布局方向划分出主轴和纵轴主轴表示容器依次摆放子Widget的方向纵轴则是与主轴垂直的另一个方向。
<img src="https://static001.geekbang.org/resource/image/61/09/610157c35f4457a7fffa2005ea144609.png" alt="">
我们可以根据主轴与纵轴设置子Widget在这两个方向上的对齐规则mainAxisAlignment与crossAxisAlignment。比如主轴方向start表示靠左对齐、center表示横向居中对齐、end表示靠右对齐、spaceEvenly表示按固定间距对齐而纵轴方向start则表示靠上对齐、center表示纵向居中对齐、end表示靠下对齐。
下图展示了在Row中设置不同方向的对齐规则后的呈现效果
<img src="https://static001.geekbang.org/resource/image/9f/87/9f3a8a9e197b350f6c0aad6f5195fc87.png" alt="">
<img src="https://static001.geekbang.org/resource/image/d8/9b/d8fc6d0aa98be8a6b1867b24a833b89b.png" alt="">
Column的对齐方式也是类似的我就不再过多展开了。
这里需要注意的是对于主轴而言Flutter默认是让父容器决定其长度即尽可能大类似Android中的match_parent。
在上面的例子中Row的宽度为屏幕宽度Column的高度为屏幕高度。主轴长度大于所有子Widget的总长度意味着容器在主轴方向的空间比子Widget要大这也是我们能通过主轴对齐方式设置子Widget布局效果的原因。
如果想让容器与子Widget在主轴上完全匹配我们可以通过设置Row的mainAxisSize参数为MainAxisSize.min由所有子Widget来决定主轴方向的容器长度即主轴方向的长度尽可能小类似Android中的wrap_content
```
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly, //由于容器与子Widget一样宽因此这行设置排列间距的代码并未起作用
mainAxisSize: MainAxisSize.min, //让容器宽度与所有子Widget的宽度一致
children: &lt;Widget&gt;[
Container(color: Colors.yellow, width: 60, height: 80,),
Container(color: Colors.red, width: 100, height: 180,),
Container(color: Colors.black, width: 60, height: 80,),
Container(color: Colors.green, width: 60, height: 80,),
],
)
```
<img src="https://static001.geekbang.org/resource/image/d8/96/d8d9cc480386bd11e60da51ddc081696.png" alt="">
可以看到我们设置了主轴大小为MainAxisSize.min之后Row的宽度变得和其子Widget一样大因此再设置主轴的对齐方式也就不起作用了。
## 层叠Widget布局Stack与Positioned
有些时候我们需要让一个控件叠加在另一个控件的上面比如在一张图片上放置一段文字又或者是在图片的某个区域放置一个按钮。这时候我们就需要用到层叠布局容器Stack了。
Stack容器与前端中的绝对定位、Android中的Frame布局非常类似子Widget之间允许叠加还可以根据父容器上、下、左、右四个角的位置来确定自己的位置。
**Stack提供了层叠布局的容器而Positioned则提供了设置子Widget位置的能力**。接下来我们就通过一个例子来看一下Stack和Positioned的具体用法吧。
在这个例子中我先在Stack中放置了一块300**300的黄色画布随后在(18,18)处放置了一个50**50的绿色控件然后在(18,70)处放置了一个文本控件。
```
Stack(
children: &lt;Widget&gt;[
Container(color: Colors.yellow, width: 300, height: 300),//黄色容器
Positioned(
left: 18.0,
top: 18.0,
child: Container(color: Colors.green, width: 50, height: 50),//叠加在黄色容器之上的绿色控件
),
Positioned(
left: 18.0,
top:70.0,
child: Text(&quot;Stack提供了层叠布局的容器&quot;),//叠加在黄色容器之上的文本
)
],
)
```
试着运行一下可以看到这三个子Widget都按照我们预定的规则叠加在一起了。
<img src="https://static001.geekbang.org/resource/image/bb/36/bb046cc53ea595a02a564a4387a99c36.png" alt="">
Stack控件允许其子Widget按照创建的先后顺序进行层叠摆放而Positioned控件则用来控制这些子Widget的摆放位置。需要注意的是Positioned控件只能在Stack中使用在其他容器中使用会报错。
## 总结
Flutter的布局容器强大而丰富可以将小型、单用途的基本视觉元素快速封装成控件。今天我选取了Flutter中最具代表性也最常用的几类布局Widget与你介绍了构建一个界面精美的App所需要的布局概念。
接下来,我们简单回顾一下今天的内容,以便加深理解与记忆:
首先我们认识了单子容器Container、Padding与Center。其中Container内部提供了间距、背景样式等基础属性为子Widget的摆放方式及展现样式都提供了定制能力。而Padding与Center提供的功能则正如其名一样简洁就是对齐与居中。
然后我们深入学习了多子Widget布局中的Row和Column各子Widget间对齐的规则以及容器自身扩充的规则以及如何通过Expanded控件使用容器内部的剩余空间
最后我们学习了层叠布局Stack以及与之搭配使用的定位子Widget位置的Positioned容器你可以通过它们实现多个控件堆放的布局效果。
通过今天的文章相信你已经对如何搭建App的界面有了足够的知识储备所以在下一篇文章中我会通过一些实际的例子带你认识在Flutter中如何通过这些基本控件与布局规则实现好看的界面。
## 思考题
最后,我给你留下一道思考题吧。
Row与Column自身的大小是如何决定的当它们嵌套时又会出现怎样的情况呢
欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,262 @@
<audio id="audio" title="15 | 组合与自绘我该选用何种方式自定义Widget" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/86/f6/86409969f6b6747da9bb2cd6730aabf6.mp3"></audio>
你好,我是陈航。
在上一次分享中我们认识了Flutter中最常用也最经典的布局Widget即单子容器Container、多子容器Row/Column以及层叠容器Stack与Positioned也学习了这些不同容器之间的摆放子Widget的布局规则我们可以通过它们来实现子控件的对齐、嵌套、层叠等它们也是构建一个界面精美的App所必须的布局概念。
在实际开发中我们会经常遇到一些复杂的UI需求往往无法通过使用Flutter的基本Widget通过设置其属性参数来满足。这个时候我们就需要针对特定的场景自定义Widget了。
在Flutter中自定义Widget与其他平台类似可以使用基本Widget组装成一个高级别的Widget也可以自己在画板上根据特殊需求来画界面。
接下来我会分别与你介绍组合和自绘这两种自定义Widget的方式。
## 组装
使用组合的方式自定义Widget即通过我们之前介绍的布局方式摆放项目所需要的基础Widget并在控件内部设置这些基础Widget的样式从而组合成一个更高级的控件。
这种方式对外暴露的接口比较少减少了上层使用成本但也因此增强了控件的复用性。在Flutter中**组合的思想始终贯穿在框架设计之中**这也是Flutter提供了如此丰富的控件库的原因之一。
比如在新闻类应用中我们经常需要将新闻Icon、标题、简介与日期组合成一个单独的控件作为一个整体去响应用户的点击事件。面对这类需求我们可以把现有的Image、Text及各类布局组合成一个更高级的新闻Item控件对外暴露设置model和点击回调的属性即可。
接下来,我通过一个例子为你说明如何通过组装去自定义控件。
下图是App Store的升级项UI示意图图里的每一项都有应用Icon、名称、更新日期、更新简介、应用版本、应用大小以及更新/打开按钮。可以看到这里面的UI元素还是相对较多的现在我们希望将升级项UI封装成一个单独的控件节省使用成本以及后续的维护成本。
<img src="https://static001.geekbang.org/resource/image/01/cc/0157ffe54a9cd933795af6c8d7141ecc.png" alt="">
在分析这个升级项UI的整体结构之前我们先定义一个数据结构UpdateItemModel来存储升级信息。在这里为了方便讨论我把所有的属性都定义为了字符串类型你在实际使用中可以根据需要将属性定义得更规范比如将appDate定义为DateTime类型
```
class UpdateItemModel {
String appIcon;//App图标
String appName;//App名称
String appSize;//App大小
String appDate;//App更新日期
String appDescription;//App更新文案
String appVersion;//App版本
//构造函数语法糖,为属性赋值
UpdateItemModel({this.appIcon, this.appName, this.appSize, this.appDate, this.appDescription, this.appVersion});
}
```
接下来我以Google Map为例和你一起分析下这个升级项UI的整体结构。
按照子Widget的摆放方向布局方式只有水平和垂直两种因此我们也按照这两个维度对UI结构进行拆解。
按垂直方向我们用绿色的框把这个UI拆解为上半部分与下半部分如图2所示。下半部分比较简单是两个文本控件的组合上半部分稍微复杂一点我们先将其包装为一个水平布局的Row控件。
接下来,我们再一起看看水平方向应该如何布局。
<img src="https://static001.geekbang.org/resource/image/dd/21/dd6241906557f49e184a5dc16d33e521.png" alt="">
我们先把升级项的上半部分拆解成对应的UI元素
- 左边的应用图标拆解为Image
- 右边的按钮拆解为FlatButton
- 中间部分是两个文本在垂直方向上的组合因此拆解为ColumnColumn内部则是两个Text。
拆解示意图,如下所示:
<img src="https://static001.geekbang.org/resource/image/29/0d/29c1762d9c6271049c9149b5ab06bb0d.png" alt="">
通过与拆解前的UI对比你就会发现还有3个问题待解决即控件间的边距如何设置、中间部分的伸缩截断规则又是怎样、图片圆角怎么实现。接下来我们分别来看看。
Image、FlatButton以及Column这三个控件与父容器Row之间存在一定的间距因此我们还需要在最左边的Image与最右边的FlatButton上包装一层Padding用以留白填充。
另一方面,考虑到需要适配不同尺寸的屏幕,中间部分的两个文本应该是变长可伸缩的,但也不能无限制地伸缩,太长了还是需要截断的,否则就会挤压到右边按钮的固定空间了。
因此我们需要在Column的外层用Expanded控件再包装一层让Image与FlatButton之间的空间全留给Column。不过通常情况下这两个文本并不能完全填满中间的空间因此我们还需要设置对齐格式按照垂直方向上居中水平方向上居左的方式排列。
最后一项需要注意的是升级项UI的App Icon是圆角的但普通的Image并不支持圆角。这时我们可以使用ClipRRect控件来解决这个问题。ClipRRect可以将其子Widget按照圆角矩形的规则进行裁剪所以用ClipRRect将Image包装起来就可以实现图片圆角的功能了。
下面的代码,就是控件上半部分的关键代码:
```
Widget buildTopRow(BuildContext context) {
return Row(//Row控件用来水平摆放子Widget
children: &lt;Widget&gt;[
Padding(//Paddng控件用来设置Image控件边距
padding: EdgeInsets.all(10),//上下左右边距均为10
child: ClipRRect(//圆角矩形裁剪控件
borderRadius: BorderRadius.circular(8.0),//圆角半径为8
child: Image.asset(model.appIcon, width: 80,height:80)图片控件//
)
),
Expanded(//Expanded控件用来拉伸中间区域
child: Column(//Column控件用来垂直摆放子Widget
mainAxisAlignment: MainAxisAlignment.center,//垂直方向居中对齐
crossAxisAlignment: CrossAxisAlignment.start,//水平方向居左对齐
children: &lt;Widget&gt;[
Text(model.appName,maxLines: 1),//App名字
Text(model.appDate,maxLines: 1),//App更新日期
],
),
),
Padding(//Paddng控件用来设置Widget间边距
padding: EdgeInsets.fromLTRB(0,0,10,0),//右边距为10其余均为0
child: FlatButton(//按钮控件
child: Text(&quot;OPEN&quot;),
onPressed: onPressed,//点击回调
)
)
]);
}
```
升级项UI的下半部分比较简单是两个文本控件的组合。与上半部分的拆解类似我们用一个Column控件将它俩装起来如图4所示
<img src="https://static001.geekbang.org/resource/image/7d/3d/7da3ec3d2068550fc20481ae3457173d.png" alt="">
与上半部分类似这两个文本与父容器之间存在些间距因此在Column的最外层还需要用Padding控件给包装起来设置父容器间距。
另一方面Column的两个文本控件间也存在间距因此我们仍然使用Padding控件将下面的文本包装起来单独设置这两个文本之间的间距。
同样地,通常情况下这两个文本并不能完全填满下部空间,因此我们还需要设置对齐格式,即按照水平方向上居左的方式对齐。
控件下半部分的关键代码如下所示:
```
Widget buildBottomRow(BuildContext context) {
return Padding(//Padding控件用来设置整体边距
padding: EdgeInsets.fromLTRB(15,0,15,0),//左边距和右边距为15
child: Column(//Column控件用来垂直摆放子Widget
crossAxisAlignment: CrossAxisAlignment.start,//水平方向距左对齐
children: &lt;Widget&gt;[
Text(model.appDescription),//更新文案
Padding(//Padding控件用来设置边距
padding: EdgeInsets.fromLTRB(0,10,0,0),//上边距为10
child: Text(&quot;${model.appVersion} • ${model.appSize} MB&quot;)
)
]
));
}
```
最后我们将上下两部分控件通过Column包装起来这次升级项UI定制就完成了
```
class UpdatedItem extends StatelessWidget {
final UpdatedItemModel model;//数据模型
//构造函数语法糖用来给model赋值
UpdatedItem({Key key,this.model, this.onPressed}) : super(key: key);
final VoidCallback onPressed;
@override
Widget build(BuildContext context) {
return Column(//用Column将上下两部分合体
children: &lt;Widget&gt;[
buildTopRow(context),//上半部分
buildBottomRow(context)//下半部分
]);
}
Widget buildBottomRow(BuildContext context) {...}
Widget buildTopRow(BuildContext context) {...}
}
```
试着运行一下,效果如下所示:
<img src="https://static001.geekbang.org/resource/image/87/66/8737980f8b42caf33b77197a7a165f66.png" alt="">
搞定!
**按照从上到下、从左到右去拆解UI的布局结构把复杂的UI分解成各个小UI元素在以组装的方式去自定义UI中非常有用请一定记住这样的拆解方法。**
## 自绘
Flutter提供了非常丰富的控件和布局方式使得我们可以通过组合去构建一个新的视图。但对于一些不规则的视图用SDK提供的现有Widget组合可能无法实现比如饼图k线图等这个时候我们就需要自己用画笔去绘制了。
在原生iOS和Android开发中我们可以继承UIView/View在drawRect/onDraw方法里进行绘制操作。其实在Flutter中也有类似的方案那就是CustomPaint。
**CustomPaint是用以承接自绘控件的容器并不负责真正的绘制**。既然是绘制,那就需要用到画布与画笔。
在Flutter中画布是Canvas画笔则是Paint而画成什么样子则由定义了绘制逻辑的CustomPainter来控制。将CustomPainter设置给容器CustomPaint的painter属性我们就完成了一个自绘控件的封装。
对于画笔Paint我们可以配置它的各种属性比如颜色、样式、粗细等而画布Canvas则提供了各种常见的绘制方法比如画线drawLine、画矩形drawRect、画点DrawPoint、画路径drawPath、画圆drawCircle、画圆弧drawArc等。
这样我们就可以在CustomPainter的paint方法里通过Canvas与Paint的配合实现定制化的绘制逻辑。
接下来,我们看一个例子。
在下面的代码中我们继承了CustomPainter在定义了绘制逻辑的paint方法中通过Canvas的drawArc方法用6种不同颜色的画笔依次画了6个1/6圆弧拼成了一张饼图。最后我们使用CustomPaint容器将painter进行封装就完成了饼图控件Cake的定义。
```
class WheelPainter extends CustomPainter {
// 设置画笔颜色
Paint getColoredPaint(Color color) {//根据颜色返回不同的画笔
Paint paint = Paint();//生成画笔
paint.color = color;//设置画笔颜色
return paint;
}
@override
void paint(Canvas canvas, Size size) {//绘制逻辑
double wheelSize = min(size.width,size.height)/2;//饼图的尺寸
double nbElem = 6;//分成6份
double radius = (2 * pi) / nbElem;//1/6圆
//包裹饼图这个圆形的矩形框
Rect boundingRect = Rect.fromCircle(center: Offset(wheelSize, wheelSize), radius: wheelSize);
// 每次画1/6个圆弧
canvas.drawArc(boundingRect, 0, radius, true, getColoredPaint(Colors.orange));
canvas.drawArc(boundingRect, radius, radius, true, getColoredPaint(Colors.black38));
canvas.drawArc(boundingRect, radius * 2, radius, true, getColoredPaint(Colors.green));
canvas.drawArc(boundingRect, radius * 3, radius, true, getColoredPaint(Colors.red));
canvas.drawArc(boundingRect, radius * 4, radius, true, getColoredPaint(Colors.blue));
canvas.drawArc(boundingRect, radius * 5, radius, true, getColoredPaint(Colors.pink));
}
// 判断是否需要重绘,这里我们简单的做下比较即可
@override
bool shouldRepaint(CustomPainter oldDelegate) =&gt; oldDelegate != this;
}
//将饼图包装成一个新的控件
class Cake extends StatelessWidget {
@override
Widget build(BuildContext context) {
return CustomPaint(
size: Size(200, 200),
painter: WheelPainter(),
);
}
}
```
试着运行一下,效果如下所示:
<img src="https://static001.geekbang.org/resource/image/fb/84/fb03c4222e150a29a41d53a773b94984.png" alt="">
可以看到使用CustomPainter进行自绘控件并不算复杂。这里我建议你试着用画笔和画布去实现更丰富的功能。
**在实现视觉需求上自绘需要自己亲自处理绘制逻辑而组合则是通过子Widget的拼接来实现绘制意图。**因此从渲染逻辑处理上自绘方案可以进行深度的渲染定制从而实现少数通过组合很难实现的需求比如饼图、k线图。不过当视觉效果需要调整时采用自绘的方案可能需要大量修改绘制代码而组合方案则相对简单只要布局拆分设计合理可以通过更换子Widget类型来轻松搞定。
## 总结
在面对一些复杂的UI视图时Flutter提供的单一功能类控件往往不能直接满足我们的需求。于是我们需要自定义Widget。Flutter提供了组装与自绘两种自定义Widget的方式来满足我们对视图的自定义需求。
以组装的方式构建UI我们需要将目标视图分解成各个UI小元素。通常我们可以按照从上到下、从左到右的布局顺序去对控件层次结构进行拆解将基本视觉元素封装到Column、Row中。对于有着固定间距的视觉元素我们可以通过Padding对其进行包装而对于大小伸缩可变的视觉元素我们可以通过Expanded控件让其填充父容器的空白区域。
而以自绘的方式定义控件则需要借助于CustomPaint容器以及最终承接真实绘制逻辑的CustomPainter。CustomPainter是绘制逻辑的封装在其paint方法中我们可以使用不同类型的画笔Paint利用画布Canvas提供的不同类型的绘制图形能力实现控件自定义绘制。
无论是组合还是自绘在自定义UI时有了目标视图整体印象后我们首先需要考虑的事情应该是如何将它化繁为简把视觉元素拆解细分变成自己立即可以着手去实现的一个小控件然后再思考如何将这些小控件串联起来。把大问题拆成小问题后实现目标也逐渐清晰落地方案就自然浮出水面了。
这其实就和我们学习新知识的过程是一样的,在对整体知识概念有了初步认知之后,也需要具备将复杂的知识化繁为简的能力:先理清楚其逻辑脉络,然后再把不懂的知识拆成小点,最后逐个攻破。
我把今天分享讲的两个例子放到了[GitHub](https://github.com/cyndibaby905/15_custom_ui_demo)上你可以下载后在工程中实际运行并对照着今天的知识点进行学习体会在不同场景下组合和自绘这两种自定义Widget的具体使用方法。
## 思考题
最后,我给你留下两道作业题吧。
- 请扩展UpdatedItem控件使其能自动折叠过长的更新文案并能支持点击后展开的功能。
<img src="https://static001.geekbang.org/resource/image/bf/bf/bf6c18f1f391a7f9999e21fdcaeff9bf.png" alt="">
- 请扩展Cake控件使其能够根据传入的double数组最多10个元素中数值的大小定义饼图的圆弧大小。
欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,188 @@
<audio id="audio" title="16 | 从夜间模式说起如何定制不同风格的App主题" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/c1/49/c15090c701ab71e2313709e4e560a549.mp3"></audio>
你好我是陈航。今天我和你分享的主题是从夜间模式说起如何定制不同风格的App主题。
在上一篇文章中我与你介绍了组装与自绘这两种自定义Widget的方式。对于组装我们按照从上到下、从左到右的布局顺序去分解目标视图将基本的Widget封装到Column、Row中从而合成更高级别的Widget而对于自绘我们则通过承载绘制逻辑的载体CustomPainter在其paint方法中使用画笔Paint与画布Canvas绘制不同风格、不同类型的图形从而实现基于自绘的自定义组件。
对于一个产品来说在业务早期其实更多的是处理基本功能有和无的问题工程师来负责实现功能PM负责功能好用不好用。在产品的基本功能已经完善做到了六七十分的时候再往上的如何做增长就需要运营来介入了。
在这其中如何通过用户分层去实现App的个性化是常见的增长运营手段而主题样式更换则是实现个性化中的一项重要技术手段。
比如微博、UC浏览器和电子书客户端都提供了对夜间模式的支持而淘宝、京东这样的电商类应用还会在特定的电商活动日自动更新主题样式就连现在的手机操作系统也提供了系统级切换展示样式的能力。
那么这些在应用内切换样式的功能是如何实现的呢在Flutter中在普通的应用上增加切换主题的功能又要做哪些事情呢这些问题我都会在今天的这篇文章中与你详细分享。
## 主题定制
主题又叫皮肤、配色一般由颜色、图片、字号、字体等组成我们可以把它看做是视觉效果在不同场景下的可视资源以及相应的配置集合。比如App的按钮无论在什么场景下都需要背景图片资源、字体颜色、字号大小等而所谓的主题切换只是在不同主题之间更新这些资源及配置集合而已。
因此在App开发中我们通常不关心资源和配置的视觉效果好不好看只要关心资源提供的视觉功能能不能用。比如对于图片类资源我们并不需要关心它渲染出来的实际效果只需要确定它渲染出来是一张固定宽高尺寸的区域不影响页面布局能把业务流程跑通即可。
**视觉效果是易变的,我们将这些变化的部分抽离出来,把提供不同视觉效果的资源和配置按照主题进行归类,整合到一个统一的中间层去管理,这样我们就能实现主题的管理和切换了。**
在iOS中我们通常会将主题的配置信息预先写到plist文件中通过一个单例来控制App应该使用哪种配置而Android的配置信息则写入各个style属性值的xml中通过activity的setTheme进行切换前端的处理方式也类似简单更换css就可以实现多套主题/配色之间的切换。
Flutter也提供了类似的能力**由ThemeData来统一管理主题的配置信息**。
ThemeData涵盖了Material Design规范的可自定义部分样式比如应用明暗模式brightness、应用主色调primaryColor、应用次级色调accentColor、文本字体fontFamily、输入框光标颜色cursorColor等。如果你想深入了解ThemeData的其他API参数可以参考官方文档[ThemeData](https://api.flutter.dev/flutter/material/ThemeData/ThemeData.html)。
通过ThemeData来自定义应用主题我们可以实现App全局范围或是Widget局部范围的样式切换。接下来我便分别与你讲述这两种范围的主题切换。
## 全局统一的视觉风格定制
在Flutter中应用程序类MaterialApp的初始化方法为我们提供了设置主题的能力。我们可以通过参数theme选择改变App的主题色、字体等设置界面在MaterialApp下的展示样式。
以下代码演示了如何设置App全局范围主题。在这段代码中我们设置了App的明暗模式brightness为暗色、主色调为青色
```
MaterialApp(
title: 'Flutter Demo',//标题
theme: ThemeData(//设置主题
brightness: Brightness.dark,//明暗模式为暗色
primaryColor: Colors.cyan,//主色调为青色
),
home: MyHomePage(title: 'Flutter Demo Home Page'),
);
```
试着运行一下,效果如下:
<img src="https://static001.geekbang.org/resource/image/9b/30/9b16f0a71c01b336399554ddf7591f30.png" alt="">
可以看到,虽然我们只修改了主色调和明暗模式两个参数,但按钮、文字颜色都随之调整了。这是因为默认情况下,**ThemeData中很多其他次级视觉属性都会受到主色调与明暗模式的影响**。如果我们想要精确控制它们的展示样式,需要再细化一下主题配置。
下面的例子中我们将icon的颜色调整为黄色文字颜色调整为红色按钮颜色调整为黑色
```
MaterialApp(
title: 'Flutter Demo',//标题
theme: ThemeData(//设置主题
brightness: Brightness.dark,//设置明暗模式为暗色
accentColor: Colors.black,//(按钮Widget前景色为黑色
primaryColor: Colors.cyan,//主色调为青色
iconTheme:IconThemeData(color: Colors.yellow),//设置icon主题色为黄色
textTheme: TextTheme(body1: TextStyle(color: Colors.red))//设置文本颜色为红色
),
home: MyHomePage(title: 'Flutter Demo Home Page'),
);
```
运行一下,可以看到图标、文字、按钮的颜色都随之更改了。
<img src="https://static001.geekbang.org/resource/image/2c/94/2c033e21d8c0d29735b1860378c35794.png" alt="">
## 局部独立的视觉风格定制
为整个App提供统一的视觉呈现效果固然很有必要但有时我们希望为某个页面、或是某个区块设置不同于App风格的展现样式。以主题切换功能为例我们希望为不同的主题提供不同的展示预览。
在Flutter中我们可以使用Theme来对App的主题进行局部覆盖。Theme是一个单子Widget容器与MaterialApp类似的我们可以通过设置其data属性对其子Widget进行样式定制
- 如果我们不想继承任何App全局的颜色或字体样式可以直接新建一个ThemeData实例依次设置对应的样式
- 而如果我们不想在局部重写所有的样式则可以继承App的主题使用copyWith方法只更新部分样式。
下面的代码演示了这两种方式的用法:
```
// 新建主题
Theme(
data: ThemeData(iconTheme: IconThemeData(color: Colors.red)),
child: Icon(Icons.favorite)
);
// 继承主题
Theme(
data: Theme.of(context).copyWith(iconTheme: IconThemeData(color: Colors.green)),
child: Icon(Icons.feedback)
);
```
<img src="https://static001.geekbang.org/resource/image/31/1f/31523a1f0bd4f6150b3d3c59102c831f.png" alt="">
对于上述例子而言由于Theme的子Widget只有一个Icon组件因此这两种方式都可以实现覆盖全局主题从而更改Icon样式的需求。而像这样使用局部主题覆盖全局主题的方式在Flutter中是一种常见的自定义子Widget展示样式的方法。
**除了定义Material Design规范中那些可自定义部分样式外主题的另一个重要用途是样式复用。**
比如如果我们想为一段文字复用Materia Design规范中的title样式或是为某个子Widget的背景色复用App的主题色我们就可以通过Theme.of(context)方法,取出对应的属性,应用到这段文字的样式中。
Theme.of(context)方法将向上查找Widget树并返回Widget树中最近的主题Theme。如果Widget的父Widget们有一个单独的主题定义则使用该主题。如果不是那就使用App全局主题。
在下面的例子中我们创建了一个包装了一个Text组件的Container容器。在Text组件的样式定义中我们复用了全局的title样式而在Container的背景色定义中则复用了App的主题色
```
Container(
color: Theme.of(context).primaryColor,//容器背景色复用应用主题色
child: Text(
'Text with a background color',
style: Theme.of(context).textTheme.title,//Text组件文本样式复用应用文本样式
));
```
<img src="https://static001.geekbang.org/resource/image/ad/90/adeef600fa271f6ebb4eb41f60620290.png" alt="">
## 分平台主题定制
有时候,**为了满足不同平台的用户需求,我们希望针对特定的平台设置不同的样式**。比如在iOS平台上设置浅色主题在Android平台上设置深色主题。面对这样的需求我们可以根据defaultTargetPlatform来判断当前应用所运行的平台从而根据系统类型来设置对应的主题。
在下面的例子中我们为iOS与Android分别创建了两个主题。在MaterialApp的初始化方法中我们根据平台类型设置了不同的主题
```
// iOS浅色主题
final ThemeData kIOSTheme = ThemeData(
brightness: Brightness.light,//亮色主题
accentColor: Colors.white,//(按钮)Widget前景色为白色
primaryColor: Colors.blue,//主题色为蓝色
iconTheme:IconThemeData(color: Colors.grey),//icon主题为灰色
textTheme: TextTheme(body1: TextStyle(color: Colors.black))//文本主题为黑色
);
// Android深色主题
final ThemeData kAndroidTheme = ThemeData(
brightness: Brightness.dark,//深色主题
accentColor: Colors.black,//(按钮)Widget前景色为黑色
primaryColor: Colors.cyan,//主题色Wie青色
iconTheme:IconThemeData(color: Colors.blue),//icon主题色为蓝色
textTheme: TextTheme(body1: TextStyle(color: Colors.red))//文本主题色为红色
);
// 应用初始化
MaterialApp(
title: 'Flutter Demo',
theme: defaultTargetPlatform == TargetPlatform.iOS ? kIOSTheme : kAndroidTheme,//根据平台选择不同主题
home: MyHomePage(title: 'Flutter Demo Home Page'),
);
```
试着运行一下:
<img src="https://static001.geekbang.org/resource/image/ef/c6/efdee5c8d3e46d3b889274bbe3cf80c6.png" alt="">
<img src="https://static001.geekbang.org/resource/image/c6/70/c61983ef7ab9047562c338ecd1b46970.png" alt="">
当然除了主题之外你也可以用defaultTargetPlatform这个变量去实现一些其他需要判断平台的逻辑比如在界面上使用更符合Android或iOS设计风格的组件。
## 总结
好了,今天的分享就到这里。我们简单回顾一下今天的主要内容吧。
主题设置属于App开发的高级特性归根结底其实是提供了一种视觉资源与视觉配置的管理机制。与其他平台类似Flutter也提供了集中式管理主题的机制可以在遵循Material Design规范的ThemeData中定义那些可定制化的样式。
我们既可以通过设置MaterialApp全局主题实现应用整体视觉风格的统一也可以通过Theme单子Widget容器使用局部主题覆盖全局主题实现局部独立的视觉风格。
除此之外在自定义组件过程中我们还可以使用Theme.of方法取出主题对应的属性值从而实现多种组件在视觉风格上的复用。
最后面对常见的分平台设置主题场景我们可以根据defaultTargetPlatform来精确识别当前应用所处的系统从而配置对应的主题。
## 思考题
最后,我给你留下一个课后小作业吧。
在上一篇文章中我与你介绍了如何实现App Store升级项UI自定义组件布局。现在请在这个自定义Widget的基础上增加切换夜间模式的功能。
<img src="https://static001.geekbang.org/resource/image/87/54/87fe49b5f8ba32823619040845c19d54.png" alt="">
欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,204 @@
<audio id="audio" title="17 | 依赖管理图片、配置和字体在Flutter中怎么用" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/16/b2/16bd4d37485415b29979f4571760e5b2.mp3"></audio>
你好,我是陈航。
在上一篇文章中我与你介绍了Flutter的主题设置也就是将视觉资源与视觉配置进行集中管理的机制。
Flutter提供了遵循Material Design规范的ThemeData可以对样式进行定制化既可以初始化App时实现全局整体视觉风格统一也可以在使用单子Widget容器Theme实现局部主题的覆盖还可以在自定义组件时取出主题对应的属性值实现视觉风格的复用。
一个应用程序主要由两部分内容组成代码和资源。代码关注逻辑功能而如图片、字符串、字体、配置文件等资源则关注视觉功能。如果说上一次文章更多的是从逻辑层面分享应该如何管理资源的配置那今天的分享则会从物理存储入手与你介绍Flutter整体的资源管理机制。
资源外部化即把代码与资源分离是现代UI框架的主流设计理念。因为这样不仅有利于单独维护资源还可以对特定设备提供更准确的兼容性支持使得我们的应用程序可以自动根据实际运行环境来组织视觉功能适应不同的屏幕大小和密度等。
随着各类配置各异的终端设备越来越多资源管理也越来越重要。那么今天我们就先看看Flutter中的图片、配置和字体的管理机制吧。
## 资源管理
在移动开发中常见的资源类型包括JSON文件、配置文件、图标、图片以及字体文件等。它们都会被打包到App安装包中而App中的代码可以在运行时访问这些资源。
在Android、iOS平台中为了区分不同分辨率的手机设备图片和其他原始资源是区别对待的
- iOS使用Images.xcassets来管理图片其他的资源直接拖进工程项目即可
- Android的资源管理粒度则更为细致使用以drawable+分辨率命名的文件夹来分别存放不同分辨率的图片其他类型的资源也都有各自的存放方式比如布局文件放在res/layout目录下资源描述文件放在res/values目录下原始文件放在assets目录下等。
而在Flutter中资源管理则简单得多资源assets可以是任意类型的文件比如JSON配置文件或是字体文件等而不仅仅是图片。
而关于资源的存放位置Flutter并没有像Android那样预先定义资源的目录结构所以我们可以把资源存放在项目中的任意目录下只需要使用根目录下的pubspec.yaml文件对这些资源的所在位置进行显式声明就可以了以帮助Flutter识别出这些资源。
而在指定路径名的过程中,我们既可以对每一个文件进行挨个指定,也可以采用子目录批量指定的方式。
接下来,**我以一个示例和你说明挨个指定和批量指定这两种方式的区别。**
如下所示我们将资源放入assets目录下其中两张图片background.jpg、loading.gif与JSON文件result.json在assets根目录而另一张图片food_icon.jpg则在assets的子目录icons下。
```
assets
├── background.jpg
├── icons
│ └── food_icon.jpg
├── loading.gif
└── result.json
```
对于上述资源文件存放的目录结构,以下代码分别演示了挨个指定和子目录批量指定这两种方式:通过单个文件声明的,我们需要完整展开资源的相对路径;而对于目录批量指定的方式,只需要在目录名后加路径分隔符就可以了:
```
flutter:
assets:
- assets/background.jpg #挨个指定资源路径
- assets/loading.gif #挨个指定资源路径
- assets/result.json #挨个指定资源路径
- assets/icons/ #子目录批量指定
- assets/ #根目录也是可以批量指定的
```
需要注意的是,**目录批量指定并不递归,只有在该目录下的文件才可以被包括,如果下面还有子目录的话,需要单独声明子目录下的文件。**
完成资源的声明后,我们就可以在代码中访问它们了。**在Flutter中对不同类型的资源文件处理方式略有差异**,接下来我将分别与你介绍。
对于图片类资源的访问我们可以使用Image.asset构造方法完成图片资源的加载及显示在第12篇文章“[经典控件文本、图片和按钮在Flutter中怎么用](https://time.geekbang.org/column/article/110292)”中,你应该已经了解了具体的用法,这里我就不再赘述了。
而对于其他资源文件的加载我们可以通过Flutter应用的主资源Bundle对象rootBundle来直接访问。
对于字符串文件资源我们使用loadString方法而对于二进制文件资源则通过load方法。
以下代码演示了获取result.json文件并将其打印的过程
```
rootBundle.loadString('assets/result.json').then((msg)=&gt;print(msg));
```
与Android、iOS开发类似**Flutter也遵循了基于像素密度的管理方式**如1.0x、2.0x、3.0x或其他任意倍数Flutter可以根据当前设备分辨率加载最接近设备像素比例的图片资源。而为了让Flutter更好地识别我们的资源目录应该将1.0x、2.0x与3.0x的图片资源分开管理。
以background.jpg图片为例这张图片位于assets目录下。如果想让Flutter适配不同的分辨率我们需要将其他分辨率的图片放到对应的分辨率子目录中如下所示
```
assets
├── background.jpg //1.0x图
├── 2.0x
│ └── background.jpg //2.0x图
└── 3.0x
└── background.jpg //3.0x图
```
而在pubspec.yaml文件声明这个图片资源时仅声明1.0x图资源即可:
```
flutter:
assets:
- assets/background.jpg #1.0x图资源
```
1.0x分辨率的图片是资源标识符而Flutter则会根据实际屏幕像素比例加载相应分辨率的图片。这时如果主资源缺少某个分辨率资源Flutter会在剩余的分辨率资源中选择最接近的分辨率资源去加载。
举个例子如果我们的App包只包括了2.0x资源对于屏幕像素比为3.0的设备则会自动降级读取2.0x的资源。不过需要注意的是即使我们的App包没有包含1.0x资源我们仍然需要像上面那样在pubspec.yaml中将它显示地声明出来因为它是资源的标识符。
**字体则是另外一类较为常用的资源**。手机操作系统一般只有默认的几种字体,在大部分情况下可以满足我们的正常需求。但是,在一些特殊的情况下,我们可能需要使用自定义字体来提升视觉体验。
在Flutter中使用自定义字体同样需要在pubspec.yaml文件中提前声明。需要注意的是字体实际上是字符图形的映射。所以除了正常字体文件外如果你的应用需要支持粗体和斜体同样也需要有对应的粗体和斜体字体文件。
在将RobotoCondensed字体摆放至assets目录下的fonts子目录后下面的代码演示了如何将支持斜体与粗体的RobotoCondensed字体加到我们的应用中
```
fonts:
- family: RobotoCondensed #字体名字
fonts:
- asset: assets/fonts/RobotoCondensed-Regular.ttf #普通字体
- asset: assets/fonts/RobotoCondensed-Italic.ttf
style: italic #斜体
- asset: assets/fonts/RobotoCondensed-Bold.ttf
weight: 700 #粗体
```
这些声明其实都对应着TextStyle中的样式属性如字体名family对应着 fontFamily属性、斜体italic与正常normal对应着style属性、字体粗细weight对应着fontWeight属性等。在使用时我们只需要在TextStyle中指定对应的字体即可
```
Text(&quot;This is RobotoCondensed&quot;, style: TextStyle(
fontFamily: 'RobotoCondensed',//普通字体
));
Text(&quot;This is RobotoCondensed&quot;, style: TextStyle(
fontFamily: 'RobotoCondensed',
fontWeight: FontWeight.w700, //粗体
));
Text(&quot;This is RobotoCondensed italic&quot;, style: TextStyle(
fontFamily: 'RobotoCondensed',
fontStyle: FontStyle.italic, //斜体
));
```
<img src="https://static001.geekbang.org/resource/image/8a/59/8a8a853b0718dffde0fa409746368259.png" alt="">
## 原生平台的资源设置
在前面的第5篇文章“[从标准模板入手体会Flutter代码是如何运行在原生系统上的](https://time.geekbang.org/column/article/106199)”中我与你介绍了Flutter应用实际上最终会以原生工程的方式打包运行在Android和iOS平台上因此Flutter启动时依赖的是原生Android和iOS的运行环境。
上面介绍的资源管理机制其实都是在Flutter应用内的而在Flutter框架运行之前我们是没有办法访问这些资源的。Flutter需要原生环境才能运行但是有些资源我们需要在Flutter框架运行之前提前使用比如要给应用添加图标或是希望在等待Flutter框架启动时添加启动图我们就需要在对应的原生工程中完成相应的配置所以**下面介绍的操作步骤都是在原生系统中完成的。**
我们先看一下**如何更换App启动图标**。
对于Android平台启动图标位于根目录android/app/src/main/res/mipmap下。我们只需要遵守对应的像素密度标准保留原始图标名称将图标更换为目标资源即可
<img src="https://static001.geekbang.org/resource/image/9d/99/9d8d84ec282488f9c3d184646bec6599.png" alt="">
对于iOS平台启动图位于根目录ios/Runner/Assets.xcassets/AppIcon.appiconset下。同样地我们只需要遵守对应的像素密度标准将其替换为目标资源并保留原始图标名称即可
<img src="https://static001.geekbang.org/resource/image/b1/36/b1c2f7d4181b58a778fade3dfd1c7336.png" alt="">
然后。我们来看一下**如何更换启动图**。
对于Android平台启动图位于根目录android/app/src/main/res/drawable下是一个名为launch_background的XML界面描述文件。
<img src="https://static001.geekbang.org/resource/image/c4/d3/c40510574d63ddd1e8909722c8fc8fd3.png" alt="">
我们可以在这个界面描述文件中自定义启动界面,也可以换一张启动图片。在下面的例子中,我们更换了一张居中显示的启动图片:
```
&lt;?xml version=&quot;1.0&quot; encoding=&quot;utf-8&quot;?&gt;
&lt;layer-list xmlns:android=&quot;http://schemas.android.com/apk/res/android&quot;&gt;
&lt;!-- 白色背景 --&gt;
&lt;item android:drawable=&quot;@android:color/white&quot; /&gt;
&lt;item&gt;
&lt;!-- 内嵌一张居中展示的图片 --&gt;
&lt;bitmap
android:gravity=&quot;center&quot;
android:src=&quot;@mipmap/bitmap_launcher&quot; /&gt;
&lt;/item&gt;
&lt;/layer-list&gt;
```
而对于iOS平台启动图位于根目录ios/Runner/Assets.xcassets/LaunchImage.imageset下。我们保留原始启动图名称将图片依次按照对应像素密度标准更换为目标启动图即可。
<img src="https://static001.geekbang.org/resource/image/ff/21/ffa2c557a267efad08391236bf5ea921.png" alt="">
## 总结
好了,今天的分享就到这里。我们简单回顾一下今天的内容。
将代码与资源分离不仅有助于单独维护资源还可以更精确地对特定设备提供兼容性支持。在Flutter中资源可以是任意类型的文件可以被放到任意目录下但需要通过pubspec.yaml文件将它们的路径进行统一地显式声明。
Flutter对图片提供了基于像素密度的管理方式我们需要将1.0x2.0x与3.0x的资源分开管理但只需要在pubspec.yaml中声明一次。如果应用中缺少对于高像素密度设备的资源支持Flutter会进行自动降级。
对于字体这种基于字符图形映射的资源文件Flutter提供了精细的管理机制可以支持除了正常字体外还支持粗体、斜体等样式。
最后由于Flutter启动时依赖原生系统运行环境因此我们还需要去原生工程中设置相应的App启动图标和启动图。
## 思考题
最后,我给你留下两道思考题吧。
1. 如果我们只提供了1.0x与2.0x的资源图片对于像素密度为3.0的设备Flutter会自动降级到哪套资源
1. 如果我们只提供了2.0x的资源图片对于像素密度为1.0的设备Flutter会如何处理呢
你可以参考原生平台的经验,在模拟器或真机上实验一下。
欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,162 @@
<audio id="audio" title="18 | 依赖管理第三方组件库在Flutter中要如何管理" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/03/ee/030439504f9d02f8d98e331bdd8ac7ee.mp3"></audio>
你好,我是陈航。
在上一篇文章中我与你介绍了Flutter工程的资源管理机制。在Flutter中资源采用先声明后使用的机制在pubspec.yaml显式地声明资源路径后才可以使用。
对于图片Flutter基于像素密度设立不同分辨率的目录分开管理但只需要在pubspec.yaml声明一次而字体则基于样式支持除了正常字体还可以支持粗体、斜体等样式。最后由于Flutter需要原生运行环境因此对于在其启动之前所需的启动图和图标这两类特殊资源我们还需要分别去原生工程中进行相应的设置。
其实除了管理这些资源外pubspec.yaml更为重要的作用是管理Flutter工程代码的依赖比如第三方库、Dart运行环境、Flutter SDK版本都可以通过它来进行统一管理。所以pubspec.yaml与iOS中的Podfile、Android中的build.gradle、前端的package.json在功能上是类似的。
那么今天这篇文章我就主要与你分享在Flutter中如何通过配置文件来管理工程代码依赖。
## Pub
Dart提供了包管理工具Pub用来管理代码和资源。从本质上说package实际上就是一个包含了pubspec.yaml文件的目录其内部可以包含代码、资源、脚本、测试和文档等文件。包中包含了需要被外部依赖的功能抽象也可以依赖其他包。
与Android中的JCenter/Maven、iOS中的CocoaPods、前端中的npm库类似Dart提供了官方的包仓库Pub。通过Pub我们可以很方便地查找到有用的第三方包。
当然,这并不意味着我们可以简单地拿别人的库来拼凑成一个应用程序。**Dart提供包管理工具Pub的真正目的是让你能够找到真正好用的、经过线上大量验证的库复用他人的成果来缩短开发周期提升软件质量。**
在Dart中库和应用都属于包。pubspec.yaml是包的配置文件包含了包的元数据比如包的名称和版本、运行环境也就是Dart SDK与Fluter SDK版本、外部依赖、内部配置比如资源管理
在下面的例子中我们声明了一个flutter_app_example的应用配置文件其版本为1.0Dart运行环境支持2.1至3.0之间依赖flutter和cupertino_icon
```
name: flutter_app_example #应用名称
description: A new Flutter application. #应用描述
version: 1.0.0
#Dart运行环境区间
environment:
sdk: &quot;&gt;=2.1.0 &lt;3.0.0&quot;
#Flutter依赖库
dependencies:
flutter:
sdk: flutter
cupertino_icons: &quot;&gt;0.1.1&quot;
```
运行环境和依赖库cupertino_icons冒号后面的部分是版本约束信息由一组空格分隔的版本描述组成可以支持指定版本、版本号区间以及任意版本这三种版本约束方式。比如上面的例子中cupertino_icons引用了大于0.1.1的版本。
需要注意的是,由于元数据与名称使用空格分隔,因此版本号中不能出现空格;同时又由于大于符号“&gt;”也是YAML语法中的折叠换行符号因此在指定版本范围的时候必须使用引号 比如"&gt;=2.1.0 &lt; 3.0.0"。
**对于包,我们通常是指定版本区间,而很少直接指定特定版本**,因为包升级变化很频繁,如果有其他的包直接或间接依赖这个包的其他版本时,就会经常发生冲突。
而**对于运行环境如果是团队多人协作的工程建议将Dart与Flutter的SDK环境写死统一团队的开发环境**避免因为跨SDK版本出现的API差异进而导致工程问题。
比如在上面的示例中我们可以将Dart SDK写死为2.3.0Flutter SDK写死为1.2.1。
```
environment:
sdk: 2.3.0
flutter: 1.2.1
```
基于版本的方式引用第三方包需要在其Pub上进行公开发布我们可以访问[https://pub.dev/](https://pub.dev/)来获取可用的第三方包。而对于不对外公开发布或者目前处于开发调试阶段的包我们需要设置数据源使用本地路径或Git地址的方式进行包声明。
在下面的例子中我们分别以路径依赖以及Git依赖的方式声明了package1和package2这两个包
```
dependencies:
package1:
path: ../package1/ #路径依赖
date_format:
git:
url: https://github.com/xxx/package2.git #git依赖
```
在开发应用时,我们可以不写明具体的版本号,而是以区间的方式声明包的依赖;但对于一个程序而言,其运行时具体引用哪个版本的依赖包必须要确定下来。因此,**除了管理第三方依赖包管理工具Pub的另一个职责是找出一组同时满足每个包版本约束的包版本。**包版本一旦确定,接下来就是下载对应版本的包了。
对于dependencies中的不同数据源Dart会使用不同的方式进行管理最终会将远端的包全部下载到本地。比如对于Git声明依赖的方式Pub会clone Git仓库对于版本号的方式Pub则会从pub.dartlang.org下载包。如果包还有其他的依赖包比如package1包还依赖package3包Pub也会一并下载。
然后,在完成了所有依赖包的下载后,**Pub会在应用的根目录下创建.packages文件**,将依赖的包名与系统缓存中的包文件路径进行映射,方便后续维护。
最后,**Pub会自动创建pubspec.lock文件**。pubspec.lock文件的作用类似iOS的Podfile.lock或前端的package-lock.json文件用于记录当前状态下实际安装的各个直接依赖、间接依赖的包的具体来源和版本号。
比较活跃的第三方包的升级通常比较频繁因此对于多人协作的Flutter应用来说我们需要把pubspec.lock文件也一并提交到代码版本管理中这样团队中的所有人在使用这个应用时安装的所有依赖都是完全一样的以避免出现库函数找不到或者其他的依赖错误。
**除了提供功能和代码维度的依赖之外,包还可以提供资源的依赖**。在依赖包中的pubspec.yaml文件已经声明了同样资源的情况下为节省应用程序安装包大小我们需要复用依赖包中的资源。
在下面的例子中我们的应用程序依赖了一个名为package4的包而它的目录结构是这样的
```
pubspec.yaml
└──assets
├──2.0x
│ └── placeholder.png
└──3.0x
└── placeholder.png
```
其中placeholder.png是可复用资源。因此在应用程序中我们可以通过Image和AssetImage提供的package参数根据设备实际分辨率去加载图像。
```
Image.asset('assets/placeholder.png', package: 'package4');
AssetImage('assets/placeholder.png', package: 'package4');
例子
```
## 例子
接下来,我们通过一个日期格式化的例子,来演示如何使用第三方库。
在Flutter中提供了表达日期的数据结构[DateTime](https://api.flutter.dev/flutter/dart-core/DateTime-class.html)这个类拥有极大的表示范围可以表达1970-01-01 UTC时间后 100,000,000天内的任意时刻。不过如果我们想要格式化显示日期和时间DateTime并没有提供非常方便的方法我们不得不自己取出年、月、日、时、分、秒来定制显示方式。
值得庆幸的是我们可以通过date_format这个第三方包来实现我们的诉求date_format提供了若干常用的日期格式化方法可以很方便地实现格式化日期的功能。
**首先**我们在Pub上找到date_format这个包确定其使用说明
<img src="https://static001.geekbang.org/resource/image/5a/f9/5ad48b85c516aea99ea464c4da6ac2f9.png" alt="">
date_format包最新的版本是1.0.6,于是**接下来**我们把date_format添加到pubspec.yaml中
```
dependencies:
date_format: 1.0.6
```
**随后**IDEAndroid Studio监测到了配置文件的改动提醒我们进行安装包依赖更新。于是我们点击Get dependencies下载date_format :
<img src="https://static001.geekbang.org/resource/image/a6/87/a635ff7d4eb26aa287bb2c904b9bb887.png" alt="">
下载完成后我们就可以在工程中使用date_format来进行日期的格式化了
```
print(formatDate(DateTime.now(), [mm, '月', dd, '日', hh, ':', n]));
//输出2019年06月30日01:56
print(formatDate(DateTime.now(), [m, '月第', w, '周']));
//输出6月第5周
```
## 总结
好了,今天的分享就到这里。我们简单回顾一下今天的内容。
在Flutter中资源与工程代码依赖属于包管理范畴采用包的配置文件pubspec.yaml进行统一管理。
我们可以通过pubspec.yaml设置包的元数据比如包的名称和版本、运行环境比如Dart SDK与Fluter SDK版本、外部依赖和内部配置。
对于依赖的指定可以以区间的方式确定版本兼容范围也可以指定本地路径、Git、Pub这三种不同的数据源包管理工具会找出同时满足每个依赖包版本约束的包版本然后依次下载并通过.packages文件建立下载缓存与包名的映射最后统一将当前状态下实际安装的各个包的具体来源和版本号记录至pubspec.lock文件。
现代编程语言大都自带第依赖管理机制其核心功能是为工程中所有直接或间接依赖的代码库找到合适的版本但这并不容易。就比如前端的依赖管理器npm的早期版本就曾因为不太合理的算法设计导致计算依赖耗时过长依赖文件夹也高速膨胀一度被开发者们戏称为“黑洞”。而Dart使用的Pub依赖管理机制所采用的[PubGrub算法](https://github.com/dart-lang/pub/blob/master/doc/solver.md)则解决了这些问题因此被称为下一代版本依赖解决算法在2018年底被苹果公司吸纳成为Swift所采用的[依赖管理器算法](https://github.com/apple/swift-package-manager/pull/1918)。
当然如果你的工程里的依赖比较多并且依赖关系比较复杂即使再优秀的依赖解决算法也需要花费较长的时间才能计算出合适的依赖库版本。如果我们想减少依赖管理器为你寻找代码库依赖版本所耗费的时间一个简单的做法就是从源头抓起在pubspec.yaml文件中固定那些依赖关系复杂的第三方库们及它们递归依赖的第三方库的版本号。
## 思考题
最后,我给你留下两道思考题吧。
1. pubspec.yaml、.packages与pubspec.lock这三个文件在包管理中的具体作用是什么
1. .packages与pubspec.lock是否需要做代码版本管理呢为什么
欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,220 @@
<audio id="audio" title="19 | 用户交互事件该如何响应?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/f7/1a/f7ebc8651bb709cc64538f30f6630c1a.mp3"></audio>
你好,我是陈航。今天,我和你分享的主题是,如何响应用户交互事件。
在前面两篇文章中我和你一起学习了Flutter依赖的包管理机制。在Flutter中包是包含了外部依赖的功能抽象。对于资源和工程代码依赖我们采用包配置文件pubspec.yaml进行统一管理。
通过前面几个章节的学习我们已经掌握了如何在Flutter中通过内部实现和外部依赖去实现自定义UI完善业务逻辑。但除了按钮和ListView这些动态的组件之外我们还无法响应用户交互行为。那今天的分享中我就着重与你讲述Flutter是如何监听和响应用户的手势操作的。
手势操作在Flutter中分为两类
- 第一类是原始的指针事件Pointer Event即原生开发中常见的触摸事件表示屏幕上触摸或鼠标、手写笔行为触发的位移行为
- 第二类则是手势识别Gesture Detector表示多个原始指针事件的组合操作如点击、双击、长按等是指针事件的语义化封装。
接下来,我们先看一下原始的指针事件。
## 指针事件
指针事件表示用户交互的原始触摸数据如手指接触屏幕PointerDownEvent、手指在屏幕上移动PointerMoveEvent、手指抬起PointerUpEvent以及触摸取消PointerCancelEvent这与原生系统的底层触摸事件抽象是一致的。
在手指接触屏幕触摸事件发起时Flutter会确定手指与屏幕发生接触的位置上究竟有哪些组件并将触摸事件交给最内层的组件去响应。与浏览器中的事件冒泡机制类似事件会从这个最内层的组件开始沿着组件树向根节点向上冒泡分发。
不过Flutter无法像浏览器冒泡那样取消或者停止事件进一步分发我们只能通过hitTestBehavior去调整组件在命中测试期内应该如何表现比如把触摸事件交给子组件或者交给其视图层级之下的组件去响应。
关于组件层面的原始指针事件的监听Flutter提供了Listener Widget可以监听其子Widget的原始指针事件。
现在我们一起看一个Listener的案例。我定义了一个宽度为300的红色正方形Container利用Listener监听其内部Down、Move及Up事件
```
Listener(
child: Container(
color: Colors.red,//背景色红色
width: 300,
height: 300,
),
onPointerDown: (event) =&gt; print(&quot;down $event&quot;),//手势按下回调
onPointerMove: (event) =&gt; print(&quot;move $event&quot;),//手势移动回调
onPointerUp: (event) =&gt; print(&quot;up $event&quot;),//手势抬起回调
);
```
我们试着在红色正方形区域内进行触摸点击、移动、抬起可以看到Listener监听到了一系列原始指针事件并打印出了这些事件的位置信息
```
I/flutter (13829): up PointerUpEvent(Offset(97.7, 287.7))
I/flutter (13829): down PointerDownEvent(Offset(150.8, 313.4))
I/flutter (13829): move PointerMoveEvent(Offset(152.0, 313.4))
I/flutter (13829): move PointerMoveEvent(Offset(154.6, 313.4))
I/flutter (13829): up PointerUpEvent(Offset(157.1, 312.3))
```
## 手势识别
使用Listener可以直接监听指针事件。不过指针事件毕竟太原始了如果我们想要获取更多的触摸事件细节比如判断用户是否正在拖拽控件直接使用指针事件的话就会非常复杂。
通常情况下响应用户交互行为的话我们会使用封装了手势语义操作的Gesture如点击onTap、双击onDoubleTap、长按onLongPress、拖拽onPanUpdate、缩放onScaleUpdate等。另外Gesture可以支持同时分发多个手势交互行为意味着我们可以通过Gesture同时监听多个事件。
**Gesture是手势语义的抽象而如果我们想从组件层监听手势则需要使用GestureDetector**。GestureDetector是一个处理各种高级用户触摸行为的Widget与Listener一样也是一个功能性组件。
接下来我们通过一个案例来看看GestureDetector的用法。
我定义了一个Stack层叠布局使用Positioned组件将1个红色的Container放置在左上角并同时监听点击、双击、长按和拖拽事件。在拖拽事件的回调方法中我们更新了Container的位置
```
//红色container坐标
double _top = 0.0;
double _left = 0.0;
Stack(//使用Stack组件去叠加视图便于直接控制视图坐标
children: &lt;Widget&gt;[
Positioned(
top: _top,
left: _left,
child: GestureDetector(//手势识别
child: Container(color: Colors.red,width: 50,height: 50),//红色子视图
onTap: ()=&gt;print(&quot;Tap&quot;),//点击回调
onDoubleTap: ()=&gt;print(&quot;Double Tap&quot;),//双击回调
onLongPress: ()=&gt;print(&quot;Long Press&quot;),//长按回调
onPanUpdate: (e) {//拖动回调
setState(() {
//更新位置
_left += e.delta.dx;
_top += e.delta.dy;
});
},
),
)
],
);
```
运行这段代码并查看控制台输出可以看到红色的Container除了可以响应我们的拖拽行为外还能够同时响应点击、双击、长按这些事件。
<img src="https://static001.geekbang.org/resource/image/b8/4e/b8c4fa6e898ef154afc217d5ebf0b54e.gif" alt="">
尽管在上面的例子中我们对一个Widget同时监听了多个手势事件但最终只会有一个手势能够得到本次事件的处理权。对于多个手势的识别Flutter引入了**手势竞技场Arena**的概念,用来识别究竟哪个手势可以响应用户事件。手势竞技场会考虑用户触摸屏幕的时长、位移以及拖动方向,来确定最终手势。
**那手势竞技场具体是怎么实现的呢?**
实际上GestureDetector内部对每一个手势都建立了一个工厂类Gesture Factory。而工厂类的内部会使用手势识别类GestureRecognizer来确定当前处理的手势。
而所有手势的工厂类都会被交给RawGestureDetector类以完成监测手势的大量工作使用Listener监听原始指针事件并在状态改变时把信息同步给所有的手势识别器然后这些手势会在竞技场决定最后由谁来响应用户事件。
有些时候我们可能会在应用中给多个视图注册同类型的手势监听器,比如微博的信息流列表中的微博,点击不同区域会有不同的响应:点击头像会进入用户个人主页,点击图片会进入查看大图页面,点击其他部分会进入微博详情页等。
像这样的手势识别发生在多个存在父子关系的视图时,手势竞技场会一并检查父视图和子视图的手势,并且通常最终会确认由子视图来响应事件。而这也是合乎常理的:从视觉效果上看,子视图的视图层级位于父视图之上,相当于对其进行了遮挡,因此从事件处理上看,子视图自然是事件响应的第一责任人。
在下面的示例中我定义了两个嵌套的Container容器分别加入了点击识别事件
```
GestureDetector(
onTap: () =&gt; print('Parent tapped'),//父视图的点击回调
child: Container(
color: Colors.pinkAccent,
child: Center(
child: GestureDetector(
onTap: () =&gt; print('Child tapped'),//子视图的点击回调
child: Container(
color: Colors.blueAccent,
width: 200.0,
height: 200.0,
),
),
),
),
);
```
运行这段代码然后在蓝色区域进行点击可以发现尽管父容器也监听了点击事件但Flutter只响应了子容器的点击事件。
```
I/flutter (16188): Child tapped
```
<img src="https://static001.geekbang.org/resource/image/8e/da/8e99c7bbc5d0b52b3ba80263f989e7da.png" alt="">
为了让父容器也能接收到手势我们需要同时使用RawGestureDetector和GestureFactory来改变竞技场决定由谁来响应用户事件的结果。
在此之前,**我们还需要自定义一个手势识别器**让这个识别器在竞技场被PK失败时能够再把自己重新添加回来以便接下来还能继续去响应用户事件。
在下面的代码中我定义了一个继承自点击手势识别器TapGestureRecognizer的类并重写了其rejectGesture方法手动地把自己又复活了
```
class MultipleTapGestureRecognizer extends TapGestureRecognizer {
@override
void rejectGesture(int pointer) {
acceptGesture(pointer);
}
}
```
接下来我们需要将手势识别器和其工厂类传递给RawGestureDetector以便用户产生手势交互事件时能够立刻找到对应的识别方法。事实上RawGestureDetector的初始化函数所做的配置工作就是定义不同手势识别器和其工厂类的映射关系。
这里由于我们只需要处理点击事件所以只配置一个识别器即可。工厂类的初始化采用GestureRecognizerFactoryWithHandlers函数完成这个函数提供了手势识别对象创建以及对应的初始化入口。
在下面的代码中我们完成了自定义手势识别器的创建并设置了点击事件回调方法。需要注意的是由于我们只需要在父容器监听子容器的点击事件所以只需要将父容器用RawGestureDetector包装起来就可以了而子容器保持不变
```
RawGestureDetector(//自己构造父Widget的手势识别映射关系
gestures: {
//建立多手势识别器与手势识别工厂类的映射关系从而返回可以响应该手势的recognizer
MultipleTapGestureRecognizer: GestureRecognizerFactoryWithHandlers&lt;
MultipleTapGestureRecognizer&gt;(
() =&gt; MultipleTapGestureRecognizer(),
(MultipleTapGestureRecognizer instance) {
instance.onTap = () =&gt; print('parent tapped ');//点击回调
},
)
},
child: Container(
color: Colors.pinkAccent,
child: Center(
child: GestureDetector(//子视图可以继续使用GestureDetector
onTap: () =&gt; print('Child tapped'),
child: Container(...),
),
),
),
);
```
运行一下这段代码我们可以看到当点击蓝色容器时其父容器也收到了Tap事件。
```
I/flutter (16188): Child tapped
I/flutter (16188): parent tapped
```
## 总结
好了今天的分享就到这里。我们来简单回顾下Flutter是如何响应用户事件的。
首先我们了解了Flutter底层原始指针事件以及对应的监听方式和冒泡分发机制。
然后我们学习了封装了底层指针事件手势语义的Gesture了解了多个手势的识别方法以及其同时支持多个手势交互的能力。
最后我与你介绍了Gesture的事件处理机制在Flutter中尽管我们可以对一个Widget监听多个手势或是对多个Widget监听同一个手势但Flutter会使用手势竞技场来进行各个手势的PK以保证最终只会有一个手势能够响应用户行为。如果我们希望同时能有多个手势去响应用户行为需要去自定义手势利用RawGestureDetector和手势工厂类在竞技场PK失败时手动把它复活。
在处理多个手势识别场景,很容易出现手势冲突的问题。比如,当需要对图片进行点击、长按、旋转、缩放、拖动等操作的时候,如何识别用户当前是点击还是长按,是旋转还是缩放。如果想要精确地处理复杂交互手势,我们势必需要介入手势识别过程,解决异常。
不过需要注意的是冲突的只是手势的语义化识别过程原始指针事件是不会冲突的。所以在遇到复杂的冲突场景通过手势很难搞定时我们也可以通过Listener直接识别原始指针事件从而解决手势识别的冲突。
我把今天分享所涉及到的[事件处理demo](https://github.com/cyndibaby905/19_gesture_demo)放到了GitHub上你可以下载下来自己运行进一步巩固学习效果。
## 思考题
最后,我给你留下两个思考题吧。
1. 对于一个父容器中存在按钮FlatButton的界面在父容器使用GestureDetector监听了onTap事件的情况下如果我们点击按钮父容器的点击事件会被识别吗为什么
1. 如果监听的是onDoubleTap事件在按钮上双击父容器的双击事件会被识别吗为什么
欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,310 @@
<audio id="audio" title="20 | 关于跨组件传递数据,你只需要记住这三招" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/1e/fc/1e5f7703bd5ef647cb041a7f8e2398fc.mp3"></audio>
你好,我是陈航。
在上一篇文章中我带你一起学习了在Flutter中如何响应用户交互事件手势。手势处理在Flutter中分为两种原始的指针事件处理和高级的手势识别。
其中指针事件以冒泡机制分发通过Listener完成监听而手势识别则通过Gesture处理。但需要注意的是虽然Flutter可以同时支持多个手势包括一个Widget监听多个手势或是多个Widget监听同一个手势但最终只会有一个Widget的手势能够响应用户行为。为了改变这一点我们需要自定义手势修改手势竞技场对于多手势优先级判断的默认行为。
除了需要响应外部的事件之外UI框架的另一个重要任务是处理好各个组件之间的数据同步关系。尤其对于Flutter这样大量依靠组合Widget的行为来实现用户界面的框架来说如何确保数据的改变能够映射到最终的视觉效果上就显得更为重要。所以在今天这篇文章中我就与你介绍在Flutter中如何进行跨组件数据传递。
在之前的分享中通过组合嵌套的方式利用数据对基础Widget的样式进行视觉属性定制我们已经实现了多种界面布局。所以你应该已经体会到了在Flutter中实现跨组件数据传递的标准方式是通过属性传值。
但是对于稍微复杂一点的、尤其视图层级比较深的UI样式一个属性可能需要跨越很多层才能传递给子组件这种传递方式就会导致中间很多并不需要这个属性的组件也需要接收其子Widget的数据不仅繁琐而且冗余。
所以对于数据的跨层传递Flutter还提供了三种方案InheritedWidget、Notification和EventBus。接下来我将依次为你讲解这三种方案。
## InheritedWidget
InheritedWidget是Flutter中的一个功能型Widget适用于在Widget树中共享数据的场景。通过它我们可以高效地将数据在Widget树中进行跨层传递。
在前面的第16篇文章“[从夜间模式说起如何定制不同风格的App主题](https://time.geekbang.org/column/article/112148)”中我与你介绍了如何通过Theme去访问当前界面的样式风格从而进行样式复用的例子比如Theme.of(context).primaryColor。
**Theme类是通过InheritedWidget实现的典型案例**。在子Widget中通过Theme.of方法找到上层Theme的Widget获取到其属性的同时建立子Widget和上层父Widget的观察者关系当上层父Widget属性修改的时候子Widget也会触发更新。
接下来我就以Flutter工程模板中的计数器为例与你说明InheritedWidget的使用方法。
- 首先为了使用InheritedWidget我们定义了一个继承自它的新类CountContainer。
- 然后我们将计数器状态count属性放到CountContainer中并提供了一个of方法方便其子Widget在Widget树中找到它。
- 最后我们重写了updateShouldNotify方法这个方法会在Flutter判断InheritedWidget是否需要重建从而通知下层观察者组件更新数据时被调用到。在这里我们直接判断count是否相等即可。
```
class CountContainer extends InheritedWidget {
//方便其子Widget在Widget树中找到它
static CountContainer of(BuildContext context) =&gt; context.inheritFromWidgetOfExactType(CountContainer) as CountContainer;
final int count;
CountContainer({
Key key,
@required this.count,
@required Widget child,
}): super(key: key, child: child);
// 判断是否需要更新
@override
bool updateShouldNotify(CountContainer oldWidget) =&gt; count != oldWidget.count;
}
```
然后我们使用CountContainer作为根节点并用0初始化count。随后在其子Widget Counter中我们通过InheritedCountContainer.of方法找到它获取计数状态count并展示
```
class _MyHomePageState extends State&lt;MyHomePage&gt; {
@override
Widget build(BuildContext context) {
//将CountContainer作为根节点并使用0作为初始化count
return CountContainer(
count: 0,
child: Counter()
);
}
}
class Counter extends StatelessWidget {
@override
Widget build(BuildContext context) {
//获取InheritedWidget节点
CountContainer state = CountContainer.of(context);
return Scaffold(
appBar: AppBar(title: Text(&quot;InheritedWidget demo&quot;)),
body: Text(
'You have pushed the button this many times: ${state.count}',
),
);
}
```
运行一下,效果如下图所示:
<img src="https://static001.geekbang.org/resource/image/da/2e/da4662202739ac00969b111d1c30f12e.png" alt="">
可以看到InheritedWidget的使用方法还是比较简单的无论Counter在CountContainer下层什么位置都能获取到其父Widget的计数属性count再也不用手动传递属性了。
**不过InheritedWidget仅提供了数据读的能力如果我们想要修改它的数据则需要把它和StatefulWidget中的State配套使用**。我们需要把InheritedWidget中的数据和相关的数据修改方法全部移到StatefulWidget中的State上而InheritedWidget只需要保留对它们的引用。
我们对上面的代码稍加修改删掉CountContainer中持有的count属性增加对数据持有者State以及数据修改方法的引用
```
class CountContainer extends InheritedWidget {
...
final _MyHomePageState model;//直接使用MyHomePage中的State获取数据
final Function() increment;
CountContainer({
Key key,
@required this.model,
@required this.increment,
@required Widget child,
}): super(key: key, child: child);
...
}
```
然后我们将count数据和其对应的修改方法放在了State中仍然使用CountContainer作为根节点完成了数据和修改方法的初始化。
在其子Widget Counter中我们还是通过InheritedCountContainer.of方法找到它将计数状态count与UI展示同步将按钮的点击事件与数据修改同步
```
class _MyHomePageState extends State&lt;MyHomePage&gt; {
int count = 0;
void _incrementCounter() =&gt; setState(() {count++;});//修改计数器
@override
Widget build(BuildContext context) {
return CountContainer(
model: this,//将自身作为model交给CountContainer
increment: _incrementCounter,//提供修改数据的方法
child:Counter()
);
}
}
class Counter extends StatelessWidget {
@override
Widget build(BuildContext context) {
//获取InheritedWidget节点
CountContainer state = CountContainer.of(context);
return Scaffold(
...
body: Text(
'You have pushed the button this many times: ${state.model.count}', //关联数据读方法
),
floatingActionButton: FloatingActionButton(onPressed: state.increment), //关联数据修改方法
);
}
}
```
运行一下可以看到我们已经实现InheritedWidget数据的读写了。
<img src="https://static001.geekbang.org/resource/image/d1/35/d18d85bdd7d8ccf095535db077f3cd35.gif" alt="">
## Notification
Notification是Flutter中进行跨层数据共享的另一个重要的机制。如果说InheritedWidget的数据流动方式是从父Widget到子Widget逐层传递那Notificaiton则恰恰相反数据流动方式是从子Widget向上传递至父Widget。这样的数据传递机制适用于子Widget状态变更发送通知上报的场景。
在前面的第13篇文章“[经典控件UITableView/ListView在Flutter中是什么](https://time.geekbang.org/column/article/110859)”中我与你介绍了ScrollNotification的使用方法ListView在滚动时会分发通知我们可以在上层使用NotificationListener监听ScrollNotification根据其状态做出相应的处理。
自定义通知的监听与ScrollNotification并无不同而如果想要实现自定义通知我们首先需要继承Notification类。Notification类提供了dispatch方法可以让我们沿着context对应的Element节点树向上逐层发送通知。
接下来我们一起看一个具体的案例吧。在下面的代码中我们自定义了一个通知和子Widget。子Widget是一个按钮在点击时会发送通知
```
class CustomNotification extends Notification {
CustomNotification(this.msg);
final String msg;
}
//抽离出一个子Widget用来发通知
class CustomChild extends StatelessWidget {
@override
Widget build(BuildContext context) {
return RaisedButton(
//按钮点击时分发通知
onPressed: () =&gt; CustomNotification(&quot;Hi&quot;).dispatch(context),
child: Text(&quot;Fire Notification&quot;),
);
}
}
```
而在子Widget的父Widget中我们监听了这个通知一旦收到通知就会触发界面刷新展示收到的通知信息
```
class _MyHomePageState extends State&lt;MyHomePage&gt; {
String _msg = &quot;通知:&quot;;
@override
Widget build(BuildContext context) {
//监听通知
return NotificationListener&lt;CustomNotification&gt;(
onNotification: (notification) {
setState(() {_msg += notification.msg+&quot; &quot;;});//收到子Widget通知更新msg
},
child:Column(
mainAxisAlignment: MainAxisAlignment.center,
children: &lt;Widget&gt;[Text(_msg),CustomChild()],//将子Widget加入到视图树中
)
);
}
}
```
运行一下代码,可以看到,我们每次点击按钮之后,界面上都会出现最新的通知信息:
<img src="https://static001.geekbang.org/resource/image/e8/dd/e8c504730c0580dc699a0807039c76dd.gif" alt="">
## EventBus
无论是InheritedWidget还是Notificaiton它们的使用场景都需要依靠Widget树也就意味着只能在有父子关系的Widget之间进行数据共享。但是组件间数据传递还有一种常见场景这些组件间不存在父子关系。这时事件总线EventBus就登场了。
事件总线是在Flutter中实现跨组件通信的机制。它遵循发布/订阅模式允许订阅者订阅事件当发布者触发事件时订阅者和发布者之间可以通过事件进行交互。发布者和订阅者之间无需有父子关系甚至非Widget对象也可以发布/订阅。这些特点与其他平台的事件总线机制是类似的。
接下来我们通过一个跨页面通信的例子来看一下事件总线的具体使用方法。需要注意的是EventBus是一个第三方插件因此我们需要在pubspec.yaml文件中声明它
```
dependencies:
event_bus: 1.1.0
```
EventBus的使用方式灵活可以支持任意对象的传递。所以在这里我们传输数据的载体就选择了一个有字符串属性的自定义事件类CustomEvent
```
class CustomEvent {
String msg;
CustomEvent(this.msg);
}
```
然后我们定义了一个全局的eventBus对象并在第一个页面监听了CustomEvent事件一旦收到事件就会刷新UI。需要注意的是千万别忘了在State被销毁时清理掉事件注册否则你会发现State永远被EventBus持有着无法释放从而造成内存泄漏
```
//建立公共的event bus
EventBus eventBus = new EventBus();
//第一个页面
class _FirstScreenState extends State&lt;FirstScreen&gt; {
String msg = &quot;通知:&quot;;
StreamSubscription subscription;
@override
initState() {
//监听CustomEvent事件刷新UI
subscription = eventBus.on&lt;CustomEvent&gt;().listen((event) {
setState(() {msg+= event.msg;});//更新msg
});
super.initState();
}
dispose() {
subscription.cancel();//State销毁时清理注册
super.dispose();
}
@override
Widget build(BuildContext context) {
return new Scaffold(
body:Text(msg),
...
);
}
}
```
最后我们在第二个页面以按钮点击回调的方式触发了CustomEvent事件
```
class SecondScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return new Scaffold(
...
body: RaisedButton(
child: Text('Fire Event'),
// 触发CustomEvent事件
onPressed: ()=&gt; eventBus.fire(CustomEvent(&quot;hello&quot;))
),
);
}
}
```
运行一下,多点击几下第二个页面的按钮,然后返回查看第一个页面上的消息:
<img src="https://static001.geekbang.org/resource/image/5a/d5/5a50441b322568f4f3d77aea87d2aed5.gif" alt="">
可以看到EventBus的使用方法还是比较简单的使用限制也相对最少。
这里我准备了一张表格把属性传值、InheritedWidget、Notification与EventBus这四种数据共享方式的特点和使用场景做了简单总结供你参考
<img src="https://static001.geekbang.org/resource/image/b2/66/b2a78dbefdf30895504b2017355ae066.png" alt="">
## 总结
好了今天的分享就到这里。我们来简单回顾下在Flutter中如何实现跨组件的数据共享。
首先我们认识了InheritedWidget。对于视图层级比较深的UI样式直接通过属性传值的方式会导致很多中间层增加冗余属性而使用InheritedWidget可以实现子Widget跨层共享父Widget的属性。需要注意的是InheritedWidget中的属性在子Widget中只能读如果有修改的场景我们需要把它和StatefulWidget中的State配套使用。
然后我们学习了Notification这种由下到上传递数据的跨层共享机制。我们可以使用NotificationListener在父Widget监听来自子Widget的事件。
最后我与你介绍了EventBus这种无需发布者与订阅者之间存在父子关系的数据同步机制。
我把今天分享所涉及到的三种跨组件的[数据共享方式demo](https://github.com/cyndibaby905/20_data_transfer)放到了GitHub你可以下载下来自己运行体会它们之间的共同点和差异。
## 思考题
最后,我来给你留下一个思考题吧。
请你分别概括属性传值、InheritedWidget、Notification与EventBus的优缺点。
欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,217 @@
<audio id="audio" title="21 | 路由与导航Flutter是这样实现页面切换的" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/08/1d/080c8bed2049e96e96328047b45cea1d.mp3"></audio>
你好,我是陈航。
在上一篇文章中我带你一起学习了如何在Flutter中实现跨组件数据传递。其中InheritedWidget适用于子Widget跨层共享父Widget数据的场景如果子Widget还需要修改父Widget数据则需要和State一起配套使用。而Notification则适用于父Widget监听子Widget事件的场景。对于没有父子关系的通信双方我们还可以使用EventBus实现基于订阅/发布模式的机制实现数据交互。
如果说UI框架的视图元素的基本单位是组件那应用程序的基本单位就是页面了。对于拥有多个页面的应用程序而言如何从一个页面平滑地过渡到另一个页面我们需要有一个统一的机制来管理页面之间的跳转通常被称为**路由管理或导航管理**。
我们首先需要知道目标页面对象在完成目标页面初始化后用框架提供的方式打开它。比如在Android/iOS中我们通常会初始化一个Intent或ViewController通过startActivity或pushViewController来打开一个新的页面而在React中我们使用navigation来管理所有页面只要知道页面的名称就可以立即导航到这个页面。
其实Flutter的路由管理也借鉴了这两种设计思路。那么今天我们就来看看如何在一个Flutter应用中管理不同页面的命名和过渡。
## 路由管理
在Flutter中页面之间的跳转是通过Route和Navigator来管理的
- Route是页面的抽象主要负责创建对应的界面接收参数响应Navigator打开和关闭
- 而Navigator则会维护一个路由栈管理RouteRoute打开即入栈Route关闭即出栈还可以直接替换栈内的某一个Route。
而根据是否需要提前注册页面标识符Flutter中的路由管理可以分为两种方式
- 基本路由。无需提前注册,在页面切换时需要自己构造页面实例。
- 命名路由。需要提前注册页面标识符,在页面切换时通过标识符直接打开新的路由。
接下来,我们先一起看看基本路由这种管理方式吧。
### 基本路由
在Flutter中**基本路由的使用方法和Android/iOS打开新页面的方式非常相似**。要导航到一个新的页面我们需要创建一个MaterialPageRoute的实例调用Navigator.push方法将新页面压到堆栈的顶部。
其中MaterialPageRoute是一种路由模板定义了路由创建及切换过渡动画的相关配置可以针对不同平台实现与平台页面切换动画风格一致的路由切换动画。
而如果我们想返回上一个页面则需要调用Navigator.pop方法从堆栈中删除这个页面。
下面的代码演示了基本路由的使用方法:在第一个页面的按钮事件中打开第二个页面,并在第二个页面的按钮事件中回退到第一个页面:
```
class FirstScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return RaisedButton(
//打开页面
onPressed: ()=&gt; Navigator.push(context, MaterialPageRoute(builder: (context) =&gt; SecondScreen()));
);
}
}
class SecondPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return RaisedButton(
// 回退页面
onPressed: ()=&gt; Navigator.pop(context)
);
}
}
```
运行一下代码,效果如下:
<img src="https://static001.geekbang.org/resource/image/18/10/18956211d36dffacea40592a2cc9cd10.gif" alt="">
可以看到,基本路由的使用还是比较简单的。接下来,我们再看看命名路由的使用方法。
### 命名路由
基本路由使用方式相对简单灵活适用于应用中页面不多的场景。而在应用中页面比较多的情况下再使用基本路由方式那么每次跳转到一个新的页面我们都要手动创建MaterialPageRoute实例初始化页面然后调用push方法打开它还是比较麻烦的。
所以Flutter提供了另外一种方式来简化路由管理即命名路由。我们给页面起一个名字然后就可以直接通过页面名字打开它了。这种方式简单直观**与React中的navigation使用方式类似**。
要想通过名字来指定页面切换我们必须先给应用程序MaterialApp提供一个页面名称映射规则即路由表routes这样Flutter才知道名字与页面Widget的对应关系。
路由表实际上是一个Map&lt;String,WidgetBuilder&gt;其中key值对应页面名字而value值则是一个WidgetBuilder回调函数我们需要在这个函数中创建对应的页面。而一旦在路由表中定义好了页面名字我们就可以使用Navigator.pushNamed来打开页面了。
下面的代码演示了命名路由的使用方法在MaterialApp完成了页面的名字second_page及页面的初始化方法注册绑定后续我们就可以在代码中以second_page这个名字打开页面了
```
MaterialApp(
...
//注册路由
routes:{
&quot;second_page&quot;:(context)=&gt;SecondPage(),
},
);
//使用名字打开页面
Navigator.pushNamed(context,&quot;second_page&quot;);
```
可以看到,命名路由的使用也很简单。
不过**由于路由的注册和使用都采用字符串来标识,这就会带来一个隐患**:如果我们打开了一个不存在的路由会怎么办?
也许你会想到,我们可以约定使用字符串常量去定义、使用路由,但我们无法避免通过接口数据下发的错误路由标识符场景。面对这种情况,无论是直接报错或是不响应错误路由,都不是一个用户体验良好的解决办法。
**更好的办法是**对用户进行友好的错误提示比如跳转到一个统一的NotFoundScreen页面也方便我们对这类错误进行统一收集、上报。
在注册路由表时Flutter提供了UnknownRoute属性我们可以对未知的路由标识符进行统一的页面跳转处理。
下面的代码演示了如何注册错误路由处理。和基本路由的使用方法类似,我们只需要返回一个固定的页面即可。
```
MaterialApp(
...
//注册路由
routes:{
&quot;second_page&quot;:(context)=&gt;SecondPage(),
},
//错误路由处理统一返回UnknownPage
onUnknownRoute: (RouteSettings setting) =&gt; MaterialPageRoute(builder: (context) =&gt; UnknownPage()),
);
//使用错误名字打开页面
Navigator.pushNamed(context,&quot;unknown_page&quot;);
```
运行一下代码,可以看到,我们的应用不仅可以处理正确的页面路由标识,对错误的页面路由标识符也可以统一跳转到固定的错误处理页面了。
<img src="https://static001.geekbang.org/resource/image/dc/97/dc007d9b1313c88a22aa27b3e1f5a897.gif" alt="">
### 页面参数
与基本路由能够精确地控制目标页面初始化方式不同命名路由只能通过字符串名字来初始化固定目标页面。为了解决不同场景下目标页面的初始化需求Flutter提供了路由参数的机制可以在打开路由时传递相关参数在目标页面通过RouteSettings来获取页面参数。
下面的代码演示了如何传递并获取参数使用页面名称second_page打开页面时传递了一个字符串参数随后在SecondPage中我们取出了这个参数并将它展示在了文本中。
```
//打开页面时传递字符串参数
Navigator.of(context).pushNamed(&quot;second_page&quot;, arguments: &quot;Hey&quot;);
class SecondPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
//取出路由参数
String msg = ModalRoute.of(context).settings.arguments as String;
return Text(msg);
}
}
```
除了页面打开时需要传递参数,对于特定的页面,在其关闭时,也需要传递参数告知页面处理结果。
比如在电商场景下,我们会在用户把商品加入购物车时,打开登录页面让用户登录,而在登录操作完成之后,关闭登录页面返回到当前页面时,登录页面会告诉当前页面新的用户身份,当前页面则会用新的用户身份刷新页面。
与Android提供的startActivityForResult方法可以监听目标页面的处理结果类似Flutter也提供了**返回参数**的机制。在push目标页面时可以设置目标页面关闭时监听函数以获取返回参数而目标页面可以在关闭路由时传递相关参数。
下面的代码演示了如何获取参数在SecondPage页面关闭时传递了一个字符串参数随后在上一页监听函数中我们取出了这个参数并将它展示了出来。
```
class SecondPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
body: Column(
children: &lt;Widget&gt;[
Text('Message from first screen: $msg'),
RaisedButton(
child: Text('back'),
//页面关闭时传递参数
onPressed: ()=&gt; Navigator.pop(context,&quot;Hi&quot;)
)
]
));
}
}
class _FirstPageState extends State&lt;FirstPage&gt; {
String _msg='';
@override
Widget build(BuildContext context) {
return new Scaffold(
body: Column(children: &lt;Widget&gt;[
RaisedButton(
child: Text('命名路由(参数&amp;回调)'),
//打开页面,并监听页面关闭时传递的参数
onPressed: ()=&gt; Navigator.pushNamed(context, &quot;third_page&quot;,arguments: &quot;Hey&quot;).then((msg)=&gt;setState(()=&gt;_msg=msg)),
),
Text('Message from Second screen: $_msg'),
],),
);
}
}
```
运行一下可以看到在关闭SecondPage重新回到FirstPage页面时FirstPage把接收到的msg参数展示了出来
<img src="https://static001.geekbang.org/resource/image/df/90/dfb17d88a9755a0a8bafde69ff1df090.gif" alt="">
## 总结
好了,今天的分享就到这里。我们简单回顾一下今天的主要内容吧。
Flutter提供了基本路由和命名路由两种方式来管理页面间的跳转。其中基本路由需要自己手动创建页面实例通过Navigator.push完成页面跳转而命名路由需要提前注册页面标识符和页面创建方法通过Navigator.pushNamed传入标识符实现页面跳转。
对于命名路由如果我们需要响应错误路由标识符还需要一并注册UnknownRoute。为了精细化控制路由切换Flutter提供了页面打开与页面关闭的参数机制我们可以在页面创建和目标页面关闭时取出相应的参数。
可以看到关于路由导航Flutter综合了Android、iOS和React的特点简洁而不失强大。
而在中大型应用中,我们通常会使用命名路由来管理页面间的切换。命名路由的最重要作用,就是建立了字符串标识符与各个页面之间的映射关系,使得各个页面之间完全解耦,应用内页面的切换只需要通过一个字符串标识符就可以搞定,为后期模块化打好基础。
我把今天分享所涉及的的知识点打包到了[GitHub](https://github.com/cyndibaby905/21_router_demo)上,你可以下载工程到本地,多运行几次,从而加深对基本路由、命名路由以及路由参数具体用法的印象。
## 思考题
最后,我给你留下两个小作业吧。
1. 对于基本路由,如何传递页面参数?
1. 请实现一个计算页面这个页面可以对前一个页面传入的2个数值参数进行求和并在该页面关闭时告知上一页面计算的结果。
欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。