BLoC выносит бизнес-логику из виджетов: UI передаёт ввод внутрь, блок выпускает наружу неизменяемые состояния, а виджеты перестраиваются по последнему из них. Cubit принимает ввод вызовами методов, а Bloc принимает его объектами событий, которые обрабатывает on<Event>.
**Cubit**: публичные методы вызывают emit(newState); меньше всего кода, для большинства экранов этого хватает
**Bloc**: любой ввод это класс события, поэтому у каждого изменения есть именованная причина, которую может залогировать BlocObserver.onTransition
**Трансформеры событий есть только у Bloc: debounce, отбрасывание, последовательность
Что выбрать**: начинайте с Cubit; переходите на Bloc, когда входящие действия конкурируют друг с другом или нужна трассировка на уровне событий. Переход механический, UI почти не меняется.
FlutterMiddle
Состояние на BLoC
Паттерн BLoC, Cubit, Events, States, BlocBuilder, BlocConsumer
Закрыто вопросов
0 / 22
- 0Повторить
- 4Часто спрашивают
Вопросы
Все четыре подписываются на один и тот же блок: BlocBuilder и BlocSelector перестраивают UI, BlocListener выполняет побочные эффекты без перестройки, а BlocConsumer делает и то и другое. buildWhen и listenWhen получают (previous, current) и решают, дойдёт ли это изменение до builder или до listener.
**BlocBuilder**: перестраивается на каждое изменение состояния; buildWhen сужает это до изменений, которые важны виджету
**BlocSelector**: достаёт из состояния одно значение и перестраивается, только когда оно меняется по ==; самый простой способ читать одно поле
**BlocListener**: вызывается один раз на изменение состояния для навигации, снекбара или диалога; свой child он не перестраивает
**BlocConsumer**: builder и listener на одном блоке, у каждого свой buildWhen и listenWhen
**Почему эффекты не кладут в builder**: Flutter может вызвать builder много раз для одного состояния (перестройка родителя, смена темы), и переход или снекбар оттуда повторится
**MultiBlocListener**: убирает вложенность нескольких listener-ов так же, как MultiBlocProvider для провайдеров
BlocSelector перестраивается, когда выбранное значение не == предыдущему, а этот selector каждый раз возвращает новый List из .toList(). Списки в Dart сравниваются по ссылке, поэтому любое изменение состояния, даже новый поисковый запрос, перестраивает весь список.
Как selector решает: после каждого изменения состояния он вызывает selector и сравнивает результат с прошлым через ==; если равны, перестройки нет. Свежий список никогда не равен прошлому, и виджет ведёт себя как обычный BlocBuilder.
Исправление: выбирайте то, что сохраняет ссылку или значение: state.todos остаётся тем же экземпляром, пока задачи действительно не изменятся (неизменяемое состояние плюс copyWith), а фильтрация переезжает в builder. Подойдут и количество, bool или Equatable-объект.
То же правило в других местах: context.select сравнивает так же, поэтому любой селектор, который собирает список, map или set через where, map или spread, перестраивает виджет на каждое изменение.
Производный список нужен часто: храните его в состоянии как поле, которое вычисляет cubit, или перейдите на BlocBuilder и сравнивайте списки через ListEquality из package:collection в buildWhen.
Как поймать: отслеживание перестроек в Flutter DevTools или widget-тест, который меняет только запрос и считает вызовы builder.
BlocProvider(create:) создаёт блок и владеет им: блок закрывается, когда этот провайдер уходит из дерева. BlocProvider.value одалживает экземпляр, которым владеет кто-то другой, и никогда его не закрывает, а именно это и нужно открытому маршруту или диалогу.
Почему новый экран не находит блок: страница монтируется под Navigator, а не под экраном, который её открыл, а context.read ищет только среди предков; вы получите ProviderNotFoundException («Could not find the correct Provider<CartBloc>»); BlocProvider.of напишет «BlocProvider.of() called with a context that does not contain a CartBloc»
**context в builder маршрута принадлежит самому маршруту**: поэтому context.read<CartBloc>() внутри MaterialPageRoute(builder: ...) тоже падает; читайте блок до push
Решение: final cart = context.read<CartBloc>();, затем BlocProvider.value(value: cart, child: DetailsPage()); с диалогами и bottom sheet так же
**Никогда не оборачивайте существующий блок в create:: при закрытии страница закроет блок, которым не владеет
Блок нужен многим маршрутам**: поднимите провайдер выше MaterialApp или в ShellRoute из go_router, а не передавайте его по цепочке
**MultiBlocProvider**: плоский список вместо вложенных провайдеров; элементы вкладываются по порядку списка, поэтому следующий create может прочитать предыдущий
emit отбрасывает новое состояние, если оно == текущему (после первого emit), и BlocSelector, buildWhen и bloc_test тоже сравнивают через ==. Без равенства по значению == означает идентичность, и те же данные в новом объекте всегда считаются новым состоянием.
Лишние перестройки и эффекты: повторная загрузка тех же данных перестраивает каждый BlocBuilder и снова вызывает каждый BlocListener, так что один результат может показать снекбар дважды
Тесты падают: expect: () => [ProfileLoaded(user)] никогда не совпадёт: ожидаемое и выпущенное состояния остаются разными объектами
Как его получить: наследовать Equatable и перечислить все поля в props, сгенерировать через freezed или написать == и hashCode вручную; equatable и freezed сравнивают списки и map по содержимому
Вложенным моделям оно тоже нужно: props: [user] помогает, только если у самого User есть равенство по значению
Обратная сторона: поле, забытое в props, или список, изменённый на месте, делают реальное изменение «равным», и emit пропадает
То же состояние намеренно: чтобы показать одну и ту же ошибку два раза подряд, добавьте в состояние счётчик или метку времени
emit пропускает состояние, которое == текущему. state.items.add(item) меняет список, который уже лежит в текущем состоянии, а emit(state) передаёт тот же самый экземпляр, поэтому каждый emit после первого отбрасывается и ничего не перестраивается.
Почему первое нажатие работает: bloc пропускает самый первый emit, даже если состояние равно начальному, поэтому первый изменённый список отрисовывается; со второго нажатия emit выходит сразу
Новая обёртка не спасает: emit(CartState(state.items)) это новый объект вокруг того же изменённого списка; Equatable сравнивает содержимое списков, а с обеих сторон один и тот же список, так что они всё равно равны
Решение: собрать новый список: emit(CartState([...state.items, item])), а списки в состоянии держать неизменяемыми (List.unmodifiable или freezed), чтобы изменение на месте бросало исключение, а не ломалось молча
Другие причины «потерянного» emit: поле, забытое в props, buildWhen или BlocSelector, отфильтровавший изменение, или виджет, читающий другой экземпляр, созданный вторым BlocProvider(create:)
Быстрая диагностика: логируйте onEvent и onChange в BlocObserver: событие без изменения значит, что его «съело» равенство; если нет даже события, виджет общается с другим экземпляром
BlocProvider по умолчанию ленивый: create не выполняется, пока блок кто-нибудь не прочитает, а блок, который подписывается в конструкторе, до этого момента просто не существует.
Что запускает создание: первый context.read, context.watch, BlocProvider.of или BlocBuilder ниже провайдера. Если провайдер просто стоит в дереве или числится в MultiBlocProvider, создание не запускается.
**Решение: lazy: false: для блоков, работа которых начинается раньше любого виджета: сессия, сеть, пуш-токены, remote config
Для экранных блоков оставьте ленивое создание**: и запускайте их явно через ..add(const FeedStarted())
**BlocProvider.value ленивым не бывает: он принимает готовый экземпляр и закрывать его тоже не станет
Это симптом, а не загадка**: несозданный блок никогда не закрывается, поэтому брейкпойнт в close() молчит не просто так
Разовый эффект лежит в состоянии обычным полем, поэтому все следующие состояния тащат его за собой, и BlocListener показывает его снова.
Почему повторяется: listener срабатывает на каждое изменение состояния; переключение фильтра выпускает состояние, у которого другой filter, но всё тот же message: 'Saved'
И переживает перемонтирование: после закрытия маршрута или смены вкладки первое же изменение приносит устаревшее поле, а hydrated_bloc ещё и запишет его на диск
Способ 1: сбросить после показа: показать в listener, а сразу после добавить событие, обработчик которого выпустит состояние с очищенным message
Способ 2: держать эффекты вне состояния: отдельный Stream<FeedEffect> из блока, подписка в initState, отмена в dispose
Проверка: если поле окажется неверным при повторной отрисовке того же состояния, это эффект, а не состояние
add лишь кладёт событие в поток событий блока. Обработчик запустится в лучшем случае через микротаск, поэтому на следующей строке ещё ничего не выпущено.
Как устроен конвейер: add кладёт событие в StreamController; его забирает трансформер, а debounce может придержать на секунды; обработчик выполняется, emit синхронно обновляет state; BlocBuilder узнаёт об этом ещё через микротаск
**Значит, state свежий сразу после emit внутри обработчика**: и устаревший сразу после add на месте вызова
Почему так сделано: именно очередь позволяет существовать трансформерам, порядку и BlocObserver
Пишите это слушателем: BlocListener с listenWhen, который закрывает экран, когда status стал success; этот listener и есть тот колбэк, который вы пытались написать в строку
Если вызывающей стороне правда нужен future: сделайте метод Cubit, возвращающий Future<void>; await bloc.stream.firstWhere(...) работает, но пропускает состояния, выпущенные до подписки, и зависает, если условие не выполнится
Обработчик вернул управление раньше, чем закончил работу. Emitter действителен только до завершения future, который вернул обработчик, а колбэк вызвал emit уже после этого.
Здесь: обработчик не async и не возвращает future из then, поэтому завершается сразу, а колбэк позже пишет в мёртвый emitter. Сделайте его async и дождитесь загрузки через await.
Та же ошибка в других обличьях: забытый await, stream.listen((v) => emit(...)) внутри обработчика, Timer.periodic, Completer, который завершает кто-то другой
Привяжите стрим к обработчику: await emit.forEach(...) и await emit.onEach(...) существуют именно для этого
Отмена это не то же, что завершение: после отмены через restartable() emit молча ничего не делает, а после нормального завершения даёт эту ошибку; длинные циклы страхуйте через if (emit.isDone) return;
Только ассерт в debug: сообщение приходит из assert, поэтому релизная сборка не бросает исключение, а поздний emit всё равно проходит; так баг доживает до продакшена как emit не в порядке или после close(). У Cubit emitter нет: emit после close() бросает исключение в любой сборке, поэтому проверяйте isClosed после каждого await.
Выполнятся оба обработчика: LoggedIn выпустит AuthLoading из общего обработчика, а затем Authenticated из конкретного. Общий обработчик ошибочен, потому что он заодно поглощает все будущие события, у которых нет своего обработчика.
Правила «срабатывает первый подходящий» нет: Bloc даёт каждому on<E> собственную подписку с фильтром event is E, поэтому LoggedIn подходит и под on<AuthEvent>, и под on<LoggedIn>
Два писателя, одно состояние: общий обработчик выпускает AuthLoading, а конкретный выпускает результат; у каждого свой emitter, и работают они конкурентно, поэтому если бы общий обработчик выпускал состояние после await, он мог бы перезаписать Authenticated
Баг похуже: событие без своего обработчика, например TokenRefreshed, добавленный в следующем спринте, теперь ловит только общий обработчик: спиннер крутится вечно, а debug-проверка «add(TokenRefreshed) was called without a registered event handler» так и не срабатывает
Порядок регистрации не даёт приоритета: он лишь определяет, какая подписка получит событие первой
Один и тот же конкретный тип дважды запрещён: второй on<LoggedIn> в debug бросает StateError: на один тип события допускается один обработчик
Решение: логирование уходит в BlocObserver.onEvent, который видит все события всех блоков и не встаёт в цепочку обработчиков; состояние загрузки каждый обработчик выпускает сам
Ожидаемые сбои становятся состоянием, а неожиданные уходят в телеметрию. Первое делает try/catch внутри обработчика, а второе обеспечивают addError и onError.
**try/catch в обработчике**: превратите исключение в выпущенное состояние, emit(Failure(message)). Сюда относятся ошибки сети, валидация и ответы 4xx.
**addError(error, stackTrace)**: записывает ошибку, не меняя состояния; уходит в onError и дальше в observer. Берите редко, для действительно неожиданного.
**Bloc.onError**: последний рубеж для конкретного блока, а onError у observer работает на всё приложение и годится для Crashlytics или Sentry. Всегда зовите super.onError.
Частая ошибка: дать обработчику упасть с исключением: нужный emit так и не случится, и экран останется на старом состоянии
Подписка должна жить внутри блока: либо ею владеет emitter внутри обработчика, либо сам блок, превращая данные в приватные события.
**await emit.forEach(repo.watch(), onData: ..., onError: ...)**: самый короткий правильный вариант, когда блок просто отражает стрим; обработчик живёт столько же, сколько стрим, и bloc отменит подписку при close()
**await emit.onEach(...)**: то же время жизни, но onData ничего не возвращает: emit вы вызываете сами, ноль или несколько раз на элемент
Либо подписка в конструкторе: _sub = repo.watch().listen((d) => add(_DataReceived(d)));, а в close() её отменить. Обновления идут обычным конвейером, поэтому их видит observer и к ним применяются трансформеры.
**Регистрируйте такой обработчик как restartable(): иначе повторное событие заведёт вторую подписку рядом с первой
Не держите Emitter дольше его обработчика**: поздний emit бросит исключение
BlocObserver работает как один глобальный слушатель всех Bloc и Cubit в приложении. Его назначают один раз (Bloc.observer = AppBlocObserver(); в main() до runApp()), и он видит всё, а сами блоки о нём не знают.
Что можно переопределить: onCreate, onEvent (только у Bloc), onChange, onTransition (только у Bloc), onError, onClose
**onChange против onTransition: change это «старое состояние → новое», а transition называет ещё и событие-причину; поэтому у Cubit переходов нет
Для чего он нужен: лог всех переходов при отладке, аналитика, отправка падений в Sentry или Crashlytics, замеры производительности
Вызывайте super**: базовые методы пустые, но линт @mustCallSuper его требует, и код останется верным, если в базовом классе появится логика
Сквозную логику держат здесь: observer видит все блоки, и не нужно добавлять обработчик-логер в каждый из них
bloc_test даёт хелпер blocTest: он собирает блок, отправляет в него события и проверяет точную последовательность выпущенных состояний, без дерева виджетов.
Из чего состоит: build создаёт блок с моками зависимостей, seed задаёт стартовое состояние, act добавляет события, expect перечисляет состояния по порядку
**wait: подождать, пока сработает debounce или throttle у трансформера
verify: дополнительные проверки после того, как состояния сверены
Состояния должны сравниваться**: через equatable или freezed, иначе expect их не сопоставит; матчеры isA<T>() подходят, когда важен только тип
**Зависимости мокайте через mocktail: подмените репозиторий, а не ходите в сеть
Эффекты в UI**: их проверяют виджет-тестом, подставив фейковый блок через BlocProvider.value
hydrated_bloc сохраняет состояние блока в локальное хранилище как JSON и восстанавливает при следующем запуске; так переживают перезапуск тема, язык или черновик.
Как: наследуйтесь от HydratedBloc или HydratedCubit и реализуйте fromJson и toJson; верните null из toJson, чтобы это состояние не сохранялось
Настраивается один раз: инициализируйте HydratedBloc.storage в main() до runApp()
Что сохранять: тему, язык, пройденный онбординг, последний фильтр, черновик текста
Что не сохранять: состояние авторизации (выводите его из свежего токена), любые секреты (flutter_secure_storage) и большие данные (полноценная база)
Миграции: если форма состояния изменилась, fromJson обязан понять старую или вернуть null и откатиться к начальному; поле с версией внутри JSON служит самым дешёвым способом
В тестах: подставляйте фейковый Storage в памяти в setUp, никогда не настоящий на диске
BLoC самый явный и прослеживаемый, но требует больше всего классов, Riverpod гибок и с меньшим количеством церемоний, ChangeNotifier проще всего и слабее всего на масштабе. Для чата я бы выбрал BLoC (стримы, порядок сообщений, лог переходов); Riverpod будет таким же разумным выбором для команды, которая его знает.
**Provider + ChangeNotifier**: изменяемый объект плюс notifyListeners(); шаблонного кода почти нет, но ничто не фиксирует, почему изменилось состояние, состояния загрузки и ошибки пишутся вручную, а забытый notifyListeners() превращается в тихий баг
Riverpod: провайдеры объявлены вне дерева, поиск проверяется на этапе компиляции, AsyncValue для загрузки и ошибок, auto-dispose и family. Платить за это приходится собственными понятиями (жизненный цикл ref, кодогенерация).
BLoC: любое изменение проходит через событие или метод Cubit в неизменяемое состояние; BlocObserver логирует каждый переход, трансформеры управляют конкурентностью, bloc_test проверяет точную последовательность состояний. Платить за это приходится наибольшим числом классов и файлов на фичу.
Почему BLoC для чата: входящие сообщения это стрим (emit.forEach), отправки должны идти по порядку (sequential()), а лог переходов выручает, когда сообщение «пропало»; со StreamProvider или AsyncNotifier Riverpod закрывает то же самое
Трансформер решает, как события выстраиваются в очередь к одному обработчику on<Event>. По умолчанию concurrent() запускает их параллельно; sequential() ставит в очередь, droppable() игнорирует новые, пока занят, restartable() отменяет текущий.
**concurrent(): по умолчанию: каждое событие запускает свой обработчик параллельно, emit могут прийти в любом порядке, для пользовательских сценариев обычно неверно; безопасно, только когда обработчики действительно независимы
sequential(): по одному, в порядке поступления; для записей, где важен порядок: обновления корзины, цепочка сохранений
droppable(): игнорировать новые события, пока одно в работе; кнопка «Отправить», которая не должна стрелять дважды
restartable(): отменить текущий обработчик и начать заново с новым событием; поиск по мере ввода, автодополнение
Подвох**: у отменённого обработчика emitter мёртв, поэтому в длинной цепочке проверяйте emit.isDone перед отправкой
Debounce ставят перед политикой: сначала debounce по потоку событий (остаётся последнее событие после паузы), затем restartable(); в коде debounceTime(d).switchMap(mapper) даёт поведение restartable
current хранит снимок state, сделанный до await. Когда два вызова add пересекаются, оба строят список из одного и того же старого снимка, поэтому вызов, завершившийся последним, затирает другой, и один товар молча пропадает из корзины.
Почему так происходит: Cubit не ставит вызовы методов в очередь: второй add стартует, пока первый ждёт на await, и оба захватили один и тот же state.items. Побеждает ответ, пришедший последним, и это классическое потерянное обновление (lost update).
Исправление: читайте state после await, прямо там, где вызываете emit, чтобы каждое обновление строилось на свежем списке. Между этим чтением и синхронным emit никакой другой код выполниться не может, так что ничего не вклинится.
Почему баг плавающий: нужны два одновременно выполняющихся вызова add (быстрый двойной тап, медленная сеть); по одному всё работает. Тот вызов, что завершится вторым, затирает товар другого, независимо от порядка ответов. Бэкенд добавил оба, поэтому после перезагрузки корзина правильная.
Если нужна ещё и очередь запросов: переходите на Bloc с sequential() из bloc_concurrency, чтобы пересекающиеся добавления вставали в очередь; droppable() подходит для кнопки отправки, где повторный тап нужно игнорировать, но не для добавления в корзину.
Как поймать: blocTest, в котором act вызывает add дважды без await, а мок задерживает каждый ответ; в итоговом состоянии ожидаются оба товара.
Не давайте одному блоку звать другой напрямую: так ни один из них нельзя протестировать отдельно. Координировать должен кто-то снаружи.
Общий репозиторий, лучше всего для сигналов уровня приложения: оба блока зависят от сервиса со стримом изменений, каждый реагирует сам, и друг о друге они не знают. Сессия, сеть, статус синхронизации.
**BlocListener в виджете, лучше всего для одного экрана**: на нужном переходе блока A он зовёт context.read<B>().add(...); оркестрирует виджет, блоки остаются в неведении
**Подписка на stream другого блока, крайний случай**: один блок слушает другой и добавляет свои приватные события; связь теснее, и подписку обязательно отменять в close()
Правило: репозиторий для общих сигналов, listener для координации на экране, подписка на стрим только на случай, когда ничего другого не подошло
Никогда: импортировать блок B внутрь блока A и звать его методы
Держите данные и статус в одном объекте состояния, чтобы обновление меняло статус, не выбрасывая уже показанные элементы.
Один класс со статусом: status, posts, error в одном неизменяемом классе с copyWith. Обновление это state.copyWith(status: FeedStatus.loading), а UI продолжает рисовать state.posts под маленьким индикатором.
**Sealed-иерархия тоже подходит, если у загруженного состояния есть флаг isRefreshing**: в обычном Loading элементов нет, поэтому обновление рисует пустой экран
Как выбирать: объединение, когда формы не связаны между собой, например «вышел» и «вошёл»; статус плюс данные, когда один экран грузит, обновляет и листает одну коллекцию
Различайте «пусто» и «ещё не загружено»: пустой список и незагруженный список не должны рисоваться одним виджетом
В любом случае: равенство по всем полям, иначе emit проглотят; список не меняйте на месте; разовые эффекты вроде String? snackbarMessage в состоянии не держите
Блоки, объявленные выше MaterialApp, живут всё время работы приложения. Выход из аккаунта лишь сменил маршрут, а каждый блок сохранил состояние, подписки и кеши прошлого пользователя.
Почему состояние выживает: BlocProvider закрывает созданный блок только когда сам уходит из дерева, а переход на экран входа убирает экраны, а не провайдеры выше Navigator
Простой перестройки мало: те же типы виджетов на тех же местах, и Flutter переиспользует элементы, а переиспользованный BlocProvider оставит себе прежний блок
**Привяжите ключ поддерева к id пользователя, выше Navigator**: стройте MultiBlocProvider(key: ValueKey(auth.userId), ...) в MaterialApp.builder из состояния авторизации: новый id размонтирует старые элементы, и все блоки закроются и создадутся пустыми. Держите его выше Navigator, иначе открытые маршруты не найдут блоки (go_router: то же в ShellRoute или builder)
То, что обязано жить всегда, обрабатывает выход явно: отмените подписки репозиториев, очистите кеши, сделайте await HydratedBloc.storage.clear() и выпустите начальное состояние
Что всё равно протечёт: запросы в полёте завершатся уже в новой сессии, а синглтоны репозиториев, cookie и кеш картинок живут вне блоков