写点什么

以购物车为例探讨 Flutter 的状态管理的必要性

作者:岛上码农
  • 2022 年 5 月 08 日
  • 本文字数:4465 字

    阅读完需:约 15 分钟

以购物车为例探讨 Flutter 的状态管理的必要性

本文主要内容翻译自 Flutter 官方文档:Simple app state management,状态管理系列文章会比较多,先从官方的示例文档开始,能够更好地理解状态管理的概念。

前言

声明式 UI 程序的主要特点是 UI 界面的实际绘制和声明界面的代码是分离的。本人刚接触 Flutter 的时候就很不适应,以前 iOS 写个文本控件,修改文字内容时直接修改 UIText 的 text 属性即可,但是对于 Flutter 而言,Text 组件的内容初始化之后不可以直接修改,而是需要通过状态管理更改数据后再触发对应的方法重新构建 UI 界面(典型的就是调用 setState 方法触发 build)。这也是现代响应式框架的特点,像 React,Vue,SwiftUI 都是类似的思路。


由于数据和界面分离,使得代码的业务逻辑更清晰,也易于封装和共用。状态管理成为了核心业务所在,因此十分重要。

购物车示例

为了演示状态管理,我们以简单的购物车为例。我们的应用有两个独立的页面:商品列表(GoodsList)和购物车(MyCart)。业务逻辑也很简单:


  • 从商品列表点击添加按钮时就把商品添加到购物车

  • 从购物车页面可以看到已经添加进去的商品。

  • 商品列表的商品如果已经加入到了购物车就打勾,不再允许重复加入。


为了简化业务逻辑,这里没有实现商品修改数量和购物车的移除商品功能。应用的组件结构如下图所示。



这里我们就会有一个问题,我们在哪里管理购物车的状态?是在 MyCart 中还是别的地方?

状态管理提升

在 Flutter 中,将状态管理置于使用状态的组件的上层会更加合理。这是因为,像 Flutter 这样的声明式框架,如果要改变 UI 界面,必须重建组件。我们不能通过 MyCart.updateWith(somethingNew)来更新界面。换言之,我们不能在外部调用组件的某个方法来显示地更改组件。即便是你想这么做,你得绕过框架的限制而不是利用框架的优势。


// 糟糕的示例void myTapHandler() {  var cartWidget = somehowGetMyCartWidget();  cartWidget.updateWith(item);}
复制代码


即便是上面的代码能够工作,之后我们也需要实现对应的 updateWith 方法。


