solo под riverpod
Приложение на riverpod остаётся на riverpod: граф провайдеров, кеш,
AsyncValue, по которому ветвится экран. Эта страница о том, что встаёт под
провайдер, когда у работы в нём есть порядок, который надо держать, и ресурс,
который надо закрыть. Контроллер solo живёт в провайдере: провайдер его
создаёт, экран читает через провайдер его состояние, а освобождение провайдера
контроллер закрывает. Код идёт на riverpod 3, на чистом Dart:
flutter_riverpod даёт тот же Ref.
Там, где riverpod оставляет работу приложению, её разбирают эта страница и её соседи:
| Ловушка в riverpod | Что на неё отвечает | Где |
|---|---|---|
Ref после await в методе нотифаера, поле, которое сверяют с текущим вызовом |
Метод контроллера не держит Ref, а emit отменённой задачи до состояния не доходит |
Нотифаер, который начинает заново |
| Провайдер освобождён, пока идёт его работа | ref.onDispose(job.cancel) |
Провайдер, которого больше нет |
| Флаг и бросок после освобождения в debounce | ctx.pause в задаче под провайдером; в solo — accumulate с debounce |
Пауза самой задачи, Поиск, который стреляет на каждый символ |
| Состояние контроллера на экране | ref.notifyListeners и select |
Контроллер под провайдером |
| Ресурс взят раньше, чем зарегистрирована его уборка | discard там, где ресурс берётся, onClose для того, что держит контроллер |
Камера, которую закрывают при уходе экрана |
| Форма, отправленная дважды | Policy.droppable |
Форма, которую отправляют один раз |
Рецепт debounce из руководства
riverpod после освобождения бросает Exception('Cancelled'). Приложение,
которое хочет отличать эту отмену от сбоя по типу, бросает свой класс;
async_job уже даёт этот тип всему коду: Cancelled.
Каждый раздел открывается версией, к которой ведёт API самого riverpod, и показывает, что этот код делает. Состояния и трассы под фрагментами — то, что код печатает при запуске.
Контроллер под провайдером
Заголовок раздела «Контроллер под провайдером»Контроллер — камера. open() публикует Opening, открывает устройство
и публикует Ready с ним:
sealed class CameraState { const CameraState();}
final class Closed extends CameraState { const Closed();}
final class Opening extends CameraState { const Opening();}
final class Ready extends CameraState { const Ready(this.device);
final Device device;}final class Camera extends Solo<CameraState> { Camera() : super(const Closed());
Job<void> open() => enqueue<CameraState, void>( key: #camera, policy: Policy.restart, ifCancelled: (state, cancelled) => const Closed(), (ctx) async { ctx.emit(const Opening()); final device = await ctx.join( Device.open, discard: (device) => device.close(), ); ctx.emit(Ready(device)); }, );}Device изображает камеру платформы: открытие занимает 50 мс, а close() её
освобождает. ctx.join ждёт открытия, даже если задачу тем временем отменили,
а его discard закрывает устройство, которое пришло к задаче, не закончившейся
Done. ifCancelled возвращает камеру в Closed, когда задачу отменяют, —
следующим open() при Policy.restart или close() контроллера. В трассах
устройство названо номером: Ready(1) держит первое открытое.
Провайдер создаёт контроллер и закрывает его при освобождении. Экрану нужно
и состояние, а контроллер зовёт своих слушателей при каждом изменении.
ref.notifyListeners — то, что riverpod даёт провайдеру, чей объект меняется
на месте, поэтому слушателем контроллера становится этот вызов.
Первая попытка
Заголовок раздела «Первая попытка»// Первая попытка: контроллер зовёт уже освобождённый Ref.final camera = Provider.autoDispose<Camera>((ref) { final controller = Camera()..addListener(ref.notifyListeners); ref.onDispose(() => unawaited(controller.close())); return controller;});Экран открывает камеру и уходит через 10 мс, пока устройство ещё открывается.
outcome — исход задачи, которую вернул open(), states — состояния,
которые видел экран, а device — то, что стало с устройством:
screen left at 10 msoutcome: Cancelled(closed)states: Closed, Openingdevice: open 1 start, open 1 done, close 1zone: Cannot use the Ref of Provider<Camera> after it has been disposed.Экран был последним слушателем провайдера, поэтому riverpod освобождает
провайдер, а onDispose закрывает контроллер. close() отменяет открытие,
и его ifCancelled публикует Closed. Слушатель контроллера — всё ещё
ref.notifyListeners у Ref, освобождённого мгновением раньше; вызов бросает,
а ошибку слушателя контроллер отдаёт зоне.
Снятие слушателя
Заголовок раздела «Снятие слушателя»final camera = Provider.autoDispose<Camera>((ref) { final controller = Camera()..addListener(ref.notifyListeners); ref.onDispose(() { controller.removeListener(ref.notifyListeners); unawaited(controller.close()); }); return controller;});
// Экран: ref.watch(camera.select((camera) => camera.currentState))Тот же экран уходит через 10 мс:
screen left at 10 msoutcome: Cancelled(closed)states: Closed, Openingdevice: open 1 start, open 1 done, close 1zone: nothingСлушатель уходит раньше, чем закрывается контроллер, и Closed отмены
не доходит ни до кого: экран ушёл вместе с провайдером. removeListener
сравнивает через ==, а два отрыва метода ref.notifyListeners равны, поэтому
второе упоминание метода снимает то, что добавила первая строка.
Экран читает состояние через select: сам провайдер ничего не меняет, а только
оповещает, и select отдаёт экрану каждый новый currentState.
StreamProvider над потоком SoloStream не подошёл бы: поток несёт изменения,
а не состояние, в котором камера находится, когда экран подписывается.
Во Flutter экран может и взять контроллер через
ref.watch(camera.select((camera) => camera)), и строить по нему SoloBuilder
из flutter_solo. select здесь
нужен: мост оповещает слушателей camera при каждой смене состояния, и виджет
с ref.watch(camera) перестраивался бы на каждую.
Камера, которую закрывают при уходе экрана
Заголовок раздела «Камера, которую закрывают при уходе экрана»Экран открывает камеру, когда появляется, и закрывает её, когда уходит,
в какой бы момент это ни случилось: пока устройство ещё открывается или после
того, как оно открылось. Без контроллера riverpod держит устройство
в собственном провайдере и закрывает его в onDispose.
Первая попытка
Заголовок раздела «Первая попытка»// Первая попытка: уборка регистрируется после await.final camera = FutureProvider.autoDispose<Device>((ref) async { final device = await Device.open(); ref.onDispose(device.close); return device;});Экран уходит через 10 мс, пока устройство открывается; наблюдатель контейнера печатает то, что падает:
screen left at 10 msdevice: open 1 start, open 1 doneobserver: UnmountedRefExceptionУстройство остаётся открытым. riverpod освобождает провайдер, когда экран
уходит, а onDispose, который закрыл бы устройство, ещё не зарегистрирован:
строка, которая его регистрирует, ждёт за await. Когда открытие кончается,
провайдера уже нет, и эта строка бросает UnmountedRefException.
ProviderObserver видит его в providerDidFail как Ref, использованный
после освобождения; открытое устройство не называет никто.
Флаг до await
Заголовок раздела «Флаг до await»Руководство riverpod по отмене велит
провайдеру отметить своё освобождение до первого await и проверить отметку
после него:
final camera = FutureProvider.autoDispose<Device>((ref) async { var disposed = false; ref.onDispose(() => disposed = true); final device = await Device.open(); if (disposed) { await device.close(); throw StateError('the screen left while the camera opened'); } ref.onDispose(device.close); return device;});Экран уходит через 10 мс, а затем, со свежим провайдером, через 80 мс:
screen left at 10 msdevice: open 1 start, open 1 done, close 1screen left at 80 msdevice: open 1 start, open 1 done, close 1Это работает: устройство закрывается по обе стороны открытия. Уборка живёт
в двух местах, в проверке после await и в onDispose следом за ней,
а порядок держит автор: флаг регистрируется до await.
Уход после открытия камеры
Заголовок раздела «Уход после открытия камеры»Под контроллером из первого раздела уход во время открытия устройство
закрывает: discard забирает устройство, которое пришло к отменённой задаче.
Тот же экран уходит через 80 мс, после открытия:
screen left at 80 msoutcome: Done(null)states: Closed, Opening, Ready(1)device: open 1 start, open 1 doneУстройство остаётся открытым. close() отменяет задачи контроллера, а открытие
к этому времени кончилось Done: отменять уже нечего. Устройство в Ready
теперь принадлежит контроллеру, а не задаче, и close() его не закрывает.
onClose
Заголовок раздела «onClose»final class Camera extends Solo<CameraState> { Camera() : super(const Closed());
Job<void> open() => enqueue<CameraState, void>( key: #camera, policy: Policy.restart, ifCancelled: (state, cancelled) => const Closed(), (ctx) async { ctx.emit(const Opening()); final device = await ctx.join( Device.open, discard: (device) => device.close(), ); ctx.emit(Ready(device)); }, );
@override void onClose() { if (currentState case Ready(:final device)) { unawaited(device.close()); } }}Экран уходит через 10 мс, а затем, со свежим провайдером, через 80 мс:
screen left at 10 msoutcome: Cancelled(closed)states: Closed, Openingdevice: open 1 start, open 1 done, close 1screen left at 80 msoutcome: Done(null)states: Closed, Opening, Ready(1)device: open 1 start, open 1 done, close 1onClose выполняется один раз, когда все задачи контроллера закончились,
и закрывает устройство, которое держит состояние. Уход во время открытия идёт
как раньше: задача кончается Cancelled, ifCancelled публикует Closed,
а discard закрывает устройство. Ни флаг, ни порядок регистраций автору
держать не нужно: гонку ухода с открытием держит discard в месте, где
устройство берётся, а то, что контроллер держит после этого, — onClose.
Контроллер, у которого закрытие — отдельная задача, ждущая оборудование,
разобран в разделе Освобождение камеры.
Форма, которую отправляют один раз
Заголовок раздела «Форма, которую отправляют один раз»Форма отправляет сообщение на сервер, и второе нажатие, пока сообщение в пути,
не должно отправить его снова. send изображает API: он отвечает через 30 мс
квитанцией с номером своего вызова.
Первая попытка
Заголовок раздела «Первая попытка»Для отправки формы в riverpod 3 есть мутации, пока экспериментальные,
в package:riverpod/experimental/mutation.dart. Их документация на время
MutationPending заменяет кнопку индикатором загрузки. Вызывающий, который
идёт не через эту кнопку, читает то же состояние перед тем, как запустить
мутацию:
// Первая попытка: каждый вызывающий проверяет мутацию перед запуском.final sendMessage = Mutation<String>();
Future<String?> submit(ProviderContainer container, String text) async { if (container.read(sendMessage) is MutationPending) return null; return sendMessage.run(container, (_) => send(text));}Во Flutter те же строки берут WidgetRef экрана. Экран отправляет 'hi',
а затем 'bye', пока 'hi' в пути:
right after the first: MutationPending<String>()first: sent hi #1second: nullcalls: 1Охрана работает: run переводит мутацию в MutationPending до того, как
вернуть управление, и второй вызов застаёт её там. Отличие от ответа ниже
узкое. Второй вызывающий получает null, а не квитанцию ушедшего сообщения.
И охрана живёт в каждом вызывающем, а не в мутации. Её документация говорит,
что пока мутации никак не ограничивают параллельные вызовы. Вызывающий без
проверки отправит сообщение второй раз.
Policy.droppable
Заголовок раздела «Policy.droppable»Форма — контроллер под провайдером, так же как камера в первом разделе:
sealed class MessageState { const MessageState();}
final class Editing extends MessageState { const Editing();}
final class Sent extends MessageState { const Sent(this.receipt);
final String receipt;}final class MessageForm extends Solo<MessageState> { MessageForm() : super(const Editing());
Job<String> submit(String text) => enqueue<MessageState, String>( key: #submit, policy: Policy.droppable, (ctx) async { final receipt = await ctx.join(() => send(text)); ctx.emit(Sent(receipt)); return receipt; }, );}Те же 'hi' и 'bye', и каждый вызывающий ждёт value той задачи, которую
вернул его submit:
same job: truefirst: sent hi #1second: sent hi #1calls: 1state: Sent(sent hi #1)Вызов, пришедший, пока идёт задача его ключа, получает при Policy.droppable
эту же задачу, и API вызывается один раз. Оба вызывающих ждут value одной
задачи и получают квитанцию. Вызывающий, который продолжает через then, берёт
задачу через shared(), как объясняет раздел
Очередь и политики. Отправка идёт через
ctx.join: сообщение — работа, которая должна закончиться, прежде чем очередь
двинется дальше. Под ctx.abandonable отменённая отправка шла бы и после конца
своей задачи, а следующий submit начал бы вторую, пока первая ещё в пути.
Цена droppable — оборотная сторона того же правила: второй вызов с другими
аргументами тоже получает первую задачу. 'bye', набранное, пока 'hi' было
в пути, так и не уходит, а его вызывающий получает квитанцию 'hi'.
Что вы берёте на себя
Заголовок раздела «Что вы берёте на себя»Контроллер добавляет слой. Провайдер держит контроллер, контроллер держит
состояние, и это состояние — тип контроллера, а не AsyncValue. Мост
из первого раздела — код приложения, по одному на контроллер,
и removeListener в его onDispose обязателен.
Сколько живёт контроллер, решает провайдер. ref.read
у autoDispose-провайдера без слушателя создаёт контроллер и сразу его
освобождает, а задача, о которой его попросили, кончается Cancelled:
read without a listeneroutcome: Cancelled(closed)device: open 1 start, open 1 done, close 1Перестройка провайдера закрывает контроллер и отменяет его задачи:
ref.invalidate или изменение провайдера, который мост читает через
ref.watch. Здесь экран остаётся, пока камера открыта, а провайдер
инвалидируют; экран получает новый контроллер, в Closed:
camera invalidatedstates: Closed, Opening, Ready(1), Closeddevice: open 1 start, open 1 done, close 1the first controller finished: trueУпавшую задачу контроллера riverpod не повторяет: его повтор — для провайдера, который не смог построиться, а провайдер контроллера не падает. Повтор пишется в теле задачи, как показывает раздел Повтор упавшего вызова.
Policy.droppable теряет аргументы второго вызова, как показывает форма.
А solo — ещё одна зависимость со своей моделью: очередь, ключи, политики,
правила состояния.
solo не нужен для одной операции под провайдером: это задача async_job, как
показывает страница С riverpod и bloc.
Не нужен он и для запроса, который riverpod держит на каждый аргумент через
семейство провайдеров: с этим справляется сам riverpod.