Flutter 下篇

这节课难度可能要level up了(,知识点可能比较杂,并且很多,所以就把第三次和第四次的文档写在了一起,交叉起来

PS:没写完的部分大概是属于第四次培训的内容

PSS:如果讲得快的话,第四次培训可以给大家来一个Code Running,给大家演示一下如何从新建项目开始构建出一个可用的Flutter应用

PSSS:第三次培训的Demo已经发到群文件里,配合Demo阅读效果更加喔~

建议阅读顺序

  1. 自定义组件
  2. 媒体查询
  3. 布局和约束
  4. Navigator.of(context).push()
  5. Navigator.of(context).pop()
  6. StatefulWidget
  7. 组件的生命周期
  8. 用户输入
  9. 页面返回数据
  10. GestureDetector
  11. 状态管理库:Provider
  12. 数据持久化
  13. 网络请求
  14. FutureBuilder

自定义组件🗡️

在Flutter中,小到一个文本,大到整个APP,都是组件(Widget),当然也包括你的某个页面,如果你去查看StatelessWidget和StatefulWidget的源代码,你就会发现,它们都是继承自Widget类

所谓的自定义组件,其实就是将flutter的原生组件进行组合,来达到我们想要的效果,并且自定义组件还能利于我们复用,不用将类似的代码复制粘贴很多遍。

在上次培训中给出的Demo中,其实就已经涉及到了自定义组件了,例如DemoCard组件,这里贴一份源代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
import 'package:flutter/material.dart';

class DemoCard extends StatelessWidget {
const DemoCard({
super.key,
required this.cardTitle,
required this.children,
});

final String cardTitle;
final List<Widget> children;

@override
Widget build(BuildContext context) {
// 获取屏幕宽度
double screenWidth = MediaQuery.of(context).size.width;
return SizedBox(
// 限制宽度为屏幕的80%
width: screenWidth * 0.8,
child: Card(
// 设置Card的阴影颜色
shadowColor: Colors.grey,
// 设置Card的阴影强度(暂时可以这么理解)
elevation: 10,
margin: EdgeInsets.all(10),
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
children: [
Row(
children: [
Text(
cardTitle,
style: TextStyle(
color: const Color.fromARGB(255, 118, 118, 118)
),
),
],
),
SizedBox(
// 限制分割线的宽度为屏幕宽度的60%
width: screenWidth * 0.6,
height: 1,
// 分割线组件
child: Divider(height: 100, color: Colors.black,)
),
// Padding组件,可以为组件添加内边距
Padding(
padding: const EdgeInsets.all(8.0),
// Wrap组件可以让其内部的子组件大小溢出时自动换行,从而避免溢出
child: Wrap(
spacing: 5.0,
children: children,
),
)
],
),
),
),
);
}
}

其实,我们将buildreturn的内容直接写到需要的地方也是可以的,但正如你所见,这段代码是很长的,将这一大段代码反复复制粘贴是没有必要的,我们可以直接将其单独抽象出来,当需要使用时,直接用DemoCard这个自定义组件就行了,无需再复制这么一大段代码,而当你修改DemoCard这一个组件时,使用了这个组件的地方都会发生变化,更加方便

媒体查询❓

所谓的媒体查询就是查询当前设备的一些信息,我们最常用的是查询设备的屏幕大小信息,在之前的Demo中也有使用过,具体的用法如下:

1
2
MediaQuery.of(context).size.width; // 获取屏幕宽度
MediaQuery.of(context).size.height; // 获取屏幕高度

观察上面的两行代码发现,用到了一个叫context的变量,而这个变量只在build中有,所以我们大部分时候都是在build中来使用媒体查询,当然你也可以编写一个函数来将build中的context传进去,类似下面这样:

1
2
3
4
5
6
7
8
9
double getScreenWidth(BuildContext context){
return MediaQuery.of(context).size.width;
}

@override
Widget build(BuildContext context){
double width = getScreenWidth(context);
// 略....
}

💡

使用MediaQuery获取到的屏幕尺寸不是物理像素(Physics Pixel),而是逻辑像素(Logic Pixel),这是大多数UI框架抽象屏幕尺寸的方式,之所以要抽象屏幕像素,是为了给开发者提供方便,开发者不需要去关心UI元素大小的缩放问题,有了逻辑像素,你的组件在任何屏幕上的视觉效果都是一致的,关于各种不同像素之间的差别,请参见:https://juejin.cn/post/6844904094344151054

布局和约束🕸️

在开始之前,你可以先将约束理解为不等式或者区间

此节尤为重要,如果你在编写Flutter应用的过程中经常遇到不明所以的组件严重溢出或者无论如何都无法调整组件大小的情况,那么看完这一节应该对你有所帮助。

Flutter中两种布局模型:

  • 基于 RenderBox 的盒模型布局。
  • 基于 Sliver ( RenderSliver ) 按需加载列表布局。

虽然有些不同,但在布局上的流程是相似的:

  1. 上层组件(父组件)向下层组件(子组件)传递约束(constraints)条件。
  2. 下层组件确定自己的大小,然后告诉上层组件。注意下层组件的大小必须符合父组件的约束
  3. 上层组件确定下层组件相对于自身的偏移和确定自身的大小(大多数情况下会根据子组件的大小来确定自身的大小)。

可以看到,所谓的布局其实就是父子组件之间对自身尺寸的计算结果的相互传递的过程。

这里有一个非常重要的原则:任何时候子组件都必须先遵守父组件的约束,在此基础上应用子组件自己的约束条件,即父组件和子组件的约束求一个交集

你可能会问了,那如果父组件的约束条件和子组件的约束条件的交集为空怎么办?对于这个问题,自然是以父组件的约束为主,子组件的约束条件会被直接忽略,这也是为什么在有些地方无论你如何调整一个组件的大小都无法生效的原因,举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
AppBar(
// 标题栏的标题属性,可以接收一个Widget
title: Row(
children: [
Icon(Icons.favorite, color: Colors.red,),
SizedBox(width: 20,),
Text(
"移动第二次培训",
style: TextStyle(
color: Colors.white
),
)
],
),
actions: [
SizedBox(
width: 80,
height: 80,
child: CircularProgressIndicator(
color: Colors.white,
),
)
],
// 设置标题栏颜色,颜色从我们设置的主题配色方案中生成
backgroundColor: Theme.of(context).colorScheme.primary,
),

