学线培训:winter-flutter-1
Widget 简介(了解)
Widget 概念
在前面的介绍中,我们知道在Flutter中几乎所有的对象都是一个 widget 。与原生开发中“控件”不同的是,Flutter 中的 widget 的概念更广泛,它不仅可以表示UI元素,也可以表示一些功能性的组件如:用于手势检测的 GestureDetector
、用于APP主题数据传递的 Theme
等等,而原生开发中的控件通常只是指UI元素。在后面的内容中,我们在描述UI元素时可能会用到“控件”、“组件”这样的概念,读者心里需要知道他们就是 widget ,只是在不同场景的不同表述而已。由于 Flutter 主要就是用于构建用户界面的,所以,在大多数时候,读者可以认为 widget 就是一个控件,不必纠结于概念。
Flutter 中是通过 Widget 嵌套 Widget 的方式来构建UI和进行实践处理的,所以记住,Flutter 中万物皆为Widget。
Flutter中的树(比较抽象,尽量理解即可)
既然 Widget 只是描述一个UI元素的配置信息,那么真正的布局、绘制是由谁来完成的呢?Flutter 框架的的处理流程是这样的:
- 根据 Widget 树生成一个 Element 树,Element 树中的节点都继承自
Element
类。 - 根据 Element 树生成 Render 树(渲染树),渲染树中的节点都继承自
RenderObject
类。 - 根据渲染树生成 Layer 树,然后上屏显示,Layer 树中的节点都继承自
Layer
类。
真正的布局和渲染逻辑在 Render 树中,Element 是 Widget 和 RenderObject 的粘合剂,可以理解为一个中间代理。我们通过一个例子来说明,假设有如下 Widget 树:
1 | Container( // 一个容器 widget |
注意,如果 Container 设置了背景色,Container 内部会创建一个新的 ColoredBox 来填充背景,相关逻辑如下:
1 | if (color != null) |
而 Image 内部会通过 RawImage 来渲染图片、Text 内部会通过 RichText 来渲染文本,所以最终的 Widget树、Element 树、渲染树结构如下:
这里需要注意:
- 三棵树中,Widget 和 Element 是一一对应的,但并不和 RenderObject 一一对应。比如
StatelessWidget
和StatefulWidget
都没有对应的 RenderObject。 - 渲染树在上屏前会生成一棵 Layer 树。
- 以上的内容比较涉及底层原理,这里只是让大家了解一下界面渲染原理,不必深究。
Context
build
方法有一个context
参数,它是BuildContext
类的一个实例,表示当前 widget 在 widget 树中的上下文,每一个 widget 都会对应一个 context 对象(因为每一个 widget 都是 widget 树上的一个节点)。实际上,context
是当前 widget 在 widget 树中位置中执行”相关操作“的一个句柄(handle),比如它提供了从当前 widget 开始向上遍历 widget 树以及按照 widget 类型查找父级 widget 的方法。下面是在子树中获取父级 widget 的一个示例:
1 | class ContextRoute extends StatelessWidget { |
通过Context获取State对象
context
对象有一个findAncestorStateOfType()
方法,该方法可以从当前节点沿着 widget 树向上查找指定类型的 StatefulWidget 对应的 State 对象。通常用于在子 widget 树中获取父级 StatefulWidget 的State 对象。
一般来说,如果 StatefulWidget 的状态是私有的(不应该向外部暴露),那么我们代码中就不应该去直接获取其 State 对象;如果StatefulWidget的状态是希望暴露出的(通常还有一些组件的操作方法),我们则可以去直接获取其State对象。但是通过 context.findAncestorStateOfType
获取 StatefulWidget 的状态的方法是通用的,我们并不能在语法层面指定 StatefulWidget 的状态是否私有,所以在 Flutter 开发中便有了一个默认的约定:**如果 StatefulWidget 的状态是希望暴露出的,应当在 StatefulWidget 中提供一个of
静态方法来获取其 State 对象,开发者便可直接通过该方法来获取;如果 State不希望暴露,则不提供of
方法。**这个约定在 Flutter SDK 里随处可见。
但其实也可以通过GlobalKey去获取,但是代价比较大,加上key的概念更加抽象难以理解,也不做解释。
StatefulWidget 生命周期:
跟安卓相比,基本一模一样。
热重载
Flutter的热重载(hot reload)功能可以帮助您在无需重新启动应用的情况下快速、轻松地进行测试、构建用户界面、添加功能以及修复错误。 通过将更新后的源代码文件注入正在运行的Dart虚拟机(VM)中来实现热重载。在虚拟机使用新的的字段和函数更新类后,Flutter框架会自动重新构建widget树,以便您快速查看更改的效果。
要热重载一个Flutter应用程序:
从受支持的IntelliJ IDE或终端窗口运行应用程序。物理机或虚拟器都可以运行。
修改项目中的一个Dart文件。大多数类型的代码更改可以重新加载; 有关需要完全重新启动的更改列表,请参阅限制。
单击工具栏上的Hot Reload按钮。
如果您正在使用命令行flutter run
运行应用程序,请在终端窗口输入r
成功执行热重载后,您将在控制台中看到类似于以下内容的消息:
1 | Performing hot reload... |
在android studio中的位置:
比较简单,可以自行尝试
路由管理
路由(Route)在移动开发中通常指页面(Page),这跟 Web 开发中单页应用的 Route 概念意义是相同的,Route 在 Android中 通常指一个 Activity,在 iOS 中指一个 ViewController。所谓路由管理,就是管理页面之间如何跳转,通常也可被称为导航管理。Flutter 中的路由管理和原生开发类似,无论是 Android 还是 iOS,导航管理都会维护一个路由栈,路由入栈(push)操作对应打开一个新页面,路由出栈(pop)操作对应页面关闭操作,而路由管理主要是指如何来管理路由栈。
同样地,我们在原生中常常遇到一个界面在不可见之后是否要销毁的问题以及清空路由栈等等操作,而这些操作在Flutter中更加便利,我们可以直接调用Navigator进行统一的路由管理,其中的操作其实也不仅限于push与pop。
Navigator
Navigator
是一个路由管理的组件,它提供了打开和退出路由页方法。Navigator
通过一个栈来管理活动路由集合,其实就是路由栈。通常当前屏幕显示的页面就是栈顶的路由。Navigator
提供了一系列方法来管理路由栈,在此我们只介绍其最常用的两个方法:
Future push(BuildContext context, Route route)
将给定的路由入栈(即打开新的页面),返回值是一个Future
对象,用以接收新路由出栈(即关闭)时的返回数据。但是实际上,如果你仅仅是想打开一个新的界面,完全可以这样:
1 | IconButton( |
意味着将这个界面打开(入栈)。
bool pop(BuildContext context, [ result ])
将栈顶路由出栈,也就是关闭当前界面,result
为页面关闭时返回给上一个页面的数据。
同上,如果没有什么需求,直接这样就行:
1 | IconButton( |
PS.
Navigator
还有很多其它方法,如Navigator.replace
、Navigator.popUntil
等,通过这些方法的使用甚至组合使用,其实可以解决很多逻辑上的难题,详情自行使用搜索引擎进行查阅,在此不再赘述,路由逻辑上的问题,只要能够发现,一般都会有现成的比较成熟的解决方案。下面我们还需要介绍一下路由相关的另一个概念“命名路由”。
实例方法
Navigator类中第一个参数为context的静态方法都对应一个Navigator的实例方法, 比如Navigator.push(BuildContext context, Route route)
等价于Navigator.of(context).push(Route route)
,下面命名路由相关的方法也是一样的。这个在上面的context与widget树部分有提到。
路由传值
很多时候,在路由跳转时我们需要带一些参数,比如打开商品详情页时,我们需要带一个商品id,这样商品详情页才知道展示哪个商品信息;又比如我们在填写订单时需要选择收货地址,打开地址选择页并选择地址后,可以将用户选择的地址返回到订单页等等。下面我们通过一个简单的示例来演示新旧路由如何传参。
示例
我们创建一个TipRoute
路由,它接受一个提示文本参数,负责将传入它的文本显示在页面上,另外TipRoute
中我们添加一个“返回”按钮,点击后在返回上一个路由的同时会带上一个返回参数,下面我们看一下实现代码。
TipRoute
实现代码:
1 | class TipRoute extends StatelessWidget { |
下面是打开新路由TipRoute
的代码:
1 | class RouterTestRoute extends StatelessWidget { |
运行上面代码,点击RouterTestRoute
页的“打开提示页”按钮,会打开TipRoute
页
需要说明:
提示文案“我是提示xxxx”是通过
TipRoute
的text
参数传递给新路由页的。我们可以通过等待Navigator.push(…)
返回的Future
来获取新路由的返回数据。在
TipRoute
页中有两种方式可以返回到上一页;第一种方式是直接点击导航栏返回箭头,第二种方式是点击页面中的“返回”按钮。这两种返回方式的区别是前者不会返回数据给上一个路由,而后者会。下面是分别点击页面中的返回按钮和导航栏返回箭头后,RouterTestRoute
页中print
方法在控制台输出的内容:1
2I/flutter (27896): 路由返回值: 我是返回值
I/flutter (27896): 路由返回值: null
上面介绍的是非命名路由的传值方式,命名路由的传值方式会有所不同,我们会在下面介绍命名路由时介绍。
命名路由
所谓“命名路由”(Named Route)即有名字的路由,我们可以先给路由起一个名字,然后就可以通过路由名字直接打开新的路由了,这为路由管理带来了一种直观、简单的方式。其实这种方式只是方便对路由进行统一管理,可以理解为键值对,与直接使用并无太大区别。通过使用命名路由,可以将路由与跳转的界面进行解耦,方便修改目标界面(不过小型项目倒无所谓)。
路由表
要想使用命名路由,我们必须先提供并注册一个路由表(routing table),这样应用程序才知道哪个名字与哪个路由组件相对应。路由表的定义如下:
1 | Map<String, WidgetBuilder> routes; |
它是一个Map
,key为路由的名字,是个字符串;value是个builder
回调函数,用于生成相应的路由widget。我们在通过路由名字打开新路由时,应用会根据路由名字在路由表中查找到对应的WidgetBuilder
回调函数,然后调用该回调函数生成路由widget并返回。
注册路由表
路由表的注册方式很简单,我们回到之前“计数器”的示例,然后在MyApp
类的build
方法中找到MaterialApp
,添加routes
属性,代码如下:
1 | MaterialApp( |
现在我们就完成了路由表的注册。上面的代码中home
路由并没有使用命名路由,如果我们也想将home
注册为命名路由应该怎么做呢?其实很简单,直接看代码:
1 | MaterialApp( |
可以看到,我们只需在路由表中注册一下MyHomePage
路由,然后将其名字作为MaterialApp
的initialRoute
属性值即可,该属性决定应用的初始路由页是哪一个命名路由。
通过路由名打开新路由页
要通过路由名称来打开新路由,可以使用Navigator
的pushNamed
方法:
1 | Future pushNamed(BuildContext context, String routeName,{Object arguments}) |
Navigator
除了pushNamed
方法,还有pushReplacementNamed
等其他管理命名路由的方法,可以自行使用搜索引擎查看他们的作用。接下来我们通过路由名来打开新的路由页,修改TextButton
的onPressed
回调代码,改为:
1 | onPressed: () { |
热重载应用,再次点击“open new route”按钮,依然可以打开新的路由页。
命名路由参数传递
在Flutter最初的版本中,命名路由是不能传递参数的,后来才支持了参数;下面展示命名路由如何传递并获取路由参数:
我们先注册一个路由:
1 | routes:{ |
在路由页通过RouteSetting
对象获取路由参数:
1 | class EchoRoute extends StatelessWidget { |
在打开路由时传递参数
1 | Navigator.of(context).pushNamed("new_page", arguments: "hi"); |
PS.
关于一些路由的基本逻辑操作,可以参考这篇博客
布局逻辑
在原生开发的时候一直提倡大家用LinearLayout,其中一个原因就是Flutter的默认布局逻辑基本就是LinearLayout,所以在原生使用相对布局的同学可能会比较难受,不过用习惯了就好,毕竟这个布局逻辑是很容易接受的。
如果大家看《Flutter实战》,在布局类组件(第四章)可以先忽略4.1和4.2,这两部分可能对大家来说特别陌生、难以接受,不过还是推荐了解一下SizedBox,这个容器简单好用。
下面主要说一下线性布局与弹性布局。
线性布局
Flutter 中通过Row
和Column
来实现线性布局,类似于Android 中的LinearLayout
控件。Row
和Column
都继承自Flex
,我们将在弹性布局一节中详细介绍Flex
。
主轴和纵轴
对于线性布局,有主轴和纵轴之分,如果布局是沿水平方向,那么主轴就是指水平方向,而纵轴即垂直方向;如果布局沿垂直方向,那么主轴就是指垂直方向,而纵轴就是水平方向。在线性布局中,有两个定义对齐方式的枚举类MainAxisAlignment
和CrossAxisAlignment
,分别代表主轴对齐和纵轴对齐。
Row
Row可以沿水平方向排列其子widget。定义如下:
1 | Row({ |
textDirection
:表示水平方向子组件的布局顺序(是从左往右还是从右往左),默认为系统当前Locale环境的文本方向(如中文、英语都是从左往右,而阿拉伯语是从右往左)。mainAxisSize
:表示Row
在主轴(水平)方向占用的空间,默认是MainAxisSize.max
,表示尽可能多的占用水平方向的空间,此时无论子 widgets 实际占用多少水平空间,Row
的宽度始终等于水平方向的最大宽度;而MainAxisSize.min
表示尽可能少的占用水平空间,当子组件没有占满水平剩余空间,则Row
的实际宽度等于所有子组件占用的的水平空间;mainAxisAlignment
:表示子组件在Row
所占用的水平空间内对齐方式,如果mainAxisSize
值为MainAxisSize.min
,则此属性无意义,因为子组件的宽度等于Row
的宽度。只有当mainAxisSize
的值为MainAxisSize.max
时,此属性才有意义,MainAxisAlignment.start
表示沿textDirection
的初始方向对齐,如textDirection
取值为TextDirection.ltr
时,则MainAxisAlignment.start
表示左对齐,textDirection
取值为TextDirection.rtl
时表示从右对齐。而MainAxisAlignment.end
和MainAxisAlignment.start
正好相反;MainAxisAlignment.center
表示居中对齐。读者可以这么理解:textDirection
是mainAxisAlignment
的参考系。verticalDirection
:表示Row
纵轴(垂直)的对齐方向,默认是VerticalDirection.down
,表示从上到下。crossAxisAlignment
:表示子组件在纵轴方向的对齐方式,Row
的高度等于子组件中最高的子元素高度,它的取值和MainAxisAlignment
一样(包含start
、end
、center
三个值),不同的是crossAxisAlignment
的参考系是verticalDirection
,即verticalDirection
值为VerticalDirection.down
时crossAxisAlignment.start
指顶部对齐,verticalDirection
值为VerticalDirection.up
时,crossAxisAlignment.start
指底部对齐;而crossAxisAlignment.end
和crossAxisAlignment.start
正好相反;children
:子组件数组。
示例
请阅读下面代码,先想象一下运行的结果:
1 | Column( |
实际运行结果如图所示:
解释:第一个Row
很简单,默认为居中对齐;第二个Row
,由于mainAxisSize
值为MainAxisSize.min
,Row
的宽度等于两个Text
的宽度和,所以对齐是无意义的,所以会从左往右显示;第三个Row
设置textDirection
值为TextDirection.rtl
,所以子组件会从右向左的顺序排列,而此时MainAxisAlignment.end
表示左对齐,所以最终显示结果就是图中第三行的样子;第四个 Row 测试的是纵轴的对齐方式,由于两个子 Text 字体不一样,所以其高度也不同,我们指定了verticalDirection
值为VerticalDirection.up
,即从低向顶排列,而此时crossAxisAlignment
值为CrossAxisAlignment.start
表示底对齐。
Column
Column
可以在垂直方向排列其子组件。参数和Row
一样,不同的是布局方向为垂直,主轴纵轴正好相反,读者可类比Row
来理解,下面看一个例子:
1 | import 'package:flutter/material.dart'; |
运行效果如图所示:
解释:
- 由于我们没有指定
Column
的mainAxisSize
,所以使用默认值MainAxisSize.max
,则Column
会在垂直方向占用尽可能多的空间,此例中会占满整个屏幕高度。 - 由于我们指定了
crossAxisAlignment
属性为CrossAxisAlignment.center
,那么子项在Column
纵轴方向(此时为水平方向)会居中对齐。注意,在水平方向对齐是有边界的,总宽度为Column
占用空间的实际宽度,而实际的宽度取决于子项中宽度最大的Widget。在本例中,Column
有两个子Widget,而显示“world”的Text
宽度最大,所以Column
的实际宽度则为Text("world")
的宽度,所以居中对齐后Text("hi")
会显示在Text("world")
的中间部分。
实际上,Row
和Column
都只会在主轴方向占用尽可能大的空间,而纵轴的长度则取决于他们最大子元素的长度。如果我们想让本例中的两个文本控件在整个手机屏幕中间对齐,我们有两种方法:
将
Column
的宽度指定为屏幕宽度;这很简单,我们可以通过ConstrainedBox
或SizedBox
(我们将在后面章节中专门介绍这两个Widget)来强制更改宽度限制,例如:1
2
3
4
5
6
7
8
9
10ConstrainedBox(
constraints: BoxConstraints(minWidth: double.infinity),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
Text("hi"),
Text("world"),
],
),
);将
minWidth
设为double.infinity
,可以使宽度占用尽可能多的空间。使用
Center
组件;将在后面章节中介绍。
特殊情况
如果Row
里面嵌套Row
,或者Column
里面再嵌套Column
,那么只有最外面的Row
或Column
会占用尽可能大的空间,里面Row
或Column
所占用的空间为实际大小,下面以Column
为例说明:
1 | Container( |
运行效果如图所示:
如果要让里面的Column
占满外部Column
,可以使用Expanded
组件:
1 | Expanded( |
运行效果如图所示:
我们将在介绍弹性布局时详细介绍Expanded。
弹性布局
弹性布局允许子组件按照一定比例来分配父容器空间。弹性布局的概念在其它UI系统中也都存在,如 H5 中的弹性盒子布局,Android中 的FlexboxLayout
等。Flutter 中的弹性布局主要通过Flex
和Expanded
来配合实现。
Flex
Flex
组件可以沿着水平或垂直方向排列子组件,如果你知道主轴方向,使用Row
或Column
会方便一些,因为Row
和Column
都继承自Flex
,参数基本相同,所以能使用Flex的地方基本上都可以使用Row
或Column
。Flex
本身功能是很强大的,它也可以和Expanded
组件配合实现弹性布局。接下来我们只讨论Flex
和弹性布局相关的属性(其它属性已经在介绍Row
和Column
时介绍过了)。
1 | Flex({ |
Flex
继承自MultiChildRenderObjectWidget
,对应的RenderObject
为RenderFlex
,RenderFlex
中实现了其布局算法。
Expanded
Expanded 只能作为 Flex 的孩子(否则会报错),它可以按比例“扩伸”Flex
子组件所占用的空间。因为 Row
和Column
继都承自Flex,所以 Expanded 也可以作为它们的孩子。
也就是说,Expanded组件必须用在Row、Column、Flex内,并且从Expanded到封装它的Row、Column、Flex的路径必须只包括StatelessWidgets或StatefulWidgets组件(不能是其他类型的组件,像RenderObjectWidget,它是渲染对象,不再改变尺寸了,因此Expanded不能放进RenderObjectWidget)。
注意一点:在Row中使用Expanded的时候,无法指定Expanded中的子组件的宽度width,但可以指定其高度height。同理,在Column中使用Expanded的时候,无法指定Expanded中的子组件的高度height,可以指定宽度width。
1 | const Expanded({ |
flex
参数为弹性系数,如果为 0 或null
,则child
是没有弹性的,即不会被扩伸占用的空间。如果大于0,所有的Expanded
按照其 flex 的比例来分割主轴的全部空闲空间。下面我们看一个例子:
1 | class FlexLayoutTestRoute extends StatelessWidget { |
运行效果如图所示:
示例中的Spacer
的功能是占用指定比例的空间,实际上它只是Expanded
的一个包装类,Spacer
的源码如下:
1 | class Spacer extends StatelessWidget { |
小结
弹性布局比较简单,唯一需要注意的就是Row
、Column
以及Flex
的关系。
Expanded经常会出现应用不正确的报错,一般是将Expanded用在了一个无界widget中导致渲染器报错,大家多去尝试、踩坑。
可以参考这个博客去了解一些expanded与col和row的组合使用
PS.
状态管理
这里提一嘴状态管理,但是初期遇到的状态管理问题可能并不多。
响应式的编程框架中都会有一个永恒的主题——“状态(State)管理”,无论是在 React/Vue(两者都是支持响应式编程的 Web 开发框架)还是 Flutter 中,他们讨论的问题和解决的思想都是一致的。所以,StatefulWidget
的状态应该被谁管理?Widget本身?父 Widget ?都会?还是另一个对象?答案是取决于实际情况!以下是管理状态的最常见的方法:
- Widget 管理自己的状态。
- Widget 管理子 Widget 状态。
- 混合管理(父 Widget 和子 Widget 都管理状态)。
实际上,还是以计数器为例,一个很经典的问题就是,点击加号之后如何更新界面?如果你使用setState,当然可以,但是会将整个widget树都更新一遍,但是实际上我们很明确只会更新树中的一个节点的属性,这是很浪费性能的。在者,若果在一个界面上改变了某个值,如何在其余界面上将这个改变表现出来?或者说,如何通知其他界面更新这个值?这都是状态管理的问题。或许你可以重新跳转到那个界面,但同样地,如果这个界面本就在路由栈中,重新构建这个界面会浪费性能,而且可能导致路由错乱。
为此,开发者们可谓耗尽心机,为此做了很多库,比如Provider和GetX,都是很好用的状态管理库,如果有兴趣可以尝试,GetX优先。
参考资料: