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

solo

solo управляет состоянием и асинхронной работой в Dart. Контроллер хранит текущее состояние и выполняет задачи по одной, отсюда и имя пакета. Каждая задача может объявить, в каких состояниях ей разрешено запускаться и продолжать работу. Вызывающий код может дождаться её результата или запросить отмену.

Пакет подходит для экранов, сессий и устройств, операции которых работают с общим состоянием и должны выполняться в определённом порядке. Зависимости от Flutter нет. flutter_solo добавляет то, что нужно во Flutter: миксин SoloListenable, который даёт контроллеру интерфейс ValueListenable, виджеты SoloBuilder и SoloSelector и listen, который отдаёт подписку для отмены, а не просит хранить колбэк.

Окно терминала
dart pub add solo

Для Flutter установите flutter_solo. Он реэкспортирует solo:

Окно терминала
flutter pub add flutter_solo

solo реэкспортирует 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 мс. Он печатает:

loading
loaded Ada Lovelace
Ada Lovelace
loading
Cancelled(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 показывает и отказ задаче из очереди.

Допустим, имя из раздела «Быстрый старт» загружается для пользователя сессии. Сессию ведёт другой контроллер, и 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, не разрешает зависимости.

Вызывающий код обращается к методам контроллера и получает Job для каждой операции. Политика очереди выбирается при каждом вызове, а все корневые Job используют одну очередь. solo и bloc рядом содержит соответствия API и сравнивает одиннадцать прикладных сценариев с реализациями на обоих пакетах.

Пакет не включает политики повторов, пулы исполнителей, внедрение зависимостей, сохранение состояния или фильтрацию равных состояний. Если работе нужны отмена и освобождение ресурсов, но не правила состояния и очередь контроллера, можно использовать async_job напрямую. Для значения без асинхронного жизненного цикла может хватить ValueNotifier.