跳到主內容

UI 層案例研究

對實現 MVVM 架構的應用程式 UI 層進行詳細介紹。

Flutter 應用程式中每個功能的 UI 層應由兩個元件組成:View(檢視)和 ViewModel(檢視模型)。

A screenshot of the booking screen of the compass app.

廣義上講,ViewModel 管理 UI 狀態,而 View 顯示 UI 狀態。View 和 ViewModel 之間是一對一的關係;每個 View 都有一個完全對應的 ViewModel 來管理該檢視的狀態。每對 View 和 ViewModel 共同構成一個單一功能的 UI。例如,一個應用可能包含名為 LogOutViewLogOutViewModel 的類。

定義 ViewModel

#

ViewModel 是一個負責處理 UI 邏輯的 Dart 類。ViewModel 以領域資料模型作為輸入,並將這些資料以 UI 狀態的形式公開給對應的 View。它們封裝了 View 可以附加到事件處理器(如按鈕點選)上的邏輯,並負責將這些事件傳送到應用程式的資料層,從而觸發資料變更。

以下程式碼片段是一個名為 HomeViewModel 的 ViewModel 類宣告。它的輸入是提供資料的 Repository(倉庫)。在本例中,該 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 會依賴於多個倉庫。

正如前文 HomeViewModel 的示例宣告所示,倉庫應該是 ViewModel 中的私有成員,否則 View 將能夠直接訪問應用程式的資料層。

UI 狀態

#

ViewModel 的輸出是 View 渲染所需的資料,通常稱為 UI 狀態(UI State),或者簡稱為狀態。UI 狀態是完全渲染一個檢視所需的資料的不可變快照。

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 重新渲染檢視。在 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 所依賴的公共成員。當新資料從資料層流向 ViewModel 並需要發出新狀態時,會呼叫 notifyListeners

A screenshot of the booking screen of the compass app.

此圖從高層次展示了 Repository 中的新資料如何向上傳播到 UI 層並觸發 Flutter 小部件重新構建。

  1. Repository 將新狀態提供給 ViewModel。
  2. ViewModel 更新其 UI 狀態以反映新資料。
  3. 呼叫 ViewModel.notifyListeners,通知 View 存在新的 UI 狀態。
  4. View(Widget)重新渲染。

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

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 是應用中的一個小部件(Widget)。通常,View 代表應用中擁有獨立路由且在小部件樹頂部包含 Scaffold 的一個螢幕,例如 HomeScreen,但這並非總是如此。

有時,View 是一個封裝了需要在整個應用中複用功能的 UI 元素。例如,Compass 應用有一個名為 LogoutButton 的檢視,它可以被放置在 widget 樹中使用者期望找到登出按鈕的任何位置。LogoutButton 檢視有其自己的 ViewModel,名為 LogoutViewModel。而在更大的螢幕上,螢幕上可能會有多個在移動裝置上會佔據全屏的檢視。

View 中的 Widget 承擔三項職責:

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

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

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

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

大多數情況下,檢視唯一的輸入應該是 key(所有 Flutter widget 都將其作為可選引數)以及該檢視對應的 ViewModel。

在 View 中顯示 UI 資料

#

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

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

  final HomeViewModel viewModel;

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

在 widget 內部,您可以從 viewModel 訪問傳入的預訂記錄。在下述程式碼中,booking 屬性被提供給了子 widget。

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 widget 使用 ListenableBuilder widget 監聽來自 ViewModel 的更新。當提供的 Listenable 發生變化時,ListenableBuilder 下方的整個 widget 子樹都會重新渲染。在本例中,提供的 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 widget 來刪除先前預訂的事件。

回顧上一程式碼片段中的這段程式碼:

A clip that demonstrates the 'dismissible' functionality of the Compass app.
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),
  ),
),

HomeScreen 上,使用者的已儲存行程由 _Booking widget 表示。當 _Booking 被消除(dismissed)時,會執行 viewModel.deleteBooking 方法。

已儲存的預訂屬於會話或檢視生命週期之外的應用程式狀態,只有倉庫應該修改此類狀態。因此,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 應用中,這些處理使用者事件的方法被稱為命令(Commands)。

命令 (Command) 物件

#

命令負責從 UI 層發起並流向資料層的互動。具體在該應用中,Command 也是一種幫助安全更新 UI 的型別,無論響應時間或內容如何。

Command 類包裝了一個方法,並幫助處理該方法的不同狀態,例如 running(執行中)、complete(完成)和 error(錯誤)。這些狀態使得在 UI 上顯示不同的內容(例如當 Command.running 為真時顯示載入指示器)變得很容易。

以下是 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 方法是非同步的,因此它無法保證在檢視想要渲染時資料就已經可用。這正是 Compass 應用使用 Command原因。在 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 widget 建立之前就已解析,也不會有問題,因為 Command 物件仍然存在,並公開了正確的狀態。

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

反饋

#

由於本網站的這一部分正在不斷完善,我們歡迎您提供反饋