Flutter 的状态管理方案:setState、BLoC、ValueNotifier、Provider

2,868 阅读7分钟

Flutter 的状态管理方案:setState、BLoC、ValueNotifier、Provider

本文是这个视频中的重点内容,我们比较了不同的状态管理方案。

例如,我们使用简单的身份验证流程。当登录请求发起时,设置正在加载中的状态。

为简单起见,此流程由三种可能的状态组成:

图上的状态可以由如下状态机表示,其中包括加载状态和认证状态:

当登录的请求正在进行中,我们会禁用登录按钮并展示进度指示器。

此示例 app 展示了如何使用各种状态管理方案处理加载状态。

主要导航

登录页面的主要导航是通过一个小部件实现的,该小部件使用 Drawer 菜单在不同选项中进行选择。

代码如下:

class SignInPageNavigation extends StatelessWidget {
  const SignInPageNavigation({Key key, this.option}) : super(key: key);
  final ValueNotifier<Option> option;

  Option get _option => option.value;
  OptionData get _optionData => optionsData[_option];

  void _onSelectOption(Option selectedOption) {
    option.value = selectedOption;
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(_optionData.title),
      ),
      drawer: MenuSwitcher(
        options: optionsData,
        selectedOption: _option,
        onSelected: _onSelectOption,
      ),
      body: _buildContent(context),
    );
  }

  Widget _buildContent(BuildContext context) {
    switch (_option) {
      case Option.vanilla:
        return SignInPageVanilla();
      case Option.setState:
        return SignInPageSetState();
      case Option.bloc:
        return SignInPageBloc.create(context);
      case Option.valueNotifier:
        return SignInPageValueNotifier.create(context);
      default:
        return Container();
    }
  }
}

这个 widget 展示了这样一个 Scaffold

  • AppBar 的标题是选中的项目名称
  • drawer 使用了自定义构造器 MenuSwitcher
  • body 使用了一个 switch 语句来区分不同的页

参考流程(vanilla)

要启用登录,我们可以从没有加载状态的简易 vanilla 实现开始:

class SignInPageVanilla extends StatelessWidget {
  Future<void> _signInAnonymously(BuildContext context) async {
    try {
      final auth = Provider.of<AuthService>(context);
      await auth.signInAnonymously();
    } on PlatformException catch (e) {
      await PlatformExceptionAlertDialog(
        title: '登录失败',
        exception: e,
      ).show(context);
    }
  }

  @override
  Widget build(BuildContext context) {
    return Center(
      child: SignInButton(
        text: '登录',
        onPressed: () => _signInAnonymously(context),
      ),
    );
  }
}

当点击 SignInButton 按钮,就调用 _signInAnonymously 方法。

这里使用了 Provider 来获取 AuthService 对象,并将它用于登录。

札记

  • AuthService 是一个对 Firebase Authentication 的简单封装。详情请见这篇文章
  • 身份验证状态由一个祖先 widget 处理,该 widget 使用 onAuthStateChanged 来决定展示哪个页面。我在前一篇文章中介绍了这一点。

setState

加载状态可以经过以下流程,添加到刚刚的实现中:

  • 将我们的 widget 转化为 StatefulWidget
  • 定义一个局部 state 变量
  • 将该 state 放进 build 方法中
  • 在登录前和登录后更新它

以下是最终代码:

class SignInPageSetState extends StatefulWidget {
  @override
  _SignInPageSetStateState createState() => _SignInPageSetStateState();
}

class _SignInPageSetStateState extends State<SignInPageSetState> {
  bool _isLoading = false;

  Future<void> _signInAnonymously() async {
    try {
      setState(() => _isLoading = true);
      final auth = Provider.of<AuthService>(context);
      await auth.signInAnonymously();
    } on PlatformException catch (e) {
      await PlatformExceptionAlertDialog(
        title: '登录失败',
        exception: e,
      ).show(context);
    } finally {
      setState(() => _isLoading = false);
    }
  }

  @override
  Widget build(BuildContext context) {
    return Center(
      child: SignInButton(
        text: '登录',
        loading: _isLoading,
        onPressed: _isLoading ? null : () => _signInAnonymously(),
      ),
    );
  }
}

重要提示:请注意我们如何使用 finally 闭包。无论是否抛出异常,这都可被用于执行某些代码。

BLoC

加载状态可以由 BLoC 中,stream 的值表示。

