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 类内置了此功能。
反馈
#由于本网站的这一部分正在不断完善,我们欢迎您提供反馈!