我们在一个标题栏中显示了一个加载动画组件,效果如下:

img

我们明明指定的是一个80*80的尺寸,应当是一个正圆,这其实就是因为AppBar给了其子组件一个约束,我们可以通过Widget Inspector来查看这个CircularProgressIndicator的约束信息:

img

可以看到,宽是unconstrained的,也就是无约束的,但是高被限制在了[0, 56],这就是为什么它是一个长方形而不是正方形,它的实际尺寸是80 * 56

通过上面的这个小🌰,想必大家也看出来了,所谓的约束,其实就是宽和高的不等式,当我们指定某个组件的宽为某个固定值时,比如说80,就相当于给了它一个80 <= width <= 80的约束(像这样的约束我们叫做紧约束),这个不等式的左右两边是相等的,而无约束其实就是:-∞ <= width <= +∞,对于高来说也是一样的。

明白了上面的基础概念,先来介绍一下基于 RenderBox 的盒模型布局的几个基本组件。

基于 RenderBox 的盒模型布局

ConstrainedBox

这个组件就是为其子组件添加约束的,我们利用之前学到的自定义组件,先定义一个红色的矩形组件:

1
2
3
4
5
6
7
8
9
10
11
12
class RedBox extends StatelessWidget{
RedBox({super.key});

@override
Widget build(BuildContext context){
return DecoratedBox(
decoration: BoxDecoration(
color: Colors.red,
)
);
}
}

BoxDecoration我们上节课的Demo中用过,至于这个DecoratedBox,它其实就是一个阉割版的Container,它的decoration属性与Container的功能是一样的,却少了width、height等许多属性,也就是说它的尺寸完全是由其 子组件撑开 和 其父组件约束 来共同决定的,这里我们没有给它设置子组件,也就是说,它的尺寸完全由父组件决定。

接下来我们使用ConstrainedBox来包裹RedBox:

1
2
3
4
5
6
7
8
9
10
ConstrainedBox(
constraints: BoxConstraints(
minWidth: double.infinity, //宽度尽可能大
minHeight: 50.0 //最小高度为50像素
),
child: Container(
height: 5.0,
child: RedBox() , // 使用我们定义的RedBox组件
),
)

效果:

img

我们在constraints属性中传入了一个BoxConstraints对象,这个对象就是用来定义约束的。

可以看到,我们虽然将Container的高度设置为5像素,但是最终却是50像素,这正是ConstrainedBox最小高度限制生效了。如果将Container的高度设置为80像素,那么最终红色区域的高度也会是80像素,因为在此示例中,ConstrainedBox只限制了最小高度,并未限制最大高度

除此之外,ConstrainedBox当然还有maxWidth, maxHeight等属性,大家可以自行尝试。

SizedBox

这个组件就简单了,它只有widthheight连个属性,就是指定一个紧约束给子组件:

1
2
3
4
5
SizedBox(
width: 80.0,
height: 80.0,
child: RedBox()
)

效果:

img

其实上面的代码完全可以用ConstrainedBox来改写:

1
2
3
4
5
6
7
8
9
ConstrainedBox(
constraints: BoxConstraints(
minWidth: 80,
maxWidth: 80,
minHeight: 80,
maxHeight: 80,
)
child: RedBox(),
)

当然,BoxConstraints还为我们提供了简写的形式,上面的代码可以简化成:

1
2
3
4
ConstrainedBox(
constraints: BoxConstraints.tightFor(width: 80.0,height: 80.0),
child: RedBox(),
)

tightFor含义为紧约束

💡

Container的width和height属性和SizedBox的功能完全一样

多重限制

