模型-檢視-ViewModel (MVVM) 是一種設計模式,它將應用程式的某個功能分解為三個部分:模型、ViewModel 和檢視。檢視和 ViewModel 構成了應用程式的 UI 層。儲存庫和服務代表了應用程式的資料層,或 MVVM 的模型層。

命令是一個類,它封裝了一個方法,並有助於處理該方法的不同狀態,例如正在執行、完成和錯誤。

ViewModel 可以使用命令來處理互動和執行操作。同樣,它們也可以用於顯示不同的 UI 狀態,例如在操作執行時顯示載入指示器,或在操作失敗時顯示錯誤對話方塊。

隨著應用程式的增長和功能的擴大,ViewModel 可能會變得非常複雜。命令可以幫助簡化 ViewModel 和重用程式碼。

在本指南中,您將學習如何使用命令模式來改進您的 ViewModel。

實現 ViewModel 時的挑戰

#

Flutter 中的 ViewModel 類通常透過擴充套件 ChangeNotifier 類來實現。這使得 ViewModel 在資料更新時可以呼叫 notifyListeners() 來重新整理檢視。

dart
class HomeViewModel extends ChangeNotifier {
  // ···
}

ViewModel 包含 UI 狀態的表示,包括正在顯示的資料。例如,這個 HomeViewModel 向檢視公開了 User 例項。

dart
class HomeViewModel extends ChangeNotifier {

  User? get user => // ...
  // ···
}

ViewModel 還包含通常由檢視觸發的操作;例如,負責載入 userload 操作。

dart
class HomeViewModel extends ChangeNotifier {

  User? get user => // ...
  // ···
  void load() {
    // load user
  }
  // ···
}

ViewModel 中的 UI 狀態

#

ViewModel 除了資料之外,還包含 UI 狀態,例如檢視是否正在執行或是否遇到錯誤。這使得應用程式能夠告知使用者操作是否已成功完成。

dart
class HomeViewModel extends ChangeNotifier {

  User? get user => // ...

  bool get running => // ...

  Exception? get error => // ...

  void load() {
    // load user
  }
  // ···
}

您可以使用執行狀態來在檢視中顯示進度指示器

dart
ListenableBuilder(
  listenable: widget.viewModel,
  builder: (context, _) {
    if (widget.viewModel.running) {
      return const Center(child: CircularProgressIndicator());
    }
    // ···
  },
)

或者使用執行狀態來避免多次執行操作

dart
void load() {
  if (running) {
    return;
  }
  // load user
}

當 ViewModel 包含多個操作時,管理操作的狀態可能會變得複雜。例如,向 HomeViewModel 新增一個 edit() 操作可能會導致以下結果

dart
class HomeViewModel extends ChangeNotifier {
  User? get user => // ...

  bool get runningLoad => // ...

  Exception? get errorLoad => // ...

  bool get runningEdit => // ...

  Exception? get errorEdit => // ...

  void load() {
    // load user
  }

  void edit(String name) {
    // edit user
  }
}

load()edit() 操作之間的執行狀態共享可能並不總是有效,因為您可能希望在 load() 操作執行時顯示與 edit() 操作執行時不同的 UI 元件,並且您將面臨 error 狀態的同樣問題。

從 ViewModel 觸發 UI 操作

#

ViewModel 類在執行 UI 操作以及 ViewModel 狀態發生變化時可能會遇到問題。

例如,您可能希望在發生錯誤時顯示一個 SnackBar,或者在操作完成時導航到另一個螢幕。要實現這一點,請監聽 ViewModel 中的變化,並根據狀態執行操作。

在檢視中

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

@override
void dispose() {
  widget.viewModel.removeListener(_onViewModelChanged);
  super.dispose();
}
dart
void _onViewModelChanged() {
  if (widget.viewModel.error != null) {
    // Show Snackbar
  }
}

每次執行此操作時,您都需要清除錯誤狀態,否則該操作將在每次呼叫 notifyListeners() 時發生。

dart
void _onViewModelChanged() {
  if (widget.viewModel.error != null) {
    widget.viewModel.clearError();
    // Show Snackbar
  }
}

