Flutter-状态 (State) 管理

状态 (State) 管理介绍

当你使用 Futter 进行开发时,有时会需要在 app 的不同界面中,共享应用程序的状态,在这里你可以找到许多有用的方案以及一些可以深思的问题。

在接下来的文档里,你将会学习一些基础的状态管理知识。


图片.png

状态管理中的声明式编程思维

如果你是从命令式框架(例如 Android SDK 或者 iOS UIKit)转到 Flutter 应用,那么,你需要开始从一个新的角度来考虑 app 开发了。

因此,很多在命令式框架下的假设可能并不适用于 Flutter。例如,在 Flutter 应用中这是可行的,重新构建你的部分界面,而不是直接去修改它。如果有需要的话,Flutter 甚至可以在每一帧上都很快做到这点。

Flutter 应用是 声明式 的,这也就意味着 Flutter 构建的用户界面就是应用的当前状态。


ui-equals-function-of-state.png

当你的 Flutter 应用的状态发生改变时(例如,用户在设置界面中点击了一个开关选项),你改变了状态,这会触发用户界面的重绘。去改变用户界面本身是没有必要的(例如 widget.setText )— 你改变了状态,那么用户界面将重新构建。

声明式 UI 介绍 中你可以阅读更多有关声明式编程思维的信息。

声明式的编程风格有许多好处。值得注意的是,用户界面任何状态的改变都只有一种编码途径。一旦给定任意状态,你就描述了用户界面应该长什么样,并且它就是这样。

刚开始的时候,这种编码风格可能看起来不像命令式的那么直观。这也是本章为什么出现在这的原因。
状态 (State) 管理介绍

短时 (ephemeral) 和应用 (app) 状态的区别

短时状态

短时状态 (有时也称 用户界面(UI)状态 或者 局部状态) 是你可以完全包含在一个独立 widget 中的状态。

这是一个有点儿模糊的定义,这里有几个例子。

  • 一个 PageView 组件中的当前页面
  • 一个复杂动画中当前进度
  • 一个 BottomNavigationBar 中当前被选中的 tab

widget 树中其他部分不需要访问这种状态。不需要去序列化这种状态,这种状态也不会以复杂的方式改变。

换句话说,不需要使用状态管理架构(例如 ScopedModel, Redux)去管理这种状态。你需要用的只是一个 StatefulWidget

在下方你可以看到一个底部导航栏中当前被选中的项目是如何被被保存在 _MyHomepageState 类的 _index 变量中。在这个例子中, _index 是一个短时状态。

class MyHomepage extends StatefulWidget {
  @override
  _MyHomepageState createState() => _MyHomepageState();
}

class _MyHomepageState extends State<MyHomepage> {
  int _index = 0;

  @override
  Widget build(BuildContext context) {
    return BottomNavigationBar(
      currentIndex: _index,
      onTap: (newIndex) {
        setState(() {
          _index = newIndex;
        });
      },
      // ... items ...
    );
  }
}

在这里,使用 setState() 和一个在有状态 Widget 的 State 类中的变量是很自然的。你的 app 中的其他部分不需要访问 _index。这个变量只会在 MyHomepage widget 中改变。而且,如果用户关闭并重启这个 app,你不会介意 _index 重置回0.

应用状态

如果你想在你的应用中的多个部分之间共享一个非短时的状态,并且在用户会话期间保留这个状态,我们称之为应用状态(有时也称共享状态)。

应用状态的一些例子:

  • 用户选项
  • 登录信息
  • 一个社交应用中的通知
  • 一个电商应用中的购物车
  • 一个新闻应用中的文章已读/未读状态

为了管理应用状态,你需要研究你的选项。你的选择取决于你的应用的复杂度和限制,你的团队之前的经验以及其他方面。请继续阅读。

没有明确的规则

需要说明的是,你可以使用 State 和 setState() 管理你的应用中的所有状态。实际上Flutter团队在很多简单的示例程序(包括你每次使用 flutter create 命令创建的初始应用)中正是这么做的。

也可以用另外一种方式。比如,在一个特定的应用中,你可以指定底部导航栏中被选中的项目不是一个短时状态。你可能需要在底部导航栏类的外部来改变这个值,并在对话期间保留它。在种情况下 _index 就是一个应用状态。

没有一个明确、普遍的规则来区分一个变量属于短时状态还是应用状态,有时你不得不在此之间重构。比如,刚开始你认为一些状态是短时状态,但随着应用不断增加功能,有些状态需要被改变为应用状态。

因此,请有保留地遵循以下这张流程图:


ephemeral-vs-app-state.png

当我们就 React 的 setState 和 Redux 的 Store 哪个好这个问题问 Redux 的作者 Dan Abramov 时, 他如此回答:

“经验原则是: 选择能够减少麻烦的方式

总之,在任何 Flutter 应用中都存在两种概念类型的状态,短时状态经常被用于一个单独 widget 的本地状态,通常使用 StatesetState() 来实现。其他的是你的应用应用状态,在任何一个 Flutter 应用中这两种状态都有自己的位置。如何划分这两种状态取决于你的偏好以及应用的复杂度。

简单的应用状态管理

现在大家已经了解了 声明式的编程思维短时 (ephemeral) 与应用 (app) 状态 之间的区别,现在可以学习如何管理简单的全局应用状态。

在这里,我们打算使用 provider package。如果你是 Flutter 的初学者,而且也没有很重要的理由必须选择别的方式来实现(Redux、Rx、hooks 等等),那么这就是你应该入门使用的。provider 非常好理解而且不需要写很多代码。它也会用到一些在其它实现方式中用到的通用概念。

即便如此,如果你已经从其它响应式框架上积累了丰富的状态管理经验的话,那么可以在 状态 (State) 管理参考 中找到相关的 package 和教程。

示例

图片.png

为了演示效果,我们实现下面这个简单应用。
程序有三个独立的页面:一个登陆提示,一个类别页面,一个购物车页面(分别用 MyLoginScreen, MyCatalog,MyCart widget 来展示)。虽然看上去是一个购物应用程序,但是你也可以和社交网络应用类比(把类别页面替换成朋友圈,把购物车替换成关注的人)。

类别页面包含一个自定义的 app bar (MyAppBar) 以及一个包含元素列表的可滑动的视图 (MyListItems)。

这是应用程序对应的可视化的 widget 树。


simple-widget-tree.png

所以我们有至少 6 个 Widget 的子类。他们中有很多需要访问一些全局的状态。比如,MyListItem 会被添加到购物车中。但是它可能需要检查和自己相同的元素是否已经被添加到购物车中。

这里我们出现了第一个问题:我们把当前购物车的状态放在哪合适呢?

提高状态的层级

在 Flutter 中,有必要将存储状态的对象置于 widget 树中对应 widget 的上层。

为什么呢?在类似 Flutter 的声明式框架中,如果你想要修改 UI,那么你需要重构它。并没有类似 MyCart.updateWith(somethingNew) 的简单调用方法。换言之,你很难通过外部调用方法修改一个 widget。即便你自己实现了这样的模式,那也是和整个框架不相兼容。

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

即使你实现了上面的代码,也得处理 MyCart widget 中的代码:

// 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 的状态,然后把最新的数据添加进去。但是这样的方式很难避免出现 bug。

在 Flutter 中,每次当 widget 内容发生改变的时候,你就需要构造一个新的。你会调用 MyCart(contents)(构造函数),而不是 MyCart.updateWith(somethingNew)(调用方法)。因为你只能通过父类的 build 方法来构建新 widget,如果你想修改 contents,就需要调用 MyCart 的父类甚至更高一级的类。

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

这里 MyCart 可以在各种版本的 UI 中调用同一个代码路径。

// 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 widget 就会消失,完全被新的 widget 替代。


simple-widget-tree-with-cart.png

这就是我们所说的 widget 是不可变的。因为它们会直接被替换。

现在我们知道在哪里放置购物车的状态,接下来看一下如何读取该状态。

读取状态

当用户点击类别页面中的一个元素,它会被添加到购物车里。然而当购物车在 widget 树中,处于 MyListItem 的层级之上时,又该如何访问状态呢?

一个简单的实现方法是提供一个回调函数,当 MyListItem 被点击的时候可以调用。Dart 的函数都是 first class 对象,所以你可以以任意方式传递它们。所以在 MyCatalog 里你可以使用下面的代码:

@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 在 widget 中存在一种机制,能够为其子孙节点提供数据和服务。(换言之,不仅仅是它的子节点,所有在它下层的 widget 都可以)。就像你所了解的, Flutter 中的 Everything is a Widget™。这里的机制也是一种 widget —InheritedWidget, InheritedNotifier, InheritedModel等等。我们这里不会详细解释他们,因为这些 widget 都太底层。

我们会用一个 package 来和这些底层的 widget 打交道,就是 provider package 。

provider package 中,你无须关心回调或者 InheritedWidgets。但是你需要理解三个概念:

  • ChangeNotifier
  • ChangeNotifierProvider
  • Consumer

ChangeNotifier

ChangeNotifier 是 Flutter SDK 中的一个简单的类。它用于向监听器发送通知。换言之,如果被定义为 ChangeNotifier,你可以订阅它的状态变化。(这和大家所熟悉的观察者模式相类似)。

