您 Flutter 應用中每個功能的 UI 層應由兩個元件構成:一個 View 和一個 ViewModel

A screenshot of the booking screen of the compass app.

廣義上講,ViewModel 管理 UI 狀態,View 顯示 UI 狀態。View 和 ViewModel 之間存在一對一的關係;每個 View 都對應一個管理該 View 狀態的 ViewModel。每一對 View 和 ViewModel 構成一個單一功能的 UI。例如,一個應用可能包含名為 LogOutViewLogOutViewModel 的類。

定義 ViewModel

#

ViewModel 是一個負責處理 UI 邏輯的 Dart 類。ViewModel 以領域資料模型作為輸入,並將該資料作為 UI 狀態暴露給相應的 View。它們封裝了 View 可以附加到事件處理程式(如按鈕點選)的邏輯,並負責將這些事件傳送到應用的資料層,在那裡發生資料更改。

以下程式碼片段是一個名為 HomeViewModel 的 ViewModel 類的宣告。它的輸入是提供其資料的儲存庫。在這種情況下,ViewModel 依賴於 BookingRepositoryUserRepository 作為引數。

home_viewmodel.dart
dart
class HomeViewModel {
  HomeViewModel({
    required BookingRepository bookingRepository,
    required UserRepository userRepository,
  }) :
    // Repositories are manually assigned because they're private members.
    _bookingRepository = bookingRepository,
    _userRepository = userRepository;

  final BookingRepository _bookingRepository;
  final UserRepository _userRepository;
  // ...
}

ViewModel 始終依賴於資料儲存庫,資料儲存庫作為引數傳遞給 ViewModel 的建構函式。ViewModel 和儲存庫之間存在多對多關係,大多數 ViewModel 將依賴於多個儲存庫。

如前面 HomeViewModel 示例宣告所示,儲存庫應為 ViewModel 的私有成員,否則 View 將直接訪問應用的資料層。

UI 狀態

#

ViewModel 的輸出是 View 渲染所需的資料,通常稱為UI 狀態,或簡稱為狀態。UI 狀態是完全渲染 View 所需資料的不可變快照。

A screenshot of the booking screen of the compass app.

ViewModel 將狀態作為公共成員公開。在以下程式碼示例的 ViewModel 中,公開的資料是 User 物件,以及使用者儲存的行程,這些行程以 List<BookingSummary> 型別物件的形式公開。

home_viewmodel.dart
dart
class HomeViewModel {
  HomeViewModel({
   required BookingRepository bookingRepository,
   required UserRepository userRepository,
  }) : _bookingRepository = bookingRepository,
      _userRepository = userRepository;
 
  final BookingRepository _bookingRepository;
  final UserRepository _userRepository;

  User? _user;
  User? get user => _user;

  List<BookingSummary> _bookings = [];
 
  /// Items in an [UnmodifiableListView] can't be directly modified,
  /// but changes in the source list can be modified. Since _bookings
  /// is private and bookings is not, the view has no way to modify the
  /// list directly.
  UnmodifiableListView<BookingSummary> get bookings => UnmodifiableListView(_bookings);

  // ...
}

如前所述,UI 狀態應為不可變的。這是無 bug 軟體的關鍵部分。

Compass 應用使用 package:freezed 來強制執行資料類的不可變性。例如,以下程式碼顯示了 User 類的定義。freezed 提供深度不可變性,並生成有用方法(如 copyWithtoJson)的實現。

user.dart
dart
@freezed
class User with _$User {
  const factory User({
    /// The user's name.
    required String name,

    /// The user's picture URL.
    required String picture,
  }) = _User;

  factory User.fromJson(Map<String, Object?> json) => _$UserFromJson(json);
}

更新 UI 狀態

#

除了儲存狀態之外,ViewModel 還需要在資料層提供新狀態時通知 Flutter 重新渲染 View。在 Compass 應用中,ViewModel 擴充套件 ChangeNotifier 來實現此目的。