这个就很简单了,我们前面也明示过了(,其实就是不同的约束对应的区间取交集就完了,没有交集就以父组件的为主,忽略子组件的约束,我们来看一下例子:

1
2
3
4
5
6
7
ConstrainedBox(
constraints: BoxConstraints(minWidth: 60.0, minHeight: 60.0), //父
child: ConstrainedBox(
constraints: BoxConstraints(minWidth: 90.0, minHeight: 20.0),//子
child: RedBox(),
),
)

这里就不放截图了,最终显示效果是宽90,高60,也就是说是子ConstrainedBoxminWidth生效,而minHeight是父ConstrainedBox生效,分析:

  • 对于宽

父约束:[60, +∞),子约束:[90, +∞),求交集:[90, +∞)

  • 对于高

父约束:[60, +∞),子约束:[20, +∞),求交集:[60, +∞)

这下明白约束是如何传递和计算的了吗?

UnconstrainedBox和组件溢出

看它的名字你就知道,这个组件会给其子组件传递一个宽和高都是无穷大的约束,这并不意味子组件就会无穷大,它的含义是,子组件的大小完全取决于其自身的约束,而与父组件无关。

这个组件最常用的一个场景就是取消父组件既定的约束条件,比如我们刚才的例子中,我们如果给内部的组件加上一个UnconstrainedBox,就可以取消AppBar的约束:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
AppBar(
// 省略无关代码...
actions: [
UnconstrainedBox(
child: SizedBox(
width: 80,
height: 80,
child: CircularProgressIndicator(
color: Colors.red,
strokeCap: StrokeCap.round,
),
),
)
],
// 省略无关代码...
),

效果:

img

这截图的时机还真不好抓(

可以看到,它成功地变成了正圆,但是发生了溢出,可以看到图中提示我们上下均溢出了12像素,我们来算一算对不对,AppBar原来的约束是[0, 56],我们这个地方是高80,而这个组件在垂直方向上又是局中的,所以我们可以算出 上溢出=下溢出= (80 - 56) / 2 = 12,正好对上了!

那么这个加载动画是溢出的哪一个组件呢?那当然是UnconstrainedBox了,**任何组件仅有可能在它的直接父组件中发生溢出,**你可能想问:不是说无穷大的约束吗,咋会溢出啊??

溢出指的是子组件的大小超出了父组件的大小,和约束的关系不大,只不过大部分情况下,子组件只要遵循父组件的约束就不会发生溢出,而这里就是一个例外,实际上,UnconstrainedBox本身是有大小的,它的父组件是AppBar,所以它遵循AppBar传下来的约束,没错,UnconstrainedBox的高度最多就只能到56!只不过它是一个完全透明的组件,所以我们看不出来它的高度,但是在Widget Inspector中,我们能看得一清二楚:

img

这就是使用UnconstrainedBox时要注意的一点:估计好子组件的大小,不要溢出了!

💡

一般来说为了防止溢出,我们只在需要将子组件缩小时使用UnconstrainedBox,这样就一定不会溢出。

基于 Sliver 按需加载列表布局

这类模型的布局方法比较复杂,我们不需要像盒模型那样了解得很深,只需要知道它们怎么用就行了

当我们在手机中显示一些内容的时候,难免会遇到数据量很大,屏幕上显示不全的情况,比如说微博和QQ这样的应用中,在遇到很长的列表时,我们是可以往下翻的,这就是Sliver按需加载的列表布局,也称可滚动组件

💡

何为按需加载

按需加载,即当一些组件被计算出显示在屏幕外面的时候,那么这个组件就不会被渲染,因为即使渲染了,用户也看不到,反而浪费性能,只有待其由于用户滑动而有机会显示在屏幕上时才进行渲染

这里介绍几个常用的组件

ListView

ListView是最常用的可滚动组件之一,基本的用法如下:

1
2
3
4
5
ListView(
children: [
// 一堆子组件
]
);

它可以接受多个子组件,这些子组件按照自己的大小沿y轴排列(可以通过指定scrollDirection为Axis.horizental来实现沿x轴排列,这时候滚动方向也会变成横向的)

ListView.builder()

这是ListView的另一个构造函数,当我们的子组件数量不确定或者很多时,可以用这个,用法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
ListView.builder(
itemCount: 40,
itemBuilder: (context, index) {
if (index == 0) {
return Text(
"遭到特殊对待的文本😭\nTips: 可以往下滑",
style: TextStyle(fontSize: 25),
);
}
return Container(
// 尝试设置width的宽,但是没有作用,请思考这是为什么
width: 10,
height: 30,
color: _genRandomColor(),
child: Text(
"色块 $index",
// 让文字颜色也随机
style: TextStyle(color: _genRandomColor()),
),
);
},
),

效果:

img

你的在使用builder时,itemCount是几就会循环执行几次itemBuilder回调函数,并且每一次执行都会把当前的index传入,index的值从0一直增大到itemCount - 1

SingleChildScrollView

其实这个组件不支持按需加载,我们之所以将其放在这里介绍,是因为它在App中表现出来的行为和ListView很相似,都是可以往下拖动,并且它也是一个非常常用的组件。

件如其名,它只能拥有一个子组件,并且它还可以像ListView那样滚动,当它的子组件非常长的时候(超过了屏幕能显示的最大范围),它就可以进行滚动了,比如说:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
SingleChildScrollView(
child: Column(
children: [
Container(
width: double.infinity
height: 100,
color: Colors.red,
),
Container(
width: double.infinity
height: 300,
color: Colors.blue,
),
Container(
width: double.infinity
height: 600,
color: Colors.black,
),
]
)
)

我们让它接受一个Column作为其子组件,这个Colmn自然是非常长的,里面有3个高度上百的Container,此时Column的长度已经超出了屏幕,用户就可以通过向下滑动的方式来查看未一次性显示出来的内容。

⚠️

使用这个组件我们要注意其性能问题,它是不支持按需加载的,何为按需加载请看这里。这意味着没有显示在屏幕上的那一部分也会被渲染,如果它的子组件非常地长,那么就会造成很多不必要的性能开销,可能导致App的卡顿。请尽量在其子组件尺寸不大时使用。

路由管理(页面跳转)🦌

我们使用一个叫做Navigator的类来实现页面跳转,基本上来说,我们就使用两个方法:push(进入新页面)和pop(返回上一个页面)

代码的基本格式为:Navigator.of(context).push() 或者 Navigator.of(context).pop(),可以看到想要使用路由跳转,就必须要有context对象。

使用的格式比较固定:

1
2
3
4
5
Navigator.of(context).push(MaterialPageRoute(
builder: (context){
// 在这里返回你的新页面
}
))

你可能想问,MaterialPageRoute是什么,这其实是一个安卓平台上的页面跳转定义,对应的还有iOS平台上的:CupertinoPageRoute,关于Material和Cupertino的详细信息,请查看:Material和Cupertino。这两者最明显的区别在于页面跳转的动画不一样,大家可以试试看。

这个就简单了,它可以直接就这么调用:

1
Navigator.of(context).pop();

如果当前页面不是第一个页面,那么就会关闭当前页面并返回上一个页面;如果当前页面是第一个页面,那么就会返回系统桌面,取决于具体的系统设置,应用将处于后台运行或者直接退出

页面返回数据

建议先看完StatefulWidget🐒用户输入👀后再回来阅读本节

我们经常会遇到以下这个需求:打开一个新页面,用户会进行一些输入,输入提交后将输入内容传递给上一个页面处理。这就涉及到如何在两个页面之间进行信息交换,想要实现这个功能就需要push方法和pop方法的联动使用。下面给出一个示例:

A页面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
import 'package:demo02/test_demo/b_page.dart';
import 'package:flutter/material.dart';

class APage extends StatefulWidget {
const APage({super.key});

@override
State<APage> createState() => _APageState();
}

class _APageState extends State<APage> {
String _input = '';

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text("页面返回数据"),
),
body: Center(
child: Column(
children: [
ElevatedButton(
onPressed: () async {
// 这里的跳转页面的代码发生了变化,注意这段代码要和BPage中的返回逻辑结合起来看
String res = await Navigator.of(context).push(MaterialPageRoute(builder:(context) {
return BPage();
},));
setState(() {
_input = res;
});
},
child: Text('前往B页面'),
),
Text("你输入的内容: $_input")
],
),
),
);
}
}

B页面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
import 'package:flutter/material.dart';

class BPage extends StatefulWidget {
const BPage({super.key});

@override
State<BPage> createState() => _BPageState();
}

class _BPageState extends State<BPage> {
TextEditingController _textEditingController = TextEditingController();

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text("在横线输入文字然后点击提交"),
),
body: Center(
child: Column(
children: [
TextField(
controller: _textEditingController,
),
ElevatedButton(
onPressed: (){
// 将用户输入的字符串数据返回给上级页面
Navigator.of(context).pop<String>(_textEditingController.text);
},
child: Text("提交")
)
],
),
),
);
}
}

注意观察这两个页面中的push和pop方法的调用,当一个页面(B页面)的pop方法定义了返回数据时,那么进入这个页面(B页面)的push方法(A页面中)就会返回一个Future对象,我们await这个对象就可以得到它的返回数据了。

