Руководства

Несколько UseCase на одном экране

Объединение независимых снимков на границе представления без переноса атомарной бизнес-координации в MVP.

v1.1.0Средний уровень

Несколько UseCase на одном экране

Экран профиля может загружать данные и сохранять изменения через разные UseCase. MVP способен представить оба процесса, не объявляя их единой бизнес-транзакцией.

Итоговый поток выглядит так:

text
LoadProfileUseCase.state ─┐
                         ├─> ProfileModel -> ProfilePresenter -> ProfileViewState
SaveProfileUseCase.state ─┘                         └──────────> ProfileSaved effect

1. Поместите оба снимка в Model

dart
final class ProfileModel {
  const ProfileModel({
    required this.profile,
    required this.save,
    required this.reload,
    required this.saveName,
  });

  final UseCaseSnapshot<ProfileState> profile;
  final UseCaseSnapshot<SaveProfileState> save;
  final void Function() reload;
  final void Function(String name) saveName;
}

Model содержит снимки и разрешённые операции, но не экземпляры UseCase. Presenter не может регистрировать команды, закрывать UseCase или обходить его публичную входную границу.

2. Наблюдайте оба источника

ModelBinding.changes содержит оба Stream, повторяющих последнее значение. После сигнала любого источника read() получает оба актуальных снимка. Presenter работает с полным состоянием функции, а не с частичным событием.

dart
final ModelBinding<ProfileModel> binding = ModelBinding<ProfileModel>(
  read: () => ProfileModel(
    profile: loadProfile.state,
    save: saveProfile.state,
    reload: () => loadProfile.add(const LoadProfile()),
    saveName: (name) => saveProfile.add(SaveProfile(name)),
  ),
  changes: <Stream<Object?>>[loadProfile.stream, saveProfile.stream],
);

3. Постройте единые правила экрана

Presenter объединяет загрузку, сохранение, имя, статус и доступность кнопок в один ProfileViewState. View не определяет, какой UseCase занят, и не объединяет две подписки.

dart
final class ProfilePresenter
    extends FlutterPresenter<ProfileModel, ProfileViewState, ProfileEffect> {
  void reload() => model.reload();

  void saveExampleName() => model.saveName('Grace Hopper');

  @override
  ProfileViewState buildViewState(BuildContext context) {
    final bool loading =
        model.profile.phase == UseCaseExecutionPhase.processing;
    final bool saving = model.save.phase == UseCaseExecutionPhase.processing;
    return ProfileViewState(
      title: model.profile.state.name.isEmpty
          ? 'Profile'
          : model.profile.state.name,
      status: loading
          ? 'Загрузка профиля…'
          : saving
          ? 'Сохранение ${model.save.state.name}…'
          : 'Профиль готов',
      isLoading: loading,
      isSaving: saving,
      canSave: !loading && !saving,
    );
  }
}

4. Публикуйте эффект только при значимом переходе

Повтор последнего состояния и явное начальное чтение могут дать эквивалентные снимки. Перед ProfileSaved сравните предыдущую и текущую фазы сохранения. Не публикуйте эффект безусловно из onModelChanged.

dart
@override
void onModelChanged(ProfileModel previous, ProfileModel current) {
  final bool saveJustFinished =
      previous.save.phase != UseCaseExecutionPhase.finished &&
      current.save.phase == UseCaseExecutionPhase.finished &&
      current.save.result == UseCaseExecutionResult.completed;
  if (saveJustFinished) {
    emitEffect(ProfileSaved(current.save.state.name));
  }
}

5. Подключите View к составной границе

dart
MvpView<ProfileModel, ProfileViewState, ProfileEffect, ProfilePresenter>(
  model: binding,
  createPresenter: ProfilePresenter.new,
  onEffect: (context, effect) {
    if (effect case ProfileSaved(:final name)) {
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text('$name сохранён.')),
      );
    }
  },
  builder: (context, viewState, presenter) => ProfileScreenBody(
    viewState: viewState,
    onReload: presenter.reload,
    onSave: presenter.saveExampleName,
  ),
)

ProfileScreenBody получает готовые к отображению значения и обратные вызовы. Он не импортирует UseCase Forge и не анализирует фазы выполнения.

6. Оставьте бизнес-координацию в бизнес-слое

Агрегация для экрана корректна, пока загрузка и сохранение остаются независимыми операциями. Если они обязаны выполняться атомарно, требуется единая координирующая бизнес-граница. Ark MVP не запрещает такое решение, но и не навязывает его.

7. Закройте настоящих владельцев

Владелец функции закрывает оба UseCase. MvpView закрывает один Presenter. Model и ViewState остаются неизменяемыми значениями без ресурсов.