UI 层案例研究
Flutter 应用中每个功能的UI 层应由两个组件组成:视图
和 视图模型
。
最一般地说,视图模型管理 UI 状态,视图显示 UI 状态。视图和视图模型是一对一的关系;每个视图都只有一个相应的视图模型来管理该视图的状态。每对视图和视图模型构成单个功能的 UI。例如,一个应用程序可能有名为 LogOutView
的类和一个 LogOutViewModel
。
定义视图模型
#视图模型是一个负责处理 UI 逻辑的 Dart 类。视图模型将领域数据模型作为输入,并将这些数据作为 UI 状态暴露给其对应的视图。它们封装了视图可以附加到事件处理程序(如按钮按下)的逻辑,并管理将这些事件发送到应用程序的数据层,在那里发生数据更改。
以下代码片段是名为 HomeViewModel
的视图模型类的类声明。它的输入是提供其数据的存储库。在本例中,视图模型依赖于 BookingRepository
和 UserRepository
作为参数。
class HomeViewModel {
HomeViewModel({
required BookingRepository bookingRepository,
required UserRepository userRepository,
}) :
// 存储库是手动分配的,因为它们是私有成员。
_bookingRepository = bookingRepository,
_userRepository = userRepository;
final BookingRepository _bookingRepository;
final UserRepository _userRepository;
// ...
}
视图模型始终依赖于数据存储库,这些存储库作为参数提供给视图模型的构造函数。视图模型和存储库之间存在多对多的关系,大多数视图模型将依赖于多个存储库。
如前面的 HomeViewModel
示例声明所示,存储库应为视图模型上的私有成员,否则视图将直接访问应用程序的数据层。
UI 状态
#视图模型的输出是视图需要呈现的数据,通常称为UI 状态或简称状态。UI 状态是完全呈现视图所需数据的不可变快照。
视图模型将状态作为公共成员公开。在下面的代码示例中的视图模型中,公开的数据是 User
对象,以及用户的已保存行程,这些行程作为 List<TripSummary>
类型的对象公开。
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 = [];
/// [UnmodifiableListView] 中的项目不能直接修改,
/// 但可以修改源列表中的更改。由于 _bookings 是私有的,而 bookings 不是,因此视图无法直接修改列表。
UnmodifiableListView<BookingSummary> get bookings => UnmodifiableListView(_bookings);
// ...
}
如前所述,UI 状态应该是不可变的。这是无错误软件的关键部分。
指南针应用程序使用package:freezed
来强制执行数据类的不可变性。例如,以下代码显示了 User
类定义。freezed
提供深度不可变性,并为有用的方法(如 copyWith
和 toJson
)生成实现。
@freezed
class User with _$User {
const factory User({
/// 用户的姓名。
required String name,
/// 用户的图片 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 状态。 - 视图(widget)重新渲染。
例如,当用户导航到主页屏幕并创建视图模型时,将调用 _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();
}
}
}
定义视图
#视图是应用程序中的 widget。通常,视图表示应用程序中具有自己路由并包含其 widget 子树顶部的Scaffold
的一个屏幕,例如 HomeScreen
,但这并非总是如此。
有时,视图是一个单一的 UI 元素,它封装了需要在整个应用程序中重复使用的功能。例如,指南针应用程序有一个名为 LogoutButton
的视图,它可以放在用户可能期望找到注销按钮的 widget 树中的任何位置。LogoutButton
视图有自己的视图模型,名为 LogoutViewModel
。在大屏幕上,屏幕上可能有多个视图,这些视图在移动设备上会占据全屏。
视图中的 widget 具有三个职责:
- 它们显示来自视图模型的数据属性。
- 它们侦听来自视图模型的更新,并在有新数据可用时重新渲染。
- 如果适用,它们将来自视图模型的回调附加到事件处理程序。
继续 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 都将其作为可选参数),以及视图对应的视图模型。
在视图中显示 UI 数据
#视图依赖于其状态的视图模型。在指南针应用程序中,视图模型作为参数传递到视图的构造函数中。以下代码片段摘自 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(
// 为简洁起见,删除了一些代码。
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 侦听来自视图模型的更新。ListenableBuilder
widget 下的 widget 子树中的所有内容
当提供的Listenable
发生更改时,重新渲染。在本例中,提供的 Listenable
是视图模型。回想一下,视图模型的类型为ChangeNotifier
,它是 Listenable
类型的子类型。
@override
Widget build(BuildContext context) {
return Scaffold(
// 为简洁起见,删除了一些代码。
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
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),
),
),
![演示指南针应用“可忽略”功能的剪辑。](/assets/images/docs/app-architecture/case-study/dismissible.gif)
在 HomeScreen
上,用户的已保存行程由 _Booking
widget 表示。当 _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;
}
// 为简洁起见,省略了一些代码。
// 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
bool get error => _result is Error;
/// 如果操作成功完成则为 true
bool get completed => _result is Ok;
/// 内部执行实现
Future<void> _execute(action) async {
if (_running) return;
// 发出运行状态 - 例如按钮显示加载状态
_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 = 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
方法是异步的,因此它不能保证在视图想要渲染时数据可用。这说明了指南针应用程序为什么使用 Commands
。在视图的 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,
);
}
// 命令已完成且无错误。
// 返回主视图 widget。
return child!;
},
),
// ...
因为 load
命令是一个存在于视图模型上的属性,而不是短暂的东西,所以何时调用 load
方法或何时解析它并不重要。例如,如果 load
命令在 HomeScreen
widget 甚至创建之前就已解析,则不会出现问题,因为 Command
对象仍然存在,并且公开了正确状态。
此模式标准化了在应用程序中解决常见 UI 问题的方法,使您的代码库不易出错且更具可扩展性,但并非每个应用程序都希望实现此模式。是否要使用它很大程度上取决于您做出的其他架构选择。许多帮助您管理状态的库都有自己的工具来解决这些问题。例如,如果您要在应用程序中使用流 和StreamBuilders
,则 Flutter 提供的AsyncSnapshot
类具有此内置功能。
反馈
#由于本网站部分内容正在不断发展,我们欢迎您的反馈!
除非另有说明,否则本网站上的文档反映的是 Flutter 的最新稳定版本。页面最后更新于 2025-01-30。 查看源代码 或 报告问题。