我们需要一些额外的示例代码来设置:

class SignInBloc {
  final _loadingController = StreamController<bool>();
  Stream<bool> get loadingStream => _loadingController.stream;

  void setIsLoading(bool loading) => _loadingController.add(loading);

  dispose() {
    _loadingController.close();
  }
}

class SignInPageBloc extends StatelessWidget {
  const SignInPageBloc({Key key, @required this.bloc}) : super(key: key);
  final SignInBloc bloc;

  static Widget create(BuildContext context) {
    return Provider<SignInBloc>(
      builder: (_) => SignInBloc(),
      dispose: (_, bloc) => bloc.dispose(),
      child: Consumer<SignInBloc>(
        builder: (_, bloc, __) => SignInPageBloc(bloc: bloc),
      ),
    );
  }

  Future<void> _signInAnonymously(BuildContext context) async {
    try {
      bloc.setIsLoading(true);
      final auth = Provider.of<AuthService>(context);
      await auth.signInAnonymously();
    } on PlatformException catch (e) {
      await PlatformExceptionAlertDialog(
        title: '登录失败',
        exception: e,
      ).show(context);
    } finally {
      bloc.setIsLoading(false);
    }
  }

  @override
  Widget build(BuildContext context) {
    return StreamBuilder<bool>(
      stream: bloc.loadingStream,
      initialData: false,
      builder: (context, snapshot) {
        final isLoading = snapshot.data;
        return Center(
          child: SignInButton(
            text: '登录',
            loading: isLoading,
            onPressed: isLoading ? null : () => _signInAnonymously(context),
          ),
        );
      },
    );
  }
}

简而言之,这段代码:

  • 使用 StreamController<bool> 添加一个 SignInBloc,用于处理加载状态。
  • 通过静态 create 方法中的 Provider / Consumer,让 SignInBloc 可以访问我们的 widget。
  • _signInAnonymously 方法中,通过调用 bloc.setIsLoading(value) 来更新 stream。
  • 通过 StreamBuilder 来检查加载状态,并使用它来设置登录按钮。

关于 RxDart 的注意事项

BehaviorSubject 是一种特殊的 stream 控制器,它允许我们同步地访问 stream 的最后一个值。

作为 BloC 的替代方案,我们可以使用 BehaviorSubject 来跟踪加载状态,并根据需要进行更新。

我会通过 GitHub 项目 来展示具体如何实现。

ValueNotifier

ValueNotifier 可以被用于持有一个值,并当它变化的时候通知它的监听者。

实现相同的流程代码如下:

class SignInPageValueNotifier extends StatelessWidget {
  const SignInPageValueNotifier({Key key, this.loading}) : super(key: key);
  final ValueNotifier<bool> loading;

  static Widget create(BuildContext context) {
    return ChangeNotifierProvider<ValueNotifier<bool>>(
      builder: (_) => ValueNotifier<bool>(false),
      child: Consumer<ValueNotifier<bool>>(
        builder: (_, ValueNotifier<bool> isLoading, __) =>
            SignInPageValueNotifier(
              loading: isLoading,
            ),
      ),
    );
  }

  Future<void> _signInAnonymously(BuildContext context) async {
    try {
      loading.value = true;
      final auth = Provider.of<AuthService>(context);
      await auth.signInAnonymously();
    } on PlatformException catch (e) {
      await PlatformExceptionAlertDialog(
        title: '登录失败',
        exception: e,
      ).show(context);
    } finally {
      loading.value = false;
    }
  }

  @override
  Widget build(BuildContext context) {
    return Center(
      child: SignInButton(
        text: '登录',
        loading: loading.value,
        onPressed: loading.value ? null : () => _signInAnonymously(context),
      ),
    );
  }
}

静态 create 方法中,我们使用了 ValueNotifier<bool>ChangeNotifierProviderConsumer,这为我们提供了一种表示加载状态的方法,并在更改时重建 widget。

ValueNotifier vs ChangeNotifier

ValueNotifierChangeNotifier 密切相关。

实际上,ValueNotifier 就是实现了 ValueListenable<T>ChangeNotifier 的子类。

这是 Flutter SDK 中 ValueNotifier 的实现:

/// A [ChangeNotifier] that holds a single value.
///
/// When [value] is replaced with something that is not equal to the old
/// value as evaluated by the equality operator ==, this class notifies its
/// listeners.
class ValueNotifier<T> extends ChangeNotifier implements ValueListenable<T> {
  /// Creates a [ChangeNotifier] that wraps this value.
  ValueNotifier(this._value);

