Skip to main content

乐观状态

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

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

这种技术被称为乐观状态、乐观UI或乐观用户体验。在这个示例中,您将使用乐观状态并遵循[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

#

使用以下代码向SubscriptionRepository添加一个名为subscribe()的新异步方法:

dart
class SubscriptionRepository {
  /// 模拟网络请求然后失败。
  Future<void> subscribe() async {
    // 模拟网络请求
    await Future.delayed(const Duration(seconds: 1));
    // 一秒钟后失败
    throw Exception('订阅失败');
  }
}

已添加对await Future.delayed()的调用(持续时间为一秒),以模拟长时间运行的请求。方法执行将暂停一秒钟,然后继续运行。

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

实现SubscribeButtonViewModel

#

为了表示订阅状态以及可能的错误状态,请向SubscribeButtonViewModel添加以下公共成员:

dart
// 用户是否已订阅
bool subscribed = false;

// 订阅操作是否失败
bool error = false;

启动时两者都设置为false

遵循乐观状态的思想,subscribed状态将在用户点击订阅按钮后立即更改为true。并且只有在操作失败时才会更改回false

当操作失败时,error状态将更改为true,指示SubscribeButton小部件向用户显示错误消息。一旦错误显示,变量应恢复为false

接下来,实现一个异步subscribe()方法:

dart
// 订阅操作
Future<void> subscribe() async {
  // 订阅时忽略点击
  if (subscribed) {
    return;
  }

  // 乐观状态。
  // 如果订阅失败,它将被恢复。
  subscribed = true;
  // 通知侦听器更新UI
  notifyListeners();

  try {
    await subscriptionRepository.subscribe();
  } catch (e) {
    print('订阅失败: $e');
    // 恢复到之前的状态
    subscribed = false;
    // 设置错误状态
    error = true;
  } finally {
    notifyListeners();
  }
}

如前所述,该方法首先将subscribed状态设置为true,然后调用notifyListeners()。这强制UI更新,并且按钮更改其外观,向用户显示文本“已订阅”。

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

如果没有异常,则该过程已完成,因为UI已经反映了成功状态。

完整的SubscribeButtonViewModel应如下所示:

dart
/// 订阅按钮视图模型。
/// 处理订阅操作并将状态公开给订阅。
class SubscribeButtonViewModel extends ChangeNotifier {
  SubscribeButtonViewModel({
    required this.subscriptionRepository,
  });

  final SubscriptionRepository subscriptionRepository;

  // 用户是否已订阅
  bool subscribed = false;

  // 订阅操作是否失败
  bool error = false;

  // 订阅操作
  Future<void> subscribe() async {
    // 订阅时忽略点击
    if (subscribed) {
      return;
    }

    // 乐观状态。
    // 如果订阅失败,它将被恢复。
    subscribed = true;
    // 通知侦听器更新UI
    notifyListeners();

    try {
      await subscriptionRepository.subscribe();
    } catch (e) {
      print('订阅失败: $e');
      // 恢复到之前的状态
      subscribed = false;
      // 设置错误状态
      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('已订阅')
            : const Text('订阅'),
      );
    },
  );
}

此build方法包含一个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
/// 侦听ViewModel更改。
void _onViewModelChange() {
  // 如果订阅操作失败
  if (widget.viewModel.error) {
    // 重置错误状态
    widget.viewModel.error = false;
    // 显示错误消息
    ScaffoldMessenger.of(context).showSnackBar(
      const SnackBar(
        content: Text('订阅失败'),
      ),
    );
  }
}

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

/// 模拟订阅操作的按钮。
/// 例如,订阅新闻通讯或流媒体频道。
class SubscribeButton extends StatefulWidget {
  const SubscribeButton({
    super.key,
    required this.viewModel,
  });

  /// 订阅按钮视图模型。
  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('已订阅')
              : const Text('订阅'),
        );
      },
    );
  }

  /// 侦听ViewModel更改。
  void _onViewModelChange() {
    // 如果订阅操作失败
    if (widget.viewModel.error) {
      // 重置错误状态
      widget.viewModel.error = false;
      // 显示错误消息
      ScaffoldMessenger.of(context).showSnackBar(
        const SnackBar(
          content: Text('订阅失败'),
        ),
      );
    }
  }
}

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

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

/// 订阅按钮视图模型。
/// 处理订阅操作并将状态公开给订阅。
class SubscribeButtonViewModel extends ChangeNotifier {
  SubscribeButtonViewModel({
    required this.subscriptionRepository,
  });

  final SubscriptionRepository subscriptionRepository;

  // 用户是否已订阅
  bool subscribed = false;

  // 订阅操作是否失败
  bool error = false;

  // 订阅操作
  Future<void> subscribe() async {
    // 订阅时忽略点击
    if (subscribed) {
      return;
    }

    // 乐观状态。
    // 如果订阅失败,它将被恢复。
    subscribed = true;
    // 通知侦听器更新UI
    notifyListeners();

    try {
      await subscriptionRepository.subscribe();
    } catch (e) {
      print('订阅失败: $e');
      // 恢复到之前的状态
      subscribed = false;
      // 设置错误状态
      error = true;
    } finally {
      notifyListeners();
    }
  }
}

/// 订阅库。
class SubscriptionRepository {
  /// 模拟网络请求然后失败。
  Future<void> subscribe() async {
    // 模拟网络请求
    await Future.delayed(const Duration(seconds: 1));
    // 一秒钟后失败
    throw Exception('订阅失败');
  }
}