跳转至正文

UI 层案例研究

实现 MVVM 架构的应用 UI 层 walkthrough。

Flutter 应用中每个功能的 UI 层 应由两个组件构成:ViewViewModel

A screenshot of the booking screen of the compass app.

概括而言,view model 管理 UI 状态,view 展示 UI 状态;二者一一对应,每对 view 与 view model 构成单一功能的 UI。例如应用可有 LogOutViewLogOutViewModel

定义 view model

#

View model 以领域数据模型为输入,向对应 view 暴露为 UI 状态;封装 view 可挂到按钮按压等事件处理器的逻辑,并将事件发往发生数据变更的应用数据层。

以下片段为 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;
  // ...
}

View model 始终依赖通过构造函数传入的数据仓库;与仓库为多对多关系,多数 view model 依赖多个仓库。

如前述 HomeViewModel,仓库应为 view model 的私有成员,否则 view 可直接访问数据层。

UI 状态

#

View model 的输出是 view 渲染所需数据,通常称为 UI State 或简称 state。 UI state 是完整渲染 view 所需数据的不可变快照。

A screenshot of the booking screen of the compass app.

View model 以公共成员暴露状态。下例中暴露 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 state 应不可变,这是少 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 状态

#

除存储状态外,数据层提供新状态时 view model 须通知 Flutter 重新渲染 view。 Compass 中 view model 继承 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.

This figure shows from a high-level how new data in the repository propagates up to the UI layer and triggers a re-build of your Flutter widgets.

该图从宏观展示仓库中的新数据如何向上传到 UI 层并触发 Flutter widget 重建。

  1. 仓库向 view model 提供新状态。

  2. View model 更新 UI 状态以反映新数据。

  3. 调用 ViewModel.notifyListeners,通知 View 有新 UI State。

  4. View(widget)重新渲染。

例如用户进入 Home 屏幕并创建 view model 时调用 _load;完成前 UI state 为空,view 显示加载指示器;成功完成后 view model 有新数据,须通知 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 是应用内的 widget。常代表带独立路由、widget 树顶层含 Scaffold 的屏幕(如 HomeScreen),但未必如此。

有时 view 是可在应用中复用的单一 UI 元素,例如 Compass 的 LogoutButton 及其 LogoutViewModel;大屏上可能同时显示多个在手机上占全屏的 view。

View 内 widget 有三项职责:

  • 展示 view model 的数据属性。

  • 监听 view model 更新并在有新数据时重新渲染。

  • 将 view model 的回调挂到事件处理器(如适用)。

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 与对应 view model。

在 view 中展示 UI 数据

#

View 依赖 view model 获取状态;Compass 在 view 构造函数中传入 view model。以下片段来自 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 访问传入的 bookings;下例将 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 通过 ListenableBuilder 监听 view model;其子树在 Listenable 变化时重建,此处为 ChangeNotifier 类型的 view model。

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 须监听用户事件,由 view model 通过暴露封装逻辑的回调处理。

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

HomeScreen 上,用户可通过滑动 Dismissible 删除已预订项。

回顾上一片段中的代码:

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 表示行程;dismiss 时执行 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 中,这些处理用户事件的方法称为 command

Command 对象

#

Command 负责从 UI 层回流数据层的交互;Command 类型可安全更新 UI,不受响应时间或内容影响。

Command 包装方法并处理 runningcompleteerror 等状态,便于展示加载等 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 继承 ChangeNotifierexecute 中多次 notifyListeners(),使 view 以极少逻辑处理多状态(后文有例)。

Command 为抽象类,由 Command0Command1 等实现,数字表示参数个数;示例见 Compass utils 目录

确保 view 在数据存在前即可渲染

#

在 view model 构造函数中创建 command。

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 使用 Command 的原因;在 Widget.build 中用 command 条件渲染不同 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 command 是 view model 上的持久属性,调用与完成时机不影响正确状态暴露。

该模式标准化常见 UI 问题的解决方式,但并非所有应用都需要;是否采用取决于其他架构选择。许多状态管理库自带类似工具,例如 streamStreamBuilders 配合 AsyncSnapshot

反馈

#

网站本节内容仍在完善中, 欢迎提供反馈