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

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

前言

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

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

购物车示例

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

  • 从商品列表点击添加按钮时就把商品添加到购物车
  • 从购物车页面可以看到已经添加进去的商品。
  • 商品列表的商品如果已经加入到了购物车就打勾,不再允许重复加入。

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

购物车组件.png

这里我们就会有一个问题,我们在哪里管理购物车的状态?是在 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 组件会消失,然后用新的来替换。

购物车组件变更状态.png

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

访问状态

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

@override
Widget 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 实现局部刷新从而提高性能。
运行效果
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 215,463评论 6 497
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,868评论 3 391
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 161,213评论 0 351
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,666评论 1 290
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,759评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,725评论 1 294
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,716评论 3 415
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,484评论 0 270
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,928评论 1 307
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,233评论 2 331
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,393评论 1 345
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,073评论 5 340
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,718评论 3 324
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,308评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,538评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,338评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,260评论 2 352

推荐阅读更多精彩内容