С riverpod и bloc
Задача встаёт под провайдер riverpod или обработчик bloc, и слой состояния
менять не нужно. Провайдер или обработчик знает, когда работа перестала быть
нужна; он отменяет задачу, а задача останавливается в следующей контрольной
точке и выполняет уборку. Код ниже идёт на riverpod 3, bloc 9
с bloc_concurrency, http 1.5 и dio 5, на чистом Dart: flutter_riverpod
и flutter_bloc дают те же Ref и Emitter.
| Где идёт задача | Что её отменяет | Раздел |
|---|---|---|
FutureProvider.autoDispose |
ref.onDispose(job.cancel) |
Провайдер, которого больше нет |
Метод AsyncNotifier |
Следующий вызов и ref.onDispose, через поле |
Нотифаер, который начинает заново |
| Обработчик bloc | emit.forEach(watch(job), …) |
Обработчик, который перезапускается |
| Тело, которое шлёт HTTP-запрос | Собственный токен клиента, отменённый в ctx.onCancel |
Запрос, который надо прервать |
Провайдер, которого больше нет
Заголовок раздела «Провайдер, которого больше нет»Экран показывает строки, которые задача читает из базы. Задача открывает базу, читает три страницы по 20 мс и закрывает базу, чем бы ни кончилась:
Job<List<String>> readRows(String query) => Job((ctx) async { final db = await ctx.join( () => Database.open(query), dispose: (db) => db.close(), ); return [ for (var page = 1; page <= 3; page++) await ctx.join(() => db.readPage(page)), ];});Database — та же база, что в быстром старте README, только читает
по страницам; она печатает, когда открывается, читает страницу и закрывается.
Провайдер autoDispose: уходит экран — уходит и последний слушатель,
и riverpod освобождает провайдер. На этом чтение должно остановиться, а база —
закрыться. В трассах ниже экран уходит во время чтения первой страницы.
Первая попытка
Заголовок раздела «Первая попытка»Провайдер возвращает значение задачи:
// Первая попытка: задача читает дальше, когда провайдера уже нет.final rows = FutureProvider.autoDispose.family<List<String>, String>( (ref, query) => readRows(query).value,);database opened for dartscreen leftdart: page 1 readdart: page 2 readdart: page 3 readdatabase for dart closedriverpod бросает переданный ему future, а задаче никто ничего не говорит: она читает все страницы впустую.
Вторая попытка
Заголовок раздела «Вторая попытка»ref.onCancel по имени — то самое место для отмены:
// Вторая попытка: экран на паузе отменяет задачу, и начинается новая.final rows = FutureProvider.autoDispose.family<List<String>, String>(( ref, query,) { final job = readRows(query); ref.onCancel(job.cancel); return job.value;});database opened for dartscreen covereddart: page 1 readdatabase for dart closedscreen showndatabase opened for dartdart: page 1 readdart: page 2 readdart: page 3 readdatabase for dart closedКогда экран уходит, это работает. Но riverpod 3 зовёт onCancel, когда
последний слушатель уходит или встаёт на паузу, а Flutter ставит на паузу
слушателей экрана, который закрыт непрозрачным маршрутом. Задача кончается
Cancelled, а Cancelled — это Exception: riverpod считает его сбоем
и повторяет провайдер, как только экран снова виден, и чтение начинается заново
с первой страницы. С выключенными повторами экран вместо этого возвращается
к ошибке.
Отмена при освобождении
Заголовок раздела «Отмена при освобождении»onDispose вызывается, когда состояние, которому служит задача, выбрасывают:
когда провайдер освобождается и когда он перестраивается, но не на паузе:
final rows = FutureProvider.autoDispose.family<List<String>, String>(( ref, query,) { final job = readRows(query); ref.onDispose(job.cancel); return job.value;});database opened for dartscreen leftdart: page 1 readdatabase for dart closedОсвобождение отменяет задачу, join дожидается страницы, которая уже читается,
и база закрывается. Экран, который просит другой запрос, слушает другой
провайдер семейства, а тот, который он перестал слушать, освобождается так же.
До зоны отмена не доходит, но ProviderObserver её всё равно слышит:
providerDidFail получает Cancelled(manual) освобождённого провайдера.
Наблюдатель, который шлёт сбои в отчёты, её пропускает:
final class CrashReports extends ProviderObserver { @override void providerDidFail( ProviderObserverContext context, Object error, StackTrace stackTrace, ) { if (error is Cancelled) return; report(error, stackTrace); }}Cancelled, которым задача кончилась сама, например по своему сроку, для
riverpod тоже сбой, и провайдер повторяет его, как любой другой.
Нотифаер, который начинает заново
Заголовок раздела «Нотифаер, который начинает заново»Нотифаер, который запускает задачу из метода, держит текущую в поле. Следующий вызов отменяет её, прежде чем начать новую. Освобождение, которое riverpod запускает и перед перестройкой, отменяет ту, что идёт, и очищает поле:
final class Search extends AsyncNotifier<List<String>> { Job<List<String>>? _current;
@override List<String> build() { ref.onDispose(() { unawaited(_current?.cancel()); _current = null; }); return const []; }
Future<void> search(String query) async { unawaited(_current?.cancel()); final job = _current = readRows(query); state = const AsyncLoading();
final outcome = await job.done; // Состояние теперь принадлежит более новому поиску, перестройке или // освобождению. if (!identical(job, _current)) return; state = switch (outcome) { Done(:final value) => AsyncData(value), Failed(:final error, :final stackTrace) => AsyncError(error, stackTrace), Cancelled() => AsyncError(outcome, StackTrace.current), }; }}
final search = AsyncNotifierProvider.autoDispose<Search, List<String>>( Search.new,);database opened for dsearch: dadatabase opened for dad: page 1 readdatabase for d closedda: page 1 readda: page 2 readda: page 3 readdatabase for da closedstate: [da: page 1, da: page 2, da: page 3]Проверка перед записью состояния важна так же, как отмена. Отменённый поиск всё
равно кончается, и без проверки его Cancelled лёг бы поверх поиска, который
его сменил. Перестройка, например после ref.invalidate, сохраняет нотифаер
и его поле, а ref.mounted к тому времени снова истинно: что состояние больше
не его, старому поиску говорит только очищенное поле.
Обработчик, который перезапускается
Заголовок раздела «Обработчик, который перезапускается»Поиск идёт на каждую клавишу, и bloc с restartable() из bloc_concurrency
оставляет только последний: новый запрос должен останавливать поиск перед ним,
а close() — тот, что идёт. События и состояние:
final class Searched { const Searched(this.query);
final String query;}
final class SearchState { const SearchState(this.rows, [this.error]);
final List<String> rows; final Object? error;}Первая попытка
Заголовок раздела «Первая попытка»Обработчик ждёт значение задачи:
// Первая попытка: restartable() бросает обработчик, а не задачу.final class SearchBloc extends Bloc<Searched, SearchState> { SearchBloc() : super(const SearchState([])) { on<Searched>((event, emit) async { final rows = await readRows(event.query).value; if (!emit.isDone) emit(SearchState(rows)); }, transformer: restartable()); }}database opened for aa: page 1 readdatabase opened for aba: page 2 readab: page 1 reada: page 3 readdatabase for a closedbloc closedabc: page 2 readabc: page 3 readdatabase for abc closedrestartable() перестаёт слушать старый обработчик, а close() — все,
но обработчик — это Future, и отменить его нечем. Поиск по a дочитывает
до конца рядом с поиском по ab, а поиск по abc читает и после того, как
bloc закрыт.
Стрим, который отменяет задачу
Заголовок раздела «Стрим, который отменяет задачу»Колбэка на свою отмену у Emitter нет, но подписки своих forEach и onEach
он отменяет сразу: до них доходят и restartable(), и close(). Поэтому
задача уходит в стрим, отмена которого отменяет задачу:
/// Значение [job] как стрим. Отмена подписки отменяет задачу.////// Слушать его нужно сразу: значение читается в `onListen`, а стрим, который/// никто не слушает, оставляет задачу без присмотра и её провал без/// наблюдения.Stream<T> watch<T>(Job<T> job) { late final StreamController<T> controller; controller = StreamController<T>( onListen: () { job.value .then(controller.add, onError: controller.addError) .whenComplete(controller.close); }, onCancel: job.cancel, ); return controller.stream;}
final class SearchBloc extends Bloc<Searched, SearchState> { SearchBloc() : super(const SearchState([])) { on<Searched>((event, emit) async { await emit.forEach( watch(readRows(event.query)), onData: SearchState.new, onError: (error, stackTrace) => SearchState(const [], error), ); }, transformer: restartable()); }}database opened for aa: page 1 readdatabase opened for aba: page 2 readdatabase for a closedbloc closedabc: page 2 readdatabase for abc closedПоиск по a останавливается на странице, которую читал, и close() так же
останавливает поиск по abc. Ни тот, ни другой этой уборки не ждёт: эмиттер
не ждёт job.cancel(), а restartable() запускает новый обработчик раньше,
чем вообще отменяет старый, поэтому база для ab открывается раньше, чем
закрывается база для a, а close() возвращается раньше, чем закроется база
для abc. Ресурсу, который может быть открыт только один раз, нужно, чтобы
новый поиск дождался старого.
close() отменяет идущие обработчики при concurrent(), который стоит
по умолчанию, и при droppable() и restartable(), так что watch стоит
своего и без restartable(). При sequential() он сначала дожидается идущего
обработчика и событий в очереди, и отменять уже нечего. onError: нужен для
задачи, которая кончается Cancelled сама, например по своему сроку: forEach
передаёт ему ошибку, а без onError: bloc отдаёт её в onError самого блока
и дальше в зону.
watch — код страницы, а не часть async_job: скопируйте его.
Обработчику с одним вызовом задача для этого не нужна. В emit.forEach так же
идёт любой стрим, отмена которого останавливает вызов, —
CancelableOperation.fromFuture(call, onCancel: token.cancel).asStream()
из package:async с собственным токеном клиента, как в разделе
«Запрос, который надо прервать», —
и restartable() и close() останавливают вызов через него. Задача окупается,
когда обработчик делает несколько шагов, как readRows открывает базу и читает
её страницу за страницей: каждый ctx.join — место, где работа
останавливается, а база закрывается через dispose, написанный там, где она
открывается. С одним bloc обработчик проверяет emit.isDone перед каждой
страницей и закрывает базу в finally, а без проверки чтение доходит до конца.
Запрос, который надо прервать
Заголовок раздела «Запрос, который надо прервать»Задача забирает отчёт с сервера, который отвечает 300 мс. Когда задачу отменяют, запрос должен остановиться: соединение закрывается, и сервер перестаёт готовить ответ, который никто не прочтёт.
Первая попытка
Заголовок раздела «Первая попытка»Тело ждёт запрос через join, как в быстром старте:
// Первая попытка: отмена ждёт ответа целиком.final job = Job<String>((ctx) async { final response = await ctx.join(() => client.get(url)); return response.body;});cancelserver: sending the bodyoutcome: Cancelled(manual)join дожидается действия, которое начал, и остановиться запросу никто
не говорит: отмена кончается, только когда пришёл весь ответ. ctx.abandonable
завершил бы задачу сразу, но запрос шёл бы дальше, и ответ пришёл бы уже
никому, как показывает раздел
«Остановка операции».
Токен клиента
Заголовок раздела «Токен клиента»Оба клиента принимают собственный сигнал остановки, и ctx.onCancel подаёт его
вместе с отменой, как раздел
«Токен через onCancel» делает это для
базы:
// http 1.5 и новее: запрос с триггером прерывания.final job = Job<String>((ctx) async { final abort = Completer<void>(); ctx.onCancel(abort.complete);
final response = await ctx.join(() async { final request = http.AbortableRequest( 'GET', url, abortTrigger: abort.future, ); return http.Response.fromStream(await client.send(request)); }); return response.body;});// dio: CancelToken.final job = Job<String>((ctx) async { final token = CancelToken(); ctx.onCancel(token.cancel);
final response = await ctx.join( () => dio.getUri<String>(url, cancelToken: token), ); return response.data!;});cancelserver: client hung upoutcome: Cancelled(manual)Оба печатают одно и то же: клиент закрывает соединение, и сервер тело так
и не отправляет. Client из http, который не поддерживает abortTrigger, его
не замечает, и тогда запрос идёт до конца, как в первой попытке.
Прерванный запрос всё равно бросает: RequestAbortedException из http,
DioException типа cancel из dio. join пропускает её дальше, и она доходит
до зоны как неперехваченная ошибка, как любой провал тела, которого не несёт
ни один исход. Одна строка в main называет обе остановкой клиента:
Job.isCancellationError = (error) => error is http.RequestAbortedException || error is DioException && error.type == DioExceptionType.cancel;Названная ею ошибка доходит до наблюдателя и на нём останавливается. Фильтр разобран в разделе «Ошибки отмены» страницы об ошибках.