ATL-2026-004architecturerepeatable

Экран закрылся, операция осталась: кто владеет асинхронной работой во Flutter

Маршрут может исчезнуть, пока операция продолжается. Детерминированный Flutter-эксперимент разделяет доставку в UI, отмену источника, владельца операции и обработку поздних значений и ошибок.

Опубликовано
9 сентября 2026 г.
Проверено
3 сентября 2026 г.

Экран закрылся, операция осталась: кто владеет асинхронной работой во Flutter

Пользователь открывает профиль, меняет имя и нажимает «Сохранить». Кнопка блокируется, начинается запрос. Не дожидаясь ответа, пользователь возвращается назад.

Через секунду сервер подтверждает сохранение. Экран уже уничтожен. Показывать сообщение некому, но данные изменились. В другом сценарии вместо сохранения запущен поиск подсказок, и его результат действительно больше не нужен. В третьем — началась оплата, которую опасно считать отменённой только потому, что закрылся маршрут.

На уровне Flutter эти случаи выглядят одинаково: между await и следующим обращением к интерфейсу BuildContext успел стать немонтированным. Обычное исправление тоже одинаково:

final result = await repository.saveProfile(profile);

if (!context.mounted) {
  return;
}

Navigator.of(context).pop(result);

Проверка необходима. Но она отвечает только на вопрос, можно ли сейчас использовать этот BuildContext. Судьба самой операции остаётся за пределами условия.

Что гарантирует mounted

Документация Flutter формулирует контракт достаточно жёстко: свойства и методы BuildContext допустимо использовать только пока mounted == true. После unmount этот же context больше никогда не станет mounted. Для State.dispose граница такая же: объект удалён из дерева навсегда, вызывать setState уже нельзя, retained resources и подписки следует освободить.

Из этого следуют два корректных действия:

  • не обращаться к закрытому экрану после async gap;
  • отписать экран от принадлежащих ему источников в dispose.

Третье действие часто добавляется незаметно: считать закрытие экрана отменой всего, что он когда-либо запускал. В документации такой гарантии нет.

dispose описывает жизнь presentation-объекта. Сетевой запрос, запись в базу, вычисление в isolate или backend-команда могут иметь другой срок жизни. Более того, dispose вообще не гарантирован при внезапном завершении процесса. Если корректность операции держится только на этом callback, она уже зависит не от архитектуры, а от удачного завершения приложения.

Экран закончился. Пользовательское намерение — не обязательно.

Почему Future не становится владельцем

Future<T> представляет будущий результат асинхронного вычисления. Он может завершиться значением или ошибкой, а может не завершиться вовсе. Общего метода cancel() у Future нет.

Метод timeout иногда принимают за отмену:

final result = await request.timeout(const Duration(seconds: 10));

Контракт метода скромнее: вызывающая ветка прекращает ждать исходный Future после заданного времени. Из этого не следует, что сокет закрыт, вычисление остановлено или внешний сервис не применит запрос позже.

У StreamSubscription граница другая. После cancel() конкретная подписка больше не получает события, а возвращённый Future завершается после cleanup потока. При этом документация говорит, что потоку может потребоваться остановить источник. Слово «может» здесь существенно: прекращение доставки одному listener и фактическая отмена источника не являются одной универсальной операцией.

Поэтому для разбора нужны отдельные понятия.

Операция, источник и доставка

Операцией далее называется одна попытка выполнить намерение: сохранить профиль, загрузить файл, получить подсказки. Источник — механизм, который реально выполняет работу: HTTP-клиент, база, поток, isolate или внешний сервис. Доставка — передача progress, value либо error конкретному потребителю.

Наконец, владелец операции — компонент, который определяет:

  • identity конкретного запуска;
  • срок жизни намерения;
  • терминальный исход;
  • возможность и момент отмены;
  • cleanup;
  • получателя результата;
  • судьбу позднего завершения.

Имя компонента вторично. В небольшом экране владельцем может быть State. В feature — объект, который переживает отдельный route. Для фоновой синхронизации им станет application service, а для длительной server-side работы — backend.

Сначала требуется ответственность. Потом название класса.

Четыре вопроса вместо одного cancel()

Перед выбором механизма достаточно последовательно ответить на четыре вопроса.

Жив ли получатель

Для Flutter-экрана ответ дают mounted и lifecycle подписки. Если получатель уничтожен, доставка UI-события прекращается. Это ещё не решение об операции.

Нужен ли результат после закрытия экрана

Подсказка для исчезнувшего поля поиска обычно не нужна. Загружаемый файл, подтверждённое сохранение или синхронизация могут понадобиться независимо от текущего route. Оплата требует ещё более строгого ответа: закрытие экрана не доказывает ни отмену, ни успех.

Умеет ли источник останавливаться

