UI 层案例研究
Flutter 应用程序中每个功能的UI 层应由两个组件组成:一个View
和一个ViewModel
。
从最普遍的意义上讲,视图模型管理 UI 状态,而视图显示 UI 状态。视图和视图模型之间存在一对一的关系;对于每个视图,都正好有一个对应的视图模型来管理该视图的状态。每对视图和视图模型构成单个功能的 UI。例如,一个应用程序可能具有名为LogOutView
的类和一个LogOutViewModel
。
定义视图模型
#视图模型是一个负责处理 UI 逻辑的 Dart 类。视图模型将域数据模型作为输入,并将这些数据作为 UI 状态公开给其对应的视图。它们封装了视图可以附加到事件处理程序(如按钮按下)的逻辑,并管理将这些事件发送到应用程序的数据层,在数据层中发生数据更改。
以下代码片段是名为HomeViewModel
的视图模型类的类声明。其输入是提供其数据的存储库。在本例中,视图模型依赖于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<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 = [];
/// 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
,提醒 View 有新的 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
小部件侦听来自视图模型的更新。在ListenableBuilder
小部件下的子树中的所有内容都会在提供的Listenable
更改时重新渲染。在本例中,提供的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
为真时显示加载指示器。
以下是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
方法是异步的,因此它无法保证数据在视图想要渲染时可用。这就是指南针应用程序使用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,
);
}
// The command has completed without error.
// Return the main view widget.
return child!;
},
),
// ...
因为load
命令是视图模型上存在的属性,而不是短暂的东西,所以load
方法何时被调用或何时解析并不重要。例如,如果load
命令在HomeScreen
小部件甚至创建之前解析,则不会有问题,因为Command
对象仍然存在,并且公开了正确的状态。
此模式标准化了应用程序中常见 UI 问题的解决方法,使您的代码库更不容易出错且更具可扩展性,但并非每个应用程序都需要实现此模式。是否使用它很大程度上取决于您做出的其他架构选择。许多帮助您管理状态的库都有自己的工具来解决这些问题。例如,如果您在应用程序中使用流和StreamBuilder
,Flutter 提供的AsyncSnapshot
类已内置了此功能。
反馈
#由于本网站的此部分正在不断发展,我们欢迎您提供反馈!
除非另有说明,否则本网站上的文档反映了 Flutter 的最新稳定版本。页面上次更新于 2024-12-04。 查看源代码 或 报告问题。