$ sudo teach|
    $ sudo teach|
    IT school
  • Telegram
  • Партнёрам
  • Все курсы
$ sudo teach IT
OOO "SALEPROFIT"Контакты и реквизитыIT-Park Logo

Школа

  • Блог
  • Проверить сертификат

Сотрудничество

  • Стать учителем
  • Партнёрская программа
  • О проекте

Право

  • Оферта
  • Политика конфиденциальности

© 2023–2026 $ sudo teach IT™. All Rights Reserved. Public user contributions licensed under CC BY-SA 4.0 license with attribution required
TelegramGitHubYouTube
ГлавнаяБлогDart для кроссплатформенных приложений: управление состоянием без боли

Dart для кроссплатформенных приложений: управление состоянием без боли

$ sudo teach IT
·29 июля 2026 г.·10 мин·70
Dart для кроссплатформенных приложений: управление состоянием без боли

Разберём подходы к состоянию и жизненному циклу: когда использовать Future/Stream, как избегать утечек подписок и как организовать обновления UI. Статья подойдёт тем, кто уже пишет на Dart и хочет сделать архитектуру чище.

Содержание
Что именно мы называем «состоянием» в Dart-приложенияхFuture vs Stream: когда что выбирают и почемуFuture: одноразовый результатStream: поток событий во времениЧастая ошибка: смешивать Future и Stream без понимания гранулярностиОрганизация жизненного цикла: где хранить операции и где обновлять UIОсновной принцип: виджет — не место для «вечной логики»Шаблон: подписка на Stream и гарантированная отменаЧто насчёт Future в жизненном цикле?Отдельный нюанс: отмена «внутри» FutureИзбегаем утечек подписок: типичные сценарии и контрмерыСценарий 1: подписка в build()Сценарий 2: подписка в didUpdateWidget без аккуратного cancelСценарий 3: подписки в циклах/повторенияхСценарий 4: «вечный» Stream без потребностиUI-обновления: как не превращать setState в хаосПочему «просто setState» иногда становится проблемойПрактический подход: модель состояния как единый объектГде размещать бизнес-логику: репозиторий vs контроллер vs UI-слойКогда использовать Stream в UI: StreamBuilder и его границыОбновления UI при сериях событий: гонки, очередность и «последний запрос выигрывает»Типовая проблема: «быстрый запрос пришёл позже»Комбинирование Stream/ Future: «состояние как результат» над несколькими источникамиПример: состояние экрана формируется из двух потоков

Кроссплатформенные приложения на Dart (чаще всего Flutter) быстро обрастают состоянием: загрузки, результаты запросов, кэш, прогресс, формы, режимы авторизации, ответы от WebSocket и т. д. Проблема в том, что состояние редко статично. Оно живёт в течение всего жизненного цикла виджета (и зачастую — дольше), меняется асинхронно и должно безопасно обновлять UI.

Если подходить к этому «на глаз», со временем появляются типичные симптомы: дублирующиеся подписки, гонки между запросами, обновления UI после удаления виджета, разъехавшиеся источники правды и трудноотлаживаемые баги. В этой статье разберём, как выстроить управление состоянием и жизненным циклом так, чтобы было меньше боли: когда использовать Future и Stream, как избегать утечек подписок, и как организовать обновления UI вокруг изменений.

Постараюсь не ограничиваться теорией. Ниже будет архитектурная логика и практические шаблоны, которые можно переносить на свои проекты. В конце — аккуратная рекомендация по углублению: курс «Dart Free» как способ структурировать материал и закрепить практику (без попыток «продать» что-то поверх проблематики).


Что именно мы называем «состоянием» в Dart-приложениях

В контексте Flutter состояние — это не только набор полей в State виджета. Чаще всего речь про три слоя:

  1. UI-состояние
    Всё, что влияет на отображение: выбранная вкладка, содержимое поля ввода, состояние кнопки, индикаторы загрузки, ошибки в форме.

  2. Состояние данных
    Доменная информация, которую вы показываете: список сущностей, текущий профиль, результат вычислений, статус синхронизации.

  3. Жизненный цикл асинхронных операций
    Куда «уезжают» запросы: отмена/прерывание, таймауты, повторные попытки, подписки на события и т. п.