home_viewmodel.dart
dart
class HomeViewModel extends ChangeNotifier {
  HomeViewModel({
   required BookingRepository bookingRepository,
   required UserRepository userRepository,
  }) : _bookingRepository = bookingRepository,
      _userRepository = userRepository;
  final BookingRepository _bookingRepository;
  final UserRepository _userRepository;

  User? _user;
  User? get user => _user;

  List<BookingSummary> _bookings = [];
  List<BookingSummary> get bookings => _bookings;

  // ...
}

HomeViewModel.user 是 View 依賴的公共成員。當新資料從資料層流經並需要發出新狀態時,將呼叫 notifyListeners

A screenshot of the booking screen of the compass app.

此圖高層級地展示了儲存庫中的新資料如何傳播到 UI 層並觸發 Flutter 小部件的重新構建。

  1. 從儲存庫向 ViewModel 提供新狀態。
  2. ViewModel 更新其 UI 狀態以反映新資料。
  3. 呼叫 ViewModel.notifyListeners,通知 View 新的 UI 狀態。
  4. View (widget) 重新渲染。

例如,當用戶導航到主螢幕並建立 ViewModel 時,將呼叫 _load 方法。在此方法完成之前,UI 狀態為空,View 顯示載入指示器。當 _load 方法完成後,如果成功,ViewModel 中將有新資料,並且必須通知 View 新資料可用。

home_viewmodel.dart
dart
class HomeViewModel extends ChangeNotifier {
  // ...

 Future<Result> _load() async {
    try {
      final userResult = await _userRepository.getUser();
      switch (userResult) {
        case Ok<User>():
          _user = userResult.value;
          _log.fine('Loaded user');
        case Error<User>():
          _log.warning('Failed to load user', userResult.error);
      }

      // ...

      return userResult;
    } finally {
      notifyListeners();
    }
  }
}

定義 View

#

View 是應用中的一個小部件。通常,View 代表應用中的一個螢幕,該螢幕有自己的路由,並在小部件樹的頂部包含一個 Scaffold,例如 HomeScreen,但這並非總是如此。

有時 View 是一個封裝了需要在整個應用中重用功能的單一 UI 元素。例如,Compass 應用有一個名為 LogoutButton 的 View,它可以放置在使用者期望找到登出按鈕的任何位置。LogoutButton View 有自己的 ViewModel,名為 LogoutViewModel。在較大的螢幕上,螢幕上可能存在多個 View,它們在移動裝置上會佔據整個螢幕。

View 中的小部件有三項職責:

  • 它們顯示 ViewModel 中的資料屬性。
  • 它們監聽來自 ViewModel 的更新,並在新資料可用時重新渲染。
  • 如果適用,它們將來自 ViewModel 的回撥附加到事件處理程式。

A diagram showing a view's relationship to a view model.

繼續 Home 功能示例,以下程式碼顯示了 HomeScreen View 的定義。

home_screen.dart
dart
class HomeScreen extends StatelessWidget {
  const HomeScreen({super.key, required this.viewModel});

  final HomeViewModel viewModel;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      // ...
    );
  }
}

大多數情況下,View 的唯一輸入應該是 key(所有 Flutter 小部件都可以選擇性地接受它)以及 View 對應的 ViewModel。

在 View 中顯示 UI 資料

#

View 依賴於 ViewModel 來獲取其狀態。在 Compass 應用中,ViewModel 作為引數傳遞到 View 的建構函式中。以下示例程式碼片段來自 HomeScreen 小部件。

home_screen.dart
dart
class HomeScreen extends StatelessWidget {
  const HomeScreen({super.key, required this.viewModel});

  final HomeViewModel viewModel;

  @override
  Widget build(BuildContext context) {
    // ...
  }
}

在小部件內部,您可以訪問從 viewModel 傳遞過來的預訂資訊。在以下程式碼中,booking 屬性被提供給子小部件。

