跳转至正文

乐观状态

通过实现乐观状态提升应用响应速度的感知。

构建用户体验时,性能 感知 有时与代码实际性能同样重要。通常用户不愿等操作完成才看到结果,超过几毫秒的操作在用户看来可能显得「慢」或「无响应」。

开发者可在后台任务尚未完成前展示成功的 UI 状态以缓解这种负面感知。例如点击「Subscribe」按钮后立即变为「Subscribed」,即使订阅 API 的后台调用仍在进行。

该技术称为乐观状态 (Optimistic State)、乐观 UI 或乐观用户体验。在本实用教程中,你将按 Flutter 架构指南 使用乐观状态实现一个应用功能。

示例功能:订阅按钮

#

本示例实现类似视频流媒体应用或新闻通讯中的订阅按钮。

Application with subscribe button

点击按钮后应用调用外部 API 执行订阅操作,例如在数据库中记录用户已加入订阅列表。为演示起见,不实现真实后端,而用模拟网络请求的假操作替代。

调用成功时,按钮文字从 “Subscribe” 变为 “Subscribed”,背景色也会改变。

若调用失败,按钮文字应恢复为 “Subscribe”,并通过 Snackbar 等方式向用户显示错误信息。

按乐观状态思路,点击后按钮应立即变为 “Subscribed”,仅当请求失败时才恢复为 “Subscribe”。

Animation of application with subscribe button

功能架构

#

先定义功能架构。按架构指南,在 Flutter 项目中创建以下 Dart 类:

  • 名为 SubscribeButtonStatefulWidget

  • 继承 ChangeNotifierSubscribeButtonViewModel

  • SubscriptionRepository

dart
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 以连接各层:

dart
class SubscribeButtonViewModel extends ChangeNotifier {
  SubscribeButtonViewModel({required this.subscriptionRepository});

  final SubscriptionRepository subscriptionRepository;
}

并将 SubscribeButtonViewModel 传入 SubscribeButton widget:

dart
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:

dart
SubscribeButton(
  viewModel: SubscribeButtonViewModel(
    subscriptionRepository: SubscriptionRepository(),
  ),
)

实现 SubscriptionRepository

#

SubscriptionRepository 中新增异步方法 subscribe(),代码如下:

dart
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 添加以下公共成员:

dart
// 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() 方法:

dart
// 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 设回 falseerror 设为 true,最后再次 notifyListeners() 使 UI 回到 “Subscribe”。

无异常则流程结束,因 UI 已反映成功状态。

完整的 SubscribeButtonViewModel 如下:

dart
/// 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 方法中添加以下代码:

dart
@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

dart
class SubscribeButtonStyle {
  static const unsubscribed = ButtonStyle(
    backgroundColor: WidgetStatePropertyAll(Colors.red),
  );

  static const subscribed = ButtonStyle(
    backgroundColor: WidgetStatePropertyAll(Colors.green),
  );
}

现在运行应用,按下按钮会看到变化,但会恢复初始状态且不显示错误。

处理错误

#

要处理错误,在 SubscribeButtonState 中添加 initState()dispose(),再添加 _onViewModelChange() 方法。

dart
@override
void initState() {
  super.initState();
  widget.viewModel.addListener(_onViewModelChange);
}

@override
void dispose() {
  widget.viewModel.removeListener(_onViewModelChange);
  super.dispose();
}
dart
/// 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 状态,并微调按钮样式表示操作进行中。

交互示例

#

本示例展示 SubscribeButtonSubscribeButtonViewModelSubscriptionRepository,用乐观状态实现订阅点击。

点击后文字立即变为 “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');
  }
}