在构建用户体验时,性能的感知有时与代码的实际性能同样重要。通常,用户不喜欢等待一个操作完成才能看到结果,并且从用户的角度来看,任何超过几毫秒的操作都可能被认为是“慢”或“无响应”。

开发者可以通过在后台任务完全完成之前呈现一个成功的 UI 状态来帮助缓解这种负面感知。一个例子是点击“订阅”按钮,即使订阅 API 的后台调用仍在运行,也能立即看到它变为“已订阅”。

这种技术被称为乐观状态(Optimistic State)、乐观 UI(Optimistic UI)或乐观用户体验(Optimistic User Experience)。在本教程中,你将使用乐观状态并遵循 Flutter 架构指南来实现一个应用程序功能。

示例功能:订阅按钮

#

本示例实现了一个订阅按钮,类似于你在视频流应用或新闻通讯中可能会找到的按钮。

Application with subscribe button

当按钮被点击时,应用程序会调用外部 API,执行订阅操作,例如在数据库中记录用户已在订阅列表中。出于演示目的,你不会实现实际的后端代码,而是用一个模拟网络请求的虚假操作来替代此调用。

如果调用成功,按钮文本将从“订阅”变为“已订阅”。按钮的背景颜色也将改变。

相反,如果调用失败,按钮文本应恢复为“订阅”,并且 UI 应向用户显示错误消息,例如使用 Snackbar。

遵循乐观状态的理念,按钮在被点击后应立即变为“已订阅”,并且仅在请求失败时才变回“订阅”。

Animation of application with subscribe button

功能架构

#

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

  • 一个名为 SubscribeButtonStatefulWidget
  • 一个名为 SubscribeButtonViewModel 并扩展 ChangeNotifier 的类
  • 一个名为 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 组件和 SubscribeButtonViewModel 代表了此解决方案的表示层。该组件将显示一个按钮,根据订阅状态显示文本“订阅”或“已订阅”。视图模型将包含订阅状态。当按钮被点击时,组件将调用视图模型来执行操作。

SubscriptionRepository 将实现一个订阅方法,该方法在操作失败时会抛出异常。视图模型在执行订阅操作时将调用此方法。

接下来,通过将 SubscriptionRepository 添加到 SubscribeButtonViewModel 来将它们连接起来。

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

  final SubscriptionRepository subscriptionRepository;
}

并将 SubscribeButtonViewModel 添加到 SubscribeButton 组件。

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 组件:

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

实现 SubscriptionRepository

#

将一个名为 subscribe() 的新异步方法添加到 SubscriptionRepository 中,代码如下:

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() 调用,以模拟长时间运行的请求。方法执行将暂停一秒,然后继续运行。

为了模拟请求失败,订阅方法最后会抛出一个异常。这将在稍后用于演示在实现乐观状态时如何从失败的请求中恢复。

实现 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 更新,按钮会改变其外观,向用户显示“已订阅”文本。

然后该方法执行对存储库的实际调用。此调用由 try-catch 块包装,以捕获可能抛出的任何异常。如果捕获到异常,subscribed 状态将恢复为 false,并且 error 状态将设置为 true。最后会再次调用 notifyListeners() 以将 UI 更改回“订阅”。

如果没有异常,则流程完成,因为 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 的构建方法,然后实现该功能的错误处理。

将以下代码添加到构建方法中:

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

此构建方法包含一个 ListenableBuilder,它侦听来自视图模型的更改。然后构建器创建一个 FilledButton,该按钮将根据视图模型状态显示文本“已订阅”或“订阅”。按钮样式也将根据此状态改变。此外,当按钮被点击时,它会运行视图模型中的 subscribe() 方法。

SubscribeButtonStyle 可以在这里找到。将此类别添加到 SubscribeButton 旁边。你可以随意修改 ButtonStyle

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

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

如果你现在运行应用程序,你将看到当你按下按钮时它的变化,但是它会变回原始状态而不会显示错误。

处理错误

#

为了处理错误,将 initState()dispose() 方法添加到 SubscribeButtonState 中,然后添加 _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() 调用将 _onViewModelChange() 方法注册为在视图模型通知监听器时被调用。在组件被处置时调用 removeListener() 很重要,以避免错误。

_onViewModelChange() 方法检查 error 状态,如果为 true,则向用户显示一个 Snackbar,其中包含错误消息。同时,error 状态会重置为 false,以避免在视图模型中再次调用 notifyListeners() 时多次显示错误消息。

高级乐观状态

#

在本教程中,你学习了如何实现具有单个二元状态的乐观状态,但你可以通过引入第三个时间状态来创建更高级的解决方案,该状态指示操作仍在运行。

例如,在聊天应用程序中,当用户发送新消息时,应用程序会在聊天窗口中显示新消息,但会带有一个图标,指示消息仍在等待发送。当消息发送成功后,该图标将被移除。

在订阅按钮示例中,你可以在视图模型中添加另一个标志,指示 subscribe() 方法仍在运行,或者使用命令模式的运行状态,然后稍微修改按钮样式以显示操作正在运行。

互动示例

#

此示例展示了 SubscribeButton 组件与 SubscribeButtonViewModelSubscriptionRepository,它们共同实现了具有乐观状态的订阅点击操作。

当你点击按钮时,按钮文本从“订阅”变为“已订阅”。一秒后,存储库抛出一个异常,该异常被视图模型捕获,按钮恢复显示“订阅”,同时显示一个带有错误消息的 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');
  }
}