本文主要内容翻译自 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 中,函数是一等对象,因此可以将函数作为参数传递。因此,在商品列表中我们可以用代码这么实现:
@override
Widget build(BuildContext context) {
return SomeWidget(myTapCallback);
}
void myTapCallback(Item item) {
// 处理商品点击事件
}
这样也能正常工作,但是,如果我们的应用很多地方都要用到商品列表这个组件,那么我们的商品点击处理方法会散落在各个组件中,结果很难维护(当相同的代码被重复使用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
。
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:简单状态管理示例。运行效果如下(对原示例做了些许改动,以像真正的购物车)。可以看到,使用了状态管理有下面几个好处:
- 页面间的数据是同步的。
- 即便退出页面后,再进入之前的状态还是保持的,这也是为什么要把状态管理放置在更上层级的原因之一。
- 业务代码和界面是分离的,界面只负责页面的渲染和交互,而具体的业务逻辑在状态管理中实现。代码更容易维护。
- 大部分页面可以设置为无状态组件,通过 Provider 实现局部刷新从而提高性能。