跳到主内容

简单的应用状态管理

一种简单的状态管理形式。

既然你已经了解了声明式 UI 编程以及临时状态和应用状态之间的区别,那么你已经准备好学习简单的应用状态管理了。

在此页面中,我们将使用 provider 包。如果你刚接触 Flutter,且没有非要选择其他方案(Redux、Rx、hooks 等)的强烈理由,那么这很可能是你应该入手的方案。provider 包易于理解,代码量少,而且它所使用的概念在其他所有方案中也都适用。

话虽如此,如果你在其他响应式框架的状态管理方面有深厚的背景,可以在选项页面查看列出的相关包和教程。

我们的示例

#
An animated gif showing a Flutter app in use. It starts with the user on a login screen. They log in and are taken to the catalog screen, with a list of items. The click on several items, and as they do so, the items are marked as "added". The user clicks on a button and gets taken to the cart view. They see the items there. They go back to the catalog, and the items they bought still show "added". End of animation.

为了说明问题,请考虑以下简单的应用。

该应用有两个独立的屏幕:商品目录和购物车(分别由 MyCatalogMyCart 组件表示)。这可能是一个购物应用,但你可以设想一个简单的社交网络应用也具有相同的结构(将目录替换为“主页/墙”,将购物车替换为“收藏夹”)。

目录屏幕包含一个自定义应用栏 (MyAppBar) 和一个滚动查看的列表项视图 (MyListItems)。

这是以组件树形式展现的应用可视化图。

A widget tree with MyApp at the top, and  MyCatalog and MyCart below it. MyCart area leaf nodes, but MyCatalog have two children: MyAppBar and a list of MyListItems.

因此,我们至少有 5 个 Widget 的子类。它们中的许多需要访问“属于”其他地方的状态。例如,每个 MyListItem 都需要能够将自己添加到购物车中。它可能还想查看当前显示的商品是否已经在购物车里。

这就引出了我们的第一个问题:我们应该把当前的购物车状态放在哪里?

提升状态

#

在 Flutter 中,将状态放在使用它的组件之上是有意义的。

为什么?在像 Flutter 这样的声明式框架中,如果你想更改 UI,就必须重建它。没有简单的方法可以做到 MyCart.updateWith(somethingNew)。换句话说,通过从外部调用方法来命令式地改变组件是很困难的。即使你能做到这一点,你也是在与框架对抗,而不是让它为你提供帮助。

dart
// BAD: DO NOT DO THIS
void myTapHandler() {
  var cartWidget = somehowGetMyCartWidget();
  cartWidget.updateWith(item);
}

即使你成功实现了上述代码,你也必须在 MyCart 组件中处理以下问题:

dart
// BAD: DO NOT DO THIS
Widget build(BuildContext context) {
  return SomeWidget(
    // The initial state of the cart.
  );
}

void updateWith(Item item) {
  // Somehow you need to change the UI from here.
}

你需要考虑 UI 的当前状态并将新数据应用到其中。用这种方式很难避免错误。

在 Flutter 中,每次组件的内容发生变化时,你都会构建一个新的组件。你使用的不是 MyCart.updateWith(somethingNew)(方法调用),而是 MyCart(contents)(构造函数)。因为你只能在父组件的 build 方法中构建新组件,所以如果你想更改 contents,它必须存在于 MyCart 的父组件或更高层级。

dart
// GOOD
void myTapHandler(BuildContext context) {
  var cartModel = somehowGetMyCartModel(context);
  cartModel.add(item);
}

现在 MyCart 构建任何版本的 UI 时都只有一条代码路径。

dart
// GOOD
Widget build(BuildContext context) {
  var cartModel = somehowGetMyCartModel(context);
  return SomeWidget(
    // Just construct the UI once, using the current state of the cart.
    // ···
  );
}

在我们的示例中,contents 需要存放在 MyApp 中。每当它发生变化时,它就会从上方重建 MyCart(稍后会详细介绍)。正因如此,MyCart 不需要担心生命周期——它只需要声明对于给定的 contents 要显示什么即可。当内容发生变化时,旧的 MyCart 组件就会消失,并被新的组件完全取代。

Same widget tree as above, but now we show a small 'cart' badge next to MyApp, and there are two arrows here. One comes from one of the MyListItems to the 'cart', and another one goes from the 'cart' to the MyCart widget.

这就是我们所说的组件是不可变的含义。它们不会改变——它们会被替换。