Сильный дизайн обычно удерживает границу между слоями. UI не должен сам разруливать сложную асинхронную логику, иначе он становится «комбайном», в котором невозможно понять источник правды и порядок событий.


Future vs Stream: когда что выбирают и почему

Future: одноразовый результат

Future<T> — это «одна асинхронная операция, один результат (успешный или ошибка)». Его удобно применять, когда:

  • выполняется запрос к API и нужен ответ один раз;
  • происходит загрузка файла/конфига один раз;
  • вычисление может быть асинхронным, но без потока промежуточных событий.

Пример: загрузить профиль при открытии экрана.

code
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 с периодом).

Типичные примеры:

  • чат: поток сообщений;
  • индикатор загрузки: поток прогресса;
  • чтение из хранилища: поток изменений.
code
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 идёт безопасно.

code
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.

code
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:

code
final result = await fetchWithTimeout().timeout(
  const Duration(seconds: 5),
);

Или репозиторий с CancelableOperation из пакетов экосистемы (в зависимости от используемых библиотек). Суть: если операция может «дожить» до уничтожения экрана, вы обязаны продумать отмену на уровне источника данных.


Избегаем утечек подписок: типичные сценарии и контрмеры

Утечки подписок чаще всего появляются не потому, что «автор забыл отменить», а потому что подписка создаётся многократно или перетирается.

Сценарий 1: подписка в build()

Если подписку случайно создавать в build(), вы получите лавину подписок при каждом перестроении.

Плохой пример (условно):

code
@override
Widget build(BuildContext context) {
  messagesStream(widget.chatId).listen((messages) {
    setState(() {});
  });
  return ...
}

Контрмера: подписка — только в initState, didChangeDependencies (с аккуратной логикой) или в контроллерах, которые имеют явный жизненный цикл.

Сценарий 2: подписка в didUpdateWidget без аккуратного cancel

Если chatId меняется, вы можете подписаться заново, но забыть отменить старую.

Контрмера: перед новой подпиской отменять предыдущую.

code
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, а не куча флагов.

Например, для экрана загрузки:

code
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-обновление сводится к обновлению одной переменной:

code
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:

code
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 на каждый ввод;
  • результаты приходят не по порядку.

Если не защититься, более старый запрос перезапишет более новый результат.

Контрмеры:

  1. Дебаунс ввода — чтобы не отправлять на каждую клавишу.
  2. «Последний запрос выигрывает» — идентификатор запроса.
  3. Отмена предыдущих запросов на уровне репозитория (если реализуемо).

Пример с идентификатором (без отмены, но с контролем результата):

code
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$ — поток данных, зависящих от фильтров.

Тогда архитектурно вы хотите:

  • при смене фильтров автоматически переключать источник данных;
  • коррект

Войдите, чтобы поставить лайк и оставить комментарий.

Автор

$ sudo teach IT

Курс по теме

Dart Free

Язык программирования Dart и бесплатный курс разработки кроссплатформенных приложений

Открыть курс

Продолжите обучение

Все курсы
Python – для начинающих!

Python – для начинающих!

С нуля до профессионального уровня. Подходит для всех. Учитесь каждый день и овладейте самым популярным языком программирования.

Перейти к курсу
Solidity DLC - поймай, если сможешь

Solidity DLC - поймай, если сможешь

Аудит смарт-контрактов на реальных взломах. The DAO, Parity, Wormhole, Ronin, Euler и ещё 7 разборов: что пошло не так, сколько потеряли и что бы спасло. Атаки и защиты на Foundry, план входа в профессию аудитора.

Перейти к курсу
Ботостроение Telegram

Ботостроение Telegram

Лёгкий, быстрый и доступный способ познакомиться с миром ботостроения в Telegram. Видео, конспекты, практика и помощь – всё у нас на курсе.

Перейти к курсу
Solidity pro - всё или ничего

Solidity pro - всё или ничего

