如何在Flutter中用StateNotifier和AsyncValue处理加载和错误状态

453 阅读7分钟

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

如果我们不能在适当的时候显示一个加载或错误的用户界面,用户可能会认为应用程序没有工作,并且不知道他们试图进行的操作是否已经成功。

例如,这里是一个带有按钮的页面,我们可以用Stripe来支付产品。

使用Stripe的支付页面示例

我们可以看到,一旦按下 "支付 "按钮,就会出现一个加载指标。而支付页面本身也显示一个加载指示器,直到支付方式可用。

而如果由于任何原因支付失败,我们应该显示一些错误的用户界面来通知用户。

因此,让我们深入了解一下,我们可能在我们的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
      },
    );
  }
}

如果我们愿意,我们可以让这个小部件变得有状态,并添加两个状态变量。

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,
    );
  }
}

这种方法会起作用,但它是相当重复和容易出错的。

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

让加载和错误状态更加简洁

我们真正想要的是用一种一致的方式来管理整个应用的加载和错误状态。

为了做到这一点,我们将使用Riverpod包中的AsyncValueStateNotifier

一旦我们完成,我们将能够用几行代码显示任何加载和错误的用户界面,就像这样。

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小部件

让我们从我们前面介绍的基本PaymentButton widget开始。

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() 方法可能需要几秒钟,而且我们没有任何加载或错误UI的地方。

让我们来解决这个问题。

用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<void> 作为状态。

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() 通常用于携带一些数据,使用一个通用的<T> 参数。AsyncValue<void> 但是在我们的例子中,我们没有任何数据,所以我们可以在定义我们的StateNotifier ,在设置初始值时使用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);
});

更新的PaymentButton小组件

现在我们有一个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<void> 的实例*(记住:AsyncLoadingAsyncValue 的子类)。*
  • 我们将isLoading 变量传递给PrimaryButton ,它将负责显示正确的用户界面。

如果你不熟悉Riverpod中的监听器,请参阅我的Riverpod基本指南中关于监听提供者状态变化的部分。

这很有效,但我们能不能用更少的模板代码得到同样的结果?

Dart扩展来拯救我们

让我们在AsyncValue<void> 上定义一个扩展,这样我们就可以更容易地检查加载状态,并在出错时显示一个snackbar。

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

有了这个,对于这个特定的页面来说,加载和错误状态都得到了正确的处理。

使用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<VoidAsyncValue> 子类,在widget类和上述服务或资源库类之间进行调解
  • 修改widgetbuild() 方法,通过ref.listen() 来处理错误状态,并根据需要检查加载状态

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

  • 我们可以用小部件中的少量模板代码来处理加载和错误状态
  • 我们可以将所有的状态管理逻辑从我们的小部件移到独立的控制器类中。

编码愉快!