您 Flutter 应用中每个功能的 UI 层应由两个组件构成:一个 View 和一个 ViewModel

A screenshot of the booking screen of the compass app.

广义上讲,ViewModel 管理 UI 状态,View 显示 UI 状态。View 和 ViewModel 之间存在一对一的关系;每个 View 都对应一个管理该 View 状态的 ViewModel。每一对 View 和 ViewModel 构成一个单一功能的 UI。例如,一个应用可能包含名为 LogOutViewLogOutViewModel 的类。

定义 ViewModel

#

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

以下代码片段是一个名为 HomeViewModel 的 ViewModel 类的声明。它的输入是提供其数据的存储库。在这种情况下,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 和存储库之间存在多对多关系,大多数 ViewModel 将依赖于多个存储库。

如前面 HomeViewModel 示例声明所示,存储库应为 ViewModel 的私有成员,否则 View 将直接访问应用的数据层。

UI 状态

#

ViewModel 的输出是 View 渲染所需的数据,通常称为UI 状态,或简称为状态。UI 状态是完全渲染 View 所需数据的不可变快照。

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 重新渲染 View。在 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 依赖的公共成员。当新数据从数据层流经并需要发出新状态时,将调用 notifyListeners

A screenshot of the booking screen of the compass app.

此图高层级地展示了存储库中的新数据如何传播到 UI 层并触发 Flutter 小部件的重新构建。

  1. 从存储库向 ViewModel 提供新状态。
  2. ViewModel 更新其 UI 状态以反映新数据。
  3. 调用 ViewModel.notifyListeners,通知 View 新的 UI 状态。
  4. View (widget) 重新渲染。

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

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 是应用中的一个小部件。通常,View 代表应用中的一个屏幕,该屏幕有自己的路由,并在小部件树的顶部包含一个 Scaffold,例如 HomeScreen,但这并非总是如此。

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

View 中的小部件有三项职责:

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

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

继续 Home 功能示例,以下代码显示了 HomeScreen View 的定义。

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

大多数情况下,View 的唯一输入应该是 key(所有 Flutter 小部件都可以选择性地接受它)以及 View 对应的 ViewModel。

在 View 中显示 UI 数据

#

View 依赖于 ViewModel 来获取其状态。在 Compass 应用中,ViewModel 作为参数传递到 View 的构造函数中。以下示例代码片段来自 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 小部件监听来自 ViewModel 的更新。ListenableBuilder 小部件下的所有小部件树都会在提供的 Listenable 更改时重新渲染。在这种情况下,提供的 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 小部件来删除之前预订的活动。

回想上一代码片段中的代码:

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 方法。

已保存的预订是应用程序状态,它比会话或 View 的生命周期更持久,只有存储库应该修改这种应用程序状态。因此,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 应用中,这些处理用户事件的方法称为命令

Command 对象

#

命令负责从 UI 层开始并流向数据层的交互。特别是在此应用中,Command 也是一种类型,它有助于安全地更新 UI,无论响应时间或内容如何。

Command 类包装了一个方法,并有助于处理该方法的不同状态,例如 runningcompleteerror。当 Command.running 为 true 时,这些状态可以轻松地显示不同的 UI,例如加载指示器。

以下是 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 方法是异步的,因此它不能保证在 View 想要渲染时数据可用。这就引出了 Compass 应用使用 Commands 的原因。在 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 小部件创建之前就解析了,这也不是问题,因为 Command 对象仍然存在,并且公开了正确的状态。

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

反馈

#

随着本网站这一部分的不断发展,我们欢迎您的反馈