不同的路由即对应着不同的页面,虽然我们可以不使用路由而直接跳转页面,这对小项目是行得通的

StatefulWidget🐒

即有状态组件,这里的状态指的就是我们的业务数据,比如用户名、网络请求结果等等。

还记得在第二次培训中我们是如何创建一个无状态组件的吗?

1
2
3
4
5
6
7
8
class YourWidget extends StatelessWidget{
// .....
// ....
@override
Widget build(BuildContext context){
return const Placeholder();// your widget
}
}

关于Placeholder请看这里。我们创建一个StatelessWidget的步骤就是先创建一个继承自StatelessWidget的类,然后编写其build方法,这个build方法的返回值就是我们组件的具体定义。

接下来是创建StatefulWidget的步骤

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import 'package:flutter/material.dart';

class Counter extends StatefulWidget {
const Counter({super.key});

@override
State<Counter> createState() => _CounterState();
}

class _CounterState extends State<Counter> {
@override
Widget build(BuildContext context) {
return const Placeholder();
}
}

这就是一个最简单的有状态组件,我们来分析一下它的代码。

我们创建了一个名为Counter的类,首先它继承了StatefulWidget,这一点和我们创建无状态组件时是相似的,这个类内部包含了一个构造函数,这没什么好说,大家都再熟悉不过了。

💡

构造函数里面的key参数是什么?

key可以帮助我们在Widget 树中保存状态,有兴趣的同学可以去看看这篇文章:https://juejin.cn/post/6844903811870359559

接下来可能就是大家没见过的东西了,可以看到多了一个叫**createState**的方法,并且我们在这个方法中返回了一个名为_CounterState的类的实例,这个类的定义我们也已经写在了下面。

其实这个createState方法就是flutter用来创建状态的方法,我们只需要做两件事即可:

  • 定义我们的状态
  • 告知flutter我们定义的状态

其中createState这个方法就是做的第二件事,大家如果不理解的话就把它当作一个起手式就行

那么还剩下一件事就是定义状态了,你可能已经猜到了,这其实就是我们下面定义的_CounterState类,这个类名以下划线开头就表明它是一个私有的类,这意味着它的实例是由flutter去创建的。

我们来看看这个类是如何定义的:首先它继承了State,这个State的泛型参数填我们的StatefulWidget就行了。在这个类的内部,我们发现了熟悉的方法——build方法,不用怀疑,这个方法的作用跟StatelessWidget中的build是一样的

综上所述,一个有状态组件由它本身和一个与其配套的状态类组成

为_CounterState类添加功能

💡

有状态组件的业务逻辑大部分都是在其状态类中编写,而不是在有状态类的本身编写

接下来我们来完善一下这个_CounterState类,我们想要实现的功能是:在屏幕上展示一个按钮,按钮中有一个数字,一开始是0,每点击一次按钮中的数字就会加一:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import 'package:flutter/material.dart';

class Counter extends StatefulWidget {
const Counter({super.key});

@override
State<Counter> createState() => _CounterState();
}

class _CounterState extends State<Counter> {
// 计数变量
int _count = 0;

// 增加计数变量的方法
void _increase(){
setState(() {
_count++;
});
}

@override
Widget build(BuildContext context) {
return ElevatedButton(
onPressed: (){
// 按钮被点击后增加计数变量
_increase();
},
// 在按钮中显示当前计数值
child: Text("点按增加计数器: $_count")
);
}
}

别慌,我们一步一步来看看这段代码,首先,我们定义了一个用保存当前计数值的变量_count,并将它的初始值设为0,然后我们定义了一个_increase()方法,我们通过这个方法来将_count变量递增。

**重点来啦!**这里的**setState**就是我们今天的主角之一,调用这个方法后,如果你的状态类中的变量有变化,那么build方法就会被调用,从而根据我们新的_count值渲染出新的UI界面,这个过程对于我们肉眼看起来就是屏幕上的数字发生了变化

倘若你仅仅只改变_count变量的值,而不调用setState方法的话,这个按钮中的数据根本不会变,不信大家可以自己试试。

说到这个build方法,大家需要了解一下,当一个组件第一次被渲染到屏幕上的时候,build方法会被调用一次,当其父组件的UI更新的时候(例如调用了父组件的setState方法),build方法也会被调用,下面这张图清晰的展现了一个StatefulWidget和StatelessWidget的生命周期:

img

其他的方法我们后面介绍,现在只需关注setStatebuild方法就行了

💡

调用setState方法就一定会触发build的调用吗?

这个还真不一定,前面说只有在状态类中定义的状态发生改变时,在本例中是_count变量,才会触发build的调用,但其实,只有当状态在build方法中被访问了,才会触发build的调用,否则是不会触发的,这也是flutter内部的一个性能优化手段,你用都没用它,我更不更新UI不都一样嘛。

也就是说,调用setState时,build被调用的条件是:状态发生了改变 发生改变的状态在build中被访问过

组件的生命周期

直接把上面的图复制下来:

img

图中的Constructor表示构造函数,可以看到StatelessWidget的生命周期非常简单,我们这里就略过了,只讲讲StatefulWidget的生命周期。

我们先要认识到,为什么StatefulWidget的生命周期比StatelessWidget的生命周期要复杂的多,这其实是因为每一个StatefulWidget还有一个配套的状态类,而这个状态类才是它生命周期中的重点,它涉及到业务逻辑的重要部分,所以Flutter自然要把这一部分设计得精细一些。

我们只介绍initState、build、dispose

initState

在执行完createState方法后,状态实例会被创建,然后紧接着就会执行initState方法,这个方法是整个StatefulWidget生命周期中第一个执行的方法。我们可以在这里进行一些初始化操作,例如有网络请求时,我们可以请求一些初始数据,或者绑定一些监听器

build

这个和StatelessWidget中的build方法功能是一致的,都是在这个组件需要被显示到屏幕上的时候调用,在状态创建的过程中,他会在initState方法调用完毕后进行调用。

并且,他还会因为setState方法的调用而调用,具体的条件参见上文:https://kdocs.cn/l/cbtZCIc7uAM2?linkname=yXtYoCi910

dispose

这个就更简单了,当组件被移除时会调用这个方法,具体来说,当你退出某个页面时,这个页面中的所有组件的dispose方法都会被调用,这个方法存在的目的是为了释放一些资源,防止造成内存泄漏

