UI 層案例研究
對實現 MVVM 架構的應用程式 UI 層進行詳細介紹。
Flutter 應用程式中每個功能的 UI 層應由兩個元件組成:View(檢視)和 ViewModel(檢視模型)。
廣義上講,ViewModel 管理 UI 狀態,而 View 顯示 UI 狀態。View 和 ViewModel 之間是一對一的關係;每個 View 都有一個完全對應的 ViewModel 來管理該檢視的狀態。每對 View 和 ViewModel 共同構成一個單一功能的 UI。例如,一個應用可能包含名為 LogOutView 和 LogOutViewModel 的類。
定義 ViewModel
#ViewModel 是一個負責處理 UI 邏輯的 Dart 類。ViewModel 以領域資料模型作為輸入,並將這些資料以 UI 狀態的形式公開給對應的 View。它們封裝了 View 可以附加到事件處理器(如按鈕點選)上的邏輯,並負責將這些事件傳送到應用程式的資料層,從而觸發資料變更。
以下程式碼片段是一個名為 HomeViewModel 的 ViewModel 類宣告。它的輸入是提供資料的 Repository(倉庫)。在本例中,該 ViewModel 依賴於作為引數傳入的 BookingRepository 和 UserRepository。
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 狀態是完全渲染一個檢視所需的資料的不可變快照。
ViewModel 將狀態公開為公共成員。在下述程式碼示例的 ViewModel 中,公開的資料是一個 User 物件,以及作為 List<BookingSummary> 型別物件公開的使用者已儲存行程。
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 提供了深度不可變性,併為 copyWith 和 toJson 等實用方法生成了實現。
@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 來實現這一點。
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。
- Repository 將新狀態提供給 ViewModel。
- ViewModel 更新其 UI 狀態以反映新資料。
- 呼叫
ViewModel.notifyListeners,通知 View 存在新的 UI 狀態。 - View(Widget)重新渲染。
例如,當用戶導航到 Home 螢幕並建立 ViewModel 時,會呼叫 _load 方法。在此方法完成之前,UI 狀態為空,檢視會顯示載入指示器。當 _load 方法成功完成後,ViewModel 中會存有新資料,此時它必須通知檢視新資料已可用。
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 的回撥附加到事件處理器。
繼續 Home 功能示例,以下程式碼顯示了 HomeScreen 檢視的定義。
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。
class HomeScreen extends StatelessWidget {
const HomeScreen({super.key, required this.viewModel});
final HomeViewModel viewModel;
@override
Widget build(BuildContext context) {
// ...
}
}
在 widget 內部,您可以從 viewModel 訪問傳入的預訂記錄。在下述程式碼中,booking 屬性被提供給了子 widget。
@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 型別的子類。
@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 類上公開一個封裝了所有邏輯的回撥方法來實現的。
在 HomeScreen 上,使用者可以透過滑動 Dismissible widget 來刪除先前預訂的事件。
回顧上一程式碼片段中的這段程式碼:
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 方法轉而呼叫資料層中倉庫公開的方法,如下面的程式碼片段所示。
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 類的程式碼。為了演示目的,省略了一些程式碼。
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 是一個抽象類。它由 Command0、Command1 等具體類實現。類名中的數字指的是底層方法預期的引數個數。您可以在 Compass 應用的 utils 目錄中檢視這些實現類的示例。
確保 View 在資料存在前能夠渲染
#在 ViewModel 類中,命令是在建構函式中建立的。
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。
// ...
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 問題的方式,使您的程式碼庫更不容易出錯且更具可擴充套件性,但這並不是每個應用都必須實現的模式。是否使用它很大程度上取決於您所做的其他架構選擇。許多幫助管理狀態的庫都有自己的工具來解決這些問題。例如,如果您在應用中使用 streams 和 StreamBuilders,則 Flutter 提供的 AsyncSnapshot 類內建了此功能。
反饋
#由於本網站的這一部分正在不斷完善,我們歡迎您提供反饋!