Как пишут контракты, которые работают в основной сети. Наследование и OpenZeppelin, паттерны продакшена, оптимизация газа, ABI, delegatecall и подписи. 7 модулей, 33 урока, а в финале собственное dApp: мини-DAO с токеном, голосованием и казной.

Перейти к курсу

Другие статьи

Swift под iOS: архитектура для маленьких приложений — от MVC к MVVM без перегруза
swift

Swift под iOS: архитектура для маленьких приложений — от MVC к MVVM без перегруза

Соберём практичную структуру проекта: модели, сервисы, слой представления и правила обмена данными. Вы научитесь минимально усложнять код, чтобы он не ломался при росте.

26 июля 2026 г.
740
Dart: асинхронность для прикладных задач — Future, Stream и отмена
dart

Dart: асинхронность для прикладных задач — Future, Stream и отмена

Разберём разницу Future/Stream, как правильно собирать результаты из нескольких запросов и когда нужна отмена. На примерах покажем, как избегать подвисаний и гонок.

26 июля 2026 г.
690
Как выучить программирование самому и бесплатно: с чего начать и в каком порядке
выучить

Как выучить программирование самому и бесплатно: с чего начать и в каком порядке

Разберём понятный маршрут для новичка: как выбрать первый язык, что учить в первую очередь, какие мини-проекты делать и как не забросить занятия. Дам бесплатные ориентиры и план, чтобы знания складывались в портфолио.

21 сентября 2026 г.
1950
Что такое HTML и чем он отличается от CSS — объясню простыми словами и на примерах
такое

Что такое HTML и чем он отличается от CSS — объясню простыми словами и на примерах

Разберём, из чего состоит веб-страница: что делает HTML (смысл и структура) и что делает CSS (оформление). В конце соберёте мини-страницу и поймёте, что нужно учить в каком порядке.

30 октября 2026 г.
100
Cargo для продакшена: workspaces, фичи и стратегия версий зависимостей
cargo

Cargo для продакшена: workspaces, фичи и стратегия версий зависимостей

Разберём, как организовать Rust workspace, управлять optional features, фиксировать версии библиотек и уменьшать “дрейф” зависимостей при командной разработке и CI.

18 сентября 2026 г.
1940
Что такое SQL и зачем он нужен новичку: как думать о данных простыми словами
такое

Что такое SQL и зачем он нужен новичку: как думать о данных простыми словами

Разберём, что такое базы данных и SQL без сложной теории: как выбирать данные, фильтровать их и собирать таблицы в отчёт. В конце — короткий план, как потренироваться и собрать первые запросы для портфолио.

28 октября 2026 г.
220

Комментарии

Станьте первым в обсуждении

Поделитесь мнением о статье или задайте вопрос автору

Содержание

Что именно мы называем «состоянием» в Dart-приложенияхFuture vs Stream: когда что выбирают и почемуFuture: одноразовый результатStream: поток событий во времениЧастая ошибка: смешивать Future и Stream без понимания гранулярностиОрганизация жизненного цикла: где хранить операции и где обновлять UIОсновной принцип: виджет — не место для «вечной логики»Шаблон: подписка на Stream и гарантированная отменаЧто насчёт Future в жизненном цикле?Отдельный нюанс: отмена «внутри» FutureИзбегаем утечек подписок: типичные сценарии и контрмерыСценарий 1: подписка в build()Сценарий 2: подписка в didUpdateWidget без аккуратного cancelСценарий 3: подписки в циклах/повторенияхСценарий 4: «вечный» Stream без потребностиUI-обновления: как не превращать setState в хаосПочему «просто setState» иногда становится проблемойПрактический подход: модель состояния как единый объектГде размещать бизнес-логику: репозиторий vs контроллер vs UI-слойКогда использовать Stream в UI: StreamBuilder и его границыОбновления UI при сериях событий: гонки, очередность и «последний запрос выигрывает»Типовая проблема: «быстрый запрос пришёл позже»Комбинирование Stream/ Future: «состояние как результат» над несколькими источникамиПример: состояние экрана формируется из двух потоков