以购物车为例探讨 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)
来更新界面。换言之,我们不能在外部调用组件的某个方法来显示地更改组件。即便是你想这么做,你得绕过框架的限制而不是利用框架的优势。
即便是上面的代码能够工作,之后我们也需要实现对应的 updateWith 方法。
这个代码中需要考虑 UI 的当前状态,然后将新的数据应用到界面上。这样很难避免 bug。在 Flutter 中,一旦界面对应的内容发生改变了,每次都会新构建一个组件。我们应该使用 MyCart(contents)来替换 MyCart.updateWith(somethingNew)方法调用这种形式。这是因为,我们只能在组件的父节点的 build 方法构建新的组件,这就要求状态是在 MyCart 的父节点或者更上的层级中管理。
现在,购物车中只会有一个入口来构建 UI。
在这个例子中,contents 应该是在 MyApp 管理,一旦它发生了改变,应用将从上一层重建 MyCart 组件。这样的好处是,MyCart 无需生命周期管理,它只是声明了如何按 contents 来展示界面(MyCart 变成了无状态组件,界面和业务逻辑是分开的)。当状态发生改变后,旧的 MyCart 组件会消失,然后用新的来替换。
从这里也能够看出来为什么说组件(Widget)时不可变的,他们不会改变,而是被替换。知道在哪里管理状态了,下面我们来看如何访问状态。
访问状态
当用户点击商品列表的一个元素后,它将被加入购物车。但是我们的购物车在商品元素的上一级,这个时候怎么办?一个简单的办法时给每个元素一个回调方法,当被点击后调用该方法。在 Dart 中,函数是一等对象,因此可以将函数作为参数传递。因此,在商品列表中我们可以用代码这么实现:
这样也能正常工作,但是,如果我们的应用很多地方都要用到商品列表这个组件,那么我们的商品点击处理方法会散落在各个组件中,结果很难维护(当相同的代码被重复使用 2 次以上时,就要考虑你的设计是不是有问题了)。幸运的是,Flutter 提供了组件为下级组件(包括子组件,以及子组件的下级组件)提供数据的机制。如同 Flutter 中一切皆是组件的理念,数据传递也是一种特殊的组件:InheritedWidget
,InheritedNotifier
,InheritedModel
等等。本篇暂时不会涉及这些组件的内容,因为这些组件在更深层级实现。
这里我们需要插件 Provider,Provider 为我们隐藏了深层次的数据传递组件,从而简化状态管理。Provider 的具体使用可以参考 pub 的文档:状态管理插件 Provider。后续我们也将深入介绍 Provider 插件的使用。
Provider 之 ChangeNotifier
ChangeNotifier
是 Flutter SDK 内置的简单类,以便向监听者提供变化信息。换言之,如果对象是ChangeNotifier
对象(继承或 mixin
),那么我们就可以订阅它的变化(其实就和观察者模式相似)。在 Provider 中,ChangeNotifier
是封装应用状态的一种方式。对于简单的应用,可以使用单个 ChangeNotifier
。对于复杂应用,会有多个模型,因此会有多个 ChangeNotifier
。虽然不使用 Provider 也能使用 ChangeNotifier
,但是有了 Provider,会更加简单。在我们的购物车示例中,我们可以在一个 ChangeNotifier
中管理购物车的状态,因此我们创建一个购物车模型类来继承 ChangeNotifier
。
ChangeNotifier
唯一特殊之处在于调用notifyListeners
方法。在模型发生改变的任何时候调用该方法可能会刷新 UI 界面。而在 CartModel 的其余代码都是自身的业务逻辑。ChangeNotifier 是 flutter:foundation 的一部分,并不依赖于其他更高级的类。因此,测试起来十分简单(甚至都不需要使用组件来测试)。例如,下面时 CartModel 的一个简单的单元测试:
Provider 之 ChangeNotifierProvider
ChangeNotifierProvider 是一个为子节点提供 ChangeNotifier 实例的组件。这是在 Provider 包中定义的。我们之前讲到过要在方位状态的组件上层定义 状态,即这里的 ChangeNotifierProvider。对于 CartModel 来说,这意味着是商品列表和购物车的上层——那就是我们的 App 这一层。
需要注意,我们定义了一个构造方法来返回 CartModel 的实例对象。ChangeNotifierProvider 在没有必要的情况下不会重新构建 CartModel。而且,会在实例不再需要的时候调用 dispose 来销毁该对象。如果我们需要提供多个状态示例对象,可以使用 MultiProvider:
Provider 之 Consumer
现在 CartModel 已经能够通过在应用顶层定义的 ChangeNotifierProvider 提供给组件了,我们就可以在组件中使用了。
在 Consumer 中我们必须指定我们要访问的模型的类。在这个例子中,我们需要 CartModel,因此我们是使用 Consumer<CartModel>。如果在泛型中不指定那个类,那 Provider 包将无法帮助我们。Provider 是基于类型提供状态信息的,如果没有指定类型那它不知道组件需要什么信息。Consumer 只需要一个必填参数,那就是 builder。builder 是在 ChangeNotifier 对象发生改变时会被调用的函数。也就是在状态模型的 notifyListeners 方法被调用到时候,所有响应该状态模型的 Consumer 的 builder 方法都会被调用。builder 方法有三个参数,第一个是和组件的 build 方法相同的 context;第二个是触发 build 函数调用的 ChangeNotifier 实例对象,我们可以从中获取 UI 界面所需要的数据。第三个参数是 child,这是用于优化的。如果在我们的 Consumer 下有一个很大的子组件树,而且在模型改变的时候这些子组件树并不需要改变,那么我们就可以只需要对这个子组件构建一次:
将 Consumer 组件放置在组件树的位置越深越好,这样其他部分的某些细节改变时我无需构建大量的 UI,从而可以提升性能。
正确的做法是这样:
Provider.of 方法
在某些情况下,我们并不需要根据状态信息更改界面,而是访问状态对象以进行别的操作。例如我们有一个清空购物车的按钮,点击按钮的时候需要调用 CartModel 的 removeAll 方法,这个时候我们可以这么写:
注意,listen 参数设置为 false 表示当状态改变的时候无需通知该组件进行重建。
总结
代码已上传至 gitee:简单状态管理示例。运行效果如下(对原示例做了些许改动,以便更像真正的购物车)。可以看到,使用了状态管理有下面几个好处:
页面间的数据是同步的。
即便退出页面后,再进入之前的状态还是保持的,这也是为什么要把状态管理放置在更上层级的原因之一。
业务代码和界面是分离的,界面只负责页面的渲染和交互,而具体的业务逻辑在状态管理中实现。代码更容易维护。
大部分页面可以设置为无状态组件,通过 Provider 实现局部刷新从而提高性能。
版权声明: 本文为 InfoQ 作者【岛上码农】的原创文章。
原文链接:【http://xie.infoq.cn/article/80d4ad69b065ade3fcd0a1cab】。文章转载请联系作者。
评论