命令模式

#

您可能會發現自己一遍又一遍地重複上述程式碼,為每個 ViewModel 中的每個操作實現不同的執行狀態。此時,將此程式碼提取到一個可重用的模式中是有意義的:命令。

命令是一個封裝了 ViewModel 操作的類,並公開了操作可以具有的不同狀態。

dart
class Command extends ChangeNotifier {
  Command(this._action);

  bool get running => // ...

  Exception? get error => // ...

  bool get completed => // ...

  void Function() _action;

  void execute() {
    // run _action
  }

  void clear() {
    // clear state
  }
}

在 ViewModel 中,您不是直接用方法定義一個操作,而是建立一個命令物件

dart
class HomeViewModel extends ChangeNotifier {
  HomeViewModel() {
    load = Command(_load)..execute();
  }

  User? get user => // ...

  late final Command load;

  void _load() {
    // load user
  }
}

前面的 load() 方法變成 _load(),取而代之的是,命令 load 被公開給檢視。前面的 runningerror 狀態可以被移除,因為它們現在是命令的一部分。

執行命令

#

不再呼叫 viewModel.load() 來執行載入操作,現在您呼叫 viewModel.load.execute()

execute() 方法也可以在 ViewModel 內部呼叫。下面的程式碼行在建立 ViewModel 時執行 load 命令。

dart
HomeViewModel() {
  load = Command(_load)..execute();
}

execute() 方法將 running 狀態設定為 true,並重置 errorcompleted 狀態。當操作完成時,running 狀態變為 falsecompleted 狀態變為 true

如果 running 狀態為 true,則命令無法開始再次執行。這可以防止使用者透過快速按按鈕多次觸發命令。

命令的 execute() 方法會自動捕獲任何丟擲的 Exceptions,並在 error 狀態中公開它們。

下面的程式碼顯示了一個簡化的 Command 類,用於演示目的。您可以在本頁的末尾找到完整的實現。

dart
class Command extends ChangeNotifier {
  Command(this._action);

  bool _running = false;
  bool get running => _running;

  Exception? _error;
  Exception? get error => _error;

  bool _completed = false;
  bool get completed => _completed;

  final Future<void> Function() _action;

  Future<void> execute() async {
    if (_running) {
      return;
    }

    _running = true;
    _completed = false;
    _error = null;
    notifyListeners();

    try {
      await _action();
      _completed = true;
    } on Exception catch (error) {
      _error = error;
    } finally {
      _running = false;
      notifyListeners();
    }
  }

  void clear() {
    _running = false;
    _error = null;
    _completed = false;
  }
}

監聽命令狀態

#

Command 類擴充套件自 ChangeNotifier,允許檢視監聽其狀態。

ListenableBuilder 中,不要將 ViewModel 傳遞給 ListenableBuilder.listenable,而是傳遞命令

