跳至主要内容

UI 层案例研究

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

A screenshot of the booking screen of the compass app.

从最普遍的意义上讲,视图模型管理 UI 状态,而视图显示 UI 状态。视图和视图模型之间存在一对一的关系;对于每个视图,都正好有一个对应的视图模型来管理该视图的状态。每对视图和视图模型构成单个功能的 UI。例如,一个应用程序可能具有名为LogOutView的类和一个LogOutViewModel

定义视图模型

#

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

以下代码片段是名为HomeViewModel的视图模型类的类声明。其输入是提供其数据的存储库。在本例中,视图模型依赖于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<TripSummary>类型的对象公开。

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,提醒 View 有新的 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小部件侦听来自视图模型的更新。在ListenableBuilder小部件下的子树中的所有内容都会在提供的Listenable更改时重新渲染。在本例中,提供的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类包装了一个方法,并帮助处理该方法的不同状态,例如runningcompleteerror。这些状态使显示不同的 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是一个抽象类。它由具体类(如Command0 Command1)实现。类名中的整数指的是底层方法期望的参数数量。您可以在指南针应用程序的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方法是异步的,因此它无法保证数据在视图想要渲染时可用。这就是指南针应用程序使用Commands的原因。在视图的Widget.build方法中,命令用于有条件地渲染不同的 widget。

home_viewmodel.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 问题的解决方法,使您的代码库更不容易出错且更具可扩展性,但并非每个应用程序都需要实现此模式。是否使用它很大程度上取决于您做出的其他架构选择。许多帮助您管理状态的库都有自己的工具来解决这些问题。例如,如果您在应用程序中使用StreamBuilder,Flutter 提供的AsyncSnapshot 类已内置了此功能。

反馈

#

由于本网站的此部分正在不断发展,我们欢迎您提供反馈