Ошибка, состояние и эффект: три разных контракта Flutter-приложения
Разделяем результат операции, воспроизводимое состояние и действие интерфейса, сравниваем две семантики доставки и разбираем active-view контракт Ark MVP.
- Опубликовано
- 28 августа 2026 г.
- Проверено
- 22 августа 2026 г.
Ошибка, состояние и эффект: три разных контракта Flutter-приложения
Пользователь сохраняет профиль. Кнопка блокируется, запрос завершается успешно, данные на экране обновляются, внизу появляется сообщение «Профиль сохранён». Обычный сценарий, в котором сложно заподозрить архитектурную проблему.
Затем приложение уходит в фон. После возвращения Flutter заново строит часть
дерева виджетов — выполняет rebuild — и то же сообщение появляется ещё раз.
Запрос при этом не повторялся. Профиль уже сохранён. Повторилось только действие
интерфейса.
Исправление кажется очевидным: добавить флаг successMessageShown, установить
его после показа и сбросить перед следующим сохранением. Для одного экрана это
может сработать. Затем потребуется учесть закрытие маршрута, восстановление
приложения, две почти одновременные операции и момент, когда сообщение должно
считаться показанным: до вызова API интерфейса, после него или после фактического
исчезновения snackbar.
Один логический флаг незаметно получает собственный жизненный цикл.
Проблема здесь не в snackbar и не в конкретном пакете управления состоянием. Она возникает раньше — в тот момент, когда разные по смыслу данные складываются в одну модель только потому, что интерфейсу удобно на неё подписаться.
Три разных контракта
Во время сохранения существуют как минимум три вида информации.
Первый — результат операции. Сохранение завершилось успешно либо вернуло ошибку определённого типа. Этот результат нужен вызывающей стороне, чтобы решить, что делать дальше. Он не обязан знать, будет ли ошибка показана строкой, подсветкой поля или вообще останется только в диагностике.
Второй — состояние экрана. Кнопка заблокирована, индикатор виден, поля содержат актуальные значения. Состояние можно прочитать повторно: если экран перестроится десять раз, он всё равно должен показывать текущую картину. Именно это следует из декларативной модели Flutter, где UI строится как функция от состояния (Common architecture concepts).
Третий — эффект представления. Так далее будет называться действие, которое интерфейс выполняет в ответ на уже произошедшее событие: показать snackbar, открыть диалог, вернуть фокус или перейти на другой экран. Эффект не описывает экран «сейчас». Он требует правила доставки и момента, после которого считается обработанным.
Различие не терминологическое. У этих контрактов разные сроки жизни.
| Контракт | На какой вопрос отвечает | Допускает повторное чтение | Кто определяет смысл |
|---|---|---|---|
| Результат операции | Чем завершилось выполнение? | Да | прикладная операция |
| Состояние экрана | Что должно быть видно сейчас? | Да | слой представления |
| Эффект | Какое действие UI требуется выполнить? | Только по явной политике | граница представления |
Если все три роли представлены одним значением ProfileSaved, интерфейс
вынужден угадывать смысл по обстоятельствам. При обычном переходе состояния это
может выглядеть корректно. При новой подписке, восстановлении экрана или
повторном чтении текущего значения сохранённый факт снова интерпретируется как
команда что-то показать.
Факт и команда — не одно и то же.
Что уже предлагает Flutter
Официальная документация Flutter рассматривает похожую проблему через два
паттерна. Result представляет завершение операции явным успехом или ошибкой
вместо потока исключений, о котором вызывающая сторона может не знать
(Result pattern).
Command оборачивает одну операцию и публикует её состояния выполнения,
результата и ошибки
(Command pattern).
В документации эти конструкции показаны на MVVM — Model–View–ViewModel. Объект
между экраном и данными там называется ViewModel: он хранит состояние,
необходимое интерфейсу, и предоставляет команды для пользовательских действий.
Само различение результата, состояния и действия от MVVM не зависит. В другой
архитектуре эту ответственность может нести компонент с другим именем.
Особенно важна деталь в реализации Command: результат предлагается очистить
через clearResult после того, как подписчик его обработал. Документация прямо
предупреждает, что неочищенная ошибка способна повторно вызвать UI-действие при
следующем notifyListeners().
Это уже контракт потребления. Простой, но вполне реальный.
У него остаётся граница, которую невозможно закрыть одним названием метода. Когда именно результат считается потреблённым? Что произойдёт, если активного экрана нет? Должен ли новый экран получить старое действие? Может ли одно событие обработать больше одного подписчика? Ответы зависят от значения действия, а не от выбранного state manager.
Очистить до показа или после
Даже в простом варианте с clearResult приходится выбрать момент очистки.
Если очистить результат до вызова snackbar, повтор не произойдёт. Но действие будет считаться обработанным ещё до того, как интерфейс принял его. Для уведомления «Профиль сохранён» такая потеря обычно допустима.
Если очистить после выполнения UI-действия, появляется другое окно: экран может закрыться между показом и очисткой, а следующий получатель снова увидит необработанный результат. Для части сценариев повтор приемлем. Для навигации или запуска внешнего подтверждения — уже не всегда.
Поэтому «показать один раз» — недостаточное требование. Нужна семантика:
- доставлять только активному получателю или ждать следующего;
- терять событие при отсутствии экрана или сохранять;
- очищать до действия, после действия или после отдельного подтверждения;
- допускать повторную доставку;
- ограничивать срок жизни события.
Можно назвать поле oneShotEvent. Одноразовость от имени не появится.
Доставка только активному получателю
Первый вариант подходит для действий с низкой ценой потери: показать подтверждение сохранения, запустить локальную анимацию, кратко подсветить обновлённый элемент.
Контракт формулируется жёстко: эффект получает только интерфейс, подписанный в момент отправки. Получателя нет — эффект отбрасывается. История не хранится, повторная подписка ничего не восстанавливает, подтверждение не требуется.
Для реализации достаточно отдельного потока событий. Но Stream здесь является
транспортом, а не источником семантики. Поведение определяется словами
«только активному получателю» и «потеря допустима». Без них тот же поток можно
ошибочно принять за очередь, журнал или гарантированную доставку.
Преимущество варианта — малое количество состояния и отсутствие старых действий после повторного открытия экрана. Недостаток задан самим контрактом: событие теряется, если экран отсутствует ровно в момент отправки.
Это не дефект реализации. Это выбранная политика.
Очередь с подтверждением
Второй вариант нужен, когда потерю нельзя принять молча. Эффект получает идентификатор, время создания и полезную нагрузку, после чего хранится до явного подтверждения. Получатель читает первый элемент очереди, выполняет действие и сообщает, что элемент можно удалить.
sealed class ProfileEffect {
const ProfileEffect();
}
final class ShowSavedNotice extends ProfileEffect {
const ShowSavedNotice();
}
final class PendingEffect<E> {
const PendingEffect({
required this.id,
required this.createdAt,
required this.value,
});
final String id;
final DateTime createdAt;
final E value;
}
Код задаёт форму записи, но ещё не решает политику. Требуется определить:
- сохраняется ли очередь только в памяти или переживает перезапуск процесса;
- подтверждает ли действие любой подписчик или конкретный экран;
- что происходит с устаревшими эффектами;
- сохраняется ли порядок при нескольких действиях;
- допускается ли повторная доставка до подтверждения.
Очередь сложнее не из-за количества классов. Она переносит в приложение ответственность за хранение и подтверждение доставки. Если такая ответственность не нужна, очередь лишь создаст новый источник ошибок.
Ошибка не равна сообщению
Похожее смешение происходит, когда неуспешный результат сразу превращается в строку:
errorMessage = 'Не удалось сохранить профиль';
Интерфейсу строка действительно понадобится. Но на этом уровне уже потеряно, что произошло: ошибка проверки данных, отсутствие сети, конфликт версии, отказ сервера или дефект самого приложения. Невозможно обоснованно предложить повтор, изменить состояние формы или зарегистрировать конкретную причину.
Способ показа выбран раньше реакции на ошибку.
Dart различает Exception, которую вызывающая сторона должна иметь возможность
обработать программно, и Error, обозначающий программный дефект, обычно не
предназначенный для превращения в пользовательскую ветку
(Exception,
Error). Прикладное приложение
может использовать собственную более подробную модель отказов, но принцип
остаётся тем же: диагностический смысл сохраняется до границы, где принимается
решение о представлении.
Только там результат становится текстом, диалогом, подсветкой поля, предложением повторить операцию или отсутствием видимой реакции.
Минимальная архитектурная граница
До выбора пакета достаточно зафиксировать три типа:
sealed class SaveProfileOutcome {
const SaveProfileOutcome();
}
final class ProfileSaved extends SaveProfileOutcome {
const ProfileSaved(this.profile);
final Profile profile;
}
final class ProfileRejected extends SaveProfileOutcome {
const ProfileRejected(this.issue);
final SaveProfileIssue issue;
}
final class ProfileViewState {
ProfileViewState({
required this.saving,
required this.profile,
required List<SaveProfileIssue> issues,
}) : issues = List<SaveProfileIssue>.unmodifiable(issues);
final bool saving;
final Profile? profile;
final List<SaveProfileIssue> issues;
}
SaveProfileOutcome завершает операцию и сохраняет прикладной смысл результата.
ProfileViewState позволяет восстановить экран. ProfileEffect из предыдущего
примера просит границу представления выполнить действие по заранее выбранной
политике.
Количество абстракций здесь вторично. Существенно, чтобы по коду было понятно, кто владеет каждым сроком жизни и какой факт можно прочитать повторно.
Как эта граница выглядит на уровне инструмента
До этого места речь шла о контрактах, которые можно реализовать самостоятельно: закрепить в соглашениях проекта, вынести в адаптер над существующим State Manager или поддержать отдельными типами и каналами. Есть и готовые решения, которые проводят такую границу на уровне API.
Один из них — Ark MVP:
пакет ark_mvp 1.0.0 и его
Flutter-интеграция
ark_mvp_flutter 1.0.0.
Исходный код рассматриваемых версий зафиксирован в GitLab:
ark_mvp, commit 2567bc4
и
ark_mvp_flutter, commit 7a10248.
Пакеты используют Model–View–Presenter не как название для трёх классов, а как
границу преобразования:
Modelсодержит полный неизменяемый снимок бизнес-данных функции и доступные операции;Presenterпреобразует этот снимок в готовый для отображенияViewState;Viewмногократно строится изViewStateи передаёт пользовательские намерения обратно в Presenter;ViewEffectидёт по отдельному каналу и адресован только активному View.
Ark MVP не определяет тип результата бизнес-операции. Этот контракт остаётся у
приложения: SaveProfileOutcome, состояние команды или другой тип попадает в
Model вместе с остальными бизнес-данными. Presenter решает, какая часть
результата должна остаться в ViewState, а какая требует отдельного
ViewEffect.
Например, ошибка валидации остаётся в ProfileViewState, чтобы экран мог
показать её после любого rebuild. Успешное завершение сохранения может породить
ShowSavedNotice, если переход действительно произошёл в текущем жизненном
цикле Presenter. Сам эффект нельзя отправлять из buildViewState: Flutter имеет
право вызвать это преобразование повторно из-за Model, темы, локали или обычной
перестройки дерева.
В терминах примера ProfileModel — полный бизнес-снимок с текущим профилем,
ходом сохранения, результатом и операцией save. ProfilePresenter преобразует
его в уже введённый ProfileViewState и при значимом переходе отправляет
ProfileEffect. profileBinding сообщает среде выполнения MvpView, что
источник мог измениться. Среда выполнения читает новый полный ProfileModel и
обновляет Presenter.
Во Flutter эти типы подключаются через MvpView:
MvpView<
ProfileModel,
ProfileViewState,
ProfileEffect,
ProfilePresenter
>(
model: profileBinding,
createPresenter: ProfilePresenter.new,
onEffect: (context, effect) {
switch (effect) {
case ShowSavedNotice():
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Профиль сохранён')),
);
}
},
builder: (context, viewState, presenter) {
return ProfileView(
state: viewState,
onSave: presenter.save,
);
},
)
ProfileView здесь — обычный Widget, которому доступны только готовое состояние
экрана и разрешённое действие сохранения. Бизнес-снимок в него не передаётся.
MvpView создаёт один Presenter на жизненный цикл binding, подписывается на
эффекты до запуска Presenter и передаёт каждый из них в onEffect с актуальным
BuildContext. Обычный rebuild сохраняет Presenter и не воспроизводит старое
событие. Если обработчик эффекта не указан, пакет сообщает типизированную
ошибку, а не маскирует нарушение контракта.
Это реализация active-view семантики из предыдущего раздела. Канал эффектов не
имеет replay-буфера, новый View не получает старую навигацию или snackbar, а
закрывающийся экран не гарантирует доставку. Поэтому важная информация не может
существовать только как ViewEffect: она остаётся в бизнес-состоянии и затем в
ViewState.
Подтверждаемую очередь Ark MVP не предоставляет. Если действие обязано дождаться
будущего экрана, пережить перезапуск процесса или храниться до отдельного
подтверждения, такой контракт находится выше MVP-границы и требует собственного
владельца. Добавлять его внутрь ViewEffect означало бы смешать два разных
решения под одним именем.
Что проверить в проекте
Поиск обычно начинается с флагов show*, строк errorMessage, навигации внутри
бизнес-операций и обработчиков, которые очищают результат сразу после чтения.
Для каждого случая нужны четыре ответа:
- Это воспроизводимый снимок, диагностический результат или команда UI?
- Что считается обработкой?
- Допустима ли потеря при отсутствии активного экрана?
- Что должно произойти при повторной подписке?
Если ответ меняется в зависимости от случайного rebuild, контракт пока не
определён.
Эта статья не доказывает, что очередь лучше потока или что каждому snackbar нужен идентификатор. Она фиксирует более узкий вывод: состояние, результат и эффект нельзя объединять только потому, что одной подпиской удобнее обновить интерфейс.
Следующий осмысленный шаг — отдельный Flutter-эксперимент с двумя минимальными реализациями и одинаковыми сценариями: активный экран, закрытый маршрут, повторная подписка и два последовательных эффекта. До такого сравнения выбор между active-only доставкой и подтверждаемой очередью остаётся архитектурным решением для конкретного контекста, а не универсальной рекомендацией ArkTelos.
Границы результата
- Статья не доказывает, что очередь лучше потока или что каждому эффекту представления требуется идентификатор.
- Active-only доставка и подтверждаемая очередь пока не сравнены в запланированном Flutter-эксперименте с закрытым маршрутом, повторной подпиской и последовательными эффектами.
Версия в Telegram