现在我们知道了应该把购物车状态放在哪里,接下来看看如何访问它。

访问状态

#

当用户点击目录中的某个商品时,它会被添加到购物车中。但是因为购物车位于 MyListItem 之上,我们该怎么做呢?

一个简单的选择是提供一个回调函数,当 MyListItem 被点击时调用它。Dart 的函数是一等对象,所以你可以随心所欲地传递它们。因此,在 MyCatalog 内部,你可以定义以下内容:

dart
@override
Widget build(BuildContext context) {
  return SomeWidget(
    // Construct the widget, passing it a reference to the method above.
    MyListItem(myTapCallback),
  );
}

void myTapCallback(Item item) {
  print('user tapped on $item');
}

这倒也没问题,但对于需要在许多不同地方修改的应用状态,你将不得不传递大量的回调——这很快就会变得繁琐。

幸运的是,Flutter 拥有让组件向其后代(换句话说,不仅是子组件,还包括下方任何组件)提供数据和服务的机制。正如你对 Flutter 的期待一样,在“*一切皆为组件™*”的思想下,这些机制只是特殊类型的组件——InheritedWidgetInheritedNotifierInheritedModel 等。我们不会在这里介绍这些,因为对于我们想要实现的目标来说,它们稍微有点底层。

相反,我们将使用一个与底层组件协同工作但易于使用的包。它叫作 provider

在使用 provider 之前,别忘了在你的 pubspec.yaml 中添加依赖。

要将 provider 包添加为依赖,请运行 flutter pub add

flutter pub add provider

现在你可以 import 'package:provider/provider.dart'; 并开始构建了。

使用 provider,你不需要担心回调或 InheritedWidgets。但你需要理解 3 个概念:

  • ChangeNotifier
  • ChangeNotifierProvider
  • Consumer

ChangeNotifier

#

ChangeNotifier 是 Flutter SDK 中包含的一个简单类,它为监听器提供更改通知。换句话说,如果某个对象是 ChangeNotifier,你就可以订阅它的更改。(对于熟悉该术语的人来说,它是一种 Observable。)

provider 中,ChangeNotifier 是封装应用状态的一种方式。对于非常简单的应用,使用单个 ChangeNotifier 即可。在复杂的应用中,你会有多个模型,因此也会有多个 ChangeNotifier。(你完全不需要在 provider 中使用 ChangeNotifier,但它是一个很容易使用的类。)

在我们的购物应用示例中,我们希望在 ChangeNotifier 中管理购物车的状态。我们创建一个扩展它的新类,如下所示:

dart
class CartModel extends ChangeNotifier {
  /// Internal, private state of the cart.
  final List<Item> _items = [];

  /// An unmodifiable view of the items in the cart.
  UnmodifiableListView<Item> get items => UnmodifiableListView(_items);

  /// The current total price of all items (assuming all items cost $42).
  int get totalPrice => _items.length * 42;

  /// Adds [item] to cart. This and [removeAll] are the only ways to modify the
  /// cart from the outside.
  void add(Item item) {
    _items.add(item);
    // This call tells the widgets that are listening to this model to rebuild.
    notifyListeners();
  }

  /// Removes all items from the cart.
  void removeAll() {
    _items.clear();
    // This call tells the widgets that are listening to this model to rebuild.
    notifyListeners();
  }
}

唯一特定于 ChangeNotifier 的代码是对 notifyListeners() 的调用。每当模型发生可能更改应用 UI 的改变时,请调用此方法。CartModel 中的其他所有内容都是模型本身及其业务逻辑。

ChangeNotifierflutter:foundation 的一部分,不依赖于 Flutter 中的任何更高级别的类。它很容易测试(你甚至不需要使用组件测试)。例如,下面是 CartModel 的一个简单单元测试:

dart
test('adding item increases total cost', () {
  final cart = CartModel();
  final startingPrice = cart.totalPrice;
  var i = 0;
  cart.addListener(() {
    expect(cart.totalPrice, greaterThan(startingPrice));
    i++;
  });
  cart.add(Item('Dash'));
  expect(i, 1);
});

ChangeNotifierProvider

#

ChangeNotifierProvider 是一个将 ChangeNotifier 实例提供给其后代的组件。它来自 provider 包。

我们已经知道把 ChangeNotifierProvider 放在哪里了:放在需要访问它的组件之上。对于 CartModel 而言,这意味着放在 MyCartMyCatalog 二者之上的某个位置。

