Перейти к содержимому

Состояние

Solo<S> хранит одно неизменяемое состояние типа S, а Job объявляет, в каких состояниях ей позволено начаться и в каких продолжаться.

Правила Job спрашивает движок, а не её тело:

Правило Когда спрашивается Что делает отказ
Рабочий тип W у enqueue<W, T> Перед стартом, при чужих обновлениях состояния во время тела и в контрольных точках состояния Отменяет Job с RulesCancelReason: started: false перед стартом, started: true во время тела
canStart Один раз, непосредственно перед стартом Job Отменяет Job с RulesCancelReason и started: false: тело так и не выполняется
keepWhile Перед стартом, при чужих обновлениях состояния во время тела и в контрольных точках состояния Отменяет Job с RulesCancelReason: started: false перед стартом, started: true во время тела

Отказ не держит Job в очереди до подходящего состояния, и её ifFailed и ifCancelled после него не выполняются. Правила — в разделе «Правила», контрольные точки — в разделе «Чтение и изменение состояния», обработчики — в разделе «Допуск обработчиков и их ошибки».

Записи нужно свободное место, чтобы начаться, и она должна остановиться, когда устройство сообщит о паузе.

// Первая попытка: состояние проверяется один раз, на старте.
Job<void> record() => enqueue<CameraState, void>(key: 'record', (ctx) async {
final state = ctx.state;
if (state is! Ready || state.free == 0 || state.paused) return;
await for (final frame in device.frames) {
await store(frame);
}
});

Вот что происходит:

  1. Проверка выполняется один раз, в начале тела.
  2. О паузе сообщают позже, когда тело уже внутри цикла.
  3. Цикл в состояние больше не смотрит, поэтому кадр, пришедший до паузы, и два кадра, пришедших после неё, сохраняются одинаково.
  4. Задача при этом и не кончается. await for контрольной точкой не является, поэтому отмена, которую посылает close, до тела не доходит, и закрытие контроллера ждёт, пока не кончится сам стрим.
Job<void> record() => enqueue<Ready, void>(
key: 'record',
canStart: (state) => state.free > 0,
keepWhile: (state) => !state.paused,
(ctx) => ctx.each(device.frames, (child, frame) async {
await child.join(() => store(frame));
}).value,
);

Правила задаются параметрами, и движок проверяет их за задачу. Рабочий тип сужается с CameraState до Ready, поэтому правила читают state.free и state.paused без проверки типа, а условия, которые первая попытка проверяла руками, стали canStart и keepWhile.

Первый аргумент типа у enqueue<W, T> задаёт рабочий тип состояния Job, W extends S. Его проверяет движок, поэтому enqueue<Ready, void> начинается только в Ready, а тело по той же причине читает ctx.state как W. Здесь Ready, device и store принадлежат приложению. ctx.each обрабатывает стрим; его жизненный цикл разобран в разделе «Обработка стрима» страницы о стримах.

Когда обновление состояния устанавливает paused в true, keepWhile отменяет запись, и повторять это условие телу не нужно. От другой задачи из очереди такое обновление не придёт. Корневые задачи выполняются по одной, и pause(), написанная задачей, ждала бы конца записи, прежде чем поставить paused. Пока запись идёт, обновление, о котором спрашивают её правила, приходит из двух мест:

  • из externalSetState, которым контроллер отражает сообщения устройства;
  • от дочерней задачи самой записи.

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

Правило Когда проверяется
Рабочий тип W Перед стартом, при чужих обновлениях состояния во время тела и в контрольных точках состояния.
canStart Один раз, непосредственно перед стартом Job.
keepWhile Перед стартом, при чужих обновлениях состояния во время тела и в контрольных точках состояния.

Любое из этих правил отменяет Job с RulesCancelReason, а различается started. Отказ на старте заканчивает Job с started: false. Нарушение W или keepWhile во время тела заканчивает её с started: true. Эти правила отклоняют неподходящую работу; они не оставляют её в очереди до наступления подходящего состояния.

Правило отвечает и не пишет: запись изнутри keepWhile бросает StateError, и правило бросает вместе с ней.