Отмена — свойство конкретного API. Один HTTP-клиент позволяет прервать запрос, другой прекращает только ожидание ответа. Запись могла уже попасть на сервер. Для совершённого side effect вместо отмены потребуется отдельная компенсация.

Кто обработает позднее завершение

Значение можно отбросить, сохранить в repository, записать в историю или передать новому потребителю. Ошибку всё равно должен кто-то наблюдать: Future, оставшийся без error handler, способен уйти в uncaught error.

Эти вопросы нельзя заменить одним boolean isDisposed.

Три политики на одном сценарии

Для проверки был создан детерминированный Flutter-эксперимент. Реальной сети в нём нет: управляемый источник завершается только по команде теста. Это позволяет закрыть настоящий MaterialPageRoute, а затем отдельно выдать позднее значение или ошибку без зависимости от таймера и скорости машины.

Сравнивались три политики.

Операция принадлежит экрану

В первом варианте результат нужен только текущему экрану. При dispose владелец прекращает доставку и запрашивает cooperative cancellation источника:

void detach() {
  _deliver = null;

  if (policy == OwnershipPolicy.uiScoped) {
    source.requestCancellation();
  }
}

Источник в эксперименте поддерживает этот сигнал. После закрытия route тест наблюдает cancellation request, а операция получает терминальный исход cancelled. Результат не сохраняется.

Такая политика подходит для работы, смысл которой существует только внутри конкретного presentation-контекста: локальная подсказка, предварительный расчёт или запрос, который безопасно начать заново.

Операция принадлежит приложению

Во втором варианте экран является только временным наблюдателем. При его закрытии delivery отсоединяется, но источник продолжает работу. Позднее значение сохраняет владелец операции:

final value = await source.execute();

if (policy == OwnershipPolicy.applicationScoped) {
  storedResult = value;
}

_deliver?.call('completed:$value');

После закрытия route controlled source завершился значением profile-saved. UI callback уже отсутствовал, исключение Flutter не возникло, а владелец сохранил результат.

Здесь проверка mounted по-прежнему нужна на границе экрана. Но операция не обязана знать о ней: presentation подписывается и отписывается, application owner продолжает собственный lifecycle.

Отменяется только доставка

Третий источник в эксперименте не поддерживает остановку. Закрытие route прекращает UI delivery, однако Future остаётся под наблюдением владельца. Затем источник завершается synthetic error.

Тест подтверждает две вещи одновременно: уничтоженный UI не получает callback, а поздняя ошибка не теряется. Владелец фиксирует failed и сохраняет сам объект ошибки для дальнейшей политики.

Это не запасной вариант «на всякий случай». Delivery-only семантика нужна там, где источник невозможно остановить либо внешний side effect уже пересёк точку отмены. Приложение прекращает обещать невозможное, но продолжает наблюдать результат.

Один lifecycle, разные результаты

Все три теста выполняют одинаковые пользовательские действия:

  1. открыть route;
  2. запустить операцию;
  3. закрыть route до completion;
  4. завершить или отменить контролируемый источник;
  5. проверить source, delivery и terminal outcome отдельно.

Наблюдения различаются только выбранной политикой:

Политика Запрос отмены source Поздняя доставка в UI Терминальный исход Сохранение значения
UI-scoped да нет cancelled нет
Application-scoped нет нет completed да
Delivery-only нет нет failed в error-case нет, ошибка наблюдается

context.mounted не мог бы выбрать строку этой таблицы. Он одинаков во всех трёх случаях.

Что требуется от общего механизма выполнения

Когда операций становится больше, проекту обычно нужен компонент, который отслеживает отдельные запуски. Его минимальная ответственность уже следует из сценария:

  • выдавать каждой операции identity;
  • различать processing и terminal outcome;
  • передавать cooperative cancellation;
  • не принимать публикацию после terminal outcome;
  • хранить ограниченную историю для диагностики;
  • наблюдать ошибку handler, завершившегося слишком поздно.

Такой механизм можно написать внутри приложения или взять готовую реализацию. Один из вариантов — UseCase Forge из экосистемы ArkTelos: пакет usecase_forge 1.0.0 задаёт Execution для каждой типизированной Command, публикует Snapshot и записывает завершённые Entry в bounded history. Рассматриваемая версия исходников зафиксирована в GitLab на commit 16f8347.

Для этой статьи важна не вся поверхность API, а точная семантика отмены. context.cancel() немедленно завершает Execution с результатом cancelled и освобождает processing slot. При этом пакет прямо не обещает принудительно оборвать произвольный Dart Future. Handler должен наблюдать isCancellationRequested или whenCancellationRequested и остановиться там, где это возможно.

В эксперименте handler ждал внешний Completer. После context.cancel() Execution уже находилась в history как cancelled, хотя внешний Future ещё не завершился. Затем Future получил позднее значение. Handler увидел cancellation и не опубликовал его; terminal history не изменилась.

Отмена Execution и остановка источника снова оказались разными действиями.