用户输入👀

其实用户输入有两个需求:如何让用户输入、我们如何获取用户的输入内容

我们通过实际的组件来讲解。

TextField:文本输入框

如其名,就是用来输入普通的文本的。

基本的用法如下:

1
TextField();

啊没错就是这么简单,你只要写上这行代码屏幕上就会显示出一个文本框了(,用户点击就可以进行输入。

当然不可能真的这么简单(,首先就是这个文本框的宽度问题,默认情况下,TextField的宽度是和屏幕一样宽的,这肯定是不美观的,我们需要限制它的宽度,还记得我们前面学的SizedBox吗,你可能已经猜到了,我们可以通过下面这种方式来限制:

1
2
3
4
SizeBox(
width: 130,
child: TextField(),
)

至于如何限制高度,这个就留给大家自己去探索了。

获取用户输入

TextField中有一个名为controller的属性,这个属性是我们获取用户输入的关键,我们需要创建一个TextEditingController类型的变量,然后将其传给TextFieldcontroller属性,示例如下:

1
2
3
4
5
6
7
TextEditingController _controller = TextEditingController();
// 还可以这样来指定初始文本:TextEditingController _controller = TextEditingController(text: "初始文本");

// 无关代码略...
TextField(
controller: _controller
);

用户的所有输入都会反映在controllertext属性中,当我们想要使用用户的输入时,我们可以这样:

1
print(_controller.text);

_controller.text这个属性是随着输入框的文本实时变化的,所以通过这个属性我们总能获取最新的输入内容

💡

我们大部分时候只在StatefulWidget的状态类里面获取用户用户输入,至于为什么这样,你自己写代码的时候就会领悟,在StatelessWidget中获取输入是没有用处的,实际的使用例子可以看上面

输入事件

我们如何对用户的每一次输入作出响应呢?这就需要我们去监听输入事件了,这里只介绍TextField的其中一种输入事件,也是最常用的输入事件:onChanged,有两种方式可以进行监听:

在TextField中监听

写法很简单:

1
2
3
4
5
TextField(
onChanged: (value){
// 你的处理逻辑
}
)

上面的value参数就是当前输入框中的内容,我们可以在回调函数中直接使用,每当输入框中的内容发生变化时,这个回调函数就会被调用。

在TextEditingController中监听

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
TextEditingController _controller = TextEditingController();

@override
void initState(){
super.initState();
_controller.addListener((){
// 你的处理逻辑
debugPrint("输入框内容: ${_controller.text}");
});
}

// 无关代码略...
TextField(
// 一定要将_controller和TextField绑定在一起,不然监听不起作用
controller: _controller
);

这和在TextField中进行监听的效果完全一致,啊那我如何在监听器中获取当前用户输入的文本啊?

诚然,在TextField中监听,会直接给你一个value变量,而在这里,你可以直接使用_controller.text获取文本(🤌

GestureDetector

目前来说,当我们点击一个组件的时候,它不会有任何反应(除了按钮)。那么我们如何才能让一个普通的组件像按钮那样对点击甚至其他的手势操作作出反应呢?

这时候就要用到GestureDetector了,它可以响应许多类型的手势事件,包括点击、双击、滑动等等。

比如,我们利用它来实现一个自定义按钮:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import 'package:flutter/material.dart';

class CustomButton extends StatelessWidget {
const CustomButton({
super.key,
required this.onPressed,
required this.child,
});

final Widget child;
final void Function()? onPressed;

@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onPressed,
child: Container(
padding: EdgeInsets.all(4),
decoration: BoxDecoration(
borderRadius: BorderRadius.all(Radius.circular(10)),
color: onPressed == null ? Colors.grey : Colors.green,
),
child: child,
),
);
}
}

该例子也在Demo中:lib/custom_button_demo/

该按钮的使用示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import 'package:demo02/custorm_button_demo/custom_button.dart';
import 'package:flutter/material.dart';

class CustomButtonPage extends StatelessWidget {
const CustomButtonPage({super.key});

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text("自定义按钮"),
),
body: Center(
child: CustomButton(
onPressed: () {
print("⚡️电满了⚡️");
},
child: Text("⚡️点我!"),
),
),
);
}
}

我们传进去的回调函数会在CustomButton的内部被传给GestureDetector的onTap,而这个onTap就是组件GestureDetector的子组件被点击时的回调(中间把回调函数多传了一层,不知道大家能反应过来不,反应不过来多看几遍((()

状态管理库:Provider🧠

警报:烧脑内容即将来袭!!

在 Flutter 中,状态管理* 是构建应用程序的核心部分,它决定了如何在不同的组件之间共享和更新数据。你可能会遇到这样的问题:如何在多个页面或组件之间共享数据?*

Flutter 提供了多种状态管理方案,而 Provider* 是其中最简单、最常用的解决方案之一。本文将带你一起了解如何使用 Provider* 进行状态管理,并通过简单的例子帮助你轻松上手。

什么是 Provider?*

Provider* 是 Flutter 官方推荐的状态管理库。它让你可以在应用的不同组件之间共享数据

Provider 的核心思想就是:在上层 Widget 中声明状态,然后在其子组件中使用状态*。这样,当状态发生变化时,所有依赖这个状态的组件都会自动更新,而不需要手动管理更新逻辑。

Provider的优势*

​ 1.简化状态管理*:Provider 将数据和 UI 分离,数据的管理逻辑变得更加清晰,适合多页面、多组件的数据共享。

​ 2.自动更新UI*:当数据发生变化时,Provider 能够自动通知相关组件进行重绘,而不需要手动调用 setState()*。

​ 3.结构清晰*:使用 Provider 可以避免繁琐的父组件传递数据到子组件,使代码更简洁可维护。

如何使用 Provider?*

接下来我们通过一个简单的例子来演示如何使用 Provider* 来管理状态。

安装 Provider 包*

首先,在 pubspec.yaml 文件中添加 provider 依赖:

1
2
3
4
dependencies:
flutter:
sdk: flutter
provider: ^6.1.2

Ctrl+S保存pubspec.yml文件,运行 flutter pub get 安装依赖。

计数器应用:Provider版本

还记得我们在介绍StatefulWidget时编写的计数器应用吗,我们现在来使用Provider来编写一遍,大家可以看看区别

创建数据类

1
2
3
4
5
6
7
8
9
import 'package:flutter/material.dart';
class Counter extends ChangeNotifier {
int _count = 0;
int get count => _count;
void increment() {
_count++;
notifyListeners(); // 通知所有监听这个状态的组件进行更新
}
}

Counter 类通过 ChangeNotifier 继承自 Notifier,每次 increment() 方法调用时,我们就增加_count属性。

这里使用了get方法,关于get和set方法请查看附录:get和set方法

注意这里很重要:notifyListeners()* 会通知所有依赖这个状态**的组件,从而触发这些组件的更新,这能让我们一定程度上将StatfulWidget换成StatelessWidget,因为状态不再由组件管理,而是由专门的数据类来管理,这个数据类会负责通知使用了它的组件更新。

在应用中提供数据(亦可称为状态)*

接下来,我们使用 ChangeNotifierProvider 来为应用提供 Counter 状态:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'counter.dart'; // 导入刚刚定义的 Counter 类
void main() {
runApp(
ChangeNotifierProvider(
create: (context) => Counter(),
child: MyApp(),
),
);
}

class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
home: CounterScreen(),
);
}
}

