乐观状态
在构建用户体验时,性能的感知度有时与代码的实际性能同等重要。总的来说,用户不喜欢等待操作完成才能看到结果,从用户的角度来看,任何需要几毫秒以上才能完成的操作都可能被视为“慢”或“无响应”。
开发者可以通过在后台任务完全完成之前显示成功的 UI 状态来缓解这种负面感知。一个例子是点击“订阅”按钮,立即看到它变为“已订阅”,即使后台对订阅 API 的调用仍在进行中。
这种技术被称为乐观状态、乐观 UI 或乐观用户体验。在本食谱中,您将使用乐观状态并遵循 Flutter 架构指南 来实现一个应用程序功能。
示例功能:订阅按钮
#此示例实现了一个订阅按钮,类似于您在视频流应用程序或新闻通讯中可能找到的按钮。
当按钮被点击时,应用程序会调用外部 API,执行订阅操作,例如在数据库中记录用户已在订阅列表中。为了演示目的,您将不实现实际的后端代码,而是用一个模拟网络请求的假操作来替换此调用。
如果调用成功,按钮文本将从“订阅”更改为“已订阅”。按钮的背景颜色也将随之改变。
相反,如果调用失败,按钮文本应恢复为“订阅”,并且 UI 应向用户显示错误消息,例如使用 Snackbar。
遵循乐观状态的理念,按钮一旦被点击,应立即变为“已订阅”,只有在请求失败时才变回“订阅”。
功能架构
#首先定义功能架构。按照架构指南,在 Flutter 项目中创建以下 Dart 类:
- 一个名为
SubscribeButton
的StatefulWidget
- 一个名为
SubscribeButtonViewModel
并继承自ChangeNotifier
的类 - 一个名为
SubscriptionRepository
的类
class SubscribeButton extends StatefulWidget {
const SubscribeButton({super.key});
@override
State<SubscribeButton> createState() => _SubscribeButtonState();
}
class _SubscribeButtonState extends State<SubscribeButton> {
@override
Widget build(BuildContext context) {
return const Placeholder();
}
}
class SubscribeButtonViewModel extends ChangeNotifier {}
class SubscriptionRepository {}
SubscribeButton
widget 和 SubscribeButtonViewModel
代表了此解决方案的表示层。widget 将显示一个按钮,该按钮根据订阅状态显示“订阅”或“已订阅”文本。视图模型将包含订阅状态。当按钮被点击时,widget 将调用视图模型来执行操作。
SubscriptionRepository
将实现一个 subscribe
方法,该方法在操作失败时会抛出异常。视图模型在执行订阅操作时将调用此方法。
接下来,通过将 SubscriptionRepository
添加到 SubscribeButtonViewModel
中将它们连接起来。
class SubscribeButtonViewModel extends ChangeNotifier {
SubscribeButtonViewModel({required this.subscriptionRepository});
final SubscriptionRepository subscriptionRepository;
}
并将 SubscribeButtonViewModel
添加到 SubscribeButton
widget 中。
class SubscribeButton extends StatefulWidget {
const SubscribeButton({super.key, required this.viewModel});
/// Subscribe button view model.
final SubscribeButtonViewModel viewModel;
@override
State<SubscribeButton> createState() => _SubscribeButtonState();
}
现在您已经创建了基本解决方案架构,可以按如下方式创建 SubscribeButton
widget:
SubscribeButton(
viewModel: SubscribeButtonViewModel(
subscriptionRepository: SubscriptionRepository(),
),
)
实现 SubscriptionRepository
#向 SubscriptionRepository
添加一个名为 subscribe()
的新异步方法,并包含以下代码:
class SubscriptionRepository {
/// Simulates a network request and then fails.
Future<void> subscribe() async {
// Simulate a network request
await Future.delayed(const Duration(seconds: 1));
// Fail after one second
throw Exception('Failed to subscribe');
}
}
添加了对 await Future.delayed()
的调用,持续一秒钟,以模拟一个耗时的请求。方法执行将暂停一秒钟,然后继续运行。
为了模拟请求失败,subscribe
方法最后抛出一个异常。稍后将使用此异常来演示在实现乐观状态时如何从失败的请求中恢复。
实现 SubscribeButtonViewModel
#为了表示订阅状态以及可能的错误状态,请向 SubscribeButtonViewModel
添加以下公共成员:
// Whether the user is subscribed
bool subscribed = false;
// Whether the subscription action has failed
bool error = false;
两者在开始时都设置为 false
。
遵循乐观状态的理念,一旦用户点击订阅按钮,subscribed
状态将变为 true
。只有在操作失败时才会变回 false
。
当操作失败时,error
状态将变为 true
,指示 SubscribeButton
widget 向用户显示错误消息。变量应该在错误显示后恢复为 false
。
接下来,实现一个异步 subscribe()
方法。
// Subscription action
Future<void> subscribe() async {
// Ignore taps when subscribed
if (subscribed) {
return;
}
// Optimistic state.
// It will be reverted if the subscription fails.
subscribed = true;
// Notify listeners to update the UI
notifyListeners();
try {
await subscriptionRepository.subscribe();
} catch (e) {
print('Failed to subscribe: $e');
// Revert to the previous state
subscribed = false;
// Set the error state
error = true;
} finally {
notifyListeners();
}
}
如前所述,首先将 subscribed
状态设置为 true
,然后调用 notifyListeners()
。这将强制 UI 更新,按钮会改变外观,向用户显示“已订阅”文本。
然后,方法执行对 repository 的实际调用。此调用被 try-catch
包裹,以便捕获它可能抛出的任何异常。如果捕获到异常,则将 subscribed
状态重置为 false
,并将 error
状态设置为 true
。最后调用 notifyListeners()
将 UI 变回“订阅”。
如果没有异常,则过程完成,因为 UI 已经反映了成功状态。
完整的 SubscribeButtonViewModel
应如下所示:
/// Subscribe button View Model.
/// Handles the subscribe action and exposes the state to the subscription.
class SubscribeButtonViewModel extends ChangeNotifier {
SubscribeButtonViewModel({required this.subscriptionRepository});
final SubscriptionRepository subscriptionRepository;
// Whether the user is subscribed
bool subscribed = false;
// Whether the subscription action has failed
bool error = false;
// Subscription action
Future<void> subscribe() async {
// Ignore taps when subscribed
if (subscribed) {
return;
}
// Optimistic state.
// It will be reverted if the subscription fails.
subscribed = true;
// Notify listeners to update the UI
notifyListeners();
try {
await subscriptionRepository.subscribe();
} catch (e) {
print('Failed to subscribe: $e');
// Revert to the previous state
subscribed = false;
// Set the error state
error = true;
} finally {
notifyListeners();
}
}
}
实现 SubscribeButton
#在此步骤中,您将首先实现 SubscribeButton
的 build 方法,然后实现功能中的错误处理。
将以下代码添加到 build 方法中:
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: widget.viewModel,
builder: (context, _) {
return FilledButton(
onPressed: widget.viewModel.subscribe,
style: widget.viewModel.subscribed
? SubscribeButtonStyle.subscribed
: SubscribeButtonStyle.unsubscribed,
child: widget.viewModel.subscribed
? const Text('Subscribed')
: const Text('Subscribe'),
);
},
);
}
此 build 方法包含一个 ListenableBuilder
,它监听视图模型的变化。然后,构建器创建一个 FilledButton
,根据视图模型状态显示“已订阅”或“订阅”文本。按钮样式也将根据此状态而改变。此外,当按钮被点击时,它会运行视图模型中的 subscribe()
方法。
SubscribeButtonStyle
可以在此处找到。将此类添加到 SubscribeButton
旁边。您可以随意修改 ButtonStyle
。
class SubscribeButtonStyle {
static const unsubscribed = ButtonStyle(
backgroundColor: WidgetStatePropertyAll(Colors.red),
);
static const subscribed = ButtonStyle(
backgroundColor: WidgetStatePropertyAll(Colors.green),
);
}
如果您现在运行应用程序,您将看到按钮在按下时会发生变化,但它会恢复到原始状态而不会显示错误。
处理错误
#要处理错误,请将 initState()
和 dispose()
方法添加到 SubscribeButtonState
,然后添加 _onViewModelChange()
方法。
@override
void initState() {
super.initState();
widget.viewModel.addListener(_onViewModelChange);
}
@override
void dispose() {
widget.viewModel.removeListener(_onViewModelChange);
super.dispose();
}
/// Listen to ViewModel changes.
void _onViewModelChange() {
// If the subscription action has failed
if (widget.viewModel.error) {
// Reset the error state
widget.viewModel.error = false;
// Show an error message
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('Failed to subscribe')));
}
}
调用 addListener()
会注册 _onViewModelChange()
方法,以便在视图模型通知监听器时被调用。在 widget 被处置时调用 removeListener()
很重要,以避免错误。
_onViewModelChange()
方法检查 error
状态,如果为 true
,则向用户显示一个 Snackbar
,其中包含错误消息。同时,将 error
状态重置为 false
,以避免在视图模型中再次调用 notifyListeners()
时多次显示错误消息。
高级乐观状态
#在此教程中,您学习了如何实现具有单一二进制状态的乐观状态,但您可以使用此技术通过引入第三个时间状态来创建更高级的解决方案,该状态指示操作仍在进行中。
例如,在聊天应用程序中,当用户发送新消息时,应用程序将在聊天窗口中显示新聊天消息,但会有一个图标指示消息尚未送达。当消息送达时,该图标将被移除。
在订阅按钮示例中,您可以在视图模型中添加另一个标志,指示 subscribe()
方法仍在运行,或者使用 Command 模式的运行状态,然后稍微修改按钮样式以显示操作正在进行中。
互动示例
#此示例展示了 SubscribeButton
widget 与 SubscribeButtonViewModel
和 SubscriptionRepository
一起,它们实现了带有乐观状态的订阅点击操作。
当您点击按钮时,按钮文本从“订阅”变为“已订阅”。一秒钟后,repository 抛出一个异常,该异常被视图模型捕获,按钮恢复显示“订阅”,同时显示一个带有错误消息的 Snackbar。
// ignore_for_file: avoid_print
import 'package:flutter/material.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
// This widget is the root of your application.
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
body: Center(
child: SubscribeButton(
viewModel: SubscribeButtonViewModel(
subscriptionRepository: SubscriptionRepository(),
),
),
),
),
);
}
}
/// A button that simulates a subscription action.
/// For example, subscribing to a newsletter or a streaming channel.
class SubscribeButton extends StatefulWidget {
const SubscribeButton({super.key, required this.viewModel});
/// Subscribe button view model.
final SubscribeButtonViewModel viewModel;
@override
State<SubscribeButton> createState() => _SubscribeButtonState();
}
class _SubscribeButtonState extends State<SubscribeButton> {
@override
void initState() {
super.initState();
widget.viewModel.addListener(_onViewModelChange);
}
@override
void dispose() {
widget.viewModel.removeListener(_onViewModelChange);
super.dispose();
}
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: widget.viewModel,
builder: (context, _) {
return FilledButton(
onPressed: widget.viewModel.subscribe,
style: widget.viewModel.subscribed
? SubscribeButtonStyle.subscribed
: SubscribeButtonStyle.unsubscribed,
child: widget.viewModel.subscribed
? const Text('Subscribed')
: const Text('Subscribe'),
);
},
);
}
/// Listen to ViewModel changes.
void _onViewModelChange() {
// If the subscription action has failed
if (widget.viewModel.error) {
// Reset the error state
widget.viewModel.error = false;
// Show an error message
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('Failed to subscribe')));
}
}
}
class SubscribeButtonStyle {
static const unsubscribed = ButtonStyle(
backgroundColor: WidgetStatePropertyAll(Colors.red),
);
static const subscribed = ButtonStyle(
backgroundColor: WidgetStatePropertyAll(Colors.green),
);
}
/// Subscribe button View Model.
/// Handles the subscribe action and exposes the state to the subscription.
class SubscribeButtonViewModel extends ChangeNotifier {
SubscribeButtonViewModel({required this.subscriptionRepository});
final SubscriptionRepository subscriptionRepository;
// Whether the user is subscribed
bool subscribed = false;
// Whether the subscription action has failed
bool error = false;
// Subscription action
Future<void> subscribe() async {
// Ignore taps when subscribed
if (subscribed) {
return;
}
// Optimistic state.
// It will be reverted if the subscription fails.
subscribed = true;
// Notify listeners to update the UI
notifyListeners();
try {
await subscriptionRepository.subscribe();
} catch (e) {
print('Failed to subscribe: $e');
// Revert to the previous state
subscribed = false;
// Set the error state
error = true;
} finally {
notifyListeners();
}
}
}
/// Repository of subscriptions.
class SubscriptionRepository {
/// Simulates a network request and then fails.
Future<void> subscribe() async {
// Simulate a network request
await Future.delayed(const Duration(seconds: 1));
// Fail after one second
throw Exception('Failed to subscribe');
}
}