Flutter 状态管理实践

原文链接

概述

iOS 和 Android 的原生开发模式是命令式编程模式。命令式编程要求开发者一步步描述整个构建过程,从而引导程序去构建用户界面。

Flutter 则采用了声明式编程模式,框架隐藏了具体的构建过程,开发者只需要声明状态,框架会自动构建用户界面。这也就意味着 Flutter 构建的用户界面就是当前的状态。

image

状态管理

App 在运行中总是会更新用户界面,因此我们需要对状态进行有效的管理。状态管理本质上就是 如何解决状态读/写的问题。对此,我们将从两个方面去评估状态管理方案:

  • 状态访问
  • 状态更新

此外,根据 Flutter 原生支持的情况,我们将 Flutter 状态管理方案分为两类:

  • Flutter 内置的状态管理方案
  • 基于 Pub 的状态管理方案

下文,我们将以 Flutter 官方的计数器例子来介绍 Flutter 中的状态管理方案,并逐步进行优化。

关于本文涉及的源码,见【Demo 传送门】

Flutter 内置的状态管理方案

直接访问 + 直接更新

Flutter 模板工程就是【直接访问 + 直接更新】的状态管理方案。这种方案的状态访问/更新示意图如下所示。

image

很显然,【直接访问 + 直接更新】方案只适合于在单个 StatefulWidget 中进行状态管理。那么对于多层级的 Widget 结构该如何进行状态管理呢?

状态传递 + 闭包传递

对于多层级的 Widget 结构,状态是无法直接访问和更新的。因为 Widget 和 State 是分离的,并且 State 一般都是私有的,所以子 Widget 是无法直接访问/更新父 Widget 的 State。

对于这种情况,最直观的状态管理方案就是:【状态传递 + 闭包传递】。对于状态访问,父 Widget 在创建子 Widget 时就将状态传递给子 Widget;对于状态更新,父 Widget 将更新状态的操作封装在闭包中,传递给子 Widget。

这里存在一个问题:当 Widget 树层级比较深时,如果中间有些 Widget 并不需要访问或更新父 Widget 的状态时,这些中间 Widget 仍然需要进行辅助传递。很显然,这种方案在 Widget 树层级较深时,效率比较低,只适合于较浅的 Widget 树层级。

image

状态传递 + Notification

那么如何优化多层级 Widget 树结构下的状态管理方案呢?我们首先从状态更新方面进行优化。

【状态传递 + Notification】方案采用 Notification 定向地优化了状态更新的方式。

通知(Notification)是 Flutter 中一个重要的机制,在 Widget 树种,每个节点都可以分发通知,通知会沿着当前节点向上传递,所有父节点都可以通过 NotificationListener 来监听通知。Flutter 中将这种由子向父的传递通知的机制称为 通知冒泡(Notification Bubbling)。通知冒泡和用户触摸事件冒泡是相似的,但有一点不同:通知冒泡可以中止,而用户触摸事件无法中止

下图所示为这种方案的状态访问/更新示意图。

image

具体的实现源码如下所示:

// 与 父 Widget 绑定的 State
class _PassStateNotificationDemoPageState extends State<PassStateNotificationDemoPage> {
  int _counter = 0;

  void _incrementCounter() {
    setState(() {
      _counter++;
    });
  }

  @override
  Widget build(BuildContext context) {
    // 父 Widget 使用 NotificationListener 监听通知
    return NotificationListener<IncrementNotification>(
      onNotification: (notification) {
        setState(() {
          _incrementCounter();
        });
        return true;  // true: 阻止冒泡;false: 继续冒泡
      },
      child: Scaffold(
        ...
      ),
    );
  }
}

/// 子 Widget
class _IncrementButton extends StatelessWidget {
  int counter = 0;

  _IncrementButton(this.counter);

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
        onTap: () => IncrementNotification("加一操作").dispatch(context),   // 点击按钮触发通知派发
        child: ...)
    );
  }
}

/// 自定义通知
class IncrementNotification extends Notification {
  final String msg;
  IncrementNotification(this.msg);
}

InheritedWidget + Notification

【传递传递 + Notification】方案定向优化了状态的更新,那么如何进一步优化状态的访问呢?

【InheritedWidget + Notification】方案采用 InhertiedWidget 实现了在多层级 Widget 树中直接访问状态的能力。

InheritedWidget 是 Flutter 中非常重要的一个功能型组件,其提供了一种数据在 Widget 树中从上到下传递、共享的方式。这与 Notification 的传递方向正好相反。我们在父 Widget 中通过 InheritedWidget 共享一个数据,那么任意子 Widget 都能够直接获取到共享的数据。

