Dart: асинхронность для прикладных задач — Future, Stream и отмена
Разберём разницу Future/Stream, как правильно собирать результаты из нескольких запросов и когда нужна отмена. На примерах покажем, как избегать подвисаний и гонок.
Содержание
Dart: асинхронность для прикладных задач — Future, Stream и отмена
Асинхронность в Dart — не абстрактная «фича ради фичи», а практический механизм, который определяет качество UI, надёжность сетевых интеграций и стабильность фоновых вычислений. На практике разработчики чаще всего сталкиваются с тремя вопросами:
- Что выбрать:
FutureилиStream? - Как правильно собирать результаты из нескольких запросов, не получая гонки и непредсказуемые состояния?
- Когда и как нужна отмена (cancellation), чтобы не подвисать и не тратить ресурсы?
Ниже — разбор с упором на прикладные сценарии: загрузки данных, объединение нескольких источников, обработка последовательностей событий и управление жизненным циклом запросов.
Future vs Stream: что означает модель данных
Future<T> — один результат в будущем
Future описывает вычисление, которое завершится один раз — либо успешным значением типа T, либо ошибкой.
Примеры:
- HTTP-запрос, который возвращает JSON один ответ;
- чтение файла: результат появится один раз;
- вычисление, которое выдаёт итог.
Ключевые свойства:
- У
Futureнет «набора» значений. Есть ровно один финал. - Вы «подписываетесь» через
await,.then, или обработку ошибки. - Отменить сам
Futureобычно нельзя напрямую (важно для отмены — ниже).
Пример: простой запрос и ожидание:
Future<User> fetchUser() async {
final response = await http.get(Uri.parse('https://example.com/user'));
if (response.statusCode != 200) {
throw Exception('Bad status: ${response.statusCode}');
}
return User.fromJson(jsonDecode(response.body));
}
void example() async {
try {
final user = await fetchUser();
print(user.name);
} catch (e) {
print('Ошибка: $e');
}
}
Stream<T> — поток значений во времени
Stream — это последовательность значений, которая может:
- выдавать много
Tво времени; - завершиться нормально;
- завершиться с ошибкой;
- продолжаться долго (например, WebSocket, события UI).
Типовые сценарии:
- события формы: каждый ввод;
- позиция/прогресс загрузки;
- события из WebSocket или SSE;
- повторяемые опросы или «тикеры».
Ключевые свойства:
- Поток имеет жизненный цикл: подписка → обработка → отмена/закрытие.
- Отмена обычно происходит через управление подпиской (
cancel()). Stream— лучший выбор, когда результат не «один раз», а происходит во времени.
Минимальный пример потока:
Stream<int> counter() async* {
for (var i = 0; i < 5; i++) {
await Future<void>.delayed(const Duration(milliseconds: 200));
yield i;
}
}
Как правильно комбинировать несколько запросов
Одна из самых частых причин багов — попытка «на глаз» ждать несколько асинхронных операций, смешивая await в неправильном порядке и не контролируя конкурентность. Dart предоставляет инструменты, но их нужно применять в соответствии с моделью Future/Stream.
Сценарий 1: параллельно получить несколько Future и собрать результат
Если вам нужно запросить несколько ресурсов одновременно и дождаться всех — используйте Future.wait.
Пусть есть три независимых запроса:
- профиль пользователя;
- список избранного;
- настройки.
Future<Profile> loadProfile() async => /* ... */;
Future<List<Article>> loadFavorites() async => /* ... */;
Future<Settings> loadSettings() async => /* ... */;
Future<void> loadDashboard() async {
// Параллельный старт запросов
final futures = <Future>[
loadProfile(),
loadFavorites(),
loadSettings(),
];
// Ожидаем все и получаем список результатов
final results = await Future.wait(futures);
final profile = results[0] as Profile;
final favorites = results[1] as List<Article>;
final settings = results[2] as Settings;
// Далее — обновление состояния
print(profile.userName);
print('favorites: ${favorites.length}');
}
Важные нюансы Future.wait
- Если один
Futureупадёт, упадёт всё (поведение по умолчанию). - Можно настроить частичное продолжение с
eagerError(аккуратно: вам всё равно придётся продумать логику). Future.waitзапускает все futures сразу, но порядокresultsсоответствует порядку в списке futures — не забывайте про это при сборке.
Если вам важны типы и читаемость, лучше использовать обобщённую упаковку и явные ожидания:
Future<DashboardData> loadDashboard() async {
final profileF = loadProfile();
final favoritesF = loadFavorites();
final settingsF = loadSettings();
final results = await Future.wait([
profileF,
favoritesF,
settingsF,
]);
return DashboardData(
profile: results[0] as Profile,
favorites: results[1] as List<Article>,
settings: results[2] as Settings,
);
}
Сценарий 2: собрать результаты «как только готово» — и не блокировать UI
Иногда хочется не ждать всех, а по мере завершения обновлять состояние. Для Future это делается через комбинации:
- запуск всех запросов,
- обработка каждого
Futureчерезthen/awaitв отдельных ветках, - агрегация через
CompleterилиStreamController(часто удобнее сразу строитьStream).
Например, вы запускаете три запроса, и по мере готовности заполняете модель:
class DashboardParts {
final Profile? profile;
final List<Article>? favorites;
final Settings? settings;
const DashboardParts({
this.profile,
this.favorites,
this.settings,
});
DashboardParts copyWith({
Profile? profile,
List<Article>? favorites,
Settings? settings,
}) {
return DashboardParts(
profile: profile ?? this.profile,
favorites: favorites ?? this.favorites,
settings: settings ?? this.settings,
);
}
}
Future<Stream<DashboardParts>> loadDashboardIncremental() async {
final controller = StreamController<DashboardParts>();
var state = const DashboardParts();
Future<void> setProfile() async {
final profile = await loadProfile();
state = state.copyWith(profile: profile);
controller.add(state);
}
Future<void> setFavorites() async {
final favorites = await loadFavorites();
state = state.copyWith(favorites: favorites);
controller.add(state);
}
Future<void> setSettings() async {
final settings = await loadSettings();
state = state.copyWith(settings: settings);
controller.add(state);
}
// Запускаем параллельно
unawaited(Future.wait([
setProfile(),
setFavorites(),
setSettings(),
]).then((_) => controller.close()).catchError((e, st) {
controller.addError(e, st);
controller.close();
}));
return controller.stream;
}
Это один из случаев, когда переход от чистого Future к Stream логически оправдан: вы описываете изменения во времени.
Сценарий 3: гонки (race conditions) при повторных запросах
Классический баг: пользователь вводит текст или экран меняет параметр запроса, и старый запрос приходит позже — перетирает новое состояние.
Пример проблемного кода (упрощённо):
String query = '';
Future<void> onQueryChanged(String newQuery) async {
query = newQuery;
final results = await search(newQuery); // ждём долго
// Если за время ожидания query изменился — мы перезапишем UI устаревшими данными
showResults(results);
}
Антидот: идентификатор запроса (request id)
Самый простой и надёжный подход — привязать результат к «версии» запроса:
int _requestId = 0;
Future<void> onQueryChanged(String newQuery) async {
final id = ++_requestId;
final results = await search(newQuery);
// Игнорируем устаревший ответ
if (id != _requestId) return;
showResults(results);
}
Плюсы:
- не требует отмены нижнего транспорта;
- работает и для
Future, и для операций, где отмена не реализована.
Минусы:
- ресурсы на старый запрос могут быть потрачены (но UI не сломается).
Антидот 2: stream-операторы (debounce/distinct) и cancel
Если события идут как поток (например, текстовое поле в форме), то Stream помогает структурно:
debounceTime— не запускать запрос после каждого символа;distinct— игнорировать одинаковые значения;switchMap/«переключение» — отменять предыдущую ветку (в библиотечных пакетах) и слушать только последнюю.
В чистом Dart без готового switchMap иногда делают через подписки и cancel (ниже увидите механику отмены).
Отмена (cancellation): когда она нужна и как делать правильно
Проблема: «отменить Future» напрямую нельзя (в общем случае)
В Dart нет универсального способа отменить произвольный Future, потому что Future — это контракт «когда-то завершится», а не управляемая вычислительная задача.
Однако отмена может быть организована на уровне:
- сервиса/клиента (HTTP клиент поддерживает abort),
- вашей логики (проверка флага/токена),
- stream-подписок (отмена через
cancel()работает естественно).
Поэтому говорить «отмена» нужно конкретно: отменяем результат/подписку? отменяем сетевой запрос? предотвращаем обновление UI?
Отмена для Stream: отмена подписки как базовая практика
Если вы подписываетесь на поток, то отмена обычно делается так:
late final StreamSubscription<int> sub;
void start() {
sub = counter().listen(
(value) => print('value=$value'),
onError: (e, st) => print('error=$e'),
onDone: () => print('done'),
);
}
Future<void> stop() async {
await sub.cancel(); // корректно освобождает ресурсы подписки
}
Это особенно важно в UI: при уходе со страницы вы должны отменить подписку, иначе обработчики будут выполняться «после смерти виджета», и вы получите утечки/искажения состояния.
Отмена для Future: токен отмены и безопасные точки
Для прикладных задач (поиск, автозаполнение, загрузка карточек по маршруту) обычно делают комбинацию:
- токен отмены (cancel token / request id / abort controller),
- проверка токена перед применением результата,
- (желательно) отмена сетевого запроса.
Вариант 1: отмена как «не применяй результат»
Это самый практичный путь для большинства проектов. Например, вы запускаете запрос при смене параметра, но даже если отменить транспорт нельзя, вы отменяете эффект.
class CancelToken {
bool _canceled = false;
bool get canceled => _canceled;
void cancel() => _canceled = true;
}
Future<void> exampleWithCancelToken(CancelToken token) async {
final results = await search('abc');
if (token.canceled) return; // не применяем
showResults(results);
}
Дальше в UI/сервисе:
CancelToken? _token;
Future<void> onNewQuery(String q) async {
// отменяем прошлую операцию по смыслу
_token?.cancel();
final token = CancelToken();
_token = token;
final localToken = token;
await exampleWithCancelToken(localToken);
}
Это не экономит сетевой трафик, но предотвращает гонки и «подвисшие» состояния в UI.
Вариант 2: отмена с StreamController и жизненным циклом
Иногда вы строите асинхронный конвейер как Stream, и отмена делается естественно: вы прекращаете слушать и закрываете контроллер.
Stream<List<Article>> searchStream(String query) async* {
// Пример: имитация пагинации
int page = 1;
while (true) {
final chunk = await fetchPage(query, page);
if (chunk.isEmpty) break;
yield chunk;
page++;
}
}
Потребитель может остановить всё простой cancel подписки.
Если вам критична отмена «в середине», важно, чтобы источник реально проверял состояние/отмену. В чистом примере выше это делается через await и цикл — но реальный транспорт может зависеть от сети.
Когда нужна отмена, а когда достаточно request id
Рассмотрим критерии.
Отмена нужна, если:
- операция дорогая (трафик, батарея, CPU);
- запрос может длиться долго (пейджинг, трансляции, большие отчёты);
- пользователь может часто инициировать смену контекста (поиск по символам, навигация);
- вы хотите освобождать ресурсы не только на уровне UI.
Достаточно request id, если:
- отмена сложна или недоступна (нет abort API на уровне HTTP клиента/сервиса);
- главное — не допустить «старые данные сверху»;
- операция в среднем короткая, а при отмене вы теряете мало.
На практике часто делают так: request id + (опционально) abort. Это обеспечивает и корректность, и экономию ресурсов там, где возможно.
Пример: безопасный поиск с дебаунсом, гонками и отменой
Представим: есть поток ввода текста (например, из UI), запускается поиск. Нужны:
debounceTime, чтобы не дергать сервер на каждый символ;- предотвращение гонок (устаревшие ответы не должны отображаться);
- отмена предыдущего сетевого запроса там, где возможно.
Ниже — концептуальный вариант. Поскольку в стандартной библиотеке нет готового switchMap, сделаем вручную через токен и подписку.
import 'dart:async';
class CancelToken {
bool _canceled = false;
bool get canceled => _canceled;
void cancel() => _canceled = true;
}
Future<List<String>> search(String q) async {
// заглушка: имитация сети
await Future<void>.delayed(const Duration(milliseconds: 500));
if (q.length < 2) return const [];
return List.generate(3, (i) => '$q result $i');
}
Future<void> runSearchPipeline(Stream<String> queryChanges) async {
CancelToken? current;
StreamSubscription<String>? sub;
// состояние для дебаунса
Timer? debounceTimer;
sub = queryChanges.listen((q) {
debounceTimer?.cancel();
debounceTimer = Timer(const Duration(milliseconds: 300), () async {
// отменяем предыдущую операцию по смыслу
current?.cancel();
final token = CancelToken();
current = token;
final queryAtStart = q;
try {
final results = await search(queryAtStart);
if (token.canceled) return; // гонка или отмена
// Обновляем UI/состояние только актуального запроса
print('Results for "$queryAtStart": $results');
} catch (e) {
if (token.canceled) return;
print('Search error: $e');
}
});
});
// где-то в жизненном цикле:
// await sub.cancel();
}
Этот пример демонстрирует важную мысль: антигонки и отмена — это не только про отмену сети, а про управление тем, какие результаты допустимы к применению.
Подвисания и частые ошибки: что действительно ломает приложение
Ошибка 1: «await внутри цикла» вместо параллелизма
Например, вы делаете N запросов и ожидаете каждый последовательно:
for (final id in ids) {
final item = await fetchItem(id); // последовательно
items.add(item);
}
Если запросы независимы, вы получите лишнюю задержку. Правильнее параллелить:
final items = await Future.wait(ids.map(fetchItem));
Исключение — если нужно ограничить параллелизм (например, API rate limits).
Ошибка 2: отсутствие лимитов параллельности
Future.wait запускает всё сразу. Для сотен id это может:
- уронить производительность,
- вызвать throttling,
- создать лавину ошибок.
Тогда нужен контроллер конкурентности (очередь). В статье не разворачиваем реализацию, но принцип такой: вы запускаете не все N сразу, а, скажем, по 5–10 одновременно.
Ошибка 3: обновление состояния после ухода со страницы
Типичная причина утечек и «странных» ошибок: вы подписались на Stream или запустили Future, а потом пользователь ушёл. Без отмены результат продолжает приходить и пытается менять состояние.
Лечение:
- для
Stream—subscription.cancel()вdispose; - для
Future— проверкаmounted(в Flutter), request id или cancel token.
Ошибка 4: неправильное объединение Stream-событий
Если ваш поток генерирует события быстро, обработчик может не успевать. Тогда возможны:
- накопление очереди,
- «запаздывание» UI,
- рост памяти.
Нужно продумывать стратегию:
- дебаунс/троттлинг на входе,
- ограничение скорости обработки,
- буферизацию или пропуск промежуточных состояний.
Комментарии
Пока нет комментариев