dart
ListenableBuilder(
  listenable: widget.viewModel.load,
  builder: (context, child) {
    if (widget.viewModel.load.running) {
      return const Center(child: CircularProgressIndicator());
    }
  // ···
)

並監聽命令狀態的變化,以便執行 UI 操作

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

@override
void dispose() {
  widget.viewModel.removeListener(_onViewModelChanged);
  super.dispose();
}
dart
void _onViewModelChanged() {
  if (widget.viewModel.load.error != null) {
    widget.viewModel.load.clear();
    // Show Snackbar
  }
}

命令與 ViewModel 的結合

#

您可以堆疊多個 ListenableBuilder 小部件來監聽 runningerror 狀態,然後再顯示 ViewModel 資料。

dart
body: ListenableBuilder(
  listenable: widget.viewModel.load,
  builder: (context, child) {
    if (widget.viewModel.load.running) {
      return const Center(child: CircularProgressIndicator());
    }

    if (widget.viewModel.load.error != null) {
      return Center(
        child: Text('Error: ${widget.viewModel.load.error}'),
      );
    }

    return child!;
  },
  child: ListenableBuilder(
    listenable: widget.viewModel,
    builder: (context, _) {
      // ···
    },
  ),
),

您可以在單個 ViewModel 中定義多個命令類,從而簡化其實現並最小化重複程式碼量。

dart
class HomeViewModel2 extends ChangeNotifier {
  HomeViewModel2() {
    load = Command(_load)..execute();
    delete = Command(_delete);
  }

  User? get user => // ...

  late final Command load;

  late final Command delete;

  Future<void> _load() async {
    // load user
  }

  Future<void> _delete() async {
    // delete user
  }
}

擴充套件命令模式

#

命令模式可以從多個方面進行擴充套件。例如,支援不同數量的引數。

dart
class HomeViewModel extends ChangeNotifier {
  HomeViewModel() {
    load = Command0(_load)..execute();
    edit = Command1<String>(_edit);
  }

  User? get user => // ...

  // Command0 accepts 0 arguments
  late final Command0 load;

  // Command1 accepts 1 argument
  late final Command1<String> edit;

  Future<void> _load() async {
    // load user
  }

  Future<void> _edit(String name) async {
    // edit user
  }
}

整合所有概念

#

在本指南中,您學習瞭如何使用命令設計模式來改進在使用 MVVM 設計模式時 ViewModel 的實現。

下面,您可以找到 Flutter 架構指南中 Compass App 示例 中實現的完整 Command 類。它還使用 Result 來確定操作是成功完成還是出錯。

此實現還包括兩種型別的命令:Command0,用於沒有引數的操作;Command1,用於帶有一個引數的操作。

dart
// Copyright 2024 The Flutter team. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import 'dart:async';

import 'package:flutter/foundation.dart';

import 'result.dart';

/// Defines a command action that returns a [Result] of type [T].
/// Used by [Command0] for actions without arguments.
typedef CommandAction0<T> = Future<Result<T>> Function();

/// Defines a command action that returns a [Result] of type [T].
/// Takes an argument of type [A].
/// Used by [Command1] for actions with one argument.
typedef CommandAction1<T, A> = Future<Result<T>> Function(A);

/// Facilitates interaction with a view model.
///
/// Encapsulates an action,
/// exposes its running and error states,
/// and ensures that it can't be launched again until it finishes.
///
/// Use [Command0] for actions without arguments.
/// Use [Command1] for actions with one argument.
///
/// Actions must return a [Result] of type [T].
///
/// Consume the action result by listening to changes,
/// then call to [clearResult] when the state is consumed.
abstract class Command<T> extends ChangeNotifier {
  bool _running = false;

  /// Whether the action is running.
  bool get running => _running;

  Result<T>? _result;

  /// Whether the action completed with an error.
  bool get error => _result is Error;

  /// Whether the action completed successfully.
  bool get completed => _result is Ok;

  /// The result of the most recent action.
  ///
  /// Returns `null` if the action is running or completed with an error.
  Result<T>? get result => _result;

  /// Clears the most recent action's result.
  void clearResult() {
    _result = null;
    notifyListeners();
  }

  /// Execute the provided [action], notifying listeners and
  /// setting the running and result states as necessary.
  Future<void> _execute(CommandAction0<T> action) async {
    // Ensure the action can't launch multiple times.
    // e.g. avoid multiple taps on button
    if (_running) return;

    // Notify listeners.
    // e.g. button shows loading state
    _running = true;
    _result = null;
    notifyListeners();

    try {
      _result = await action();
    } finally {
      _running = false;
      notifyListeners();
    }
  }
}

/// A [Command] that accepts no arguments.
final class Command0<T> extends Command<T> {
  /// Creates a [Command0] with the provided [CommandAction0].
  Command0(this._action);

  final CommandAction0<T> _action;

  /// Executes the action.
  Future<void> execute() async {
    await _execute(_action);
  }
}

/// A [Command] that accepts one argument.
final class Command1<T, A> extends Command<T> {
  /// Creates a [Command1] with the provided [CommandAction1].
  Command1(this._action);

  final CommandAction1<T, A> _action;

  /// Executes the action with the specified [argument].
  Future<void> execute(A argument) async {
    await _execute(() => _action(argument));
  }
}