  /// The current value stored in this notifier.
  ///
  /// When the value is replaced with something that is not equal to the old
  /// value as evaluated by the equality operator ==, this class notifies its
  /// listeners.
  @override
  T get value => _value;
  T _value;
  set value(T newValue) {
    if (_value == newValue)
      return;
    _value = newValue;
    notifyListeners();
  }

  @override
  String toString() => '${describeIdentity(this)}($value)';
}

所以我们应该什么时候用 ValueNotifier,什么时候用 ChangeNotifier 呢?

  • 如果在简单值更改时需要重建 widget,请使用 ValueNotifier
  • 如果你想在 notifyListeners() 调用时有更多掌控,请使用 ChangeNotifier

关于 ScopedModel 的注意事项

ChangeNotifierProvider 非常类似于 ScopedModel。实际上,他们之间几乎相同:

  • ScopedModel ↔︎ ChangeNotifierProvider
  • ScopedModelDescendant ↔︎ Consumer

因此,如果你已经在使用 Provider,则不需要 ScopedModel,因为 ChangeNotifierProvider 提供了相同的功能。

最后的比较

上述三种实现(setState、BLoC、ValueNotifier)非常相似,只是处理加载状态的方式不同。

如下是他们的比较方式:

  • setState ↔︎ 最精简的代码
  • BLoC ↔︎ 最多的代码
  • ValueNotifier ↔︎ 中等水平

所以 setState 方案最适合这个例子,因为我们需要处理单个小部件的各自的状态

在构建自己的应用程序时,你可以根据具体情况来评估哪个方案更合适 😉

小彩蛋:实现 Drawer 菜单

跟踪当前选择的选项也是一个状态管理问题:

我首先在自定义 Drawer 菜单中使用本地状态变量和 setState 实现它。

但是登录后状态丢失了,因为 Drawer 已经从 widget 树中删除。

有一个方案,我决定在 LandingPage 中使用 ChangeNotifierProvider<ValueNotifier<Option>> 存储状态:

class LandingPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    // Used to keep track of the selected option across sign-in events
    final authService = Provider.of<AuthService>(context);
    return ChangeNotifierProvider<ValueNotifier<Option>>(
      builder: (_) => ValueNotifier<Option>(Option.vanilla),
      child: StreamBuilder<User>(
        stream: authService.onAuthStateChanged,
        builder: (context, snapshot) {
          if (snapshot.connectionState == ConnectionState.active) {
            User user = snapshot.data;
            if (user == null) {
              return Consumer<ValueNotifier<Option>>(
                builder: (_, ValueNotifier<Option> option, __) =>
                    SignInPageNavigation(option: option),
              );
            }
            return HomePage();
          } else {
            return Scaffold(
              body: Center(
                child: CircularProgressIndicator(),
              ),
            );
          }
        },
      ),
    );
  }
}

这里使用 StreamBuilder 来控制用户的身份验证状态。

通过使用 ChangeNotifierProvider<ValueNotifier<Option>> 来包装它,即使在删除 SignInPageNavigation 之后,我也能保留所选的选项。

总结如下:

  • StatefulWidget 在 state 被删除后,不再记住自己的 state。
  • 使用 Provider,我们可以选择在哪里存储 widget 树中的状态。
  • 这样,即使删除使用它的小部件,状态也会被保留

ValueNotifiersetState 需要更多的代码。但它可以用来记住状态,通过在 widget 树中放置适当的 Provider。

源代码

可以在这里找到本教程中的示例代码:

所有这些状态管理方案都在我的 Flutter & Firebase Udemy 课程中有深入介绍。这可以通过此链接进行了解(点这个链接有折扣哦):

祝你代码敲得开心!

如果发现译文存在错误或其他需要改进的地方,欢迎到 掘金翻译计划 对译文进行修改并 PR,也可获得相应奖励积分。文章开头的 本文永久链接 即为本文在 GitHub 上的 MarkDown 链接。


掘金翻译计划 是一个翻译优质互联网技术文章的社区,文章来源为 掘金 上的英文分享文章。内容覆盖 AndroidiOS前端后端区块链产品设计人工智能等领域,想要查看更多优质译文请持续关注 掘金翻译计划官方微博知乎专栏