还记得我们前面说过吗,Provider是在上层声明状态,然后在下层子组件中进行使用,我们这里直接在顶层声明了状态,这意味着我们整个APP都可以使用状态。这里使用的是ChangeNotifierProvider来声明状态,有一定的局限性,因为它只能声明一个状态,我们后面还会介绍MultiProvider,它可以为我们的应用声明多个状态

在上面的代码中,ChangeNotifierProvider 提供了 Counter 实例给整个应用,Counter 成为了可以在 MyApp 的子组件中访问和使用的状态。

在组件中使用 Provider*

现在我们来编写一个简单的界面,通过按钮来增加计数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'counter.dart'; // 导入 Counter 类

class CounterScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Provider Example'),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text('You have pushed the button this many times:'),
// 使用 Provider 读取当前的计数值
Consumer<Counter>(
builder: (context, counter, child) {
return Text(
'${counter.count}',
style: Theme.of(context).textTheme.headline4,
);
},
),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: () {
// 使用 Provider 调用 Counter 的 increment 方法
Provider.of<Counter>(context, listen: false).increment();
},
tooltip: 'Increment',
child: Icon(Icons.add),
),
);
}
}

值得注意的是,使用Provider编写的计数器应用没有使用StatefulWidget,而是StatelessWidget,这就是Provider的魅力,可以让我们从编写繁杂的状态类中解放出来。

还记得我们前面介绍的notifyListener()方法吗,调用它所通知的对象就是这些Consumer!一旦有Consumer依赖了相应的状态类(表现为Consumer的泛型参数为状态类的类型**,例如这里Consumer的泛型参数就是我们刚才定义的Counter类)

我该如何获取计数变量的值呢?我又该如何让它的值增加呢?

还记得我们刚才定义的数据类吗,我们只要想办法获取到数据类的实例就可以获取到数据和调用其中定义的递增方法。