Где заканчивается ответственность usecase_forge

Пакет помогает формализовать lifecycle продуктовой операции. Он не может определить его вместо приложения.

usecase_forge не решает:

  • означает ли уход с route отмену пользовательского намерения;
  • умеет ли HTTP-клиент остановить запрос;
  • следует ли сохранить поздний результат;
  • нужно ли показать snackbar или выполнить navigation;
  • как восстановить операцию после убийства процесса;
  • как компенсировать уже совершённый side effect.

Presentation-слой остаётся владельцем UI-подписки. Инфраструктурный adapter переводит cooperative signal в реальную отмену, если конкретный API её поддерживает. Repository или иной storage владеет persistence. UseCase связывает команду, выполнение и терминальный результат, не присваивая соседние сроки жизни.

Именно в таком виде упоминание ArkTelos имеет смысл: не как «пакет, который всё решает», а как одна реализация уже определённого класса ответственности.

Failure paths, которые нельзя пропустить

Успешное позднее завершение — самый удобный сценарий. Реальные дефекты обычно находятся рядом.

Ошибка приходит после отмены delivery

Отсутствие UI listener не освобождает владельца от error handling. Если работа продолжается, её Future должен оставаться наблюдаемым. Ошибка может попасть в диагностику, terminal result или policy восстановления, но не должна исчезнуть между dispose и completion.

Источник сообщает cancel после side effect

Cancellation acknowledgement не доказывает, что внешняя система ничего не изменила. Для записи нужен operation id и способ проверить фактический итог. Повторный запрос без reconciliation способен создать второй эффект вместо исправления первого.

Процесс уничтожен без dispose

Flutter прямо предупреждает, что lifecycle callback нельзя гарантировать при внезапном завершении приложения. Значимая операция требует durable состояния на стороне, которая переживает процесс: backend, база, platform service или иная инфраструктура. In-memory owner этого не заменяет.

Новый экран подписывается позже

Application-scoped результат должен иметь правило replay. Новый экран читает текущее состояние, получает terminal result один раз или запрашивает историю? Это уже соседняя задача доставки state/result/effect. Случайный broadcast Stream не выбирает правильную семантику автоматически.

Что проверить в проекте

Полезный аудит начинается не с поиска всех mounted, а с мест, где операция запускается из UI:

  1. Что именно пользователь попросил сделать?
  2. Перестаёт ли намерение существовать при закрытии route?
  3. Кто хранит identity запуска и terminal outcome?
  4. Что реально отменяет используемый API?
  5. Кто наблюдает позднее значение и позднюю ошибку?
  6. Может ли side effect уже быть применён к моменту cancel?
  7. Что увидит новый экран или восстановленный процесс?

Если на все вопросы отвечает один State.dispose, границы, скорее всего, ещё не определены.

Граница эксперимента

Все четыре проверки прошли на Flutter 3.47.2, Dart 3.13.2 и usecase_forge 1.0.0 под macOS arm64. Для прогона использовался отдельный временный SDK: установленная рабочая версия Flutter не обновлялась, а среда и её кэши были удалены после проверки.

Controlled source не доказывает семантику любого HTTP-клиента. In-memory storedResult не заменяет persistence. Widget disposal не воспроизводит process death. Эксперимент подтверждает более узкий механизм: закрытие route, отмена доставки и завершение операции являются независимыми событиями, пока архитектура явно не связала их выбранной политикой.

В этом и состоит практический вывод. mounted защищает Flutter API. Владелец операции защищает смысл пользовательского намерения.

Источники и воспроизведение


ArkTelos Lab: сайт | Telegram RU | Telegram EN

ArkTelos: сайт | Telegram RU | Telegram EN

Границы результата

  • Эксперимент не устанавливает одну универсально правильную политику владения: выбор зависит от срока жизни продуктового намерения и конкретного API источника.
  • Проверенная граница UseCase Forge предоставляет cooperative cancellation и терминальный результат, но не может принудительно остановить произвольный Future или владеть Flutter-навигацией и UI effects.

CODE / DATA / AGENTS

Связанные эксперименты

experimentasync-operation-after-route-close

Сравнить три явные политики владения, когда реальный Flutter-маршрут закрывается до завершения контролируемого асинхронного источника.

Контролируемый источник подтверждает последовательность приложения, но не семантику отмены конкретного HTTP-, database-, isolate- или platform API. Widget disposal не является завершением процесса, а сохранённый в памяти результат не заменяет долговечный persistence.

Открыть паспорт эксперимента →

Версия в Telegram

Читать в ArkTelos Lab

Читать в ArkTelos Lab ↗

КАНАЛЫ ARKTELOS

Новости и инженерные материалы — без смешивания языков.

Основные каналы рассказывают о развитии ArkTelos. ArkTelos Lab публикует архитектурные разборы, эксперименты и воспроизводимые исследования.