下图所示为这种方案的状态访问/更新示意图。

image

具体的源码实现如下所示:

/// 与父 Widget 绑定的 State
class _InheritedWidgetNotificationDemoPageState extends State<InheritedWidgetNotificationDemoPage> {
  int _counter = 0;

  void _incrementCounter() {
    setState(() {
      _counter++;
    });
  }

  @override
  Widget build(BuildContext context) {
    return CounterInheritedWidget(
      counter: _counter,
      child: NotificationListener<IncrementNotification>(
        onNotification: (notification) {
          setState(() {
            _incrementCounter();
          });
          return true;  // true: 阻止冒泡;false: 继续冒泡
        },
        child: Scaffold(
                ...
            ),
          ),
        ),
      ),
    );
  }
}

/// 子 Widget
class _IncrementButton extends StatelessWidget {
  _IncrementButton();

  @override
  Widget build(BuildContext context) {
    // 直接获取状态
    final counter = CounterInheritedWidget.of(context).counter;
    return GestureDetector(
        onTap: () => IncrementNotification("加一").dispatch(context),   // 派发通知
        child: ...
    );
  }
}

/// 对使用自定义的 InheritedWidget 子类对状态进行封装
class CounterInheritedWidget extends InheritedWidget {
  final int counter;

  // 需要在子树中共享的数据,保存点击次数
  CounterInheritedWidget({@required this.counter, Widget child}) : super(child: child);

  // 定义一个便捷方法,方便子树中的widget获取共享数据
  static CounterInheritedWidget of(BuildContext context) {
    return context.dependOnInheritedWidgetOfExactType<CounterInheritedWidget>();
  }

  @override
  bool updateShouldNotify(CounterInheritedWidget old) {
    // 如果返回true,则子树中依赖(build函数中有调用)本widget
    // 的子widget的`state.didChangeDependencies`会被调用
    return old.counter != counter;
  }
}

InheritedWidget + EventBus

虽然【InheritedWidget + Notification】方案在状态访问和状态更新方面都进行了优化,但是从其状态管理示意图上看,状态的更新仍然具有优化空间。

【InheritedWidget + EventBus】方案则采用了 事件总线(Event Bus)的方式管理状态更新。

事件总线是 Flutter 中的一种全局广播机制,可以实现跨页面事件通知。事件总线通常是一种订阅者模式,其包含发布者和订阅者两种角色。

【InheritedWidget + EventBus】方案将子 Widget 作为发布者,父 Widget 作为订阅者。当子 Widget 进行状态更新时,则发出事件,父 Widget 监听到事件后进行状态更新。

下图所示为这种方案的状态访问/更新示意图。

image

具体的源码实现如下所示:

/// 与父 Widget 绑定的状态
class _InheritedWidgetEventBusDemoPageState extends State<InheritedWidgetEventBusDemoPage> {
  int _counter = 0;

  void _incrementCounter() {
    setState(() {
      _counter++;
    });
  }

  @override
  void initState() {
    super.initState();
    // 订阅事件
    bus.on(EventBus.incrementEvent, (_) {
      _incrementCounter();
    });
  }

  @override
  void dispose() {
    // 取消订阅
    bus.off(EventBus.incrementEvent);
    super.dispose();
  }
  ...
}

/// 子 Widget
class _IncrementButton extends StatelessWidget {
  _IncrementButton();

  @override
  Widget build(BuildContext context) {
    final counter = CounterInheritedWidget.of(context).counter;
    return GestureDetector(
        onTap: () => bus.emit(EventBus.incrementEvent), // 发布事件
        child: ...
    );
  }
}

两种方案的对比

【InheritedWidget + Notification】和【InheritedWidget + EventBus】的区别主要在于状态更新。两者对于状态的更新其实并没有达到最佳状态,都是通过一种间接的方式实现的。

相比而言,事件总线是基于全局,逻辑难以进行收敛,并且还要管理监听事件、取消订阅。从这方面而言,【InheritedWidget + Notification】方案更优。

从状态管理示意图而言,显然【InheritedWidget + Notification】还有进一步的优化空间。这里,我们可能会想:状态能否直接提供更新方法,当子 Widget 获取到状态后,直接调用状态的更新方法呢?

image

对此,官方推荐了一套基于第三方 Pub 的 Provider 状态管理方案。

基于 Pub 的状态管理方案

Provider

【Provider】的本质是 基于 InheritedWidgetChangeNotifier 进行了封装。此外,使用缓存提升了性能,避免不必要的重绘。