Job<void> zoomIn() => enqueue<Ready, void>((ctx) async {
// Чтение служит контрольной точкой: проверяются отмена и правила.
final state = ctx.state;
// Другого способа записи у тела нет, и эта запись синхронная.
ctx.emit(state.copyWith(zoom: state.zoom + 1));
});

ctx.state, ctx.stateAs<T>() и ctx.check() проверяют отмену и правила состояния. Методы ожидания также используют контрольные точки состояния. Это делает и ctx.each, потому что его ребёнок сохранил бы правила задачи. Тело выше объявлено как enqueue<Ready, void>, поэтому ctx.state в нём уже Ready. stateAs<T>() нужен телу, у которого W шире, чем состояние, требуемое в этот момент. Он требует тип T, а не возвращает null: несовпадение отменяет Job.

Job<void> switchOn(int zoom) => enqueue<CameraState, void>(
canStart: (state) => state is Off,
(ctx) async {
// W включает все состояния, поэтому после каждого ожидания тело
// называет то, которое ему нужно.
await ctx.join(device.powerUp);
ctx.stateAs<Off>();
ctx.emit(const Ready());
await ctx.join(() => device.setZoom(zoom));
ctx.emit(ctx.stateAs<Ready>().copyWith(zoom: zoom));
},
);

switchOn начинает в Off и продолжает в Ready, который сам записывает. Его W поэтому охватывает весь CameraState, и ctx.state в нём имеет тип CameraState, у которого нет copyWith. Пока устройство включается и движется объектив, контроллер может отразить и другие факты, поэтому после каждого ожидания тело читает нужное ему состояние. В конце тело копирует тот Ready, который прочитало само, вместе со всем, что устройство сообщило во время зума.

Если связь пропадёт, контроллер отразит Disconnected через externalSetState. Ни одно правило этой задачи такое состояние не отвергает, поэтому ожидание возвращается как обычно. Следующий за ним stateAs завершает задачу ещё до emit, с исходом Cancelled(rules: is not Off) после включения и Cancelled(rules: is not Ready) после зума. Состояние остаётся Disconnected. emit не спрашивает ни одного правила задачи, поэтому записать Ready поверх камеры, которой уже нет, мешает только чтение перед ним.

check() делает ту же проверку, что и ctx.state, только без значения для чтения. Чтения и методы ожидания делают её сами, а check() закрывает промежутки между ними. Голый await и возврат из ctx.uncancellable не проверяют ничего. В таком промежутке задача, которую правила уже отменили, идёт дальше, пока кто-нибудь внутри не спросит, можно ли продолжать. Эти промежутки разобраны в разделе «Обычный await и время жизни контекста» страницы об отмене. Таблица в начале той же страницы ставит check() рядом с методами ожидания, которым он сопутствует.

ctx.emit(next) позволяет Job опубликовать состояние за пределами своего рабочего типа: например, инициализация может закончиться публикацией Ready. Следующая контрольная точка состояния отклонит такое состояние, если оно не соответствует W. Метод ожидания служит такой точкой наравне с чтением. После этого emit:

  • ctx.abandonable или ctx.join заканчивает Job отменой раньше, чем начнётся действие;
  • ctx.each заканчивает Job вместо запуска ребёнка, которого те же правила отклонили бы.

Поэтому такой переход ставят в теле последним, и после него нет ни чтений, ни ожиданий, ни ctx.each через контекст. canStart проверяется один раз, при запуске, и больше не используется, в том числе после этого emit.

После обновления состояния проверяются другие работающие тела: дочерние задачи выполняются рядом с родительской, и каждое тело отвечает своим правилам. Собственный emit Job исключён из этой проверки правил, но всё равно проверяет отмену до и после записи. И onChange (см. раздел «Наблюдение за всеми контроллерами» страницы о диагностике), и слушатель выполняются синхронно внутри самой записи. У обоих нет ctx, поэтому записать состояние они могут только через externalSetState. Хук, который так поправляет состояние и превращает Preparing одной задачи прямо в Working, может тем самым отменить излучающую Job до возврата из её собственного emit.

