solo
solo управляет состоянием и асинхронной работой в Dart. Контроллер хранит
текущее состояние и выполняет задачи по одной. Каждая задача может
объявить, в каких состояниях ей разрешено запускаться и продолжать
работу. Вызывающий код может дождаться её результата или запросить отмену.
Пакет подходит для экранов, сессий и устройств, операции которых работают
с общим состоянием и должны выполняться в определённом порядке.
Зависимости от Flutter нет.
flutter_solo добавляет
контроллер с интерфейсом ValueListenable для виджетов Flutter.
Установка
Заголовок раздела «Установка»dart pub add soloДля Flutter установите flutter_solo. Он реэкспортирует solo:
flutter pub add flutter_solosolo реэкспортирует async_job,
который предоставляет задачи, отмену и освобождение ресурсов. Импорт
package:solo/solo.dart открывает оба API. Чтобы пользоваться примерами
ниже, предварительно изучать пакет ядра не нужно.
Быстрый старт
Заголовок раздела «Быстрый старт»Контроллер предоставляет методы для операций приложения. Здесь load()
загружает имя профиля и сообщает о ходе работы через четыре неизменяемых
состояния:
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 Failure extends ProfileState { final Object error;
const Failure(this.error);}API в примере возвращает имя после небольшой задержки. Замените его клиентом API вашего приложения:
class ProfileApi { Future<String> fetchName() => Future.delayed( const Duration(milliseconds: 20), () => 'Ada Lovelace', );}
final class ProfileController extends Solo<ProfileState> { final ProfileApi api;
ProfileController(this.api) : super(const Initial());
Job<String> load() => run<ProfileState, String>( key: 'load', policy: Policy.droppable, 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; }, );}run<ProfileState, String> создаёт Job и ставит её в очередь.
ProfileState задаёт тип состояния, с которым может работать тело;
String задаёт тип результата. Тело получает контекст ctx для
изменения состояния и ожидания с учётом отмены:
ctx.emitобновляет состояние контроллера.ctx.waitждёт ответ API или бросаетCancelled, если во время ожидания Job принимает отмену.onErrorвозвращает состояние для публикации при ошибке Job.onCancelвозвращает состояние для публикации при отмене начатой Job.
Обработчики состояния вызываются после тела и освобождения его ресурсов.
В этом примере состояние становится Loaded при успехе, Failure при
ошибке или Initial при отмене. Ошибка и отмена остаются исходом Job,
даже когда обработчик обновляет состояние.
Policy.droppable с key: 'load' позволяет повторным вызовам использовать
ту же загрузку, пока она стоит в очереди или выполняется. Второй вызов
возвращает существующую Job. После её завершения следующий вызов
может начать новую загрузку.
Вызывающий код ждёт именно эту загрузку через возвращённый Job<String>:
Future<void> main() async { final profile = ProfileController(ProfileApi()); final subscription = profile.stream.listen(print); try { final job = profile.load(); profile.load(); // возвращается существующая задача
print(await job.value); if (profile.state case Loaded(:final name)) { print(name); } } finally { await subscription.cancel(); await profile.close(); }}profile.state доступен синхронно. profile.stream асинхронно доставляет
изменения подписчикам. job.value возвращает загруженное имя или бросает
ошибку Job либо Cancelled. Блок finally снимает подписку и
закрывает контроллер даже при ошибке загрузки.
Для отмены используется тот же объект Job. Этот отдельный пример запрашивает отмену сразу, поэтому Job может ещё находиться в очереди:
Future<void> cancelLoading() async { final profile = ProfileController(ProfileApi()); final job = profile.load();
await job.cancel(); print(job.outcome); // Cancelled(manual) await profile.close();}cancel() ждёт завершения Job, включая освобождение ресурсов, если
она успела начаться. При отмене до запуска тела onCancel не вызывается.
Job может запускать дочерние Job как часть своей работы; запустившая
их Job является родителем и ждёт их перед собственным завершением.
Следующая Job из очереди запускается только после завершения тела,
дочерних Job, освобождения ресурсов и обработчика состояния предыдущей.
Страницы ниже подробнее объясняют результаты, политики очереди и отмену. В частности, отмена Job сама по себе не останавливает запрос к API, который уже был отправлен.
Дюжина вызовов
Заголовок раздела «Дюжина вызовов»Всё, чем пользуются каждый день, в одном контроллере. PlayerState,
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 в очереди контроллера. Корневые Job выполняются по одной. SoloJob<Track> play(String id) => run<PlayerState, Track>( // Ключом может быть любой объект. Запись — ключ одного запроса, // а не операции. key: (_Op.play, id), // Новый запуск отменяет работающий с тем же ключом. policy: Policy.restart, // Правила: проверяются перед стартом и в каждой контрольной точке. canStart: (state) => state is! Disconnected, keepWhile: (state) => state is! Disconnected, (ctx) async { // Единственный способ изменить состояние: сеттера снаружи нет. ctx.emit(const Loading()); // Отмена прекращает это ожидание сразу; запрос может идти дальше. final track = await ctx.wait(() => api.fetch(id)); // Этого дожидаются в любом случае, а значение освобождается, // если Job закончилась раньше, чем тело успело его забрать. final handle = await ctx.join( () => device.open(track), dispose: (handle) => handle.close(), ); // Освобождение идёт в обратном порядке и при любом исходе. ctx.onDispose(handle.close); // Дочерняя Job: родитель дождётся её перед своим завершением. ctx.each(device.position, (childCtx, position) async { childCtx.emit(Playing(track, position)); }); 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)), );
SoloJob<void> setVolume(double value) => _volume.add(value);}На стороне вызова Job — это ручка: дождаться исхода или отменить.
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);Страницы
Заголовок раздела «Страницы»Они же на сайте документации, с поиском.
| Страница | О чём |
|---|---|
| Задачи и очередь | Постановка работы, исходы, ключи, политики очереди |
| Состояние | Публикация, правила, наблюдение, внешние изменения, состояние после ошибки |
| Отмена | wait, join, uncancellable, остановка операции, закрытие |
| Ресурсы и освобождение | onDispose, dispose и discard, передача, порядок |
| Дочерние задачи и стримы | Дочерние Job, стримы, слежение за другим контроллером |
| Накопление событий | collect, accumulate, debounce и throttle |
| Ошибки и наблюдение | SoloObserver, errorHandler, pending, логи |
| Тестирование | Ожидание исходов, фейковое время, таймауты |
| Flutter | SoloListenable, владение контроллером, перестроение экрана |
| Пример камеры | Один контроллер с правилами, уборкой и устройством |
| solo и bloc рядом | Десять сценариев на обоих пакетах |
Рецепты
Заголовок раздела «Рецепты»Ситуации, которые встречаются, и чем их брать. Каждая разобрана на странице, названной рядом.
| Ситуация | Чем брать | Где |
|---|---|---|
| Из пачки команд важна только последняя | accumulate с merge, берущим пришедшую |
Команды, где важна только последняя |
| Ввод в поле поиска | accumulate с AccumulationTiming.debounce |
Накопление событий |
| Поздний запрос не должен быть отброшен как дубликат раннего | ключ-запись, (Op.load, id) |
Задачи и очередь |
| Ожидающую работу обесценило только что пришедшее | queue.removeWhere перед постановкой или cancelAll(), если она может уже работать |
Команды, где важна только последняя |
| Последняя пачка должна уйти до того, как экран исчезнет | close(mode: SoloCloseMode.drain) |
Отмена |
close() не возвращается |
SoloBase.pending |
Ошибки и наблюдение |
| Журналу нужно сказать, какая операция изменила состояние | SoloTransition в onChange |
Ошибки и наблюдение |
| Шаг нельзя прервать на половине | ctx.join для вызова, ctx.uncancellable для шага |
Отмена |
| Ресурс, открытый вызовом, которого никто не дождался, всё равно надо закрыть | dispose или discard у ctx.wait и ctx.join |
Ресурсы и освобождение |
| Виджет перестраивается из-за состояния, которым не пользуется | select на SoloListenable |
Flutter |
Переход с bloc
Заголовок раздела «Переход с bloc»Вызывающий код обращается к методам контроллера и получает Job для каждой операции. Политика очереди выбирается при каждом вызове, а все корневые Job используют одну очередь. solo и bloc рядом содержит соответствия API и сравнивает десять прикладных сценариев с реализациями на обоих пакетах.
Пакет не включает политики повторов, встроенные таймауты, пулы
исполнителей, внедрение зависимостей, сохранение состояния на диск
или фильтрацию равных состояний. Если работе нужны отмена и освобождение
ресурсов, но не правила состояния и очередь контроллера, можно
использовать async_job напрямую.
Для значения без асинхронного жизненного цикла может хватить
ValueNotifier.