UI 层案例研究
实现 MVVM 架构的应用 UI 层 walkthrough。
Flutter 应用中每个功能的 UI 层 应由两个组件构成:View
与 ViewModel。
概括而言,view model 管理 UI 状态,view 展示 UI 状态;二者一一对应,每对 view 与 view model 构成单一功能的 UI。例如应用可有 LogOutView 与 LogOutViewModel。
定义 view model
#View model 以领域数据模型为输入,向对应 view 暴露为 UI 状态;封装 view 可挂到按钮按压等事件处理器的逻辑,并将事件发往发生数据变更的应用数据层。
以下片段为 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;
// ...
}
View model 始终依赖通过构造函数传入的数据仓库;与仓库为多对多关系,多数 view model 依赖多个仓库。
如前述 HomeViewModel,仓库应为 view model 的私有成员,否则 view 可直接访问数据层。
UI 状态
#View model 的输出是 view 渲染所需数据,通常称为 UI State 或简称 state。 UI state 是完整渲染 view 所需数据的不可变快照。
View model 以公共成员暴露状态。下例中暴露 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 state 应不可变,这是少 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 状态
#
除存储状态外,数据层提供新状态时 view model 须通知 Flutter 重新渲染 view。
Compass 中 view model 继承 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。
该图从宏观展示仓库中的新数据如何向上传到 UI 层并触发 Flutter widget 重建。
仓库向 view model 提供新状态。
View model 更新 UI 状态以反映新数据。
-
调用
ViewModel.notifyListeners,通知 View 有新 UI State。 View(widget)重新渲染。
例如用户进入 Home 屏幕并创建 view model 时调用 _load;完成前 UI state 为空,view 显示加载指示器;成功完成后 view model 有新数据,须通知 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 是应用内的 widget。常代表带独立路由、widget 树顶层含 Scaffold
的屏幕(如 HomeScreen),但未必如此。
有时 view 是可在应用中复用的单一 UI 元素,例如 Compass 的 LogoutButton 及其 LogoutViewModel;大屏上可能同时显示多个在手机上占全屏的 view。
View 内 widget 有三项职责:
展示 view model 的数据属性。
-
监听 view model 更新并在有新数据时重新渲染。
-
将 view model 的回调挂到事件处理器(如适用)。
延续 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 与对应 view model。
在 view 中展示 UI 数据
#
View 依赖 view model 获取状态;Compass 在 view 构造函数中传入 view model。以下片段来自 HomeScreen widget。
class HomeScreen extends StatelessWidget {
const HomeScreen({super.key, required this.viewModel});
final HomeViewModel viewModel;
@override
Widget build(BuildContext context) {
// ...
}
}
在 widget 内可通过 viewModel 访问传入的 bookings;下例将 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 通过 ListenableBuilder
监听 view model;其子树在 Listenable
变化时重建,此处为 ChangeNotifier
类型的 view model。
@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 须监听用户事件,由 view model 通过暴露封装逻辑的回调处理。
在 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 表示行程;dismiss 时执行 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 中,这些处理用户事件的方法称为 command。
Command 对象
#Command 负责从 UI 层回流数据层的交互;Command 类型可安全更新 UI,不受响应时间或内容影响。
Command 包装方法并处理 running、complete、error 等状态,便于展示加载等 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,execute 中多次 notifyListeners(),使 view 以极少逻辑处理多状态(后文有例)。
Command 为抽象类,由 Command0、Command1 等实现,数字表示参数个数;示例见 Compass utils 目录。
确保 view 在数据存在前即可渲染
#在 view model 构造函数中创建 command。
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 使用 Command 的原因;在 Widget.build 中用 command 条件渲染不同 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 command 是 view model 上的持久属性,调用与完成时机不影响正确状态暴露。
该模式标准化常见 UI 问题的解决方式,但并非所有应用都需要;是否采用取决于其他架构选择。许多状态管理库自带类似工具,例如 stream
与 StreamBuilders
配合 AsyncSnapshot。
反馈
#网站本节内容仍在完善中, 欢迎提供反馈!
除非另有说明,本文档之所提及适用于 Flutter 3.44.0 版本。本页面最后更新时间:2026-06-04。查看文档源码 或者 为本页面内容提出建议。