Правила перестают отменять Job после завершения её тела. Ручная отмена, закрытие контроллера и отмена родителя по-прежнему могут достичь её при ожидании детей или освобождении ресурсов. Job с обработчиками состояния также продолжают проверять, разрешено ли этим обработчикам менять состояние; см. Состояние после ошибки или отмены.

// Чтение в любой момент.
print(camera.currentState);
// Каждое изменение, синхронно, внутри самого изменения. Новое состояние
// слушатель читает сам, поэтому устареть прочитанному негде.
camera.addListener(() => print(camera.currentState));
// Каждое обновление, по порядку, через микротаску. Начальное состояние
// не повторяется, а публикация равного состояния тоже даёт событие.
// Нужен `with SoloStream` у контроллера, см. таблицу ниже.
final subscription = camera.stream.listen(print);

Любой код с доступом к контроллеру может читать currentState. К моменту доставки события стрима currentState может содержать более новое значение. Он намеренно назван не state: тело задачи видит все члены своего контроллера без префикса, и чтение без контрольной точки выглядело бы там в точности как проверяющий ctx.state.

Тип Что предоставляет
Solo<S> Состояние, Job, очередь, правила и слушателей.
Solo<S> with SoloStream Всё из Solo и broadcast-стрим stream.
Solo<S> with SoloListenable Всё из Solo и интерфейс Flutter ValueListenable<S>. Миксин лежит в пакете flutter_solo.

addListener принадлежит самому движку и стоит на самом Solo. Поэтому сказанное ниже верно для любого контроллера, со своей доставкой и без всякой. Слушателей зовут синхронно, в порядке регистрации, внутри самого изменения:

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

Как и у ChangeNotifier, removeListener сравнивает через ==, а не по идентичности: виджет подписывается в initState и отписывается в dispose своим же методом. hasListeners защищён, как и в ChangeNotifier: по нему наследник узнаёт, слушает ли кто-нибудь. Бросивший слушатель не останавливает ни слушателей после себя, ни переоценку правил следом. Его ошибка уходит в onListenerError, а тот отдаёт её зоне, если наследник не решит иначе.

Движок сбрасывает слушателей насовсем, когда закрытие закончилось, а не когда вызван close(). В тот же момент isFinished становится истиной. После этого addListener ничего не делает: слушатель не регистрируется и не хранится, ошибки тоже нет. На той же черте останавливается и состояние, и после неё externalSetState бросает StateError.

SoloListenable добавляет поверх addListener интерфейс Flutter ValueListenable и сообщает об ошибке слушателя через FlutterError вместо зоны. Это миксин на Solo, как и SoloStream: виджет перестраивается по value, а контроллер, которому нужны обе доставки, подмешивает оба.

Изменению, сделанному изнутри слушателя, порядок из списка выше не достаётся. Оно встаёт в конец очереди публикации, а не влезает вперёд:

externalSetState(B)
listeners of B
externalSetState(C) // слушатель пишет ещё раз
rules, against C // запись C спрашивает их сразу
next line of the listener
listeners of C // очередь дошла до C только здесь
rules, against C // запись B спрашивает их последней, о C
next line of the writer

То есть за вложенное изменение правила отвечают раньше, чем его писатель пойдёт дальше, а слушатели узнают о нём последними. До переоценки как таковое внешнее изменение не доходит вовсе: когда она запускается, спрашивают уже о вложенном.

Задачу со своими обработчиками состояния о B всё же спрашивают. Спрашивают в самой записи, раньше любого слушателя, и не чтобы отменить, а чтобы решить, смогут ли её обработчики ещё поправить состояние. B, которое её правила отвергли, отключает их насовсем, как любое несовместимое внешнее обновление в разделе Допуск обработчиков и их ошибки.

Второй вопрос о C единственный задаётся после слушателей C. Правило, которое читает только состояние, отвечает на него так же, как на первый, а задачу, отменённую первым ответом, второй раз не спрашивают. keepWhile, который читает ещё и поле контроллера, увидит записанное туда слушателями только во втором.

Слушатели выше принадлежат движку, и обычный случай они закрывают. publish остаётся швом для доставки другого рода: стрима или строки в логе.