// 糟糕的示例Widget build(BuildContext context) {  return SomeWidget(    // 购物车的初始状态  );}
void updateWith(Item item) { // 更新界面的代码}
复制代码


这个代码中需要考虑 UI 的当前状态,然后将新的数据应用到界面上。这样很难避免 bug。在 Flutter 中,一旦界面对应的内容发生改变了,每次都会新构建一个组件。我们应该使用 MyCart(contents)来替换 MyCart.updateWith(somethingNew)方法调用这种形式。这是因为,我们只能在组件的父节点的 build 方法构建新的组件,这就要求状态是在 MyCart 的父节点或者更上的层级中管理。


// 好的示例void myTapHandler(BuildContext context) {  car cartModel = somehowGetMyCartModel(context);  cartModel.add(item);}
复制代码


现在,购物车中只会有一个入口来构建 UI。


// 好的示例Widget build(BuildContext context) {  var cartModel = somehowGetMyCartModel(context);  return SomeWidget(    //只需要利用当前状态构建一次 UI  );}
复制代码


在这个例子中,contents 应该是在 MyApp 管理,一旦它发生了改变,应用将从上一层重建 MyCart 组件。这样的好处是,MyCart 无需生命周期管理,它只是声明了如何按 contents 来展示界面(MyCart 变成了无状态组件,界面和业务逻辑是分开的)。当状态发生改变后,旧的 MyCart 组件会消失,然后用新的来替换。



从这里也能够看出来为什么说组件(Widget)时不可变的,他们不会改变,而是被替换。知道在哪里管理状态了,下面我们来看如何访问状态。

访问状态

当用户点击商品列表的一个元素后,它将被加入购物车。但是我们的购物车在商品元素的上一级,这个时候怎么办?一个简单的办法时给每个元素一个回调方法,当被点击后调用该方法。在 Dart 中,函数是一等对象,因此可以将函数作为参数传递。因此,在商品列表中我们可以用代码这么实现:


@overrideWidget build(BuildContext context) {  return SomeWidget(myTapCallback);}
void myTapCallback(Item item) { // 处理商品点击事件}
复制代码


这样也能正常工作,但是,如果我们的应用很多地方都要用到商品列表这个组件,那么我们的商品点击处理方法会散落在各个组件中,结果很难维护(当相同的代码被重复使用 2 次以上时,就要考虑你的设计是不是有问题了)。幸运的是,Flutter 提供了组件为下级组件(包括子组件,以及子组件的下级组件)提供数据的机制。如同 Flutter 中一切皆是组件的理念,数据传递也是一种特殊的组件:InheritedWidgetInheritedNotifierInheritedModel 等等。本篇暂时不会涉及这些组件的内容,因为这些组件在更深层级实现。


这里我们需要插件 Provider,Provider 为我们隐藏了深层次的数据传递组件,从而简化状态管理。Provider 的具体使用可以参考 pub 的文档:状态管理插件 Provider。后续我们也将深入介绍 Provider 插件的使用。

Provider 之 ChangeNotifier

ChangeNotifier 是 Flutter SDK 内置的简单类,以便向监听者提供变化信息。换言之,如果对象是ChangeNotifier对象(继承或 mixin),那么我们就可以订阅它的变化(其实就和观察者模式相似)。在 Provider 中,ChangeNotifier是封装应用状态的一种方式。对于简单的应用,可以使用单个 ChangeNotifier。对于复杂应用,会有多个模型,因此会有多个 ChangeNotifier。虽然不使用 Provider 也能使用 ChangeNotifier,但是有了 Provider,会更加简单。在我们的购物车示例中,我们可以在一个 ChangeNotifier 中管理购物车的状态,因此我们创建一个购物车模型类来继承 ChangeNotifier


class CartModel extends ChangeNotifier {  final List<Item> _items = [];    UnmofiableListView<Item> get items => UnmodiableListView(_items);    int get totalPrice => _items.length * 42;    void add(Item item) {    _items.add(item);    notifyListeners();  }    void removeAll() {    _items.clear();    notifyListeners();  }
复制代码


ChangeNotifier唯一特殊之处在于调用notifyListeners方法。在模型发生改变的任何时候调用该方法可能会刷新 UI 界面。而在 CartModel 的其余代码都是自身的业务逻辑。ChangeNotifier 是 flutter:foundation 的一部分,并不依赖于其他更高级的类。因此,测试起来十分简单(甚至都不需要使用组件来测试)。例如,下面时 CartModel 的一个简单的单元测试:


text('adding item increass total cost', () {  final cart = CartModel();  final startingPrice = cart.totalPrice;  cart.addListener(() {    expect(cart.totalPrice, greaterThan(startingPrice));  });  cart.add(Item('Dash'));
复制代码

Provider 之 ChangeNotifierProvider

ChangeNotifierProvider 是一个为子节点提供 ChangeNotifier 实例的组件。这是在 Provider 包中定义的。我们之前讲到过要在方位状态的组件上层定义 状态,即这里的 ChangeNotifierProvider。对于 CartModel 来说,这意味着是商品列表和购物车的上层——那就是我们的 App 这一层。


void main() {  runApp(    ChangeNotifierProvider(      create: (context) => CartModel(),      child: const App(),      ),    );}
复制代码


需要注意,我们定义了一个构造方法来返回 CartModel 的实例对象。ChangeNotifierProvider 在没有必要的情况下不会重新构建 CartModel。而且,会在实例不再需要的时候调用 dispose 来销毁该对象。如果我们需要提供多个状态示例对象,可以使用 MultiProvider:


void main() {  runApp(    MultiProvider(      providers: [        ChangeNotifierProvider(create: (context) => CartModel()),        Provider(create: (context) => SomeOtherClass()),      ],      child: const App(),      ),    );}
复制代码

Provider 之 Consumer

现在 CartModel 已经能够通过在应用顶层定义的 ChangeNotifierProvider 提供给组件了,我们就可以在组件中使用了。


return Consumer<CartModel>(  builder: (context, cart, child) {    return Text('Total price is ${cart.totalPrice}');  },);
复制代码


在 Consumer 中我们必须指定我们要访问的模型的类。在这个例子中,我们需要 CartModel,因此我们是使用 Consumer<CartModel>。如果在泛型中不指定那个类,那 Provider 包将无法帮助我们。Provider 是基于类型提供状态信息的,如果没有指定类型那它不知道组件需要什么信息。Consumer 只需要一个必填参数,那就是 builder。builder 是在 ChangeNotifier 对象发生改变时会被调用的函数。也就是在状态模型的 notifyListeners 方法被调用到时候,所有响应该状态模型的 Consumer 的 builder 方法都会被调用。builder 方法有三个参数,第一个是和组件的 build 方法相同的 context;第二个是触发 build 函数调用的 ChangeNotifier 实例对象,我们可以从中获取 UI 界面所需要的数据。第三个参数是 child,这是用于优化的。如果在我们的 Consumer 下有一个很大的子组件树,而且在模型改变的时候这些子组件树并不需要改变,那么我们就可以只需要对这个子组件构建一次:


return Consumer<CartModel>(  builder: (context, cart, child) => Stack (    children: [      if(child != null) child,      Text('Total price is ${cart.totalPrice}'),    ],  ),  child: const SomeExpensiveWidget(),);
复制代码


将 Consumer 组件放置在组件树的位置越深越好,这样其他部分的某些细节改变时我无需构建大量的 UI,从而可以提升性能。


// 糟糕的示例return Consumre<CartModel>(  builder: (context, cart, child) {    return HumongousWidget(      child: AnotherMonstrousWidget(        //...        child: Text('Total price is ${cart.totalPrice}'),      ),    );  });
复制代码


正确的做法是这样:


return HumongousWidget(  child: AnotherMonstrousWidget(    //...    child: Consumre<CartModel>(      builder: (context, cart, child) {        return Text('Total price is ${cart.totalPrice}');      },    )  ),);
复制代码

Provider.of 方法

在某些情况下,我们并不需要根据状态信息更改界面,而是访问状态对象以进行别的操作。例如我们有一个清空购物车的按钮,点击按钮的时候需要调用 CartModel 的 removeAll 方法,这个时候我们可以这么写:


onPressed: () {  Provider.of<CartModel>(context, listen: false).removeAll();}
复制代码


注意,listen 参数设置为 false 表示当状态改变的时候无需通知该组件进行重建。

总结

代码已上传至 gitee:简单状态管理示例。运行效果如下(对原示例做了些许改动,以便更像真正的购物车)。可以看到,使用了状态管理有下面几个好处:


  1. 页面间的数据是同步的。

  2. 即便退出页面后,再进入之前的状态还是保持的,这也是为什么要把状态管理放置在更上层级的原因之一。

  3. 业务代码和界面是分离的,界面只负责页面的渲染和交互,而具体的业务逻辑在状态管理中实现。代码更容易维护。

  4. 大部分页面可以设置为无状态组件,通过 Provider 实现局部刷新从而提高性能。




发布于: 刚刚阅读数: 3
用户头像

岛上码农

关注

用代码连接孤岛,公众号@岛上码农 2022.03.03 加入

从南漂到北,从北漂到南的业余码农

评论

发布
暂无评论
以购物车为例探讨 Flutter 的状态管理的必要性_flutter_岛上码农_InfoQ写作社区