下图所示为这种方案的状态访问/更新示意图。

image

具体的源码实现如下所示:

/// 与父 Widget 绑定的 State
class _ProviderDemoPageState extends State<ProviderDemoPage> {

  @override
  Widget build(BuildContext context) {
    return ChangeNotifierProvider<CounterProviderState>(
      create: (_) => CounterProviderState(),    // 创建状态
      child: Scaffold(
        appBar: AppBar(
          title: Text(widget.title),
        ),
        body: Center(
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: <Widget>[
              Text(
                'You have pushed the button this many times:',
              ),
              // 使用 provider 提供的 builder 使用状态
              Consumer<CounterProviderState>(builder: (context, counter, _) => Text("${counter.value}", style: Theme.of(context).textTheme.display1)),
              _IncrementButton(),
            ],
          ),
        ),
      ),
    );
  }
}

/// 子 Widget
class _IncrementButton extends StatelessWidget {
  _IncrementButton();

  @override
  Widget build(BuildContext context) {
    // 访问状态
    final _counter = Provider.of<CounterProviderState>(context);
    return GestureDetector(
        onTap: () => _counter.incrementCounter(),   // 更新状态
        child: ...
    );
  }
}

/// 自定义的状态,继承自 ChangeNotifier
class CounterProviderState with ChangeNotifier {
  int _counter = 0;
  int get value => _counter;

  // 状态提供的更新方法
  void incrementCounter() {
    _counter++;
    notifyListeners();
  }
}

Flutter 社区早期使用的 Scoped Model 方案与 Provider 的实现原理基本是一致的。

Redux

对于声明式(响应式)编程中的状态管理,Redux 是一种常见的状态管理方案。【Redux】方案的状态管理示意图与【Provider】方案基本上是一致的。

image

在这个基础上,Redux 对于状态更新的过程进行了进一步的细分和规划,使得其数据的流动过程如下所示。

  • 所有的状态都存储在 Store 中。一般会把 Store 放在 App 顶层。
  • View 获取 Store 中存储的状态。
  • 当事件发生时,发出一个 action。
  • Reducer 接收到 action,遍历 action 表,找到匹配的 action,根据 action 生成新的状态存储到 Store 中。
  • Store 存储新状态后,通知依赖该状态的 view 更新。

一个 Store 存储多个状态,适合用于全局状态管理。

image

具体的实现源码如下所示。

/// 与父 Widget 绑定的 State
class _ReduxDemoPageState extends State<ReduxDemoPage> {
  // 初始化 Store,该过程包括了对 State 的初始化
  final store = Store<CounterReduxState>(reducer, initialState: CounterReduxState.initState());

  @override
  Widget build(BuildContext context) {
    return StoreProvider<CounterReduxState>(
      store: store,
      child: Scaffold(
        appBar: AppBar(
          title: Text(widget.title),
        ),
        body: Center(
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: <Widget>[
              Text(
                'You have pushed the button this many times:',
              ),
              // 通过 StoreConnector 访问状态
              StoreConnector<CounterReduxState, int>(
                converter: (store) => store.state.value,
                builder: (context, count) {
                  return Text("$count", style: Theme.of(context).textTheme.display1);
                },
              ),
              _IncrementButton(),
            ],
          ),
        ),
      ),
    );
  }
}

/// 子 Widget
class _IncrementButton extends StatelessWidget {
  _IncrementButton();

  @override
  Widget build(BuildContext context) {
    return StoreConnector<CounterReduxState, VoidCallback>(
      converter: (store) {
        return () => store.dispatch(Action.increment);  // 发出 Action 以进行状态更新
      },
      builder: (context, callback) {
        return GestureDetector(
            onTap: callback,
            child: StoreConnector<CounterReduxState, int>(
              converter: (store) => store.state.value,
              builder: (context, count) {
                return ...;
              },
            )
        );
      },
    );
  }
}

/// 自定义状态
class CounterReduxState {
  int _counter = 0;
  int get value => _counter;

  CounterReduxState(this._counter);

  CounterReduxState.initState() {
    _counter = 0;
  }
}

/// 自定义 Action
enum Action{
  increment
}

/// 自定义 Reducer
CounterReduxState reducer(CounterReduxState state, dynamic action) {
  if (action == Action.increment) {
    return CounterReduxState(state.value + 1);
  }
  return state;
}

BLoC

【BLoC】方案是谷歌的两位工程师 Paolo Soares 和 Cong Hui 提出的一种状态管理方案,其状态管理示意图同样与【Provider】方案是一致的。

