跳到主内容

UI 层案例研究

对实现 MVVM 架构的应用程序 UI 层进行详细介绍。

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

A screenshot of the booking screen of the compass app.

广义上讲,ViewModel 管理 UI 状态,而 View 显示 UI 状态。View 和 ViewModel 之间是一对一的关系;每个 View 都有一个完全对应的 ViewModel 来管理该视图的状态。每对 View 和 ViewModel 共同构成一个单一功能的 UI。例如,一个应用可能包含名为 LogOutViewLogOutViewModel 的类。

定义 ViewModel

#

ViewModel 是一个负责处理 UI 逻辑的 Dart 类。ViewModel 以领域数据模型作为输入,并将这些数据以 UI 状态的形式公开给对应的 View。它们封装了 View 可以附加到事件处理器(如按钮点击)上的逻辑,并负责将这些事件发送到应用程序的数据层,从而触发数据变更。

以下代码片段是一个名为 HomeViewModel 的 ViewModel 类声明。它的输入是提供数据的 Repository(仓库)。在本例中,该 ViewModel 依赖于作为参数传入的 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;
  // ...
}

ViewModel 始终依赖于作为构造函数参数提供的数据仓库。ViewModel 和仓库之间是多对多的关系,大多数 ViewModel 会依赖于多个仓库。

正如前文 HomeViewModel 的示例声明所示,仓库应该是 ViewModel 中的私有成员,否则 View 将能够直接访问应用程序的数据层。

UI 状态

#

ViewModel 的输出是 View 渲染所需的数据,通常称为 UI 状态(UI State),或者简称为状态。UI 状态是完全渲染一个视图所需的数据的不可变快照。

A screenshot of the booking screen of the compass app.

ViewModel 将状态公开为公共成员。在下述代码示例的 ViewModel 中,公开的数据是一个 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 状态应该是不可变的。这是构建无 Bug 软件的关键部分。

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 状态

#

除了存储状态外,当数据层提供新状态时,ViewModel 需要通知 Flutter 重新渲染视图。在 Compass 应用中,ViewModel 通过继承 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 是 View 所依赖的公共成员。当新数据从数据层流向 ViewModel 并需要发出新状态时,会调用 notifyListeners

A screenshot of the booking screen of the compass app.

此图从高层次展示了 Repository 中的新数据如何向上传播到 UI 层并触发 Flutter 小部件重新构建。

  1. Repository 将新状态提供给 ViewModel。
  2. ViewModel 更新其 UI 状态以反映新数据。
  3. 调用 ViewModel.notifyListeners,通知 View 存在新的 UI 状态。
  4. View(Widget)重新渲染。

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

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();
    }
  }
}

定义 View

#

View 是应用中的一个小部件(Widget)。通常,View 代表应用中拥有独立路由且在小部件树顶部包含 Scaffold 的一个屏幕,例如 HomeScreen,但这并非总是如此。

有时,View 是一个封装了需要在整个应用中复用功能的 UI 元素。例如,Compass 应用有一个名为 LogoutButton 的视图,它可以被放置在 widget 树中用户期望找到注销按钮的任何位置。LogoutButton 视图有其自己的 ViewModel,名为 LogoutViewModel。而在更大的屏幕上,屏幕上可能会有多个在移动设备上会占据全屏的视图。

View 中的 Widget 承担三项职责:

  • 显示来自 ViewModel 的数据属性。
  • 监听来自 ViewModel 的更新并在有新数据时重新渲染。
  • 如果适用,将来自 ViewModel 的回调附加到事件处理器。

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

继续 Home 功能示例,以下代码显示了 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 都将其作为可选参数)以及该视图对应的 ViewModel。

在 View 中显示 UI 数据

#

View 依赖于 ViewModel 来获取其状态。在 Compass 应用中,ViewModel 作为参数传递给 View 的构造函数。以下代码片段来自 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 监听来自 ViewModel 的更新。当提供的 Listenable 发生变化时,ListenableBuilder 下方的整个 widget 子树都会重新渲染。在本例中,提供的 Listenable 就是 ViewModel。回想一下,ViewModel 是 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,
                            ),
                      ),
                ),
              ],
            );
          }
        )
      )
  );
}

处理用户事件

#

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

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) 对象

#

命令负责从 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。这使得 View 能够以极少的逻辑处理不同的状态,您将在本页稍后的示例中看到这一点。

您可能还注意到 Command 是一个抽象类。它由 Command0Command1 等具体类实现。类名中的数字指的是底层方法预期的参数个数。您可以在 Compass 应用的 utils 目录中查看这些实现类的示例。

确保 View 在数据存在前能够渲染

#

在 ViewModel 类中,命令是在构造函数中创建的。

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 应用使用 Command原因。在 View 的 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 命令是 ViewModel 上的一个持久属性,而不是临时的,因此 load 方法何时调用或何时解析并不重要。例如,如果 load 命令在 HomeScreen widget 创建之前就已解析,也不会有问题,因为 Command 对象仍然存在,并公开了正确的状态。

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

反馈

#

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