solo
solo управляет состоянием и асинхронной работой в Dart. Контроллер хранит
текущее состояние и выполняет задачи по одной, отсюда и имя пакета. Каждая
задача может объявить, в каких состояниях ей разрешено запускаться и продолжать
работу. Вызывающий код может дождаться её результата или запросить отмену.
Пакет подходит для экранов, сессий и устройств, операции которых работают
с общим состоянием и должны выполняться в определённом порядке. Зависимости
от Flutter нет. flutter_solo
добавляет то, что нужно во Flutter: миксин SoloListenable, который даёт
контроллеру интерфейс ValueListenable, виджеты SoloBuilder и SoloSelector
и listen, который отдаёт подписку для отмены, а не просит хранить колбэк.
Установка
Заголовок раздела «Установка»dart pub add soloДля Flutter установите flutter_solo. Он реэкспортирует solo:
flutter pub add flutter_solosolo реэкспортирует async_job, который
предоставляет задачи, отмену и освобождение ресурсов. Импорт
package:solo/solo.dart открывает оба API. Чтобы пользоваться примерами ниже,
предварительно изучать пакет async_job не нужно.
Плагин анализатора solo_lints сообщает
в коде о большинстве частых ошибок. Назовите
его в analysis_options.yaml:
plugins: solo_lints: ^0.1.3Так включаются его предупреждения; линты включаются по имени, как показывает его раздел «Установка».
Быстрый старт
Заголовок раздела «Быстрый старт»Экран загружает имя профиля, а пользователь тянет экран, чтобы обновить его,
и отменяет обновление до ответа. Контроллер хранит состояние экрана и выполняет
его Job по одной. ProfileApi — клиент приложения, и его fetchName()
возвращает имя:
import 'package:solo/solo.dart';
sealed class ProfileState { const ProfileState();}
final class Initial extends ProfileState { const Initial();}
final class Loading extends ProfileState { const Loading();}
final class Loaded extends ProfileState { final String name;
const Loaded(this.name);}
final class ProfileController extends Solo<ProfileState> { final ProfileApi api;
ProfileController(this.api) : super(const Initial());
Job<String> load() => enqueue((ctx) async { ctx.emit(const Loading()); final name = await ctx.abandonable(api.fetchName); ctx.emit(Loaded(name)); return name; });}
Future<void> main() async { final profile = ProfileController(ProfileApi()); profile.addListener( () => print(switch (profile.currentState) { Initial() => 'initial', Loading() => 'loading', Loaded(:final name) => 'loaded $name', }), );
print(await profile.load().value);
// Пользователь тянет экран, чтобы обновить его, и отменяет обновление // до ответа. final refresh = profile.load(); await Future<void>.delayed(const Duration(milliseconds: 10)); await refresh.cancel(); print(refresh.outcome);
await profile.close();}example/bin/quick_start.dart запускает этот блок с API, который отвечает
через 20 мс. Он печатает:
loadingloaded Ada LovelaceAda LovelaceloadingCancelled(manual)enqueue ставит Job в очередь и возвращает её. В теле ctx.emit публикует
состояние, и слушатель слышит его внутри самого изменения, а ctx.abandonable
ждёт API и прекращает ожидание сразу, как только Job отменяют. Вызывающий код
ждёт value или зовёт cancel(), который возвращается, когда Job
завершилась. Ожидание перед cancel() даёт обновлению начаться: Job,
отменённая до запуска тела, состояние не трогает. close() снимает слушателя;
во Flutter у flutter_solo есть виджеты и listen, который возвращает
подписку.
Отменённое обновление оставило контроллер в loading, и ничто его оттуда
не выведет. Раздел
«Состояние после ошибки или отмены»
начинается с этого load и задаёт ему состояние, которое публикуется, когда
Job падает или её отменяют.
«Контроллер профиля» — этот же
контроллер в том виде, в каком им пользуются руководства, с параметрами,
которые добавляет приложение.
Дюжина вызовов
Заголовок раздела «Дюжина вызовов»Всё, чем пользуются каждый день, в одном контроллере. PlayerState с его
состояниями, Track, Download, Api и Device принадлежат приложению,
остальное принадлежит пакету.
enum _Op { play }
final class Player extends Solo<PlayerState> { final Api api; final Device device;
Player(this.api, this.device) : super(const Idle()) { // Факт извне очереди: он публикуется сразу, и о нём спрашивают // правило работающей Job. device.onDisconnect = () => externalSetState(const Disconnected()); }
/// Job в очереди контроллера. Корневые Job выполняются по одной, и эта /// заканчивается, когда трек начинает играть: дальше его играет само /// устройство. Job<Track> play(String id) => enqueue<PlayerState, Track>( // Ключом может быть любой объект. Этот ключ называет операцию, // а не трек. key: _Op.play, // Новый запуск отменяет работающий с тем же ключом. policy: Policy.restart, // Правило: его спрашивают перед стартом и при каждой смене // состояния, пока работает тело, кроме сделанной самим телом // через `ctx.emit`. keepWhile: (state) => state is! Disconnected, // Состояние для публикации, если Job упала или её отменили. Если Job // отменяется правилом, ничего не публикуется, и `Disconnected` // остаётся. Запуск, который заменил более новый, возвращает // полученное состояние, и оно ничего не публикует. ifFailed: (state, error, stackTrace) => const Idle(), ifCancelled: (state, cancelled) => cancelled.reason is ReplacedCancelReason ? state : const Idle(), (ctx) async { // Так тело меняет состояние. Сеттера снаружи нет. ctx.emit(const Loading()); // Этого дожидаются в любом случае: трек, который играет сейчас, // останавливается раньше, чем загрузится следующий. await ctx.join(device.stop); // Отмена прекращает это ожидание сразу; запрос может идти дальше. final track = await ctx.abandonable(() => api.fetch(id)); // Этого тоже дожидаются. `dispose` закрывает скачивание, когда // Job заканчивается, или сразу после возврата вызова, если Job // за это время отменили. final download = await ctx.join( () => api.download(track), dispose: (download) => download.close(), ); // Дочерняя Job: устройство читает скачанное, и её стрим кончается, // когда трек начинает играть. Тело ждёт её `value` и идёт дальше, // когда трек уже играет. await ctx.each(device.load(download), (childCtx, percent) { childCtx.emit(Buffering(track, percent)); }).value; ctx.emit(Playing(track)); return track; }, );
/// События, делящие одну Job в очереди: важна только последняя громкость. late final _volume = accumulate<PlayerState, double, void>( (ctx, value) => ctx.join(() => device.setVolume(value)), merge: (previous, incoming) => incoming, timing: AccumulationTiming.debounce(const Duration(milliseconds: 50)), );
Job<void> setVolume(double value) => _volume.add(value);
// Колбэк снимается раньше, чем состояние станет окончательным: после // этого вызов `externalSetState` бросил бы `StateError`. @override void onClose() => device.onDisconnect = null;}На стороне вызова Job служит хэндлом: можно дождаться её исхода или отменить
её, как в быстром старте. Job не Future, поэтому await player.play('t-1')
компилируется и ничего не ждёт; базовый набор package:lints отмечает это
правилом await_only_futures. Ждите done или value.
final player = Player(api, device);
final job = player.play('t-1');switch (await job.done) { case Done(:final value): print('playing $value'); case Failed(:final error): print('failed: $error'); case Cancelled(:final reason): print('cancelled: $reason');}
player.setVolume(0.4);// Сразу перестать принимать работу, выполнить то, что уже в очереди,// и закрыться.await player.close(mode: SoloCloseMode.drain);setVolume тоже возвращает Job, и вызывающий код выбрасывает её намеренно:
ответить на громкость, которую устройство не приняло, здесь нечем. Её провал
уходит в зону, как сказано в разделе
«Обработанные и необработанные ошибки»
страницы об ошибках, раз unansweredHandler не задан, и завершает эту
программу на чистом Dart. Код, которому есть чем ответить, читает done этой
Job, как здесь читают done у play.
Job, которую правила её рабочего типа отклонили на старте, кончается
Cancelled с RulesCancelReason, а не Failed. Ошибкой это не считается:
ни onError, ни зона о ней не слышат, и говорит об этом исход. Solo.debug
показывает и отказ задаче из очереди.
Почему currentState, а не state
Заголовок раздела «Почему currentState, а не state»Допустим, имя из раздела «Быстрый старт» загружается для
пользователя сессии. Сессию ведёт другой контроллер, и ProfileController
держит его в поле session. API профиля теперь принимает пользователя:
fetchName(user). Метод, который загружает имя заново, когда профиль уже
загружен:
Job<String> reload() => enqueue<Loaded, String>((ctx) async { // Снимок чужого контроллера: обычное чтение, и имя об этом // говорит. final user = session.currentState.user; final name = await ctx.abandonable(() => api.fetchName(user)); // Собственное состояние этой Job: контрольная точка, которая // бросит `Cancelled`, если Job отменена или состояние уже // не `Loaded`. if (ctx.state.name != name) { ctx.emit(Loaded(name)); } return name;});enqueue<Loaded, String> называет два типа: рабочий тип W этой Job,
то есть состояния, с которыми работает её тело, — он может быть уже S, типа
состояния контроллера, — и тип её результата. Рабочий тип действует от старта
Job до её конца: тело, которое публикует состояние вне W, отменяется
на следующей контрольной точке, как показывает
«Чтение и изменение состояния».
Если типы опустить, как в быстром старте, тип результата — то, что возвращает
тело, а рабочим типом Job служит весь S. Компилятор этого W не видит:
в теле ctx.state тогда имеет тип Object?, а не ProfileState. Тело,
которое читает ctx.state, называет оба типа.
Тело Job пишется замыканием внутри метода контроллера, поэтому в теле видны
все члены контроллера. ctx.state служит контрольной точкой. Перед чтением он
проверяет, что Job не отменена и её правила по-прежнему выполняются: рабочий
тип, здесь Loaded, и правило keepWhile, если оно у Job есть, как у play
выше. Обычное чтение, названное state, выглядело бы в точности как
ctx.state, и тело, набравшее его по привычке, читало бы состояние после
отмены, которую обязано было соблюсти: ни Cancelled, ни проверки правил,
и весь S вместо W, а у S нет name. Поэтому собственное чтение
контроллера называется currentState: голое state в теле не компилируется,
а currentState никто не наберёт по привычке там, где нужна контрольная точка.
Снаружи Job состояние читают через currentState. Внутри это чтение остаётся
верным для другого контроллера: session.currentState выше берёт чужой снимок,
и правила этой Job о нём ничего не говорят.
Страницы
Заголовок раздела «Страницы»Первые три страницы нужны любому контроллеру, в таком порядке: задачи, состояние, отмена. Следующие пять понадобятся, когда контроллеру нужно то, о чём они: ресурсы, дети, стримы, накопление и его правила. Диагностика, ошибки и тестирование — для контроллера перед выпуском, частые ошибки — список для сверки, а справочник называет каждый член вместе с разделом, который его объясняет. Flutter — для контроллера за экраном, пример камеры собирает их в одном контроллере, solo и bloc рядом — для того, кто пришёл из bloc, а solo под riverpod и solo под bloc — для того, кто остаётся на одном из них.
| Страница | О чём |
|---|---|
| Задачи и очередь | Постановка работы, исходы, ключи, политики очереди |
| Состояние | Публикация, правила, наблюдение, внешние изменения, состояние после ошибки |
| Отмена | abandonable, join, pause, uncancellable, timeout, остановка операции, закрытие |
| Ресурсы и освобождение | onDispose, dispose и discard, передача, порядок |
| Дочерние задачи и цепочки | Дочерние Job, цепочки, шаги подряд |
| Стримы | ctx.each в контроллере, слежение за другим контроллером |
| Накопление событий | collect, accumulate, debounce и throttle в четырёх рецептах |
| Правила накопления | Когда группа готова, AccumulationPolicy, одна задача на группу и shared(), merge, close() |
| Диагностика | SoloObserver, pending, логи, долгая отмена |
| Ошибки | onError, unansweredHandler, перехват и повтор, ctx.unattended |
| Тестирование | Ожидание исходов, фейковое время, таймауты |
| Частые ошибки | Семь ошибок, которые часто проходят первый тест, и правильный способ для каждой |
| Справочник | Все члены Solo, его контекста, задач, очереди и накопителей, с разделом, где о них рассказано |
| Flutter | Пакет flutter_solo: SoloListenable, владение контроллером, перестроение экрана |
| Пример камеры | Один контроллер с правилами, освобождением ресурсов и устройством |
| solo и bloc рядом | Одиннадцать сценариев на обоих пакетах |
| solo под riverpod | Контроллер под провайдером: мост, камера, которую закрывают при уходе экрана, форма, которую отправляют один раз |
| solo под bloc | Контроллер под bloc: мосты и их цена, три требования, где контроллер снимает с bloc обходной приём |
Рецепты
Заголовок раздела «Рецепты»Ситуации, которые встречаются, и чем их брать. Каждая разобрана в разделе, на который ведёт ссылка рядом.
| Ситуация | Чем брать | Где |
|---|---|---|
| Из пачки команд важна только последняя | accumulate с merge, берущим пришедшую |
Команды, где важна только последняя |
| Ввод в поле поиска | accumulate с AccumulationTiming.debounce |
Поиск, который стреляет на каждый символ |
| Поздний запрос не должен быть отброшен как дубликат раннего | ключ-запись, (_Op.load, id) |
Ключ на каждый запрос |
| Ожидающую работу обесценило только что пришедшее | queue.removeWhere перед постановкой или cancelAll(), если она может уже работать |
Когда это всё-таки разные задачи |
| Последняя пачка должна уйти до того, как экран исчезнет | close(mode: SoloCloseMode.drain) |
Отмена работы и закрытие контроллера |
close() не возвращается |
controller.pending |
Что удерживает контроллер |
| Журналу нужно сказать, какая операция изменила состояние | SoloTransition в onChange |
Наблюдение за всеми контроллерами |
| Отменённая задача должна дождаться вызова, который начала | ctx.join |
Каждое ожидание на своём месте |
| Вызов время от времени падает, и его надо повторить | цикл в теле с ctx.pause между попытками |
Повтор упавшего вызова |
| Шаг из нескольких вызовов нельзя прервать на половине | ctx.uncancellable |
Защита шага или всей задачи |
| Ресурс, открытый вызовом, которого никто не дождался, всё равно надо закрыть | dispose или discard у ctx.abandonable и ctx.join |
Получение ресурса вызовом |
| Виджет перестраивается из-за состояния, которым не пользуется | SoloSelector из flutter_solo |
Выбор одного значения |
| Очередь нужно на время остановить | Job, которая ждёт Completer в голове очереди |
Пауза очереди |
| Задача должна начаться позже, а очередь не должна её ждать | отдельная Job для ожидания, которую отменяют cancelAll и onClose |
Отложенный старт |
| Нажатие не должно ждать долгой перезагрузки того же экрана | свой контроллер для нажатий и отметки, которые снимает перезагрузка | Нажатие, которое не ждёт перезагрузки |
| Экран должен показывать новость, пока пользователь её не закроет, а камера должна работать дальше | отдельный контроллер для уведомлений и приёмка, которая называет уведомление | Уведомление, которое принимает экран |
| Событие стрима приходит микротаской позже, и это поздно | addListener, который зовёт колбэк внутри изменения |
Наблюдение за состоянием |
| Изменение | Выходит в |
|---|---|
| Исправление, которое не трогает публичный API | патч-выпуске, например 1.0.1 |
| Новый класс, член или необязательный параметр | минорном выпуске, например 1.1.0 |
| Удалённый или переименованный член, изменение сигнатуры или описанного в документации поведения | мажорном выпуске, например 2.0.0 |
| Новое значение перечисления или новый подкласс запечатанного класса | мажорном выпуске, например 2.0.0 |
С версии 1.0.0 solo следует семантическому версионированию: ограничение
^1.0.0 принимает каждый минорный и патч-выпуск, и код, написанный под 1.0.0,
с ними продолжает работать, кроме случаев ниже. Публичный API — то, что
экспортируют package:solo/solo.dart и package:solo/listeners.dart; текст
сообщений об ошибках и отладочного вывода в него не входит. listeners.dart —
список слушателей, через который уведомляет контроллер, для пакета, который
строит поверх solo свою доставку, как это делает flutter_solo; приложение
слушает через Solo.addListener, и он ему не нужен. Член, который уходит,
сначала помечается @Deprecated в минорном выпуске, с именем, которое брать
вместо него, и удаляется в следующем мажорном.
Минорный выпуск может добавить член в класс, от которого вы наследуетесь,
например в Solo или SoloObserver, и одноимённый член в вашем коде после
этого перестаёт компилироваться. Он может добавить член и в интерфейс, который
вы реализуете ради мока, например в JobContext или SoloContext, и тогда
перестаёт компилироваться и фейк, который реализует его вручную, а не через
noSuchMethod. Список изменений такого выпуска называет члены, которые он
добавляет. Новое имя верхнего уровня может столкнуться с тем же именем,
импортированным из другого пакета, пока один из импортов не скроет его через
hide.
switch по Policy, SoloPhase, SoloCloseMode, AccumulationPolicy или
SoloPending может быть исчерпывающим, и новое значение одного из этих
перечислений или новый подкласс запечатанного SoloPending сломали бы его
компиляцию: такие правки выходят только в мажорном выпуске, как и те же правки
Outcome и JobErrorAnswer из async_job. CancelReason открыт, и минорный
выпуск может добавить причину, поэтому switch по причинам оставляет ветку
по умолчанию.
solo реэкспортирует async_job, поэтому мажорный выпуск async_job выходит
вместе с мажорным выпуском solo. Ещё solo — движок на протоколе
package:async_job/engine.dart, который может измениться в минорном выпуске:
каждый выпуск solo держит async_job в пределах одной минорной версии,
и минорный выпуск async_job выходит вместе с минорным выпуском solo. Новая
минорная версия async_job приходит к вам с этим выпуском solo: пока его
нет, приложение, которое само просит новый async_job, не разрешает
зависимости.
Переход с bloc
Заголовок раздела «Переход с bloc»Вызывающий код обращается к методам контроллера и получает Job для каждой
операции. Политика очереди выбирается при каждом вызове, а все корневые Job
используют одну очередь. solo и bloc рядом
содержит соответствия API и сравнивает одиннадцать прикладных сценариев
с реализациями на обоих пакетах.
Пакет не включает политики повторов, пулы исполнителей, внедрение зависимостей,
сохранение состояния или фильтрацию равных состояний. Если работе нужны отмена
и освобождение ресурсов, но не правила состояния и очередь контроллера, можно
использовать async_job напрямую. Для
значения без асинхронного жизненного цикла может хватить ValueNotifier.