Flutter 基础 | 自定义控件 StatelessWidget & StatefulWidget
当系统组件不能满足需求时才自定义控件?在 Flutter 中这句话可能不一定成立。这一篇就解释一下为啥 Flutter 中有事没事就应该自定义一个控件。
自定义无状态控件
状态不会发生变化的控件称为无状态控件StatelessWidget
。它的状态在构建的时候已经确定,并且永远不会发生变化,即系统永远不会重新构建无状态控件。
Flutter 的控件是高度嵌套的,刚从 Android 转过来的时候,整个人是懵的,控件居中都需要嵌套一层:
Center(
child: Text('xxx'),
)
其中Center
是一个控件,Text
也是一个控件。
在 Android 原生的世界里面,用 ConstraintLayout 可以把一个界面的嵌套层级降为 0,同样的界面到了 Flutter 中,六七层嵌套起步,这么个嵌套法,界面不会卡吗?
从体感上来说,好像嵌套层多并未影响到绘制性能,以后的篇章会分析背后的原理。但这样的嵌套对阅读代码来说就已经非常不友好了。
这个底导栏在原生 Android 中可以是一个 ConstraintLayout,其中包含了平级的 3 个 ImageView 和 3 个 TextView。但在 Flutter 中,它是这样实现的:
void main() {
runApp(MyApp());
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Welcome to Flutter',
home: Scaffold(
appBar: AppBar(
title: const Text('Welcome to Flutter'),
),
body: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.call, color: Colors.blue),
Container(
margin: const EdgeInsets.only(top: 8),
child: Text(
"CALL",
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w400,
color: Colors.blue,
),
),
),
],
),
Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.near_me, color: Colors.blue),
Container(
margin: const EdgeInsets.only(top: 8),
child: Text(
"ROUTE",
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w400,
color: Colors.blue,
),
),
),
],
),
Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.share, color: Colors.blue),
Container(
margin: const EdgeInsets.only(top: 8),
child: Text(
"SHARE",
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w400,
color: Colors.blue,
),
),
),
],
)
],
),
),
);
}
}
看着末尾那一层层递进的括号,我快要疯掉。。。
因为 Flutter 是用横向+纵向的布局方式来理解这个界面的,首先是横向容器Row
,它包含三个纵向容器Column
,每个 Column 中又包含一个文字和一张图片。
所以“改善布局代码的可读性”在 Flutter 中是件头等大事。
为此 AndroidStudio 的插件也提供了快捷入口,鼠标右键控件,依次选择Refactor ▸ Extract ▸ Extract Flutter Widget…。
对上述代码中的第一个Column
进行重构,取名为BottomCallItem
,IDE 会自动生成如下代码:
class BottomCallItem extends StatelessWidget {
const BottomCallItem({
Key? key,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.call, color: Colors.blue),
Container(
margin: const EdgeInsets.only(top: 8),
child: Text(
"CALL",
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w400,
color: Colors.blue,
),
),
),
],
);
}
}
IDE 会默认将控件抽象为无状态控件StatelessWidget
。无状态控件会包含一个构造方法和build()
方法。build() 方法描述的是如何构建控件,通常这里是一些系统控件的组合。BottomCallItem 就是用垂直线性布局包裹一张图片和一段文字。
用这种方式,原本的代码就可以简化如下:
void main() {
runApp(MyApp());
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Welcome to Flutter',
home: Scaffold(
appBar: AppBar(
title: const Text('Welcome to Flutter'),
),
body: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
BottomCallItem(),
BottomRouteItem(),
BottomShareItem()
],
),
),
);
}
}
所以抽象出无状态控件通常是为了减少嵌套层次,增加代码可读性。
自定义有状态控件
让我们再进一步,底导栏中的按钮通常有选中/未选中状态。这种状态会发生变化的控件在 Flutter 中叫StatefulWidget
。
在 AndroidStudio 中一键就能把一个 StatelessWidget 转化成 StatefulWidget。
选中 StatelessWidget 类名,按Alt + Enter
,点击Convert to StatefulWidget
,就完成了一键转化。
将 BottomCallItem 重命名为 BottomBar,因为这次要自定义的控件是整个底导栏:
// 自定义底导栏
class BottomBar extends StatefulWidget {
const BottomBar({
Key? key,
}) : super(key: key);
// 构建与底导栏绑定的状态
@override
_BottomBarState createState() => _BottomBarState();
}
// 与 BottomBar 绑定的状态类
class _BottomBarState extends State<BottomBar> {
// 在状态类中构建自定义控件
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.call, color: Colors.blue),
Container(
margin: const EdgeInsets.only(top: 8),
child: Text(
"CALL",
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w400,
color: Colors.blue,
),
),
),
],
);
}
}
IDE 自动新增了一个状态类_BottomBarState
继承自State
,绘制控件的状态信息将会存储在其中,这些信息会发生变化,以触发重新构建控件,即重新调用build()
方法。
当控件被插入到绘制树时,StatefulWidget.createState()
会被调用以构建与控件绑定的状态实例。与BottomBar
绑定的是_BottomBarState
实例。
添加不可变状态
不可变状态意味着当控件实例被构建之后就不会发生变化的参数。
对于底导栏来说就是其中包含的按钮数据,将按钮数据抽象为一个实体类:
class Item {
String name = ""; // 按钮名称
IconData? icon; // 按钮图标
Item(this.name, this.icon); // 构造方法
}
BottomBar
在构造时应传入一组Item
实例:
class BottomBar extends StatefulWidget {
final List<Item> items; // 所有 StatefulWidget 的属性必须是final的
BottomBar({
Key? key,
required this.items, // 构造时传入一组按钮
}) : super(key: key);
@override
_BottomBarState createState() => _BottomBarState();
}
BottomBar 布局构建逻辑在_BottomBarState.build()
中实现:
class _BottomBarState extends State<BottomBar> {
@override
Widget build(BuildContext context) {
// 底导栏控件的容器是一个横向的线性布局
return Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
// 遍历 BarBottom 中的 items 数据,逐个构建按钮
for (var item in widget.items)
// 单个按钮是一个纵向线性布局
Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
// 单个按钮包含一个图标和一个文字控件
Icon(item.icon, color: Colors.blue),
Text(
item.name,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w400,
color: Colors.blue,
),
)
],
),
],
);
}
}
Flutter 声明式的布局代码带来的一个好处就是:布局中可以嵌入逻辑,这让动态构建布局变得轻而易举。在 Android 原生世界里,布局和逻辑是完全切割的,布局在 .xml 中,逻辑在 .java(.kt) 中。
底导栏的按钮数量是动态的,会随着传入的 items 列表长度而变。所以得动态地构建。
State
的子类可以通过widget
方便地访问到绑定控件的实例,而items
又是控件的成员变量。通过遍历 items 实现动态构建,每次遍历都会构建一个纵向的线性布局,它包含两个子控件:图标+文字,并且用Item
中的数据填充它们。
然后就可以像这样创建 BottomBar 的实例了:
BottomBar(
items: [
Item('CALL', Icons.call),
Item('ROUTE', Icons.near_me),
Item('SHARE', Icons.share)
]
);
添加可变状态
虽然 BottomBar 声明为有状态控件,但直到现在它还没有状态变化。唯一和他绑定的数据items
也是可不变的 final 类型,即控件的整个生命周期中不会发生变化。
为了让 BottomBar 能够有选中高亮,未选中置灰的效果,得为它增加可变状态。
对于 BottomBar 来说,得实现一个子控件之间的单选效果,即一个选中的控件高亮,其他的置灰。于是乎决定使用一个 Map 保存每个子控件的选中状态:
class _BottomBarState extends State<BottomBar> {
// 保存每个控件选中状态的 map
var _selectMap = {};
@override
void initState() {
super.initState();
// 初始化可变状态
for (var i = 0; i < widget.items.length; i++) {
_selectMap[widget.items[i].name] = i == 0 ? true : false;
}
}
}
可变状态通常以State
类的成员出现。State
实例被构建之后,系统提供了State.initState()
,以实现一次性的初始化。
通过遍历按钮列表为每个按钮选中状态赋初始值,以按钮名为键,以按钮是否选中的布尔值为值构建 Map。默认选中第一个按钮。
将选中状态和界面构建结合起来:
class _BottomBarState extends State<BottomBar> {
var _selectMap = {};
@override
void initState() {
super.initState();
for (var i = 0; i < widget.items.length; i++) {
_selectMap[widget.items[i].name] = i == 0 ? true : false;
}
}
@override
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
for (var item in widget.items)
Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
item.icon,
// 如果选中则呈现蓝色否则灰色
color: _selectMap[item.name] ? Colors.blue : Colors.grey),
Text(
item.name,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w400,
// 如果选中则呈现蓝色否则灰色
color: _selectMap[item.name] ? Colors.blue : Colors.grey,
),
)
],
),
],
);
}
}
运行代码,就可以展示如下界面:
下一步得让每个按钮响应点击事件,并且让高亮和点击联动。
Flutter 中为控件增加点击事件是通过包一层GestureDetector
实现的:
class _BottomBarState extends State<BottomBar> {
...
@override
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
for (var item in widget.items)
GestureDetector(
// 单击响应逻辑
onTap: () {
setState(() {
// 将所有按钮置为未选中
for (var i = 0; i < widget.items.length; i++) {
_selectMap[widget.items[i].name] = false;
}
// 将点击按钮置为选中
_selectMap[item.name] = true;
});
},
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(item.icon, color: _selectMap[item.name] ? Colors.blue : Colors.grey),
Text(
item.name,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w400,
color: _selectMap[item.name] ? Colors.blue : Colors.grey,
),
)
],
),
)
],
);
}
}
当按钮被单击时,调用State.setState()
方法,该方法的参数是VoidCallback
类型的:
abstract class State<T extends StatefulWidget> with Diagnosticable {
void setState(VoidCallback fn) {...}
}
typedef VoidCallback = void Function();
VoidCallback 是一个没有输入和输出的回调方法,通常在这个回调中更新状态。
当前场景是在该回调中遍历 Map,先将所有按钮置为未选中,然后再将被点击的那个置为选中。
调用了setState()
就是告诉系统:该控件状态发生变化,系统将触发一次重绘,即调用build()
方法,而构建控件的逻辑又依赖于状态数据_selectMap
,就这样界面重绘出了不同的样子。
最后需要在 State 生命周期结束的时候清理状态:
class _BottomBarState extends State<BottomBar> {
var _selectMap = {};
@override
void dispose() {
super.dispose();
_selectMap.clear();
}
...
}
State.dispose()
是 State 对象生命周期的终点,被 dispose 之后,它就处于unmounted
状态,表现为State.mounted
值为 false,再调用setState()
就会报错。
添加选中回调
友好的底导栏控件应该提供一个回调来告诉上层那个按钮被选中了。这回调也是一种状态,而且是不可变状态,所以将他添加到BottomBar
中:
class BottomBar extends StatefulWidget {
final List<Item> items;
// 声明选中回调
final OnTabSelect? onTabSelect;
BottomBar({
Key? key,
required this.items,
this.onTabSelect, // 在构造方法中传入回调
}) : super(key: key);
@override
_BottomBarState createState() => _BottomBarState();
}
// 将函数类型重命名
typedef OnTabSelect = void Function(int value);
用typedef
关键词将一个函数类型重命名为OnTabSelect
,void Function(int value)
表示函数接受一个 int 类型的实参但没有返回值。
然后在_BottomBarState
中引用该回调:
class _BottomBarState extends State<BottomBar> {
var _selectMap = {};
@override
void initState() {
super.initState();
for (var i = 0; i < widget.items.length; i++) {
_selectMap[widget.items[i].name] = i == 0 ? true : false;
}
}
@override
void dispose() {
super.dispose();
_selectMap.clear();
}
@override
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
for (var item in widget.items)
GestureDetector(
onTap: () {
setState(() {
for (var i = 0; i < widget.items.length; i++) {
_selectMap[widget.items[i].name] = false;
}
_selectMap[item.name] = true;
});
// 在点击事件响应逻辑中引用回调
if (widget.onTabSelect != null) {
// 将选中按钮的索引值传递出去
widget.onTabSelect!(widget.items.indexOf(item));
}
},
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(item.icon, color: _selectMap[item.name] ? Colors.blue : Colors.grey),
Text(
item.name,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w400,
color: _selectMap[item.name] ? Colors.blue : Colors.grey,
),
)
],
),
)
],
);
}
}
最后就可以像这样使用底导栏了:
BottomBar(
items: [
Item('CALL', Icons.call),
Item('ROUTE', Icons.near_me),
Item('SHARE', Icons.share)
],
onTabSelect: (index) {
print('$index');
},
);
等等~,不是说界面展示和业务逻辑(数据)要分离吗?_selectMap
即是业务数据,为了和界面隔离,它不是该出现在ViewModel
中吗?然后界面通过观察它实现刷新。
没错,但当前场景不需要这样小题大作,Flutter 把类似_selectMap
的数据称为Ephemeral state,即转瞬即逝的状态。App 的其他组件不需要了解_selectMap
的变化,它的变化只会在底导栏中发生,它的生命周期和底导栏完全同步,即使用户离开后再次返回时重新构建它也没什么不好的体验。用 Flutter 的话说,就是 Ephemeral state 不需要状态管理。
下一篇接着分享需要状态管理的 App state。
推荐阅读
转载自:https://juejin.cn/post/7030934610452152356