你不需要把 ChangeNotifierProvider 放得比必要位置更高(因为你不想污染作用域)。但在我们的例子中,位于 MyCartMyCatalog 之上的唯一组件就是 MyApp

dart
void main() {
  runApp(
    ChangeNotifierProvider(
      create: (context) => CartModel(),
      child: const MyApp(),
    ),
  );
}

请注意,我们定义了一个构建器来创建一个新的 CartModel 实例。ChangeNotifierProvider 很智能,除非绝对必要,否则*不会*重建 CartModel。当实例不再需要时,它还会自动对 CartModel 调用 dispose()

如果你想提供多个类,可以使用 MultiProvider

dart
void main() {
  runApp(
    MultiProvider(
      providers: [
        ChangeNotifierProvider(create: (context) => CartModel()),
        Provider(create: (context) => SomeOtherClass()),
      ],
      child: const MyApp(),
    ),
  );
}

Consumer

#

现在,通过顶部的 ChangeNotifierProvider 声明,CartModel 已经提供给了应用中的组件,我们可以开始使用它了。

这是通过 Consumer 组件完成的。

dart
return Consumer<CartModel>(
  builder: (context, cart, child) {
    return Text('Total price: ${cart.totalPrice}');
  },
);

我们必须指定我们想要访问的模型类型。在本例中,我们要的是 CartModel,所以我们写 Consumer<CartModel>。如果你不指定泛型 (<CartModel>),provider 包将无法帮助你。provider 是基于类型的,没有类型,它就不知道你想要什么。

Consumer 组件唯一必需的参数是 builder。Builder 是一个函数,每当 ChangeNotifier 发生变化时就会被调用。(换句话说,当你调用模型中的 notifyListeners() 时,所有相应 Consumer 组件的 builder 方法都会被调用。)

Builder 在调用时带有三个参数。第一个是 context,你在每个 build 方法中都能得到它。

builder 函数的第二个参数是 ChangeNotifier 的实例。这正是我们最初请求的对象。你可以使用模型中的数据来定义 UI 在任何给定点应该看起来是什么样子。

第三个参数是 child,它是为了优化而存在的。如果你在 Consumer 下有一个庞大的组件子树,且当模型发生变化时该子树*不会*改变,你可以构建一次并通过 builder 获取它。

dart
return Consumer<CartModel>(
  builder: (context, cart, child) => Stack(
    children: [
      // Use SomeExpensiveWidget here, without rebuilding every time.
      ?child,
      Text('Total price: ${cart.totalPrice}'),
    ],
  ),
  // Build the expensive widget here.
  child: const SomeExpensiveWidget(),
);

最佳实践是将你的 Consumer 组件放在树中尽可能深的位置。你肯定不希望仅仅因为某处的某个细节发生了变化,就重建 UI 的大部分内容。

dart
// DON'T DO THIS
return Consumer<CartModel>(
  builder: (context, cart, child) {
    return HumongousWidget(
      // ...
      child: AnotherMonstrousWidget(
        // ...
        child: Text('Total price: ${cart.totalPrice}'),
      ),
    );
  },
);

而是这样:

dart
// DO THIS
return HumongousWidget(
  // ...
  child: AnotherMonstrousWidget(
    // ...
    child: Consumer<CartModel>(
      builder: (context, cart, child) {
        return Text('Total price: ${cart.totalPrice}');
      },
    ),
  ),
);

Provider.of

#

有时,你并不真正需要模型中的*数据*来更改 UI,但你仍然需要访问它。例如,一个 ClearCart 按钮想要允许用户从购物车中删除所有内容。它不需要显示购物车的内容,只需要调用 clear() 方法即可。

我们本可以使用 Consumer<CartModel> 来实现,但这会造成浪费。我们会要求框架重建一个实际上不需要重建的组件。

对于这种情况,我们可以使用 Provider.of,并将 listen 参数设置为 false

dart
Provider.of<CartModel>(context, listen: false).removeAll();

在 build 方法中使用上述行,当 notifyListeners 被调用时,不会导致该组件重建。

整合所有概念

#

你可以查看本文涵盖的示例。如果你想要更简单的内容,请看看当使用 provider 构建简单的计数器应用时是什么样子。

通过学习这些文章,你已经大大提升了创建基于状态的应用的能力。尝试自己使用 provider 构建一个应用,以掌握这些技能。