跳到主要內容

樂觀狀態

在建構使用者體驗時,效能的感知有時與程式碼的實際效能一樣重要。一般來說,使用者不喜歡等待操作完成才看到結果,任何耗時超過幾毫秒的操作,從使用者的角度來看都可能被視為「緩慢」或「無反應」。

開發人員可以在背景任務完全完成之前,呈現成功的 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

#

將一個名為 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');
  }
}