有两种方法可以获取到数据类,上例中两种方法都用了。

  • 方法一:Consumer的builder的参数,Provider会直接把数据类实例传给第二个参数,我们直接用就完了
  • 方法二:使用静态方法Provider.of<你的数据类>(context, listen: 是否依赖),上例中的FloatingActionButton使用了这种方法。关于里面的listen参数,你只需要记住在按钮里面这样写就行了,在其他地方使用这种方法不用写listen参数。这与Flutter的父子组件依赖有关,有兴趣的同学请自行搜索(

💡

ChangeNotifier 有什么作用?*

ChangeNotifier 是一个简化的观察者模式实现。它允许类通知监听者(通常是 UI 组件)状态的变化。它的常用方法是 notifyListeners(),在状态发生变化时调用,触发 UI 更新。

观察者模式是面向对象中的一个常用的设计模式,有兴趣可以看看:https://www.runoob.com/design-pattern/observer-pattern.html

img

大家可以试试,退出计数器界面(不是退出整个应用),再重新进去,计数值还是保留着的!这与StatefulWidget版本的计数器是不同的,StatefulWidget实现的计数器会在页面退出时直接被销毁,你再次进入时,又会从0开始计数。

使用多个provider

前面说了,ChangeNotifierProvider类只能提供一个provider,我们可以将其换成MultiProvider来使用多个Provider:

1
2
3
4
5
6
7
8
9
10
11
12
void main() {
runApp(
MultiProvider(
providers: [
ChangeNotifierProvider(create:(context) => YourProvider1(),),
ChangeNotifierProvider(create:(context) => YourProvider2(),),
ChangeNotifierProvider(create:(context) => YourProvider3(),),
],
child: const YourApp()
)
);
}

数据持久化🧊

所谓的数据持久化,就是将用户数据保存在本地设备,在下一次进入应用时进行加载,这样就不会丢失用户状态和数据了。有许多方法可以实现数据持久化,包括但不限于使用本地数据库、shared_preferences,为了简单起见,我们只介绍shared_preferences

shared_preferences

shared_preferences是一种轻量级的本地化储存方式,它以 键-值对 的方式来储存数据,没错,就像Map那样。在Flutter中,想要使用shared_preferences,我们首先要在pubspec.yml中引入依赖:

1
2
dependencies:
shared_preferences: ^2.2.3

首先我们要获取shared_preferences对象:

1
final prefs = await SharedPreferences.getInstance();

注意上面的await不能少,因为getInstances()是一个异步函数,也就是说这个方法只能在async函数里调用,请注意调用的时机!

然后就可以开始愉快的增删改查了!

始终牢记shared_preferences使用键值对来存储数据!

1
2
3
prefs.setString("username", "Tomy");
prefs.setInt("age", 23);
prefs.setBool("gender", true); // 这里用true表示男,false表示女,无先后之分(((

1
prefs.remove("gender"); // 删除 gender

实际上是和增一样的,只需要对已有的键重复set即可:

1
prefs.setString("username", "Alice"); // 用户名由 Tomy 改为 Alice

1
String? username = prefs.getString("username");

注意查的时候返回的是可空类型,因为你查的键有可能没有值,也就是说这一整个键值对都可能不存在

就是这么简单,下一次启动应用的时候就可以通过查的方式来读取上次运行时增加的键值对

但其实shared_preferences也是有限制的,即它能储存的数据类型有限,仅支持基本的几个数据类型和String数组。但就目前来说已经够用了

网络请求和网络请求库Dio🎹

「俺は人间をやめるぞ! ジョジョ──ッ!! 」

什么是网络请求呢?当你打开浏览器浏览网页的时候,浏览器就会根据你输入的网址(URL)去向服务器请求数据,而这个数据就是显示在你屏幕上的网页。浏览器进行的网络请求就是HTTP请求。

倘若你嫌前摇有点长,你可以直接跳到下面这里,如果你发现看不懂再回来看这部分。

💡

网络请求的大概流程是这样的:(大概有个印象就行)

  • 建立连接:**客户端与服务器之间建立连接。**在传统的 HTTP 中,这是基于 TCP/IP 协议的。最近的 HTTP/2 和 HTTP/3 则使用了更先进的传输层协议,例如基于 TCP 的二进制协议(HTTP/2)或基于 UDP 的 QUIC 协议(HTTP/3)。

  • 发送请求客户端向服务器发送请求**,**请求中包含要访问的资源的 URL、请求方法(GET、POST、PUT、DELETE 等)、请求头(例如,Accept、User-Agent)以及可选的请求体(对于 POST 或 PUT 请求)。

  • 处理请求服务器接收到请求后,根据请求中的信息找到相应的资源,执行相应的处理操作**。**这可能涉及从数据库中检索数据、生成动态内容或者简单地返回静态文件。

  • 发送响应服务器将处理后的结果封装在响应中,并将其发送回客户端。响应包含状态码(用于指示请求的成功或失败)、响应头(例如,Content-Type、Content-Length)以及可选的响应体(例如,HTML 页面、图像数据)。

  • 关闭连接:在完成请求-响应周期后,客户端和服务器之间的连接可以被关闭,除非使用了持久连接(如 HTTP/1.1 中的 keep-alive)。

HTTP请求的详细信息请看菜鸟教程:https://www.runoob.com/http/http-messages.html

请求的目的是为了获取数据或者上传数据,HTTP请求中有众多方法都能实现这两点,时间紧迫,我们这里只介绍GET请求和POST请求

  • GET请求:最基本的请求,从服务器获取资源。用于请求数据而不对数据进行更改。例如,从服务器获取网页、图片等。
  • POST请求:向服务器发送数据以创建新资源。常用于提交表单数据或上传文件。发送的数据包含在请求体中。除此之外GET有的功能POST都有

GET和POST请求是可以带请求参数的,比如说当用户需要登录的时候,你肯定要把用户输入的用户名和密码当作参数传给后端来进行验证,后端验证后返还验证是否通过的响应,客户端收到响应后根据验证结果决定页面的跳转。

在开始之前,我们首先要安装一个自己心仪的网络请求库,我这里就选择Dio,其他的库用法都大同小异,只是写法上有细微的差别。

在pubspec.yml文件中加入依赖:

1
2
dependencies:
dio: ^5.7.0

注意,加上我们前面已经安装的shared_preferences和provider库,你的pubspec.yml文件现在看起来应该像这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
name: demo02
description: "A new Flutter project."
publish_to: 'none'
version: 0.1.0

environment:
sdk: '>=3.3.4 <4.0.0'

dependencies:
flutter:
sdk: flutter
shared_preferences: ^2.2.3
provider: ^6.1.2
dio: ^5.7.0

dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^3.0.0

flutter:
uses-material-design: true

如何使用Dio发请求

首先我们要创建一个Dio的网络请求器:

1
final Dio _dio = Dio();

使用这个_dio对象就可以发送请求了。

发送GET请求

1
2
3
4
Response res = await _dio.get(
"请求地址",
queryParameters:{} // 请求参数
);

Demo里面有一个很详细的示例,在lib/http_demo/get_baidu_page.dart中,只不过用到了FutureBuilder,大家还没学,所以代码里面给了非常详细的注释

GET请求是可以在queryParameters属性里面添加参数的,它是一个Map<String, dynamic>?类型,也就是说我们在请求时可以这样给后端传递参数:

1
2
3
4
5
6
7
Response res = await _dio.get(
"请求地址",
queryParameters:{
"username": "Tom",
"password": "123456"
} // 请求参数
);

至于后端如何获取这些参数,那都是后端的事了(

发送POST请求

代码和GET请求大同小异

1
2
3
4
5
6
Response res = await _dio.post(
"请求地址",
queryParameters:{}, // 请求参数
data: {} // 请求数据
);
res.data;

POST除了能用GET的属性,还可以使用一个叫data的属性,HTTP请求中,有一个名为请求体的部分,data中的内容就会被添加到请求体中去,如果你不知道什么叫请求体,请看:https://www.runoob.com/http/http-messages.html

同样的,这个data你传什么,怎么传,什么时候传,什么时候不传,以及啥时候用queryParameters,啥时候用data,全都取决于后端怎么设计,实际开发中,后端会给你一份接口文档,里面包含了每个后端接口的具体参数的传法,你照着来就行。

queryParameters参数会直接添加到请求的url中,比如说:

1
2
3
4
5
6
7
_dio.get(
"http://example.com",
queryParameters: {
"username": "Tom",
"password": "123456"
}
)

其实等效于:

1
2
3
_dio.get(
"http://example.com?username=Tom&password=123456"
)

当你传输一些敏感信息时,这样是不安全的,例如上述例子中的用户密码。而data就不一样,使用data来代替queryParameters,数据就不会在url中出现,而是在请求体中,相对来说会更安全一些。

FutureBuilder🏃

看这个名字你就能大概猜出来它是干什么用的了。没错,它就是根据一个Future对象的执行状态来显示组件。这个FutureBuilder真的是非常的好用,和网络相关的地方用它是很香的,赞美!!

如果你不记得Future是个啥了,这里指个路你可以去复习复习:https://kdocs.cn/l/ceIJ9SqsmB5v?linkname=sgxLJgl2ev

具体来说就是,FutureBuilder有一个future属性,它接受Future一个对象,它还有一个参数叫做builder,这是一个回调函数,也就是说我们将会在builder中去编写我们的显示内容,这个builder函数的第二个参数就厉害了,可以通过它获取传进去的Future对象的执行状态和执行完毕后的结果!废话不多说我们马上来看看咋用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';

class GetBaiduPage extends StatefulWidget {
const GetBaiduPage({
super.key
});

@override
State<GetBaiduPage> createState() => _GetBaiduPageState();
}

class _GetBaiduPageState extends State<GetBaiduPage> {
// 创建一个 Response 的Future对象
late Future<Response> _res;
// 创建网络请求对象
final Dio _dio = Dio();

@override
void initState() {
super.initState();
// 这里不用await,我们就是想把Future保留下来
// 其实是因为initState不能被标记为async方法,不信大家试试,会报错
// 这次我们取得Future中数据的方法是使用FutureBuilder组件,请大家继续往下看
_res = _dio.get("https://baidu.com",);
}

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text("获取百度首页的HTML"),
),
body: Center(
// 还记得这个组件吗,因为HTML很长,我们需要滚动才能显示完全
child: SingleChildScrollView(
child: FutureBuilder(
// 我们的Future对象在这里派上用场
// FutureBuilder的future属性接受一个Future类型,我们的_res刚好是Future类型,直接传进去
future: _res,
builder: (context, snapshot) {
// 我们需要在builder中返回我们想展示的组件
// builder的两个参数第一个不用多说,就是一个BuildContext
// 第二个参数是我们先前传进去的Future对象的信息

// 比如下面的if语句
// snapshot.connectionState储存了当前Future的执行状态
// 如果执行完了就会等于ConnectionState.done
// 我们这里判断是否执行完毕,如果没有就显示一个加载动画
if(snapshot.connectionState != ConnectionState.done){
return CircularProgressIndicator();
}
else{
// 执行完了,我们就可以通过snapshot.data来获取Future中的数据了
// 后面那个data是Response的属性,存储着服务器端发来的数据,在此处就是一个HTML
return Text(snapshot.data!.data);
}
},
),
),
),
);
}
}

