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

5,545 阅读8分钟

当系统组件不能满足需求时才自定义控件?在 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,绘制控件的状态信息将会存储在其中,这些信息会发生变化,以触发重新构建控件,即重新调用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,
                ),
              )
            ],
          ),
      ],
    );
  }
}

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

微信图片_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 不需要状态管理。

下一篇接着分享需要状态管理的 App state。

推荐阅读