跳到主内容

UI 层案例分析

一个实现 MVVM 架构的应用 UI 层详细解读。

Flutter 应用程序中每个功能的 UI 层应由两个组件组成:View (视图)ViewModel (视图模型)

A screenshot of the booking screen of the compass app.

从最广泛的意义上讲,视图模型管理 UI 状态,而视图显示 UI 状态。视图和视图模型具有一一对应的关系;对于每个视图,都有且仅有一个对应的视图模型来管理该视图的状态。每一对视图和视图模型构成了单个功能的 UI。例如,一个应用可能拥有名为 LogOutViewLogOutViewModel 的类。

定义视图模型 (View Model)

#

视图模型是负责处理 UI 逻辑的 Dart 类。视图模型将领域数据模型作为输入,并将这些数据作为 UI 状态公开给其对应的视图。它们封装了视图可以附加到事件处理程序(如按钮点击)的逻辑,并负责将这些事件发送到应用的数据层,即发生数据更改的地方。

以下代码段是一个名为 HomeViewModel 的视图模型类的声明。其输入是提供数据的 存储库 (repositories)。在这种情况下,该视图模型依赖 BookingRepositoryUserRepository 作为参数。

home_viewmodel.dart
dart
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 State) 或简称状态。UI 状态是完整渲染视图所需的数据的不可变快照。

A screenshot of the booking screen of the compass app.

视图模型将状态作为公共成员公开。在下面代码示例的视图模型中,公开的数据是一个 User 对象,以及用户保存的行程(以 List<BookingSummary> 类型的对象公开)。

home_viewmodel.dart
dart
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 状态应该是不可变的。这是构建无错软件的关键部分。

Compass 应用使用 package:freezed 来强制数据类的不可变性。例如,以下代码显示了 User 类的定义。freezed 提供了深度不可变性,并为 copyWithtoJson 等实用方法生成实现。

user.dart
dart
@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 重新渲染视图。在 Compass 应用中,视图模型通过继承 ChangeNotifier 来实现这一点。

home_viewmodel.dart
dart
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

A screenshot of the booking screen of the compass app.

该图从高层展示了存储库中的新数据如何传播到 UI 层并触发 Flutter widget 的重新构建。

  1. 新状态由存储库提供给视图模型。
  2. 视图模型更新其 UI 状态以反映新数据。
  3. 调用 ViewModel.notifyListeners,提醒视图有新的 UI 状态。
  4. 视图 (widget) 重新渲染。

例如,当用户导航到主屏幕并创建视图模型时,会调用 _load 方法。在此方法完成之前,UI 状态为空,视图显示加载指示器。当 _load 方法成功完成时,视图模型中有了新数据,它必须通知视图新数据已可用。

home_viewmodel.dart
dart
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 元素。例如,Compass 应用有一个名为 LogoutButton 的视图,它可以放置在 widget 树中用户期望找到注销按钮的任何位置。LogoutButton 视图有自己的视图模型 LogoutViewModel。在较大的屏幕上,屏幕上可能会有多个视图,而这些视图在移动设备上会占据全屏。

视图内的 widget 承担三个职责:

  • 它们显示来自视图模型的数据属性。
  • 它们监听来自视图模型的更新,并在新数据可用时重新渲染。
  • 它们将视图模型的回调附加到事件处理程序(如果适用)。

A diagram showing a view's relationship to a view model.

继续以主屏幕功能为例,以下代码显示了 HomeScreen 视图的定义。

home_screen.dart
dart
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 数据

#

视图依赖视图模型来获取状态。在 Compass 应用中,视图模型作为参数传递到视图的构造函数中。以下示例代码段来自 HomeScreen widget。

home_screen.dart
dart
class HomeScreen extends StatelessWidget {
  const HomeScreen({super.key, required this.viewModel});

  final HomeViewModel viewModel;

  @override
  Widget build(BuildContext context) {
    // ...
  }
}

在 widget 内部,你可以从 viewModel 访问传入的预订。在以下代码中,booking 属性被提供给子 widget。

home_screen.dart
dart
@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 widget 使用 ListenableBuilder widget 监听来自视图模型的更新。当提供的 Listenable 发生变化时,ListenableBuilder widget 下方的 widget 子树中的所有内容都会重新渲染。在这种情况下,提供的 Listenable 是视图模型。请记住,视图模型是 ChangeNotifier 类型,它是 Listenable 类型的子类型。

home_screen.dart
dart
@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,
                            ),
                      ),
                ),
              ],
            );
          }
        )
      )
  );
}

处理用户事件

#

最后,视图需要监听来自用户的事件,以便视图模型可以处理这些事件。这是通过在视图模型类上公开一个封装了所有逻辑的回调方法来实现的。

A diagram showing a view's relationship to a view model.

HomeScreen 上,用户可以通过滑动 Dismissible widget 来删除之前预订的活动。

回顾之前代码段中的这段代码:

A clip that demonstrates the 'dismissible' functionality of the Compass app.
home_screen.dart
dart
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 widget 表示。当一个 _Booking 被移除(dismissed)时,将执行 viewModel.deleteBooking 方法。

保存的预订是超出单个会话或视图生命周期的应用状态,只有存储库应该修改此类应用状态。因此,HomeViewModel.deleteBooking 方法转而调用数据层中存储库公开的方法,如下面的代码片段所示。

home_viewmodel.dart
dart
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 应用中,这些处理用户事件的方法被称为 commands (命令)

Command 对象

#

Command 负责从 UI 层开始并流回数据层的交互。具体在这个应用中,Command 也是一种帮助安全更新 UI 的类型,无论响应时间或内容如何。

Command 类包装了一个方法,并帮助处理该方法的不同状态,例如 running (运行中)、complete (已完成) 和 error (错误)。这些状态使得显示不同的 UI 变得容易,例如当 Command.running 为真时显示加载指示器。

以下是 Command 类的代码。出于演示目的,省略了一些代码。

command.dart
dart
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 是一个抽象类。它由 Command0Command1 等具体类实现。类名中的数字指的是基础方法预期的参数数量。你可以在 Compass 应用的 utils 目录中看到这些实现类的示例。

确保视图在数据存在前即可渲染

#

在视图模型类中,命令是在构造函数中创建的。

home_viewmodel.dart
dart
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 方法是异步的,因此它不能保证当视图想要渲染时数据是可用的。这就是为什么 Compass 应用要使用 Commands。在视图的 Widget.build 方法中,命令被用来条件性地渲染不同的 widget。

home_screen.dart
dart
// ...
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 方法何时被调用或何时解析并不重要。例如,如果加载命令在 HomeScreen widget 创建之前就已经解析,这也不会有问题,因为 Command 对象仍然存在,并公开了正确的状态。

这种模式标准化了应用中常见 UI 问题的解决方法,使你的代码库更不容易出错且更具可扩展性,但这并不是每个应用都想实现的模式。是否使用它高度取决于你做出的其他架构选择。许多帮助你管理状态的库都有自己的工具来解决这些问题。例如,如果你在应用中使用 streamsStreamBuilders,Flutter 提供的 AsyncSnapshot 类已经内置了此功能。

反馈

#

由于本网站的这一部分正在不断完善,我们欢迎您的反馈