跳转至正文

UI 层案例研究

逐步讲解一个实现 MVVM 架构的应用的 UI 层。

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 的类声明,输入为提供数据的 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;
  // ...
}

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

如前述 HomeViewModel,Repository 应为 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.

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

  1. Repository 向 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

已保存预订为持久应用状态,只应由 Repository 修改;HomeViewModel.deleteBooking 调用数据层 Repository 方法,见下。

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

反馈

#

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