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

Состояние

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

Job<void> record() => run<Ready, void>(
key: 'record',
canStart: (state) => state.free > 0,
keepWhile: (state) => !state.paused,
(ctx) => ctx.each(
camera.frames,
(child, frame) => child.join(() => store(frame)),
).value,
);

Первый аргумент типа у run<W, T> задаёт рабочий тип состояния Job, W extends S. Тело читает ctx.state как W, поэтому run<Ready, void> может начаться только в Ready. Когда чужое обновление состояния устанавливает paused в true, keepWhile отменяет запись; колбэку не нужно повторять это условие. Здесь Ready, camera и store принадлежат приложению, а ctx.each обрабатывает стрим — его жизненный цикл разобран в Дочерние задачи и стримы.

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

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

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

(ctx) async {
// Чтения — это контрольные точки: проверяются отмена и правила.
final free = ctx.state.free;
final ready = ctx.stateAs<Ready>();
ctx.check();
// Единственная запись, и она синхронная.
ctx.emit(Recording(free: free, zoom: ready.zoom));
}

ctx.state, ctx.stateAs<T>() и ctx.check() проверяют отмену и правила состояния. stateAs<T>() дополнительно требует тип T; несовпадение отменяет Job. Методы ожидания также используют контрольные точки состояния.

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

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

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

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

Любой код с доступом к контроллеру может читать state. К моменту доставки события стрима state может содержать более новое значение.

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

Слушатели SoloListenable вызываются синхронно, в порядке подписки. Его стрим остаётся асинхронным.

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

final class Camera extends Solo<CameraState> {
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());
}
});
}
@override
Future<void> close({SoloCloseMode mode = SoloCloseMode.cancel}) async {
// Сначала остановить внешний слушатель: он ещё может менять состояние.
await _link.cancel();
await super.close(mode: mode);
}
}

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

Чего стоила бы другая дорога. Если бы слушатель ставил в очередь отдельную Job для публикации Disconnected, это обновление ждало бы за текущей Job. До тех пор контроллер сообщает о подключённом состоянии, правила текущей Job не могут отреагировать на отключение, а сама она может ждать ответа, который уже не придёт. Вызов externalSetState обновляет состояние немедленно и переоценивает работающие Job, поэтому Job, правилам которой нужно подключение, отменяется. Её метод ожидания определяет, когда возобновится тело и должна ли завершиться сама операция; очередь по-прежнему ждёт тела и уборки Job перед запуском следующей корневой.

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

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

run<ProfileState, String>(
// Оба только вычисляют состояние. Они выполняются после тела, детей
// и уборки, и исход к этому моменту уже определён.
onError: (state, error, stackTrace) => Failure(error),
onCancel: (state, cancelled) => const Initial(),
(ctx) async {
ctx.emit(const Loading());
return ctx.wait(api.fetchName);
},
);

Параметры onError и onCancel у run и job позволяют контроллеру покинуть временное состояние вроде Loading, когда операция завершилась ошибкой или была отменена. Они получают текущее состояние типа S, плюс ошибку со стеком вызовов или исход Cancelled, и синхронно возвращают следующее состояние.

Подходящий обработчик выполняется перед следующей Job из очереди и перед хуком onFinish. Изменение состояния не превращает Failed или Cancelled в Done и не помечает исход обработанным для сообщений об ошибках.

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

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

Итоговый обработчик не должен затирать состояние, при котором его Job недопустима. Например, расширим состояния профиля из быстрого старта классом Disconnected в той же библиотеке и заменим метод load контроллера на такой вариант:

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

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

Если загрузку отменили вручную, пока состояние ещё совместимо, onCancel может вернуть Initial. Обработчикам не нужны дополнительные проверки state is Loading: правила Job уже выражают, какие состояния допускают операцию и её итоговую поправку.

Что происходит Что становится с обработчиками
Несовместимое внешнее обновление, когда бы оно ни пришло Отключены навсегда; последующее совместимое обновление их не возвращает.
Родитель потерял допуск Обработчики его детей тоже заблокированы.
Успех, отброшенный дубликат, тело, которое не запускалось Не выполняются вовсе.
Собственный emit Job Ничего; свои обработчики он не отключает.
Обработчик бросил ошибку Ошибка сообщается; исход и очередь не затронуты.
Правило бросило ошибку при проверке допуска Обработчики отключены, ошибка сообщена. Освобождение ресурсов всё равно выполняется.

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

Держите эти функции в границах вычисления состояния. Освобождение ресурсов — дело API уборки.