Dart для кроссплатформенных приложений: управление состоянием без боли
Разберём подходы к состоянию и жизненному циклу: когда использовать Future/Stream, как избегать утечек подписок и как организовать обновления UI. Статья подойдёт тем, кто уже пишет на Dart и хочет сделать архитектуру чище.
Содержание
Dart для кроссплатформенных приложений: управление состоянием без боли
Кроссплатформенные приложения на Dart (чаще всего Flutter) быстро обрастают состоянием: загрузки, результаты запросов, кэш, прогресс, формы, режимы авторизации, ответы от WebSocket и т. д. Проблема в том, что состояние редко статично. Оно живёт в течение всего жизненного цикла виджета (и зачастую — дольше), меняется асинхронно и должно безопасно обновлять UI.
Если подходить к этому «на глаз», со временем появляются типичные симптомы: дублирующиеся подписки, гонки между запросами, обновления UI после удаления виджета, разъехавшиеся источники правды и трудноотлаживаемые баги. В этой статье разберём, как выстроить управление состоянием и жизненным циклом так, чтобы было меньше боли: когда использовать Future и Stream, как избегать утечек подписок, и как организовать обновления UI вокруг изменений.
Постараюсь не ограничиваться теорией. Ниже будет архитектурная логика и практические шаблоны, которые можно переносить на свои проекты. В конце — аккуратная рекомендация по углублению: курс «Dart Free» как способ структурировать материал и закрепить практику (без попыток «продать» что-то поверх проблематики).
Что именно мы называем «состоянием» в Dart-приложениях
В контексте Flutter состояние — это не только набор полей в State виджета. Чаще всего речь про три слоя:
-
UI-состояние
Всё, что влияет на отображение: выбранная вкладка, содержимое поля ввода, состояние кнопки, индикаторы загрузки, ошибки в форме. -
Состояние данных
Доменная информация, которую вы показываете: список сущностей, текущий профиль, результат вычислений, статус синхронизации. -
Жизненный цикл асинхронных операций
Куда «уезжают» запросы: отмена/прерывание, таймауты, повторные попытки, подписки на события и т. п.
Сильный дизайн обычно удерживает границу между слоями. UI не должен сам разруливать сложную асинхронную логику, иначе он становится «комбайном», в котором невозможно понять источник правды и порядок событий.
Future vs Stream: когда что выбирают и почему
Future: одноразовый результат
Future<T> — это «одна асинхронная операция, один результат (успешный или ошибка)». Его удобно применять, когда:
- выполняется запрос к API и нужен ответ один раз;
- происходит загрузка файла/конфига один раз;
- вычисление может быть асинхронным, но без потока промежуточных событий.
Пример: загрузить профиль при открытии экрана.
Future<UserProfile> fetchProfile() async {
final response = await api.get('/profile');
return UserProfile.fromJson(response.data);
}
Если вам нужно обновлять UI после Future, обычно достаточно await в методе, который живёт внутри контролирующего слоя (viewmodel/bloc/controller) или в обработчике жизненного цикла. Для Future главное — аккуратно управлять таймингом (не обновлять UI после уничтожения).
Stream: поток событий во времени
Stream<T> — «много значений со временем». Он нужен, когда:
- данные обновляются событиями (WebSocket, SSE, события БД);
- вы хотите получать прогресс загрузки;
- состояние зависит от серии действий пользователя и внешних сигналов;
- полезно моделировать повторяющуюся/переисполняемую логику (например, polling с периодом).
Типичные примеры:
- чат: поток сообщений;
- индикатор загрузки: поток прогресса;
- чтение из хранилища: поток изменений.
Stream<List<Message>> messagesStream(String chatId) {
return chatRepository.watchMessages(chatId);
}
Важное отличие: Stream чаще всего подразумевает подписку, а значит — риск утечек и гонок, если не управлять жизненным циклом подписчика.
Частая ошибка: смешивать Future и Stream без понимания гранулярности
Например, вместо Stream вы делаете повторные вызовы Future в таймере, подписываясь на результат внутри виджета. Это работает, пока не появляется:
- повторные пересоздания виджета;
- несколько конкурентных запросов;
- отсутствие отмены предыдущих задач;
- гонки между более быстрым и более медленным запросом.
Результат — «мерцающие» данные и неочевидные баги.
Правильный принцип:
- если события приходят во времени — думайте
Stream; - если нужно один результат —
Future.
Организация жизненного цикла: где хранить операции и где обновлять UI
Основной принцип: виджет — не место для «вечной логики»
Flutter-виджет может пересоздаваться часто. Даже если вы используете StatefulWidget, экземпляр State может быть уничтожен и создан повторно при навигации/перестроениях. Поэтому:
- подписки на
Streamи «вечные»Future-задачи не должны переживать жизненный циклStateбез контроля; - источники правды лучше выносить в контроллер/вью-модель, которая живёт ровно столько, сколько нужно.
Вариант по-простому: контроллер живёт в State и уничтожается в dispose(). Тогда вы защищаетесь от обновлений после удаления.
Шаблон: подписка на Stream и гарантированная отмена
Ниже — базовый каркас State, где подписка удаляется в dispose() и обновление UI идёт безопасно.
class ChatScreenState extends State<ChatScreen> {
late final StreamSubscription<List<Message>> _sub;
List<Message> _messages = const [];
@override
void initState() {
super.initState();
_sub = messagesStream(widget.chatId).listen(
(messages) {
if (!mounted) return; // защита от обновлений после удаления
setState(() => _messages = messages);
},
onError: (e, st) {
// обычно ошибка отображается отдельным состоянием
if (!mounted) return;
// setState(() => _error = e.toString());
},
);
}
@override
void dispose() {
_sub.cancel(); // отмена подписки — ключ к отсутствию утечек
super.dispose();
}
@override
Widget build(BuildContext context) {
// ...
return ListView(
children: _messages.map((m) => Text(m.text)).toList(),
);
}
}
Ключевые моменты:
StreamSubscriptionнужно хранить в поле, чтобы отменить;- проверка
mountedзащищает отsetStateпосле уничтожения; - обработка ошибок должна попадать в модель состояния (а не оставаться на уровне логов).
Что насчёт Future в жизненном цикле?
С Future «подписок» как таковых нет, но есть тайминг. Частая проблема: вы запускаете Future в initState, он завершается после того, как виджет уже исчез. Это вызывает setState по удалённому объекту.
Решение — тот же принцип mounted.
class ProfileScreenState extends State<ProfileScreen> {
bool _loading = true;
UserProfile? _profile;
Object? _error;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
try {
final profile = await fetchProfile();
if (!mounted) return;
setState(() {
_profile = profile;
_loading = false;
});
} catch (e) {
if (!mounted) return;
setState(() {
_error = e;
_loading = false;
});
}
}
@override
void dispose() {
// ничего отменять не нужно, но если внутри Future есть отменяемая операция
// (например, CancelToken), её стоит завершить тут.
super.dispose();
}
}
Отдельный нюанс: отмена «внутри» Future
Future сам по себе отменить нельзя. Но внутри вы можете использовать отменяемые механизмы:
http/dioсCancelToken/CancelToken-подобными подходами;- таймауты (
timeout); - собственный флаг отмены в репозитории.
Например, используя timeout:
final result = await fetchWithTimeout().timeout(
const Duration(seconds: 5),
);
Или репозиторий с CancelableOperation из пакетов экосистемы (в зависимости от используемых библиотек). Суть: если операция может «дожить» до уничтожения экрана, вы обязаны продумать отмену на уровне источника данных.
Избегаем утечек подписок: типичные сценарии и контрмеры
Утечки подписок чаще всего появляются не потому, что «автор забыл отменить», а потому что подписка создаётся многократно или перетирается.
Сценарий 1: подписка в build()
Если подписку случайно создавать в build(), вы получите лавину подписок при каждом перестроении.
Плохой пример (условно):
@override
Widget build(BuildContext context) {
messagesStream(widget.chatId).listen((messages) {
setState(() {});
});
return ...
}
Контрмера: подписка — только в initState, didChangeDependencies (с аккуратной логикой) или в контроллерах, которые имеют явный жизненный цикл.
Сценарий 2: подписка в didUpdateWidget без аккуратного cancel
Если chatId меняется, вы можете подписаться заново, но забыть отменить старую.
Контрмера: перед новой подпиской отменять предыдущую.
StreamSubscription<List<Message>>? _sub;
@override
void didUpdateWidget(covariant ChatScreen oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.chatId != widget.chatId) {
_sub?.cancel();
_sub = messagesStream(widget.chatId).listen((messages) {
if (!mounted) return;
setState(() => _messages = messages);
});
}
}
Сценарий 3: подписки в циклах/повторениях
Например, вы делаете for по фильтрам и каждый раз подписываетесь. Если фильтры меняются — снова появляются новые подписки.
Контрмера:
- централизовать подписку (один источник событий);
- по возможности использовать операторы
Stream(например,switchMapна стороне UI-слоя или в репозитории), чтобы старые подписки автоматически «переключались».
Сценарий 4: «вечный» Stream без потребности
Иногда в интерфейс подключают «горячие» потоки, которые продолжают работать даже когда пользователь ушёл со страницы. Если источник данных не знает об отмене, подписка нужна, чтобы остановить потребление.
Контрмера: хранить подписки и отменять в dispose. Если поток «горячий» и все равно продолжит тикать — в репозитории стоит реализовать reference counting или условную активацию (в зависимости от архитектуры).
UI-обновления: как не превращать setState в хаос
Почему «просто setState» иногда становится проблемой
setState работает, но при росте приложения появляется:
- разрастание количества полей и условной логики;
- взаимозависимость частей состояния;
- трудность тестирования (логика внутри
State); - споры о том, кто должен вызывать
setState.
При этом «жёсткие» решения вроде полного перехода на сложные фреймворки не всегда оправданы. Важно отделить модель состояния от механики отображения.
Практический подход: модель состояния как единый объект
Даже если вы остаётесь на StatefulWidget, полезно держать состояние компактно: один объект ViewState, а не куча флагов.
Например, для экрана загрузки:
sealed class ProfileState {}
class ProfileLoading extends ProfileState {}
class ProfileLoaded extends ProfileState {
final UserProfile profile;
ProfileLoaded(this.profile);
}
class ProfileError extends ProfileState {
final Object error;
ProfileError(this.error);
}
Тогда UI-обновление сводится к обновлению одной переменной:
class ProfileScreenState extends State<ProfileScreen> {
ProfileState _state = ProfileLoading();
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
try {
final profile = await fetchProfile();
if (!mounted) return;
setState(() => _state = ProfileLoaded(profile));
} catch (e) {
if (!mounted) return;
setState(() => _state = ProfileError(e));
}
}
@override
Widget build(BuildContext context) {
final s = _state;
if (s is ProfileLoading) {
return const Center(child: CircularProgressIndicator());
}
if (s is ProfileLoaded) {
return Text(s.profile.name);
}
if (s is ProfileError) {
return Text('Ошибка: ${s.error}');
}
return const SizedBox.shrink();
}
}
Это не «магия». Это уменьшает вероятность рассинхронизации: вы обновляете состояние целиком, а не частично.
Где размещать бизнес-логику: репозиторий vs контроллер vs UI-слой
Обобщим практику:
- Репозиторий отвечает за работу с данными: запросы, кэширование, транзакции, источники потоков.
- Контроллер/вью-модель отвечает за композицию: как преобразовать данные в состояние UI, как объединить события пользователя и результаты репозиториев.
- UI-слой отображает состояние и отправляет интенты (например, «нажали кнопку загрузки»).
Если вы держите асинхронщину прямо в виджете, всё начинает расползаться. Иногда это оправдано для маленьких экранов, но архитектурный долг рано или поздно придётся платить.
Когда использовать Stream в UI: StreamBuilder и его границы
Flutter предлагает StreamBuilder, который упрощает отображение потока. Он сам управляет подпиской и обновлением UI, но есть ограничения:
- логика получения данных всё равно должна быть корректной (не создавайте поток в
build()без необходимости); StreamBuilderдолжен получать стабильныйstream, иначе при каждом rebuild будет создаваться новая подписка;- обработка ошибок и пустых состояний требует аккуратной модели.
Пример с фильтрацией по chatId:
class ChatScreen extends StatelessWidget {
final String chatId;
const ChatScreen({super.key, required this.chatId});
@override
Widget build(BuildContext context) {
final stream = messagesStream(chatId);
return StreamBuilder<List<Message>>(
stream: stream,
builder: (context, snapshot) {
if (snapshot.hasError) {
return Text('Ошибка: ${snapshot.error}');
}
if (!snapshot.hasData) {
return const Center(child: CircularProgressIndicator());
}
final messages = snapshot.data!;
return ListView(
children: messages.map((m) => Text(m.text)).toList(),
);
},
);
}
}
Важно: если chatId меняется, лучше делать stream через параметры/пересоздание виджета осознанно. В больших приложениях часто выносят stream в контроллер, чтобы избежать «случайных» пересозданий.
Обновления UI при сериях событий: гонки, очередность и «последний запрос выигрывает»
Одно из самых неприятных мест — гонки между асинхронными событиями.
Типовая проблема: «быстрый запрос пришёл позже»
Сценарий:
- пользователь вводит текст в поиске;
- вы отправляете
Futureна каждый ввод; - результаты приходят не по порядку.
Если не защититься, более старый запрос перезапишет более новый результат.
Контрмеры:
- Дебаунс ввода — чтобы не отправлять на каждую клавишу.
- «Последний запрос выигрывает» — идентификатор запроса.
- Отмена предыдущих запросов на уровне репозитория (если реализуемо).
Пример с идентификатором (без отмены, но с контролем результата):
class SearchController {
int _reqId = 0;
Future<List<Item>> search(String query) async {
final current = ++_reqId;
final res = await repo.search(query);
// текущий идентификатор должен совпасть, иначе результат устарел
if (current != _reqId) {
throw StateError('stale-result');
}
return res;
}
}
В UI вы ловите stale-result и игнорируете. Это работает, но лучше — отмена на уровне HTTP-клиента. Тем не менее идентификатор полезен как универсальный страховочный механизм.
Комбинирование Stream/ Future: «состояние как результат» над несколькими источниками
Часто состояние определяется не одним Future и не одним Stream, а их комбинацией:
- пользовательские фильтры (stream)
- данные (stream от репозитория)
- флаги загрузки/ошибок
- результаты «команд» (future по нажатию кнопки)
В таких случаях полезна модель: состояние строится как проекция событий.
Пример: состояние экрана формируется из двух потоков
Допустим:
filters$— поток текущих фильтров;items$— поток данных, зависящих от фильтров.
Тогда архитектурно вы хотите:
- при смене фильтров автоматически переключать источник данных;
- коррект
Комментарии
Пока нет комментариев