UI 层案例研究
您 Flutter 应用中每个功能的 UI 层应由两个组件构成:一个 View
和一个 ViewModel
。
广义上讲,ViewModel 管理 UI 状态,View 显示 UI 状态。View 和 ViewModel 之间存在一对一的关系;每个 View 都对应一个管理该 View 状态的 ViewModel。每一对 View 和 ViewModel 构成一个单一功能的 UI。例如,一个应用可能包含名为 LogOutView
和 LogOutViewModel
的类。
定义 ViewModel
#ViewModel 是一个负责处理 UI 逻辑的 Dart 类。ViewModel 以领域数据模型作为输入,并将该数据作为 UI 状态暴露给相应的 View。它们封装了 View 可以附加到事件处理程序(如按钮点击)的逻辑,并负责将这些事件发送到应用的数据层,在那里发生数据更改。
以下代码片段是一个名为 HomeViewModel
的 ViewModel 类的声明。它的输入是提供其数据的存储库。在这种情况下,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 和存储库之间存在多对多关系,大多数 ViewModel 将依赖于多个存储库。
如前面 HomeViewModel
示例声明所示,存储库应为 ViewModel 的私有成员,否则 View 将直接访问应用的数据层。
UI 状态
#ViewModel 的输出是 View 渲染所需的数据,通常称为UI 状态,或简称为状态。UI 状态是完全渲染 View 所需数据的不可变快照。
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 重新渲染 View。在 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 依赖的公共成员。当新数据从数据层流经并需要发出新状态时,将调用 notifyListeners
。
- 从存储库向 ViewModel 提供新状态。
- ViewModel 更新其 UI 状态以反映新数据。
- 调用
ViewModel.notifyListeners
,通知 View 新的 UI 状态。 - View (widget) 重新渲染。
例如,当用户导航到主屏幕并创建 ViewModel 时,将调用 _load
方法。在此方法完成之前,UI 状态为空,View 显示加载指示器。当 _load
方法完成后,如果成功,ViewModel 中将有新数据,并且必须通知 View 新数据可用。
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 的回调附加到事件处理程序。
继续 Home 功能示例,以下代码显示了 HomeScreen
View 的定义。
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
小部件。
class HomeScreen extends StatelessWidget {
const HomeScreen({super.key, required this.viewModel});
final HomeViewModel viewModel;
@override
Widget build(BuildContext context) {
// ...
}
}
在小部件内部,您可以访问从 viewModel
传递过来的预订信息。在以下代码中,booking
属性被提供给子小部件。
@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
类型的一个子类型。
@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
小部件来删除之前预订的活动。
回想上一代码片段中的代码:
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
小部件表示。当 _Booking
被滑动删除时,将执行 viewModel.deleteBooking
方法。
已保存的预订是应用程序状态,它比会话或 View 的生命周期更持久,只有存储库应该修改这种应用程序状态。因此,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 应用中,这些处理用户事件的方法称为命令。
Command 对象
#命令负责从 UI 层开始并流向数据层的交互。特别是在此应用中,Command
也是一种类型,它有助于安全地更新 UI,无论响应时间或内容如何。
Command
类包装了一个方法,并有助于处理该方法的不同状态,例如 running
、complete
和 error
。当 Command.running
为 true 时,这些状态可以轻松地显示不同的 UI,例如加载指示器。
以下是 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
方法是异步的,因此它不能保证在 View 想要渲染时数据可用。这就引出了 Compass 应用使用 Commands
的原因。在 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
小部件创建之前就解析了,这也不是问题,因为 Command
对象仍然存在,并且公开了正确的状态。
这种模式标准化了应用中常见 UI 问题的解决方案,使您的代码库更不容易出错且更具可伸缩性,但这并不是每个应用都想实现的模式。是否要使用它在很大程度上取决于您做出的其他架构选择。许多帮助您管理状态的库都有自己的工具来解决这些问题。例如,如果您在应用中使用流和StreamBuilders
,Flutter 提供的AsyncSnapshot
类已经内置了此功能。
反馈
#随着本网站这一部分的不断发展,我们欢迎您的反馈!