在 provider 中,ChangeNotifier 是一种能够封装应用程序状态的方法。对于特别简单的程序,你可以通过一个 ChangeNotifier 来满足全部需求。在相对复杂的应用中,由于会有多个模型,所以可能会有多个 ChangeNotifier。(不是必须得把 ChangeNotifier 和 provider 结合起来用,不过它确实是一个特别简单的类)。

在我们的购物应用示例中,我们打算用 ChangeNotifier 来管理购物车的状态。我们创建一个新类,继承它,像下面这样:

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). 现在全部商品的总价格(假设他们加起来 $42)
  int get totalPrice => _items.length * 42;

  /// Adds [item] to cart. This is the only way to modify the cart from outside. 将 [item] 添加到购物车。这是唯一一种能从外部改变购物车的方法。
  void add(Item item) {
    _items.add(item);
    // This call tells the widgets that are listening to this model to rebuild.
    notifyListeners();
  }
}

唯一一行和 ChangeNotifier 相关的代码就是调用 notifyListeners()。当模型发生改变并且需要更新 UI 的时候可以调用该方法。而剩下的代码就是 CartModel 和它本身的业务逻辑。

ChangeNotifierflutter:foundation 的一部分,而且不依赖 Flutter 中任何高级别类。测试起来非常简单(你都不需要使用 widget 测试)。比如,这里有一个针对 CartModel 简单的单元测试:

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

ChangeNotifierProvider

ChangeNotifierProvider widget 可以向其子孙节点暴露一个 ChangeNotifier 实例。它属于 provider package。

我们已经知道了该把 ChangeNotifierProvider 放在什么位置:在需要访问它的 widget 之上。在 CartModel 里,也就意味着将它置于 MyCart 和 MyCatalog 之上。

你肯定不愿意把 ChangeNotifierProvider 放的级别太高(因为你不希望破坏整个结构)。但是在我们这里的例子中,MyCart 和 MyCatalog 之上只有 MyApp。

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

请注意我们定义了一个 builder 来创建一个 CartModel 的实例。ChangeNotifierProvider 非常聪明,它 不会 重复实例化 CartModel,除非在个别场景下。如果该实例已经不会再被调用,ChangeNotifierProvider 也会自动调用 CartModel 的 dispose() 方法。

如果你想提供更多状态,可以使用 MultiProvider:

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

Consumer

现在 CartModel 已经通过 ChangeNotifierProvider 在应用中与 widget 相关联。我们可以开始调用它了。

完成这一步需要通过 Consumer widget。

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

我们必须指定要访问的模型类型。在这个示例中,我们要访问 CartModel 那么就写上 Consumer<CartModel>。

Consumer widget 唯一必须的参数就是 builder。当 ChangeNotifier 发生变化的时候会调用 builder 这个函数。(换言之,当你在模型中调用 notifyListeners() 时,所有和 Consumer 相关的 builder 方法都会被调用。)

builder 在被调用的时候会用到三个参数。第一个是 context。在每个 build 方法中都能找到这个参数。

builder 函数的第二个参数是 ChangeNotifier 的实例。它是我们最开始就能得到的实例。你可以通过该实例定义 UI 的内容。

第三个参数是 child,用于优化目的。如果 Consumer 下面有一个庞大的子树,当模型发生改变的时候,该子树 并不会 改变,那么你就可以仅仅创建它一次,然后通过 builder 获得该实例。

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: SomeExpensiveWidget(),
);

最好能把 Consumer 放在 widget 树尽量低的位置上。你总不希望 UI 上任何一点小变化就全盘重新构建 widget 吧。

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

换成:

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

Provider.of

有的时候你不需要模型中的 数据 来改变 UI,但是你可能还是需要访问该数据。比如,ClearCart 按钮能够清空购物车的所有商品。它不需要显示购物车里的内容,只需要调用 clear() 方法。

我们可以使用 Consumer<CartModel> 来实现这个效果,不过这么实现有点浪费。因为我们让整体框架重构了一个无需重构的 widget。

所以这里我们可以使用 Provider.of,并且将 listen 设置为 false。

Provider.of<CartModel>(context, listen: false).add(item);

在 build 方法中使用上面的代码,当 notifyListeners 被调用的时候,并不会使 widget 被重构。

把代码集成在一起

你可以在文章中 查看这个示例。如果你想参考稍微简单一点的示例,可以看看 Counter 应用程序是如何 基于 provider 实现的

如果你已经学会了并且准备使用 provider 的时候,别忘了先在 pubspec.yaml 中添加相应的依赖。

name: my_name
description: Blah blah blah.

# ...

dependencies:
  flutter:
    sdk: flutter

  provider: ^3.0.0

dev_dependencies:
  # ...
现在你可以 import 'package:provider/provider.dart';,开始写代码吧。

状态 (State) 管理参考

https://flutter.cn/docs/development/data-and-backend/state-mgmt/options

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