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

A screenshot of the booking screen of the compass app.

广义上讲,视图模型管理 UI 状态,视图显示 UI 状态。视图和视图模型之间存在一对一的关系;对于每个视图,都恰好有一个对应的视图模型来管理该视图的状态。每对视图和视图模型构成单个功能的 UI。例如,一个应用程序可能有名为 LogOutViewLogOutViewModel 的类。

定义视图模型

#

视图模型是一个 Dart 类,负责处理 UI 逻辑。视图模型将领域数据模型作为输入,并将这些数据作为 UI 状态暴露给其对应的视图。它们封装了视图可以附加到事件处理程序(如按钮按下)的逻辑,并管理将这些事件发送到应用程序的数据层,数据更改在那里发生。

以下代码片段是名为 HomeViewModel 的视图模型的类声明。其输入是提供其数据的仓库 (Repository)。在此示例中,视图模型依赖于 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 状态是完全渲染视图所需数据的不可变快照。

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

指南针应用使用 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 重新渲染视图。在指南针应用中,视图模型通过扩展 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 小部件的重新构建。

  1. 新状态由仓库提供给视图模型。
  2. 视图模型更新其 UI 状态以反映新数据。
  3. 调用 ViewModel.notifyListeners,通知视图新的 UI 状态。
  4. 视图(小部件)重新渲染。

例如,当用户导航到主屏幕并创建视图模型时,会调用 _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();
    }
  }
}

定义视图

#

视图是应用程序中的一个小部件。通常,视图代表应用程序中的一个屏幕,它有自己的路由,并在小部件子树的顶部包含一个 Scaffold,例如 HomeScreen,但情况并非总是如此。

有时,视图是一个单一的 UI 元素,它封装了需要在整个应用程序中重复使用的功能。例如,指南针应用程序有一个名为 LogoutButton 的视图,可以将其放置在用户可能希望找到注销按钮的任何小部件树中。LogoutButton 视图有其自己的视图模型,名为 LogoutViewModel。在更大的屏幕上,屏幕上可能会有多个视图,这些视图在移动设备上会占据整个屏幕。

视图中的小部件有三个职责

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

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 小部件都将其作为可选参数),以及视图对应的视图模型。

在视图中显示 UI 数据

#

视图的状态依赖于视图模型。在指南针应用中,视图模型作为参数传递到视图的构造函数中。以下代码片段来自 HomeScreen 小部件。

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

  final HomeViewModel viewModel;

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

在小部件中,您可以从 viewModel 访问传入的预订。在以下代码中,booking 属性被提供给一个子小部件。

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 小部件使用 ListenableBuilder 小部件监听视图模型的更新。当提供的 Listenable 发生变化时,ListenableBuilder 小部件下的小部件子树中的所有内容都会重新渲染。在这种情况下,提供的 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 小部件来删除之前预订的事件。

回想一下之前代码片段中的这段代码

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),
  ),
),
A clip that demonstrates the 'dismissible' functionality of the Compass app.

HomeScreen 上,用户的已保存行程由 _Booking 小部件表示。当一个 _Booking 被关闭时,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();
  }
}

在指南针应用中,这些处理用户事件的方法被称为**命令**。

命令对象

#

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

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

以下是 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 等具体类实现。类名中的整数指的是底层方法期望的参数数量。您可以在指南针应用的 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 方法是异步的,因此无法保证在视图想要渲染时数据可用。这就是指南针应用使用 Command 的原因。在视图的 Widget.build 方法中,命令用于有条件地渲染不同的小部件。

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 方法何时被调用或何时解析并不重要。例如,如果 load 命令在 HomeScreen 小部件创建之前就已解析,这不是问题,因为 Command 对象仍然存在并暴露了正确的状态。

这种模式标准化了应用程序中常见 UI 问题的解决方案,使您的代码库更不易出错且更具可伸缩性,但并非每个应用程序都希望实现此模式。是否使用它高度依赖于您所做的其他架构选择。许多帮助您管理状态的库都有自己的工具来解决这些问题。例如,如果您在应用程序中使用流 (Stream)StreamBuilders,Flutter 提供的AsyncSnapshot 类内置了此功能。

反馈

#

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