UI 层案例研究
Flutter 应用程序中每个功能的 UI 层应由两个组件构成:一个**视图 (View)
**和一个**视图模型 (ViewModel)
**。
广义上讲,视图模型管理 UI 状态,视图显示 UI 状态。视图和视图模型之间存在一对一的关系;对于每个视图,都恰好有一个对应的视图模型来管理该视图的状态。每对视图和视图模型构成单个功能的 UI。例如,一个应用程序可能有名为 LogOutView
和 LogOutViewModel
的类。
定义视图模型
#视图模型是一个 Dart 类,负责处理 UI 逻辑。视图模型将领域数据模型作为输入,并将这些数据作为 UI 状态暴露给其对应的视图。它们封装了视图可以附加到事件处理程序(如按钮按下)的逻辑,并管理将这些事件发送到应用程序的数据层,数据更改在那里发生。
以下代码片段是名为 HomeViewModel
的视图模型的类声明。其输入是提供其数据的仓库 (Repository)。在此示例中,视图模型依赖于 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;
// ...
}
视图模型始终依赖于数据仓库,数据仓库作为参数提供给视图模型的构造函数。视图模型和仓库之间是多对多的关系,并且大多数视图模型将依赖于多个仓库。
正如前面 HomeViewModel
示例声明中所示,仓库应该是视图模型上的私有成员,否则视图将直接访问应用程序的数据层。
UI 状态
#视图模型的输出是视图渲染所需的数据,通常称为**UI 状态**,或简称状态。UI 状态是完全渲染视图所需数据的不可变快照。
视图模型将状态作为公共成员公开。在以下代码示例的视图模型中,公开的数据是一个 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 状态应该是不可变的。这是无错误软件的关键部分。
指南针应用使用 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 状态
#除了存储状态,当数据层提供新状态时,视图模型还需要告诉 Flutter 重新渲染视图。在指南针应用中,视图模型通过扩展 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
是视图依赖的公共成员。当新数据从数据层流出并需要发出新状态时,会调用 notifyListeners
。
- 新状态由仓库提供给视图模型。
- 视图模型更新其 UI 状态以反映新数据。
- 调用
ViewModel.notifyListeners
,通知视图新的 UI 状态。 - 视图(小部件)重新渲染。
例如,当用户导航到主屏幕并创建视图模型时,会调用 _load
方法。在该方法完成之前,UI 状态为空,视图显示加载指示器。当 _load
方法完成时,如果成功,视图模型中将有新数据,并且必须通知视图新数据已可用。
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();
}
}
}
定义视图
#视图是应用程序中的一个小部件。通常,视图代表应用程序中的一个屏幕,它有自己的路由,并在小部件子树的顶部包含一个 Scaffold
,例如 HomeScreen
,但情况并非总是如此。
有时,视图是一个单一的 UI 元素,它封装了需要在整个应用程序中重复使用的功能。例如,指南针应用程序有一个名为 LogoutButton
的视图,可以将其放置在用户可能希望找到注销按钮的任何小部件树中。LogoutButton
视图有其自己的视图模型,名为 LogoutViewModel
。在更大的屏幕上,屏幕上可能会有多个视图,这些视图在移动设备上会占据整个屏幕。
视图中的小部件有三个职责
- 它们显示视图模型中的数据属性。
- 它们监听视图模型的更新,并在新数据可用时重新渲染。
- 如果适用,它们将视图模型的回调附加到事件处理程序。
继续主页功能示例,以下代码显示了 HomeScreen
视图的定义。
class HomeScreen extends StatelessWidget {
const HomeScreen({super.key, required this.viewModel});
final HomeViewModel viewModel;
@override
Widget build(BuildContext context) {
return Scaffold(
// ...
);
}
}
大多数情况下,视图的唯一输入应该是 key
(所有 Flutter 小部件都将其作为可选参数),以及视图对应的视图模型。
在视图中显示 UI 数据
#视图的状态依赖于视图模型。在指南针应用中,视图模型作为参数传递到视图的构造函数中。以下代码片段来自 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
小部件监听视图模型的更新。当提供的 Listenable
发生变化时,ListenableBuilder
小部件下的小部件子树中的所有内容都会重新渲染。在这种情况下,提供的 Listenable
就是视图模型。回想一下,视图模型是 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,
),
),
),
],
);
}
)
)
);
}
处理用户事件
#最后,视图需要监听来自用户的**事件**,以便视图模型可以处理这些事件。这通过在视图模型类上公开一个封装所有逻辑的回调方法来实现。
在 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
方法将被执行。
已保存的预订是应用程序状态,它会持久存在于会话或视图的生命周期之外,并且只有仓库才应该修改此类应用程序状态。因此,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();
}
}
在指南针应用中,这些处理用户事件的方法被称为**命令**。
命令对象
#命令负责从 UI 层开始并流回数据层的交互。具体到这个应用,Command
也是一种有助于安全更新 UI 的类型,无论响应时间或内容如何。
Command
类封装了一个方法,并帮助处理该方法的不同状态,例如**运行中 (running
)**、**完成 (complete
)** 和**错误 (error
)**。这些状态使得显示不同的 UI 变得容易,例如当 Command.running
为 true 时显示加载指示器。
以下是 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
。这使得视图能够以极少的逻辑处理不同的状态,您将在本页后面看到示例。
您可能还注意到 Command
是一个抽象类。它由 Command0
、Command1
等具体类实现。类名中的整数指的是底层方法期望的参数数量。您可以在指南针应用的 utils
目录中看到这些实现类的示例。
确保视图在数据存在之前即可渲染
#在视图模型类中,命令是在构造函数中创建的。
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
方法是异步的,因此无法保证在视图想要渲染时数据可用。这就是指南针应用使用 Command
的原因。在视图的 Widget.build
方法中,命令用于有条件地渲染不同的小部件。
// ...
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
命令是视图模型上存在的属性,而不是短暂的东西,因此 load
方法何时被调用或何时解析并不重要。例如,如果 load
命令在 HomeScreen
小部件创建之前就已解析,这不是问题,因为 Command
对象仍然存在并暴露了正确的状态。
这种模式标准化了应用程序中常见 UI 问题的解决方案,使您的代码库更不易出错且更具可伸缩性,但并非每个应用程序都希望实现此模式。是否使用它高度依赖于您所做的其他架构选择。许多帮助您管理状态的库都有自己的工具来解决这些问题。例如,如果您在应用程序中使用流 (Stream)和StreamBuilders
,Flutter 提供的AsyncSnapshot
类内置了此功能。
反馈
#由于本网站的此部分正在不断完善中,我们欢迎您的反馈!