Flutter 中使用 StateNotifier 和 AsyncValue 处理加载和错误状态
加载和出错状态在执行一些异步工作的应用程序中非常常见。
如果我们不能在适当的时候显示加载或出错 UI,用户可能会认为应用程序无法运行,并且不知道他们试图执行的操作是否成功。
例如,这是一个带有按钮的页面,我们可以使用该按钮通过 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 软件包中的 AsyncValue 和 StateNotifier 。
完成后,我们只需几行代码就能显示任何加载和错误 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()
来获取结账服务并使用它进行支付。
如果你不熟悉
ConsumerWidget
和ref.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
实例(请记住:AsyncLoading
是AsyncValue
的子类) - 我们将
isLoading
变量传递给PrimaryButton
,它将负责显示正确的用户界面
如果你不熟悉 Riverpod 中的监听器,请参阅我的 Riverpod essential guide 中有关 Listening to Provider State Changes 的部分。
这个方法可行,但我们能否用更少的模板代码获得同样的效果呢?
让我们在 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(),
);
}
}
有了这个功能,就能正确处理该特定页面的加载和出错状态:
使用 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 中编写少量模板代码,就能处理加载和错误状态
- 我们可以将所有状态管理逻辑从部件移到单独的控制器类中