Эфемерное состояние принадлежит одному виджету и умирает вместе с ним (фокус поля, выбранная вкладка, анимация). Состояние приложения общее для нескольких экранов или должно их пережить (вошедший пользователь, корзина, настройки). Держите значение настолько локально, насколько позволяют его читатели.
Задайте три вопроса: читает ли его кто-то снаружи виджета, должно ли оно пережить уход с экрана, важно ли оно бэкенду? Три «нет» означают, что его место в setState внутри State
Локальное дешевле: значение диспозится вместе с виджетом, а изменение перестраивает только его поддерево
Вынос всего в один стор обходится дорого: при повторном открытии экран показывает поисковый запрос из прошлого визита, а каждое нажатие клавиши будит слушателей по всему приложению
Поднимайте, когда появился второй читатель: вынести поле выше потом можно механической правкой; опускать глобальное состояние обратно куда дороже
FlutterMiddle
Моделирование состояния
Иммутабельность, freezed, sealed-классы, equality, оптимистичные апдейты, восстановление
Закрыто вопросов
0 / 19
- 0Повторить
- 2Часто спрашивают
Вопросы
Неизменяемое состояние никогда не меняют на месте: каждое обновление создаёт новый объект, обычно через copyWith. Холдер состояния узнаёт об изменении, сравнивая старое состояние с новым, а изменённый объект, сравнённый сам с собой, выглядит неизменившимся, и ничего не перестраивается.
Обнаружение изменений: emit в bloc и нотифаеры Riverpod пропускают новое состояние, равное текущему; тот же самый изменённый экземпляр равен ему всегда
Безопасное совместное использование: виджет, логгер и история для undo могут держать одно и то же состояние, и никто не поменяет его под ногами у остальных
Старое и новое рядом: listenWhen в BlocListener и ref.listen получают previous и next, а это имеет смысл, только если previous не перезаписали
Как: final-поля, const-конструктор, copyWith и коллекции, которые копируют, а не мутируют
sealed-классом с подтипом на каждое состояние, где каждый несёт только свои данные. Отдельные поля isLoading, error и data допускают комбинации, которых быть не должно, например спиннер поверх баннера ошибки, и ничто не заставляет UI обработать каждый случай.
Флаги размножаются: три флага дают восемь комбинаций, легальны из них три-четыре; остальные появляются, когда два колбэка приходят в неудачном порядке
Каждое состояние несёт свои данные: у FeedLoaded есть элементы, а у FeedFailed есть сообщение; прочитать элементы, которые ещё грузятся, нельзя
**Исчерпывающий switch**: добавьте состояние, и каждый switch, который его не учёл, перестанет компилироваться, вместо того чтобы провалиться в else с пустым экраном
Обновление без мигания: если старые строки должны оставаться на экране во время перезагрузки, добавьте загруженному состоянию флаг isRefreshing, а не возвращайтесь в Loading; AsyncValue в Riverpod хранит предыдущее значение по той же причине
История: до Dart 3 для этого нужны были юнионы freezed; сегодня хватает обычного sealed-класса
Wildcard _ ловит каждое состояние, которое switch не назвал, поэтому OrdersStale попадает в SizedBox.shrink() и ничего не рисует. К тому же wildcard отключил проверку исчерпываемости, и новый подтип скомпилировался без единой ошибки.
Почему компилируется: switch по sealed-типу обязан покрыть каждый подтип, а _ покрывает их все сразу; когда появляется новый подтип, компилятору не о чем сообщать
Исправление: перечислите каждый подтип и уберите wildcard. Тогда следующее новое состояние сломает сборку с ошибкой «The type 'OrdersState' isn't exhaustively matched by the switch cases» в каждом switch, который должен его обработать
Та же дыра: default: в switch-операторе или цепочка if (state is …) … else прячут новые состояния точно так же
Как поймать: когда _ стоит после веток, уже покрывающих все подтипы, анализатор предупреждает «This case is covered by the previous cases»: удалите wildcard, а не игнорируйте предупреждение. Виджет-тест на каждое состояние поймает пустой экран
sort() переставляет собственный список Cubit на месте, поэтому выпущенное состояние меняется в обход Cubit, и новый порядок видят все, кто его держит. final запрещает только переприсвоить поле; сам List, на который оно указывает, остаётся изменяемым.
Уведомления не будет: ничего не эмитилось, поэтому другие экраны продолжают рисовать из объекта состояния, который тихо поменялся
Страдают все держатели: второй экран, сортирующий по имени, прошлое состояние для undo, тестовая фикстура: у всех один и тот же список
Может и упасть: если список создан как const [] или через List.unmodifiable, sort бросает Unsupported operation
Как чинить: сортируйте копию, [...state.items]..sort(...), а лучше отсортируйте один раз в Cubit и эмитьте уже отсортированный список; build никогда не должен менять свои входные данные
Сделайте ошибку громкой: оборачивайте коллекции в List.unmodifiable в конструкторе состояния, чтобы случайный add или sort падал в debug и в тестах
Переопределите == и hashCode, чтобы два состояния с одинаковыми полями были равны (вручную, через Equatable или с генерацией через freezed). Холдеры состояния и селекторы сравнивают состояния через ==, решая, уведомлять ли, так что именно равенство определяет, что перестроится.
Без него: == по умолчанию сравнивает идентичность, поэтому два одинаковых состояния, созданных отдельно, «разные», и каждый emit вызывает ребилд
Ловушка кроется в коллекциях: два отдельно созданных списка с одинаковыми элементами не равны; рукописному == нужны listEquals, mapEquals или DeepCollectionEquality. Equatable и freezed сравнивают коллекции глубоко сами
**hashCode должен согласовываться**: у равных объектов равные хеши, Object.hash(Object.hashAll(items), loading), иначе ломается поиск в Set и Map
Что выбрать: Equatable для нескольких классов без кодогенерации; freezed, если заодно нужны copyWith и JSON. Записи вида (a, b) сравниваются структурно, но список внутри записи всё равно сравнивается по ссылке
Помните о цене: глубокое сравнение 10 000 строк на каждом emit означает реальную работу; если у списка один владелец, сравнивайте поле версии или updatedAt
onlyInStock нет в props, поэтому состояние, которое отличается только этим флагом, == текущему. emit в bloc отбрасывает состояние, равное текущему, и переключение перестаёт доходить до экрана.
Исправление: List<Object?> get props => [query, onlyInStock];; каждое поле, влияющее на UI, должно быть в props
Почему молча: ни ошибки, ни лога: равное состояние значит «ничего не изменилось», и BlocSelector, buildWhen или context.select отфильтровали бы его так же
Сработает раз и замрёт: самый первый emit свежего Cubit пропускается, даже если состояние равно текущему; все следующие отбрасываются, поэтому переключатель вроде бы срабатывает один раз и дальше замирает
Откуда берётся: поле добавили через несколько месяцев после создания класса и забыли про props; freezed выводит == из конструктора, так что разъехаться они не могут
Ловите тестом: expect(state.copyWith(onlyInStock: true), isNot(state)) или blocTest, в expect которого есть пропавшее состояние
На каждом уровне нужно перечислить все поля, поэтому вложенное обновление превращается в цепочку вызовов copyWith, где одно забытое поле молча сохраняет старое значение, а передача null ничего не очищает. freezed генерирует ==, hashCode и глубокий copyWith, в которых обе проблемы решены.
Вложенность: сменить город значит написать state.copyWith(user: state.user.copyWith(address: ...)), и каждый рукописный уровень добавляет ещё одно место, где теряется поле
Ловушка с null: рукописное тело читает middleName ?? this.middleName, поэтому copyWith(middleName: null) значит «без изменений»; freezed использует значение-маркер, и null действительно очищает поле
Глубокое копирование: у вложенных freezed-типов есть profile.copyWith.address(city: 'Almaty')
Цена: build_runner, сгенерированный part-файл на каждую модель и шаг кодогенерации в CI; для трёх маленьких классов хватит Equatable и рукописного copyWith
Или сделайте плоско: состояние в четыре уровня глубиной часто просит нормализации в мапы по id
load() ждёт ответ сети и затем вызывает emit, но к моменту ответа BlocProvider мог уже закрыть cubit, потому что экран закрыли. emit на закрытом cubit бросает StateError, а так как load() никто не ждёт, ошибка превращается в необработанное асинхронное исключение.
Почему: await отдаёт управление, а время жизни cubit привязано к экрану, а не к запросу; после close() любой emit бросает исключение
Исправление: проверяйте isClosed после каждого await перед emit: if (isClosed) return;. В bloc обработчики событий, зарегистрированные через on<E>, уже игнорируют emit после закрытия, поэтому проблема в основном у cubit
Лучше: ещё и остановите саму работу: храните StreamSubscription или токен отмены и отменяйте их в переопределённом close(), чтобы закрытый экран не продолжал ходить в сеть
Как поймать: тест, который вызывает load(), затем close(), затем завершает фейковый API и ожидает отсутствия ошибки
Производные значения вычисляют из состояния и не хранят, а экран читает показываемое состояние, а не копирует его. Сохранённый total устаревает в момент применения промокода, а скопированный _name остаётся старым после правки.
Копии: экран, который в initState читает профиль в поле String _name, держит снимок навсегда; читайте его в build через watch, StreamBuilder или селектор
Производные значения делайте геттерами: double get total => subtotal - discount; не может устареть; сохранённое поле total должен пересчитывать каждый, кто пишет, и кто-нибудь забудет
**Держите производное вне диска и вне ==: сериализованная устаревшая сумма вернётся после перезапуска; если вычисление дорогое, кешируйте его в холдере с ключом по входным данным, и только после замера
Черновик не является копией**: текст в поле, пока пользователь печатает, остаётся черновиком; истиной он становится при сохранении
Когда они меняются вместе или должны быть согласованы друг с другом. Отдельные нотифаеры обновляются по одному, поэтому слушатели срабатывают между записями и видят комбинацию, которая не является допустимым состоянием.
Рваное чтение: каждое .value = уведомляет синхронно; слушатель filter, который запускает загрузку, ещё видит page == 4 и грузит четвёртую страницу нового фильтра. Поставьте await между записями, и смесь попадёт ещё и в кадр
Инвариантам нужен дом: «в выделении только видимые элементы» касается двух значений; в одном классе это метод, а при разных нотифаерах правило копируется в тот виджет, который заметил проблему
Цену ребилдов решают на стороне чтения: если один большой объект перестраивает слишком много, сузьте чтение через context.select или BlocSelector, а не дробите модель
Разделяйте по времени жизни или частоте записи: смещение скролла, которое пишется 60 раз в секунду, или сессия, переживающая экран, заслуживают своего холдера
Чтобы каждая сущность хранилась один раз, а списки держали только id. Тогда правка затрагивает одну запись, и её видят все экраны; не нужно обновлять копии одного поста в ленте, профиле и поиске и рисковать пропустить одну из них.
Форма: Map<PostId, Post> posts плюс List<PostId> feed и List<PostId> saved; строка при сборке находит posts[id]
Одна запись на изменение: лайк заменяет posts[id]; с вложенными копиями придётся искать по всем спискам, и забытый покажет старое число лайков
Узкие ребилды: строка выбирает свой state.posts[id] и перестраивается только при изменении этого поста
Цена: лишний поиск по ключу, id, указывающие в пустоту после удаления, и код, который сложнее читать; удаляя сущность, убирайте её id из всех списков
Когда вложенность нормальна: данные показаны в одном месте, только для чтения или целиком заменяются при каждой загрузке; нормализуйте, когда одна и та же сущность появляется и меняется в нескольких местах
Держите один черновик, которым владеет весь флоу, с nullable-полями без валидации, и превращайте его в провалидированный доменный объект один раз, при отправке. Черновик создаёт и диспозит маршрут флоу, поэтому при выходе назад ничего не нужно чистить.
Два типа: у SignupDraft поля вида String? email; у Signup поля non-nullable и провалидированы, а один toSignup() либо собирает его, либо сообщает, чего не хватает
Зачем разделять: иначе доменная модель навсегда получает nullable-поля ради визарда, и каждому следующему потребителю приходится проверять их на null
Владеет флоу, а не шаг: каждый шаг правит общий черновик; шаг, который держит свои данные сам, теряет их, когда пользователь возвращается назад
Привязка к маршруту: создавайте холдер в маршруте флоу, например BlocProvider вокруг степпера; если это синглтон, следующий запуск откроется заполненным данными брошенного
Коммит один раз: вызывайте репозиторий после последнего шага и очищайте черновик после успеха; сохранение каждого шага на сервер превращает каждый брошенный флоу в осиротевшую запись, если только продукт явно не хочет «продолжить позже»
Сразу примените изменение к состоянию, пометьте элемент как ожидающий, отправьте запрос, а при ошибке отмените ровно это изменение и сообщите пользователю. Пометка «ожидает» говорит и UI, и откату, какое изменение ещё не подтверждено.
Статус на элемент, а не на экран: при двух сохранениях в полёте один флаг isSaving не скажет, какое из них упало; поле Sync.pending | synced | failed у строки скажет
Откатывайте только своё изменение: верните поле, которое меняли, в этой строке, а не снимок всего состояния; восстановление снимка заодно стирает всё, что пользователь сделал за это время
Ошибки обрабатывайте как одноразовые события: показывайте «Не удалось сохранить» из BlocListener, когда строка становится failed, а не из поля, которое build читает при каждом ребилде
Выбирайте кандидатов: лайки, переключатели и перестановка подходят; платежи и всё, что создаёт сервер (id, итоговая цена), не подходят
Быстрые повторные нажатия: следите, чтобы устаревший ответ не перезаписал более новое нажатие: помечайте запросы id или ставьте их в очередь через sequential() из bloc_concurrency
Оба нажатия читают state до прихода любого ответа, поэтому оба отправляют true, и после двух нажатий звёздочка остаётся включённой. Эмитьте новое значение до запроса и либо выполняйте переключения по одному, либо помечайте каждый запрос id, чтобы устаревший ответ (или его откат) игнорировался.
**await ничего не упорядочивает: тела двух обработчиков чередуются, и у каждого свой снимок старого состояния
Эмит заранее чинит чтение, но не порядок**: с emit(next) до вызова оба нажатия читают свежее состояние, но запросы всё равно могут завершиться или дойти не по порядку, а упавший старый запрос откатит поверх более нового нажатия
Токен последовательности: сначала эмитьте оптимистичное значение, увеличивайте int _latest на каждое нажатие, сохраняйте локальную копию и после await выходите, если он изменился, в том числе не делая откат; ответы на устаревшие нажатия отбрасываются
Или очередь записей: обработчик Bloc с sequential() из bloc_concurrency выполняет нажатия по одному; droppable() игнорирует нажатия, пока идёт сохранение, restartable() бросает старый обработчик
На сервере та же гонка: два запроса и туда могут прийти не по порядку; передавайте версию или ETag в If-Match, чтобы поздняя запись отклонялась
Всегда: проверяйте isClosed (или mounted в виджете) после каждого await
Дайте холдеру самое короткое время жизни, общее для всех его читателей, и пусть диспозит его тот, кто создал. Холдер, созданный выше маршрута, хранит фильтры прошлого визита, держит старые подписки и позволяет позднему ответу с закрытого экрана перезаписать новый.
Назовите событие, которое должно стереть состояние: закрытие экрана значит область маршрута, выход из аккаунта означает область сессии, смерть процесса означает область приложения. Это событие и есть область
Область маршрута: создавайте холдер в маршруте через BlocProvider(create: ...), локальный ChangeNotifierProvider или autoDispose-провайдер Riverpod, чтобы закрытие маршрута его диспозило
Симптомы слишком долгой жизни: повторно открытый экран показывает старые фильтры; подписки listen копятся, и колбэк срабатывает N раз; future из первого визита завершается и перезаписывает второй
Область сессии: кладите пользовательские холдеры в поддерево с ключом по id пользователя (KeyedSubtree(key: ValueKey(userId))); выход меняет ключ и диспозит их все
Слишком короткая жизнь тоже даёт баг: холдер, созданный в build или внутри вкладки, которую уничтожают, теряет состояние при каждом ребилде или переключении вкладки; поднимите его на уровень выше и запишите почему
Задайте restorationScopeId в MaterialApp, подмешайте RestorationMixin в State и зарегистрируйте Restorable*-свойства в restoreState; ОС сохранит их и вернёт после смерти процесса. Восстанавливайте, где был пользователь и что он ввёл, серверные данные загружайте заново, учётные данные не восстанавливайте никогда.
Как это работает
Зарегистрированные значения попадают в RestorationBucket, который движок передаёт ОС (saved instance state на Android, state restoration на iOS). Бакет маленький: храните id и ввод, а не списки
Типы: обёртки Restorable* (RestorableInt, RestorableString, RestorableTextEditingController, ...); для своего типа наследуйте RestorableValue<T>
Это не ребилд: изолят новый: initState выполняется заново, и каждый синглтон создаётся с нуля
Что восстанавливать
Восстанавливать: стек маршрутов, ввод в формах, выбранную вкладку, id того, что было открыто
Загружать заново: всё, чем владеет сервер: заказ грузится снова по восстановленному id
Правило: восстанавливайте то, что пользователь ввёл или выбрал; загружайте заново то, что сервер мог изменить за это время
Никогда: токены или флаг «авторизован», эффект «платёж выполняется», производные значения
Как проверить
На Android включите «Не сохранять действия» (Don't keep activities) или уведите приложение в фон и выполните adb shell am kill <package>; нажатие «Назад» ничего не доказывает. В виджет-тестах используйте tester.restartAndRestore()
Сделайте каждый шаг подтипом sealed-класса состояния, каждый входной сигнал сделайте подтипом sealed-класса события и напишите одну синхронную next(state, event), которая возвращает новое состояние. Недопустимые комбинации невозможно записать, а весь флоу тестируется таблицей без виджетов, сети и часов.
Форма
Состояния несут свои данные: Idle, Submitting, AwaitingConfirmation(paymentId), Paid(receipt), Failed(reason): экрана чека без чека не бывает
События описывают факты: Submitted, Accepted(paymentId), Confirmed(receipt), Rejected(reason), Retried
**switch по паре**: switch ((state, event)) перечисляет допустимые переходы, а _ => state превращает всё остальное в пустую операцию вместо падения
Почему чистая
IO остаётся снаружи: Cubit вызывает API и возвращает результат событием Accepted или Rejected; ответ для отменённого флоу просто попадает в _ => state
Идемпотентность из коробки: дубль Confirmed от повторного вебхука и пуша оставляет Paid как есть
Тестирование: один параметризованный тест по (состояние, событие, ожидаемое) фиксирует весь автомат; если состоянию нужны часы или генератор id, внедряйте их
Цена
Больше типов заранее: для экрана с двумя состояниями это перебор. Окупается там, где деньги, авторизация или синхронизация иначе могут оказаться в недопустимой комбинации
Сохраняйте номер версии рядом с данными, мигрируйте пошагово от любой старой версии до текущей, а если что-то не парсится, удаляйте сохранённые данные и стартуйте с начального состояния вместо падения.
Конверт: сохраняйте {"v": 3, "data": {...}}; без номера форму приходится угадывать по ключам
Цепочка маленьких миграций: 1 → 2, 2 → 3, каждая представляет собой чистую функцию из мапы в мапу; пользователи пропускают релизы, поэтому цепочка должна работать с любой стартовой версии
Сохраняйте доменную модель, а не UI-состояние: isRefreshing и тексты ошибок меняются каждый спринт, и на диске им не место
Путь загрузки: отсутствие данных означает первый запуск, а не ошибку; версию новее кода (откат, восстановленный бэкап) отбрасывайте; затем миграция и декодирование внутри одного try
Удаляйте до возврата запасного значения: если оставить плохие данные, следующий запуск упадёт так же, и никакой перезапуск не поможет
Фикстура на каждую выпущенную версию: старого кода записи уже нет, поэтому сохранённые JSON-файлы из каждого релиза остаются единственным доказательством, что цепочка работает. fromJson в hydrated_bloc требует той же дисциплины