home_screen.dart
dart
@override
  Widget build(BuildContext context) {
    return Scaffold(
      // Some code was removed for brevity.
      body: SafeArea(
        child: ListenableBuilder(
          listenable: viewModel,
          builder: (context, _) {
            return CustomScrollView(
              slivers: [
                SliverToBoxAdapter(...),
                SliverList.builder(
                   itemCount: viewModel.bookings.length,
                    itemBuilder: (_, index) => _Booking(
                      key: ValueKey(viewModel.bookings[index].id),
                      booking:viewModel.bookings[index],
                      onTap: () => context.push(Routes.bookingWithId(
                         viewModel.bookings[index].id)),
                      onDismissed: (_) => viewModel.deleteBooking.execute(
                           viewModel.bookings[index].id,
                         ),
                    ),
                ),
              ],
            );
          },
        ),
      ),

更新 UI

#

HomeScreen 小部件使用 ListenableBuilder 小部件監聽來自 ViewModel 的更新。ListenableBuilder 小部件下的所有小部件樹都會在提供的 Listenable 更改時重新渲染。在這種情況下,提供的 Listenable 是 ViewModel。回想一下,ViewModel 的型別是 ChangeNotifier,它是 Listenable 型別的一個子型別。

home_screen.dart
dart
@override
Widget build(BuildContext context) {
  return Scaffold(
    // Some code was removed for brevity.
      body: SafeArea(
        child: ListenableBuilder(
          listenable: viewModel,
          builder: (context, _) {
            return CustomScrollView(
              slivers: [
                SliverToBoxAdapter(),
                SliverList.builder(
                  itemCount: viewModel.bookings.length,
                  itemBuilder: (_, index) =>
                      _Booking(
                        key: ValueKey(viewModel.bookings[index].id),
                        booking: viewModel.bookings[index],
                        onTap: () =>
                            context.push(Routes.bookingWithId(
                                viewModel.bookings[index].id)
                            ),
                        onDismissed: (_) =>
                            viewModel.deleteBooking.execute(
                              viewModel.bookings[index].id,
                            ),
                      ),
                ),
              ],
            );
          }
        )
      )
  );
}

處理使用者事件

#

最後,View 需要監聽使用者的事件,以便 ViewModel 可以處理這些事件。這透過在 ViewModel 類中公開一個封裝所有邏輯的回撥方法來實現。

A diagram showing a view's relationship to a view model.

HomeScreen 中,使用者可以透過滑動 Dismissible 小部件來刪除之前預訂的活動。

回想上一程式碼片段中的程式碼:

home_screen.dart
dart
SliverList.builder(
  itemCount: widget.viewModel.bookings.length,
  itemBuilder: (_, index) => _Booking(
    key: ValueKey(viewModel.bookings[index].id),
    booking: viewModel.bookings[index],
    onTap: () => context.push(
      Routes.bookingWithId(viewModel.bookings[index].id)
    ),
    onDismissed: (_) =>
      viewModel.deleteBooking.execute(widget.viewModel.bookings[index].id),
  ),
),
A clip that demonstrates the 'dismissible' functionality of the Compass app.

HomeScreen 中,使用者的已儲存行程由 _Booking 小部件表示。當 _Booking 被滑動刪除時,將執行 viewModel.deleteBooking 方法。

已儲存的預訂是應用程式狀態,它比會話或 View 的生命週期更持久,只有儲存庫應該修改這種應用程式狀態。因此,HomeViewModel.deleteBooking 方法反過來呼叫資料層中儲存庫公開的一個方法,如以下程式碼片段所示。

home_viewmodel.dart
dart
Future<Result<void>> _deleteBooking(int id) async {
  try {
    final resultDelete = await _bookingRepository.delete(id);
    switch (resultDelete) {
      case Ok<void>():
        _log.fine('Deleted booking $id');
      case Error<void>():
        _log.warning('Failed to delete booking $id', resultDelete.error);
        return resultDelete;
    }

    // Some code was omitted for brevity.
    // final  resultLoadBookings = ...;

    return resultLoadBookings;
  } finally {
    notifyListeners();
  }
}

在 Compass 應用中,這些處理使用者事件的方法稱為命令

Command 物件

#

命令負責從 UI 層開始並流向資料層的互動。特別是在此應用中,Command 也是一種型別,它有助於安全地更新 UI,無論響應時間或內容如何。

Command 類包裝了一個方法,並有助於處理該方法的不同狀態,例如 runningcompleteerror。當 Command.running 為 true 時,這些狀態可以輕鬆地顯示不同的 UI,例如載入指示器。

以下是 Command 類的程式碼。出於演示目的,已省略部分程式碼。

command.dart
dart
abstract class Command<T> extends ChangeNotifier {
  Command();
  bool running = false;
  Result<T>? _result;

  /// true if action completed with error
  bool get error => _result is Error;

  /// true if action completed successfully
  bool get completed => _result is Ok;

  /// Internal execute implementation
  Future<void> _execute(action) async {
    if (_running) return;

    // Emit running state - e.g. button shows loading state
    _running = true;
    _result = null;
    notifyListeners();

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

Command 類本身擴充套件了 ChangeNotifier,在 Command.execute 方法中,會多次呼叫 notifyListeners。這允許 View 以極少的邏輯處理不同的狀態,您將在本頁稍後看到一個示例。

您可能還注意到 Command 是一個抽象類。它由具體的類(如 Command0Command1)實現。類名中的整數指的是底層方法期望的引數數量。您可以在 Compass 應用的utils 目錄中看到這些實現類的示例。

確保 View 在資料存在前即可渲染

#

在 ViewModel 類中,命令是在建構函式中建立的。

home_viewmodel.dart
dart
class HomeViewModel extends ChangeNotifier {
  HomeViewModel({
   required BookingRepository bookingRepository,
   required UserRepository userRepository,
  }) : _bookingRepository = bookingRepository,
      _userRepository = userRepository {
    // Load required data when this screen is built.
    load = Command0(_load)..execute();
    deleteBooking = Command1(_deleteBooking);
  }

  final BookingRepository _bookingRepository;
  final UserRepository _userRepository;

  late Command0 load;
  late Command1<void, int> deleteBooking;

  User? _user;
  User? get user => _user;

  List<BookingSummary> _bookings = [];
  List<BookingSummary> get bookings => _bookings;

  Future<Result> _load() async {
    // ...
  }

  Future<Result<void>> _deleteBooking(int id) async {
    // ...
  }

  // ...
}

Command.execute 方法是非同步的,因此它不能保證在 View 想要渲染時資料可用。這就引出了 Compass 應用使用 Commands 的原因。在 View 的 Widget.build 方法中,該命令用於有條件地渲染不同的 widget。

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

    if (viewModel.load.error) {
      return ErrorIndicator(
        title: AppLocalization.of(context).errorWhileLoadingHome,
        label: AppLocalization.of(context).tryAgain,
          onPressed: viewModel.load.execute,
        );
     }

    // The command has completed without error.
    // Return the main view widget.
    return child!;
  },
),

// ...

因為 load 命令是 ViewModel 的一個屬性,而不是短暫的,所以 load 方法何時被呼叫或何時解析並不重要。例如,如果 load 命令在 HomeScreen 小部件建立之前就解析了,這也不是問題,因為 Command 物件仍然存在,並且公開了正確的狀態。

這種模式標準化了應用中常見 UI 問題的解決方案,使您的程式碼庫更不容易出錯且更具可伸縮性,但這並不是每個應用都想實現的模式。是否要使用它在很大程度上取決於您做出的其他架構選擇。許多幫助您管理狀態的庫都有自己的工具來解決這些問題。例如,如果您在應用中使用StreamBuilders,Flutter 提供的AsyncSnapshot 類已經內建了此功能。

反饋

#

隨著本網站這一部分的不斷發展,我們歡迎您的反饋