当系统组件不能满足需求时才自定义控件?在 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(); }
required
关键词表示参数items
在构造时是必须的。构造方法中this.items
这种语法表示传入的实参直接赋值给成员items
。关于 Dart 的语法知识可以点击Flutter 基础 | Dart 语法。
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。