乐观状态
通过实现乐观状态提升应用响应速度的感知。
构建用户体验时,性能 感知 有时与代码实际性能同样重要。通常用户不愿等操作完成才看到结果,超过几毫秒的操作在用户看来可能显得「慢」或「无响应」。
开发者可在后台任务尚未完成前展示成功的 UI 状态以缓解这种负面感知。例如点击「Subscribe」按钮后立即变为「Subscribed」,即使订阅 API 的后台调用仍在进行。
该技术称为乐观状态 (Optimistic State)、乐观 UI 或乐观用户体验。在本实用教程中,你将按 Flutter 架构指南 使用乐观状态实现一个应用功能。
示例功能:订阅按钮
#本示例实现类似视频流媒体应用或新闻通讯中的订阅按钮。
点击按钮后应用调用外部 API 执行订阅操作,例如在数据库中记录用户已加入订阅列表。为演示起见,不实现真实后端,而用模拟网络请求的假操作替代。
调用成功时,按钮文字从 “Subscribe” 变为 “Subscribed”,背景色也会改变。
若调用失败,按钮文字应恢复为 “Subscribe”,并通过 Snackbar 等方式向用户显示错误信息。
按乐观状态思路,点击后按钮应立即变为 “Subscribed”,仅当请求失败时才恢复为 “Subscribe”。
功能架构
#先定义功能架构。按架构指南,在 Flutter 项目中创建以下 Dart 类:
-
名为
SubscribeButton的StatefulWidget -
继承
ChangeNotifier的SubscribeButtonViewModel类 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 显示按钮,根据订阅状态展示 “Subscribe” 或 “Subscribed”;
View model 持有订阅状态;点击时 widget 调用 view model 执行操作。
SubscriptionRepository 实现 subscribe 方法,失败时抛出异常;
view model 在执行订阅时调用该方法。
接下来将 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 向用户显示错误;错误展示后应重置为 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 更新,按钮显示 “Subscribed”。
然后调用 repository;用 try-catch 捕获异常。捕获异常时将 subscribed 设回 false、error 设为 true,最后再次 notifyListeners() 使 UI 回到 “Subscribe”。
无异常则流程结束,因 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 监听 view model 变化,创建 FilledButton,按状态显示 "Subscribed" 或 "Subscribe",样式随之变化;点击时调用 view model 的 subscribe()。
SubscribeButtonStyle 见下,放在 SubscribeButton 旁,可自行修改 ButtonStyle。
class SubscribeButtonStyle {
static const unsubscribed = ButtonStyle(
backgroundColor: WidgetStatePropertyAll(Colors.red),
);
static const subscribed = ButtonStyle(
backgroundColor: WidgetStatePropertyAll(Colors.green),
);
}
现在运行应用,按下按钮会看到变化,但会恢复初始状态且不显示错误。
处理错误
#
要处理错误,在 SubscribeButtonState 中添加 initState() 与 dispose(),再添加 _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() 在 view model 通知时调用 _onViewModelChange();
widget 销毁时须 removeListener() 以免出错。
_onViewModelChange() 检查 error,为 true 时用 Snackbar 显示错误,并将 error 重置为 false,避免 view model 再次 notifyListeners()
时重复提示。
高级乐观状态
#本教程介绍了用单一二元状态实现乐观状态;也可加入第三种临时状态表示操作仍在进行,构建更高级方案。
例如聊天应用发送新消息时,窗口先显示消息并带「待送达」图标,送达后移除图标。
订阅按钮示例中,可在 view model 增加标志表示 subscribe() 仍在运行,或使用命令模式的 running 状态,并微调按钮样式表示操作进行中。
交互示例
#
本示例展示 SubscribeButton、SubscribeButtonViewModel 与 SubscriptionRepository,用乐观状态实现订阅点击。
点击后文字立即变为 “Subscribed”;约一秒后 repository 抛出异常, view model 捕获后按钮恢复 “Subscribe” 并显示 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');
}
}
除非另有说明,本文档之所提及适用于 Flutter 3.44.0 版本。本页面最后更新时间:2026-06-18。查看文档源码 或者 为本页面内容提出建议。