Flutter 中使用 StateNotifier 和 AsyncValue 处理加载和错误状态

来源:codewithandrea.com 更新时间:2023-10-30 20:31

加载和出错状态在执行一些异步工作的应用程序中非常常见。

如果我们不能在适当的时候显示加载或出错 UI,用户可能会认为应用程序无法运行,并且不知道他们试图执行的操作是否成功。

例如,这是一个带有按钮的页面,我们可以使用该按钮通过 Stripe 支付产品费用:

Example payment page with Stripe
使用 Stripe 的付款页面示例 我们可以看到,一旦按下 "付款 "按钮,就会显示加载指示器。支付页面本身也会显示加载指示器,直到支付方式可用为止。

如果支付因任何原因失败,我们应该显示一些错误 UI 来通知用户。

因此,让我们深入了解如何在 Flutter 应用程序中处理这些问题。

使用 StatefulWidget 的加载和错误状态

加载和出错状态非常常见,我们应该在每个执行异步工作的页面或部件上处理它们。

例如,假设我们有一个 PaymentButton 用于付款:

class PaymentButton extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    // note: this is a *custom* button class that takes an extra `isLoading` argument
    return PrimaryButton(
      text: 'Pay',
      // this will show a spinner if loading is true
      isLoading: false,
      onPressed: () {
        // use a service locator or provider to get the checkout service
        // make the payment
      },
    );
  }
}

如果我们愿意,可以让这个 widget 有状态,并添加两个状态变量:

class _PaymentButtonState extends State<PaymentButton> {
  // loading and error state variables
  bool _isLoading = false;
  String _errorMessage = '';
  Future<void> pay() async {
    // make payment, update state variables, and show an alert on error
  }
  @override
  Widget build(BuildContext context) {
    // same as before, 
    return PrimaryButton(
      text: 'Pay',
      // use _isLoading variable defined above
      isLoading: _isLoading,
      onPressed: _isLoading ? null : pay,
    );
  }
}

这种方法可行,但重复性高,容易出错。

毕竟,我们不想让所有部件都是有状态的,也不想到处添加状态变量,对吗?

让加载和出错状态更干练

我们真正想要的是在整个应用中以一致的方式管理加载和出错状态。

为此,我们将使用 Riverpod 软件包中的 AsyncValueStateNotifier

完成后,我们只需几行代码就能显示任何加载和错误 UI,就像这样:

class PaymentButton extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // error handling
    ref.listen<AsyncValue<void>>(
      paymentButtonControllerProvider,
      (_, state) => state.showSnackBarOnError(context),
    );
    final paymentState = ref.watch(paymentButtonControllerProvider);
    // note: this is a *custom* button class that takes an extra `isLoading` argument
    return PrimaryButton(
      text: 'Pay',
      // show a spinner if loading is true
      isLoading: paymentState.isLoading,
      // disable button if loading is true
      onPressed: paymentState.isLoading
        ? null
        : () => ref.read(paymentButtonControllerProvider.notifier).pay(),
    );
  }
}

但是,让我们一步一步来。

基本设置:付款按钮部件

让我们从前面介绍过的基本 PaymentButton 小部件开始:

import 'package:flutter_riverpod/flutter_riverpod.dart';
// note: this time we subclass from ConsumerWidget so that we can get a WidgetRef below
class PaymentButton extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // note: this is a custom button class that takes an extra `isLoading` argument
    return PrimaryButton(
      text: 'Pay',
      isLoading: false,
      onPressed: () => ref.read(checkoutServiceProvider).pay(),
    );
  }
}

按下按钮后,我们会调用ref.read()来获取结账服务并使用它进行支付。

如果你不熟悉ConsumerWidgetref.read()语法,请参阅我的Riverpod必备指南

以下是 CheckoutService 和相应提供程序的实现方式,以供参考:

// sample interface for the checkout service
abstract class CheckoutService {
  // this will succeed or throw an error
  Future<void> pay();
}
final checkoutServiceProvider = Provider<CheckoutService>((ref) {
  // return some concrete implementation of CheckoutService
});

这样做是可行的,但 "pay() "方法可能需要几秒钟,而且我们没有任何加载或出错用户界面。

让我们来解决这个问题。

使用 AsyncValue 管理加载和出错状态

我们示例中的用户界面需要管理三种可能的状态:

  • 未加载(默认)
  • 加载
  • 错误

要表示这些状态,我们可以使用 Riverpod 软件包中的 AsyncValue 类。

以下是该类的定义,以供参考:

@sealed
@immutable
abstract class AsyncValue<T> {
  const factory AsyncValue.data(T value) = AsyncData<T>;
  const factory AsyncValue.loading() = AsyncLoading<T>;
  const factory AsyncValue.error(Object error, {StackTrace? stackTrace}) =
        AsyncError<T>;
}

请注意,该类是抽象类,我们只能使用其中一个现有的工厂构造函数将其实例化。

在引擎盖下,这些构造函数是通过以下具体类实现的:

class AsyncData<T> implements AsyncValue<T>
class AsyncLoading<T> implements AsyncValue<T>
class AsyncError<T> implements AsyncValue<T>

最重要的是,我们可以使用 AsyncValue 来表示我们关心的三种状态:

  • 未加载 → AsyncValue.data.
  • 正在加载 → AsyncValue.loading
  • 错误 → AsyncValue.error

但是,我们应该把逻辑放在哪里呢?

为此,我们需要定义一个 StateNotifier 子类,它将使用 AsyncValue 作为状态。

StateNotifier 子类

首先,我们将定义一个 PaymentButtonController 类,它依赖于 CheckoutService 并设置默认状态:

class PaymentButtonController extends StateNotifier<AsyncValue<void>> {
  PaymentButtonController({required this.checkoutService})
      // initialize state
      : super(const AsyncValue.data(null));
  final CheckoutService checkoutService;
}

注意:AsyncValue.data() 通常用于使用通用的 参数携带某些数据。但在我们的例子中,我们没有任何数据,所以我们可以在定义 StateNotifier 时使用 AsyncValue,在设置初始值时使用 AsyncValue.data(null)

然后,我们可以添加一个 pay() 方法,该方法将从 widget 类中调用:

  Future<void> pay() async {
    try {
      // set state to `loading` before starting the asynchronous work
      state = const AsyncValue.loading();
      // do the async work
      await checkoutService.pay();
    } catch (e) {
      // if the payment failed, set the error state
      state = const AsyncValue.error('Could not place order');
    } finally {
      // set state to `data(null)` at the end (both for success and failure)
      state = const AsyncValue.data(null);
    }
  }
}

请注意状态是如何被多次设置的,这样我们的部件就可以重建并相应地更新用户界面。

为了让我们的 widget 可以使用 PaymentButtonController,我们可以像这样定义一个 StateNotifierProvider

final paymentButtonControllerProvider =
    StateNotifierProvider<PaymentButtonController, AsyncValue<void>>((ref) {
  final checkoutService = ref.watch(checkoutServiceProvider);
  return PaymentButtonController(checkoutService: checkoutService);
});

更新付款按钮部件

现在我们有了 "PaymentButtonController",可以在 widget 类中使用它:

class PaymentButton extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // 1. listen for errors
    ref.listen<AsyncValue<void>>(
      paymentButtonControllerProvider,
      (_, state) => state.whenOrNull(
        error: (error) {
          // show snackbar if an error occurred
          ScaffoldMessenger.of(context).showSnackBar(
            SnackBar(content: Text(error)),
          );
        },
      ),
    );
    // 2. use the loading state in the child widget
    final paymentState = ref.watch(paymentButtonControllerProvider);
    final isLoading = paymentState is AsyncLoading<void>;
    return PrimaryButton(
      text: 'Pay',
      isLoading: isLoading,
      onPressed: isLoading
        ? null
        // note: this was previously using the checkout service
        : () => ref.read(paymentButtonControllerProvider.notifier).pay(),
    );
  }
}

几点说明

  • 如果发现错误状态,我们会使用 ref.listen()state.whenOrNull() 来显示点心栏
  • 我们会检查支付状态是否为 AsyncLoading 实例(请记住:AsyncLoadingAsyncValue 的子类)
  • 我们将isLoading变量传递给PrimaryButton,它将负责显示正确的用户界面

如果你不熟悉 Riverpod 中的监听器,请参阅我的 Riverpod essential guide 中有关 Listening to Provider State Changes 的部分。

这个方法可行,但我们能否用更少的模板代码获得同样的效果呢?

Dart扩展拯救

让我们在 AsyncValue 上定义一个扩展,这样我们就可以更轻松地检查加载状态,并在出错时显示一个小条:

extension AsyncValueUI on AsyncValue<void> {
  // isLoading shorthand (AsyncLoading is a subclass of AsycValue)
  bool get isLoading => this is AsyncLoading<void>;
  // show a snackbar on error only
  void showSnackBarOnError(BuildContext context) => whenOrNull(
        error: (error, _) {
          ScaffoldMessenger.of(context).showSnackBar(
            SnackBar(content: Text(error.toString())),
          );
        },
      );
}

有了这些变化,我们就可以简化我们的 widget 类:

class PaymentButton extends ConsumerWidget {
  const PaymentButton({Key? key}) : super(key: key);
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // 1. listen for errors
    ref.listen<AsyncValue<void>>(
      paymentButtonControllerProvider,
      (_, state) => state.showSnackBarOnError(context),
    );
    // 2. use the loading state in the child widget
    final paymentState = ref.watch(paymentButtonControllerProvider);
    return PrimaryButton(
      text: 'Pay',
      isLoading: paymentState.isLoading,
      onPressed: paymentState.isLoading
        ? null
        : () => ref.read(paymentButtonControllerProvider.notifier).pay(),
    );
  }
}

有了这个功能,就能正确处理该特定页面的加载和出错状态:

Example payment page with Stripe 使用 Stripe 的付款页面示例## 结论

下面是 "AsyncValueUI "扩展的完整实现:

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
// Bonus: define AsyncValue<void> as a typedef that we can
// reuse across multiple widgets and state notifiers
typedef VoidAsyncValue = AsyncValue<void>;
extension AsyncValueUI on VoidAsyncValue {
  bool get isLoading => this is AsyncLoading<void>;
  void showSnackBarOnError(BuildContext context) => whenOrNull(
        error: (error, _) {
          ScaffoldMessenger.of(context).showSnackBar(
            SnackBar(content: Text(error.toString())),
          );
        },
      );
}

多亏了 AsyncValueUI 扩展方法,我们才能在应用程序中轻松处理加载和出错状态。

事实上,对于每个执行异步工作的页面,我们都需要遵循两个步骤:

  • 添加一个 StateNotifier 子类,在 widget 类和上述服务或存储库类之间进行调解
  • 修改 widget 的build()方法,通过ref.listen()处理错误状态,并根据需要检查加载状态

虽然这样设置需要一些前期工作,但其优点是值得的:

  • 我们只需在 widget 中编写少量模板代码,就能处理加载和错误状态
  • 我们可以将所有状态管理逻辑从部件移到单独的控制器类中