/// Контроллер, записывающий каждое изменение в лог.
class Logged<S extends Object> extends Solo<S> {
final void Function(String line) write;
Logged(super.initialState, this.write);
@protected
@override
void publish(S previous, S current) {
super.publish(previous, current);
try {
write('$previous -> $current');
} on Object catch (error, stackTrace) {
// Состояние к этому моменту уже изменилось, и правила работающих
// задач всё равно его проверят. Ошибка, выпущенная отсюда, дошла бы
// до того, кто записал состояние, как будто запись не удалась.
Zone.current.handleUncaughtError(error, stackTrace);
}
}
}

Что должно такое переопределение и что движок должен ему:

  • super.publish идёт первым. Это он зовёт слушателей, поэтому своя доставка идёт после движковой, а не вместо неё. Так сказано аннотацией @mustCallSuper, и анализатор за этим следит.
  • @protected повторяется. Dart не переносит аннотацию на переопределение. Без неё publish становится публичным членом контроллера. Тогда код, у которого контроллер в руках, может опубликовать состояние, которого никто не записывал: слушателей позовут, доставка отработает, а currentState останется прежним. Аннотация лежит в package:meta.
  • Ошибке лучше остаться в publish. К тому времени, как доставка упала, запись уже прошла: состояние изменилось, и правила работающих задач его проверили. Тот, кто записал состояние, узнаёт о неудачной доставке так, будто не удалась сама запись.
  • Изменения за упавшим публикуются всё равно. Упавшее изменение потеряно: оно ушло из очереди публикации до вызова, а дважды не публикуется ничто. Но стоящие за ним и есть состояние, которое контроллер уже держит, и они уходят до того, как ошибка выйдет наружу. Из нескольких ошибок бросается только первая, остальные уходят в зону.

Выпущенная из publish ошибка уходит туда же, откуда пришла запись:

  • запись через ctx.emit бросает её в тело, которое позвало ctx.emit;
  • запись через externalSetState бросает её тому, кто позвал externalSetState;
  • запись обработчика состояния отдаёт её в onError и onUnanswered контроллера.

Когда externalSetState позвал хук движка, ошибка уходит дальше, в Zone.current.handleUncaughtError из dart:async, куда ошибка упавшего хука уходит и так.

SoloStream устроен как такое переопределение с broadcast-контроллером стрима за ним, а SoloListenable оставляет движковых слушателей и добавляет интерфейс Flutter ValueListenable, из-за которого контроллер понимают билдеры и Listenable.merge.

SoloStream закрывает стрим после движка, а стрим ждёт, пока каждая подписка получит событие завершения. Подписка, оставленная на паузе, держит close() уже после того, как движок закончил, и pending тогда возвращает SoloPendingStream.

Независимый источник, например устройство или сокет, меняется, не дожидаясь контроллера, а контроллеру надо показать то, что там уже произошло.

// Первая попытка: обновление ждёт за идущей задачей.
Camera(this.device) : super(const Ready()) {
_link = device.connection.listen((connected) {
if (!connected) {
// Факт отправляется как обычная работа, впереди всех ожидающих.
enqueueJob(
job<Ready, void>(
key: 'disconnect',
(ctx) async => ctx.emit(const Disconnected()),
),
first: true,
);
}
});
}

Вот что происходит:

  1. first: true ставит обновление впереди всего, что ждёт, но не впереди работающей задачи: очередь выполняет задачи по одной.
  2. Пока обновление не началось, контроллер сообщает о подключённом состоянии.
  3. Правила работающей задачи об отключении не слышат. Их спрашивают, когда состояние меняется, а оно не менялось.
  4. Хуже того, сама задача может ждать ответа, которого устройство уже не даст. Тогда обновление, которое её освободило бы, стоит в очереди за ней же.
final class Camera extends Solo<CameraState> with SoloStream {
final Device device;
late final StreamSubscription<bool> _link;
Camera(this.device) : super(const Ready()) {
_link = device.connection.listen((connected) {
// Устройство уже отключилось: отражаем факт немедленно, а не
// ставим Job, которая ждала бы за текущей.
if (!connected) {
externalSetState(const Disconnected());
}
});
}
// Вызывается один раз, когда последняя Job закончилась: в очереди
// больше некому слышать устройство.
@override
void onClose() => unawaited(_link.cancel());
}