image

【BLoC】方案的底层实现与【Provider】是非常相似的,也是基于 InheritedWidget 进行状态访问,并且对状态进行了封装,从而提供直接更新状态的方法。

但是,BLoC 的核心思想是 基于流来管理数据,并且将业务逻辑均放在 BLoC 中进行,从而实现视图与业务的分离。

  • BLoC 使用 Sink 作为输入,使用 Stream 作为输出。
  • BLoC 内部会对输入进行转换,产生特定的输出。
  • 外部使用 StreamBuilder 监听 BLoC 的输出(即状态)。
image

具体的实现源码如下所示。

/// 与父 Widget 绑定的 State
class _BlocDemoPageState extends State<BlocDemoPage> {
  // 创建状态
  final bloc = CounterBloc();

  @override
  Widget build(BuildContext context) {
    // 以 InheritedWidget 的方式提供直接方案
    return BlocProvider(
      bloc: bloc,
      child: Scaffold(
        appBar: AppBar(
          title: Text(widget.title),
        ),
        body: Center(
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: <Widget>[
              Text(
                'You have pushed the button this many times:',
              ),
              // 状态访问
              StreamBuilder<int>(stream: bloc.value, initialData: 0, builder: (BuildContext context, AsyncSnapshot<int> snapshot) {
                return Text("${snapshot.data}", style: Theme.of(context).textTheme.display1);
              },),
              _IncrementButton(),
            ],
          ),
        ),
      )
    );
  }
}

/// 子 Widget
class _IncrementButton extends StatelessWidget {
  _IncrementButton();

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
        onTap: () => BlocProvider.of(context).increment(),  // 状态更新
        child: ClipOval(child: Container(width: 50, height: 50, alignment: Alignment.center,color: Colors.blue, child: StreamBuilder<int>(stream: BlocProvider.of(context).value, initialData: 0, builder: (BuildContext context, AsyncSnapshot<int> snapshot) {
          // 状态访问
          return Text("${snapshot.data}", textAlign: TextAlign.center,style: TextStyle(fontSize: 24, color: Colors.white));
        },),),)
    );
  }
}

/// 自定义 BLoC Provider,继承自 InheritedWidget 
class BlocProvider extends InheritedWidget {
  final CounterBloc bloc;

  BlocProvider({this.bloc, Key key, Widget child}) : super(key: key, child: child);

  @override
  bool updateShouldNotify(_) => true;

  static CounterBloc of(BuildContext context) => (context.inheritFromWidgetOfExactType(BlocProvider) as BlocProvider).bloc;
}

/// 自定义的状态
class CounterBloc {
  int _counter;
  StreamController<int> _counterController;

  CounterBloc() {
    _counter = 0;
    _counterController = StreamController<int>.broadcast();
  }

  Stream<int> get value => _counterController.stream;

  increment() {
    _counterController.sink.add(++_counter);
  }

  dispose() {
    _counterController.close();
  }

}

总结

一般而言,对于普通的项目来说【Provider】方案是一种非常容易理解,并且实用的状态管理方案。

对于大型的项目而言,【Redux】 有一套相对规范的状态更新流程,但是模板代码会比较多;对于重业务的项目而言,【BLoC】能够将复杂的业务内聚到 BLoC 模块中,实现业务分离。

总之,各种状态管理方案都有着各自的优缺点,这些需要我们在实践中去发现和总结,从而最终找到一种适合自己项目的状态管理方案。

参考

  1. 状态 (State) 管理参考
  2. [译]让我来帮你理解和选择Flutter状态管理方案
  3. Flutter状态管理 - 初探与总结
  4. Flutter | 状态管理探索篇——Scoped Model(一)
  5. Flutter | 状态管理探索篇——Redux(二)
  6. Flutter | 状态管理探索篇——BLoC(三)
  7. 《Flutter 实战》
  8. Dart | 什么是Stream
  9. 异步编程:使用 stream
  10. 使用 Flutter 构建响应式移动应用
  11. Flutter入门三部曲(3) - 数据传递/状态管理 | 掘金技术征文
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 199,271评论 5 466
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 83,725评论 2 376
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 146,252评论 0 328
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 53,634评论 1 270
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 62,549评论 5 359
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 47,985评论 1 275
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,471评论 3 390
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,128评论 0 254
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,257评论 1 294
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,233评论 2 317
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,235评论 1 328
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,940评论 3 316
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,528评论 3 302
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,623评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,858评论 1 255
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,245评论 2 344
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 41,790评论 2 339