阅读 215

Flutter 基础 | 自定义控件 StatelessWidget & StatefulWidget

当系统组件不能满足需求时才自定义控件?在 Flutter 中这句话可能不一定成立。这一篇就解释一下为啥 Flutter 中有事没事就应该自定义一个控件。

自定义无状态控件

状态不会发生变化的控件称为无状态控件StatelessWidget。它的状态在构建的时候已经确定,并且永远不会发生变化。

Flutter 的控件是高度嵌套的,刚从 Android 转过来的时候,整个人是懵的,控件居中都需要嵌套一层:

Center(   child: Text('xxx'), ) 复制代码

其中Center是一个控件,Text也是一个控件。

在 Android 原生的世界里面,用 ConstraintLayout 可以把一个界面的嵌套层级降为 0,同样的界面到了 Flutter 中,六七层嵌套起步,这么个嵌套法,界面不会卡吗?

从体感上来说,好像嵌套层多并未影响到绘制性能,以后的篇章会分析背后的原理。但这样的嵌套对阅读代码来说就已经非常不友好了。

微信截图_20211109123050.png

这个底导栏在原生 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()           ],         ),       ),     );   } } 复制代码

所以抽象出无状态控件通常是为了减少嵌套层次,增加代码可读性。

自定义有状态控件

微信图片_2021110914310966666666666.png

让我们再进一步,底导栏中的按钮通常有选中/未选中状态。这种状态会发生变化的控件在 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,绘制控件的状态信息将会存储在其中,这些信息会在控件生命周期中变化。

当控件被插入到绘制树时,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,                 ),               )             ],           ),       ],     );   } } 复制代码

运行代码,就可以展示如下界面:

微信图片_2021110914310966666666666.png

下一步得让每个按钮响应点击事件,并且让高亮和点击联动。

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关键词将一个函数类型重命名为OnTabSelectvoid 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 不需要状态管理。


作者:唐子玄
链接:https://juejin.cn/post/7030934610452152356

文章分类
代码人生
版权声明:本站是系统测试站点,无实际运营。本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至 XXXXXXo@163.com 举报,一经查实,本站将立刻删除。
相关推荐