Метод externalSetState помечен @protected и вызывается внутри наследника контроллера, обычно из подписки, которую тот держит: из той самой подписки на устройство, а не из слушателя контроллера.

externalSetState обновляет состояние немедленно и переоценивает работающие Job. Поэтому Job, правилам которой нужно подключение, отменяется самим отключением. Её метод ожидания определяет, когда возобновится тело и должна ли завершиться сама операция. Очередь по-прежнему ждёт тела и уборки Job перед запуском следующей корневой.

Что делать с уведомлением, решает его смысл:

  • если оно говорит, что независимая сущность уже изменилась, отражайте этот факт немедленно через externalSetState;
  • если оно просит контроллер выполнить работу, например обновить данные или сохранить пришедшее значение, ставьте обычную Job.

Доставка события стримом сама по себе не оправдывает обход очереди.

Для собственных успеха, ошибки и отмены контроллера используйте тела Job и их обработчики состояния. externalSetState служит исключением для внешних фактов, а не универсальным сеттером для этих операций. Он работает ровно столько, сколько работает движок, включая дренаж, и бросает StateError, когда закрытие закончилось: состояние, на котором контроллер остановился, он и хранит.

Подписка гасится в onClose, а не в момент вызова close. Движок вызывает onClose один раз и позже, когда все задачи закончились, а состояние ещё не окончательное, каким бы режимом контроллер ни закрывали. Погасить источник в момент вызова close выглядит опрятнее, но это стоит дорого, как только от этого факта зависит работающая задача:

  • при close(mode: SoloCloseMode.drain) это стоит самого дренажа. Очередь работает и после вызова, а её задачам знать об отключении устройства нужнее всех;
  • SoloCloseMode.cancel от этого тоже не спасает: задачу cancellable: false, которая ждёт ответа, а устройство его уже не даст, освобождает именно отключение, и close ждёт эту задачу.

После onClose колбэк подписки выше не вызывается вовсе, поэтому его запись не придёт после конца. Другое дело колбэк, которому перед записью нужно чего-нибудь дождаться. Он начинается, пока подписка жива, а движок может закончить, пока он ждёт. Такой колбэк проверяет isFinished после последнего await, прямо перед externalSetState. Проверка, сделанная раньше, может устареть, и запись тогда бросит StateError. isFinished ложен всё время, пока движок работает, включая дренаж, и истинен, когда состояние стало окончательным.

Когда снимок сохранён, экран показывает уведомление «Снимок 7 сохранён», пока пользователь его не закроет. Экран, построенный заново, после поворота или по возвращении на него, показывает уведомление снова, пока оно открыто, а закрытое не возвращается. Камера тем временем работает: запись, начатая после снимка, идёт. Отменённый снимок уведомления не даёт.

Экран, который начал работу и сам показывает, чем она кончилась, ждёт job.done, как в разделе «Задачи и результаты». Уведомление нужно для новости, которая переживает экран: показывать её может не тот экран, который сделал снимок.

// Первая попытка: уведомление держит камеру, пока его не закроют.
final class Saved extends CameraState {
final int photo;
const Saved(this.photo);
}
Job<void> shoot() => enqueue<Ready, void>((ctx) async {
final photo = await ctx.join(device.shoot);
ctx.emit(Saved(photo));
});
Job<void> accept() =>
enqueue<Saved, void>((ctx) async => ctx.emit(const Ready()));

Уведомление здесь — состояние камеры, и экран показывает его, пока камера в нём. Но пока уведомление на экране, камера не в Ready, и правила отклоняют каждую задачу, которая стартует в Ready. record(), вызванный после снимка, кончается Cancelled(rules: is not Ready) со started: false, как и следующий shoot(): команды пропадают, а не ждут. Камера стоит, пока пользователь не закроет уведомление, и accept возвращает её в const Ready() без прежнего зума.