这段代码在Demo里面也是有的,在:lib/http_demo/get_baidu_page.dart

所有的解析都在上述代码的注释当中,这里不作单独解释,效果:

img

附录

Placeholder组件

一个占位组件,当你还没想好一个地方该放什么组件的时候你可以使用这个组件,它在UI里面显示为一个带叉的矩形框

SizedBox组件

SizedBox可以用来限制其他组件的大小,或者作为不同组件之间的分隔

  • 限制大小
1
2
3
4
5
6
7
8
SizedBox(
width: 80,
child: Column(
children: [
// 略...
]
)
)

上面的例子中,Column的宽度将会为80

CircularProgressIndicator组件

一个加载动画组件,看起来就像这样:

img

一个一直旋转的小圈圈,它的color属性可以调整圈的颜色,strokeWidth属性可以调整圈的粗细

这个组件本身是没有调整直径的属性的,我们可以在它外面套一层SizedBox组件来实现调整半径的效果:

1
2
3
4
5
6
7
SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
color: Colors.white
);
)

这样就是一个直径为20的加载动画,值得注意的是,如果width和height的值不一样,它的形状会是一个椭圆形

Material和Cupertino

省流:这是两种不同的UI风格,Material是安卓上的UI风格,Cupertino是iOS上的UI风格。

ChatGPT解释:

Material Design* 是 Google 推出的设计规范,旨在创建一致的、现代的、易用的用户界面。Flutter 提供了一套基于 Material Design 的组件库,可以让开发者快速构建符合 Material 规范的应用。

​ • 使用场景*:主要用于构建 Android 应用或希望统一风格的跨平台应用。

​ • 常见组件*:

​ • Scaffold:包含基本的应用布局结构(AppBar、Drawer、FloatingActionButton 等)。

​ • AppBar:应用栏,显示标题、操作按钮、导航图标等。

​ • FloatingActionButton:浮动操作按钮,通常用于强调主要的操作。

​ • MaterialButton、TextField、Card、SnackBar 等。

Cupertino Design* 是 Apple 的 iOS 风格的设计语言,Flutter 提供了一套对应的 Cupertino 组件库,可以让开发者构建符合 iOS 风格的应用界面。

​ • 使用场景*:主要用于构建 iOS 应用或希望在 iOS 上提供原生体验的跨平台应用。

​ • 常见组件*:

​ • CupertinoNavigationBar:iOS 风格的导航栏。

​ • CupertinoButton:iOS 风格的按钮。

​ • CupertinoPageScaffold:类似于 Scaffold,用于提供基本的页面结构。

​ • CupertinoTextField、CupertinoPicker、CupertinoSlider 等。

get和set方法

这其实不是什么新东西,只不过是Dart给我们提供的语法糖罢了。

get方法

我们经常会有这样的需求:定义一个类,其中有一个私有变量,而外部又想要访问这个私有变量,我们就可以编写一个公开的方法来返回这个私有变量,外部想要访问直接调用这个方法就行了

为什么不直接把变量声明为公有的呢?不想解释,查Google吧

话说回来,使用get方法比普通方法要更方便,因为get方法表现得像一个属性,以下是一个示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// 使用普通方法
class User {
User(this._username);

// 私有变量
String _username;

String getUsername(){
return _username;
}
}
void main(){
User u = User("Tom");
print(u.getUsername()); // 输出: Tom
}

// 使用get方法
class User {
User();

// 私有变量
String _username;

String get username{
return _username;
}
// 或者使用箭头函数
// String get username => _username;
}
void main(){
User u = User("Tom");
print(u.username); // 输出:Tom
}

可以看到无论是定义get方法还是调用get方法,都是不用加括号的,特别是在调用时,外部根本看不出来username到底是方法还是属性

当然你不一定要将get方法命名为私有变量去除下划线的形式,任何合法的名字都是可以的

set方法

这与get方法的设计理念是一样的,它在外部看来表现得就像一个属性,我们还是继续用刚才的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
// 使用普通方法
class User {
User(this._username);

// 私有变量
String _username;

String getUsername(){
return _username;
}

void setUsername(String name){
_username = name;
}
}
void main(){
User u = User("Tom");
u.setUsername("Alice")
print(u.getUsername()); // 输出:Alice
}

// 使用set方法
class User {
User();

// 私有变量
String _username;

String get username{
return _username;
}
// 或者使用箭头函数
// String get username => _username;

// void 可以省略
void set username(String name){
_username = name;
}
}
void main(){
User u = User("Tom");
// set方法表现得就像一个属性!
u.username = "Alice";
print(u.username); // 输出:Alice
}

深入Flutter底层:BuildContext,Widget和Element

咱还是暂时不深入了吧(