Если записать Saved и сразу за ним Ready, камера не стоит, но уведомление живёт одно изменение. Слушатель, добавленный через addListener, его слышит, внутри самого изменения. Экран, построенный позже, и билдер, который перестраивается на следующем кадре, видят Ready.

// Вторая попытка: приёмка ждёт за записью.
Job<void> shoot() => enqueue<Ready, void>((ctx) async {
final photo = await ctx.join(device.shoot);
ctx.emit(ctx.state.withNotice(photo));
});
Job<void> accept() =>
enqueue<Ready, void>((ctx) async => ctx.emit(ctx.state.withNotice(null)));

Здесь у Ready есть поле int? notice, а withNotice копирует состояние с другим уведомлением. Камера больше не стоит: запись стартует после снимка. Но приёмка — задача той же очереди, и она ждёт конца записи, а запись идёт, пока идут кадры. Всё это время поле держит уведомление, и экран, построенный заново, показывает его второй раз. first: true у enqueueJob не помогает: он ставит приёмку впереди ждущих задач, но не впереди работающей, как показывает первая попытка раздела «Внешнее состояние».

Поле живёт только в Ready, поэтому Disconnected, записанный поверх, стирает уведомление о снимке, который уже сохранён. Приёмка через externalSetState чистит поле сразу, но идёт в обход очереди: тело, которое прочитало состояние до ожидания и пишет его копию после, возвращает закрытое уведомление.

final class Notice {
final int photo;
Notice(this.photo);
}
final class NoticeBoard {
final Notice? notice;
const NoticeBoard([this.notice]);
}
final class Notices extends Solo<NoticeBoard> {
Notices() : super(const NoticeBoard());
Job<void> post(Notice notice) =>
enqueue<NoticeBoard, void>((ctx) async => ctx.emit(NoticeBoard(notice)));
Job<void> accept(Notice notice) => enqueue<NoticeBoard, void>((ctx) async {
// Только то уведомление, которое показал экран: новое остаётся.
if (identical(ctx.state.notice, notice)) ctx.emit(const NoticeBoard());
});
}

Камере передают доску как notices, и снимок публикует уведомление туда:

Job<void> shoot() => enqueue<Ready, void>((ctx) async {
final photo = await ctx.join(device.shoot);
notices.post(Notice(photo));
});

Уведомления живут в своём контроллере. Его очередь держит только короткие задачи, поэтому приёмка не ждёт записи. Состояние камеры об уведомлениях не знает: правила её задач о них не спрашивают, и ни одна запись камеры уведомление не стирает. Уведомление — поле NoticeBoard, а не само состояние, потому что состояние не бывает null: Solo<S extends Object>. На доске одно уведомление за раз, и второй post заменяет то, которое ещё никто не закрыл. Ту же работу сделали бы поле и его слушатели; контроллер позволяет экрану слушать доску так же, как он слушает камеру.

Приёмка называет уведомление, которое закрывает. Уведомление, опубликованное, пока экран закрывал предыдущее, остаётся, а accept без адреса стёр бы его раньше, чем его кто-то увидел. identical их различает, потому что у Notice нет const-конструктора и каждый post приносит новый объект. Уведомлению, которое создают заново из данных, например прочитанному из хранилища, для сравнения нужен id.

post стоит после работы, поэтому отменённый снимок ничего не публикует. Устройство могло всё же сохранить фото, ведь ctx.join даёт вызову дойти до конца, но уведомление говорит о том, чем кончилась задача, а задачу отменили.

Экран слушает notices через addListener или, если подмешан SoloListenable, через listen из flutter_solo. Он показывает currentState.notice и, когда пользователь его закрывает, вызывает accept с этим уведомлением. Два экрана, которые слушают доску, показывают одно и то же уведомление, и приёмка на одном убирает его с обоих.

Кто создал оба контроллера, тот закрывает доску, когда закрытие камеры завершилось: await camera.close(...), потом notices.close(). Камера, закрытая с SoloCloseMode.drain, ещё может опубликовать уведомление о снимке, который доделывает, а доска, закрытая раньше, отклонит этот post с Cancelled(closed). Закрытая доска хранит последнее состояние, и экран, который её ещё слушает, показывает уведомление, которое уже не закрыть: accept тоже кончается Cancelled(closed). Случаи:

Что делаешь Что происходит
Снимок, потом запись Запись идёт, уведомление на доске.
accept во время записи Доска пустеет микротаской позже, и экран, построенный заново, ничего не показывает.
Второй снимок публикует уведомление, пока первое принимают Второе уведомление остаётся.
Два снимка, и уведомление никто не закрывает Второе уведомление заменяет первое.
Снимок отменён Уведомления нет, хотя фото могло сохраниться.
Доска закрыта раньше, чем камера закончила дренаж Уведомление о снимке, который доделывает дренаж, теряется.

Контроллер профиля из раздела «Быстрый старт» README пакета публикует Loading до того, как его загрузка начнёт ждать. Что-то должно вывести контроллер из этого состояния, когда загрузка не пришла. Обработчики ниже публикуют Failure — состояние, которое хранит ошибку в разделе «Контроллер профиля».

// Первая попытка: отмена оставляет состояние в Loading.
enqueue<ProfileState, String>((ctx) async {
ctx.emit(const Loading());
try {
final name = await ctx.abandonable(api.fetchName);
ctx.emit(Loaded(name));
return name;
} on Object {
ctx.emit(const Initial());
rethrow;
}
});

Вот что происходит:

  1. При ошибке он работает: catch записывает Initial и передаёт ошибку дальше.
  2. При отмене catch тоже срабатывает. Но на этот раз emit внутри него проверяет отмену перед записью и на уже отменённой задаче бросает вместо того, чтобы записать.
  3. Отмените загрузку, и контроллер останется в Loading навсегда: экран крутит спиннер над работой, которой уже нет.
enqueue<ProfileState, String>(
// Оба только вычисляют состояние. Они выполняются после тела, детей
// и уборки, и исход к этому моменту уже определён.
ifFailed: (state, error, stackTrace) => Failure(error),
ifCancelled: (state, cancelled) => const Initial(),
(ctx) async {
ctx.emit(const Loading());
final name = await ctx.abandonable(api.fetchName);
ctx.emit(Loaded(name));
return name;
},
);

Параметры ifFailed и ifCancelled у enqueue, job, collect и accumulate позволяют контроллеру покинуть временное состояние вроде Loading, когда операция завершилась ошибкой или была отменена. Они получают текущее состояние типа S, плюс ошибку со стеком вызовов или исход Cancelled, и синхронно возвращают следующее состояние. Если обработчик вернул то самое состояние, которое получил, тот же объект, состояние остаётся как есть и ничего не публикуется; const-значение, которое уже лежит в состоянии, — тот же объект. Равное состояние, но другой объект, публикуется, как опубликовал бы его emit.

Подходящий обработчик выполняется перед следующей Job из очереди и перед хуком onFinish. Изменение состояния не превращает Failed или Cancelled в Done и не помечает исход обработанным для сообщений об ошибках. Задача, которая знает, какие её ошибки ожидаемы, называет их в isExpectedError, и ответа за них не просят ни у кого: об этом раздел «Назвать ожидаемые ошибки» страницы об ошибках. ifCancelled выполняется по завершении, поэтому сигналу остановить операцию место в ctx.onCancel, который доставляет его сразу.

Сохранение несовместимого внешнего состояния

Заголовок раздела «Сохранение несовместимого внешнего состояния»

Обработчик выше возвращает Initial при любой отмене загрузки. Расширим состояния профиля из раздела «Контроллер профиля» классом Disconnected в той же библиотеке, и связь теперь может пропасть, пока загрузка идёт.

// Первая попытка: ifCancelled затирает Disconnected.
final class Disconnected extends ProfileState {
const Disconnected();
}
Job<String> load() => enqueue<ProfileState, String>(
key: 'load',
policy: Policy.droppable,
ifFailed: (state, error, stackTrace) => Failure(error),
ifCancelled: (state, cancelled) => const Initial(),
(ctx) async {
ctx.emit(const Loading());
final name = await ctx.abandonable(api.fetchName);
ctx.emit(Loaded(name));
return name;
},
);

Здесь ничто не говорит, что у загрузки есть мнение о Disconnected, поэтому она продолжает идти сквозь него. А когда её отменят (пользователь, закрытый экран, что угодно), ifCancelled сделает то, ради чего написан. Он вернёт Initial поверх факта, о котором сообщило устройство. Контроллер показывает просто пустой профиль, хотя случилось другое: связи нет.

Проверка state is Disconnected внутри обработчика залатала бы этот случай. Задача всё равно продолжала бы работу, которую отключение сделало бессмысленной.

Job<String> load() => enqueue<ProfileState, String>(
key: 'load',
policy: Policy.droppable,
keepWhile: (state) => state is! Disconnected,
ifFailed: (state, error, stackTrace) => Failure(error),
ifCancelled: (state, cancelled) => const Initial(),
(ctx) async {
ctx.emit(const Loading());
final name = await ctx.abandonable(api.fetchName);
ctx.emit(Loaded(name));
return name;
},
);

Под работающей загрузкой пропадает связь, и контроллер публикует Disconnected через externalSetState. keepWhile отвергает это состояние, отменяя загрузку и отключая оба обработчика. Поэтому Disconnected остаётся видимым, а не заменяется на Initial или Failure.

Если загрузку отменили вручную, пока состояние ещё совместимо, ifCancelled по-прежнему возвращает Initial. Своя проверка state is Disconnected обработчикам не нужна: правила Job уже говорят, какие состояния допускают операцию и поправку после неё.

Что происходит Что становится с обработчиками
Несовместимое обновление, которого Job не делала сама (externalSetState или emit её родителя, её ребёнка или другого ребёнка того же родителя), когда бы оно ни пришло Отключены навсегда; последующее совместимое обновление их не возвращает.
Родитель потерял допуск Обработчики его детей тоже заблокированы.
Успех, отброшенный дубликат, тело, которое не запускалось Не выполняются вовсе.
Собственный emit Job Остаются включёнными: собственный emit их не отключает.
Правила отменяют Job на её собственной контрольной точке: после её emit состояния, которое они отвергают, или при несовпадении в stateAs Не выполняются: Job, отменённая своими правилами, оставляет состояние как есть.
Policy.restart отменяет работающую Job ради новой с тем же ключом ifCancelled выполняется, как при любой отмене, и его состояние публикуется до старта новой Job.
Обработчик бросил ошибку Ошибка сообщается; исход и очередь не затронуты.
Правило бросило ошибку при проверке допуска Обработчики отключены, ошибка сообщена. Освобождение ресурсов всё равно выполняется.
Правило бросило ошибку на контрольной точке тела, ctx.state или ctx.check() Ошибку получает тело, а Failed, который после неё остаётся, ifFailed исправляет, как любой другой.

Эти дополнительные проверки относятся к Job с собственными обработчиками состояния. Родитель без обработчиков перестаёт проверять свои правила после завершения тела; дети остаются под своими правилами и под уже потерянным допуском родителя.

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

Строку о замене показывает перезагрузка под Policy.restart. Второй вызов отменяет первую загрузку, пока та ждёт имя, и обработчик, который возвращает Initial, публикует его между двумя загрузками: слушатели слышат Loading, Initial, Loading, Loaded. Причина отмены отличает замену от остальных отмен. Обработчик, который возвращает полученное состояние, ничего не публикует, и слушатели слышат Loading, Loading, Loaded:

Job<String> reload() => enqueue<ProfileState, String>(
key: 'load',
policy: Policy.restart,
ifFailed: (state, error, stackTrace) => Failure(error),
// Новая загрузка публикует своё состояние сама.
ifCancelled: (state, cancelled) =>
cancelled.reason is ReplacedCancelReason ? state : const Initial(),
(ctx) async {
ctx.emit(const Loading());
final name = await ctx.abandonable(api.fetchName);
ctx.emit(Loaded(name));
return name;
},
);

Отменённая иначе, руками или через close(), перезагрузка по-прежнему возвращает Initial.