ChangeNotifier представляет собой класс, который хранит состояние и после каждого изменения вызывает notifyListeners(); ChangeNotifierProvider создаёт его, кладёт в дерево виджетов и вызывает dispose, когда уходит из дерева.
Поля держите приватными: наружу только геттеры и методы, чтобы любое изменение шло через метод, который уведомляет
**notifyListeners(): сообщает всем слушателям, включая провайдер, что состояние изменилось; зависимые виджеты перестроятся в следующем кадре
В UI**: context.watch<Counter>() в build, чтобы показать значение, и context.read<Counter>() в onPressed, чтобы вызвать метод
FlutterMiddle
Состояние на Provider
Пакет Provider, ChangeNotifier, Consumer, context.watch/read/select
Закрыто вопросов
0 / 21
- 0Повторить
- 2Часто спрашивают
Вопросы
Вызовы перепутаны местами: read в build берёт значение один раз без подписки, а watch в onPressed подписывается вне фазы build, поэтому срабатывает ассерт, и increment() даже не вызывается.
Ассерт: «Tried to listen to a value exposed with provider, from outside of the widget tree»
Исправление: context.watch<Counter>() в начале build, context.read<Counter>().increment() в onPressed
**Почему в колбэке read**: нажатию нужен сам объект, а не подписка; подписываться там бессмысленно, даже если бы это было разрешено
MultiProvider даёт плоскую запись вложенных провайдеров (то же дерево, только читаемое). lazy определяет, когда вызывается create: при первом чтении (по умолчанию) или сразу при монтировании провайдера.
Порядок соответствует порядку в дереве: провайдер может прочитать только те, что перечислены выше него, поэтому зависимость идёт первой
**create вызывается один раз: на всё время жизни провайдера, а не при каждой перестройке
lazy: false**: для прогрева на старте или чтобы ошибка инициализации всплыла сразу, а не на первом экране, который его прочитает
Provider<T> только хранит значение и не подписывается на него. Он перестраивает зависимые виджеты, только когда заменяется сам экземпляр, а изменение поля внутри того же экземпляра он не видит.
**create вызывается один раз: объект закеширован, и никто не узнаёт об изменении его полей
Исправление 1**: сделать Settings наследником ChangeNotifier с сеттером, который вызывает notifyListeners(), и отдавать его через ChangeNotifierProvider
Исправление 2: оставить Settings неизменяемым и заменять экземпляр целиком из родителя через Provider.value
Как об этом думать: обычный Provider означает внедрение зависимостей для того, что не меняется: репозиторий, HTTP-клиент, конфиг
watch возвращает значение и перестраивает весь виджет на каждое уведомление, read возвращает его один раз без подписки, select перестраивает только когда меняется одно производное значение, а Consumer перестраивает только свой builder. Правило: read используют в колбэках, а остальные три в build.
**context.watch<T>(): самый простой вариант; подходит для небольшого виджета, которому нужна большая часть состояния
context.read<T>()**: для onPressed, колбэков из initState, create; в build он покажет значение, которое никогда не обновится
**context.select<T, R>((t) => t.field)**: виджет перестраивается только при изменении field, а не любого другого поля
**Consumer<T>**: сужает перестройку до части большого метода build; его параметр child строится один раз и передаётся обратно
**watch вне build**: в обработчике нажатия срабатывает ассерт; используйте там read
Они отличаются тем, что заставляет зависимые виджеты перестраиваться: Provider отдаёт значение и не подписывается на него, ChangeNotifierProvider перестраивает по notifyListeners(), а FutureProvider и StreamProvider отдают последний результат Future или Stream.
**Provider<T>: для объектов, которые не меняются: репозиторий, API-клиент, конфиг
ChangeNotifierProvider<T>**: изменяемое состояние приложения; он же вызывает dispose у нотифаера
**FutureProvider<T>**: отдаёт initialData, пока future не завершится, потом результат; для того, что грузится один раз, например remote config
**StreamProvider<T>: отдаёт последнее событие; для состояния авторизации, запроса к Firestore, потока из сокета
Нет состояний загрузки и ошибки**: вы получаете initialData или значение; ошибка без catchError только отправляется в FlutterError.reportError, а значением остаётся initialData. Если экрану нужны загрузка, ошибка и повтор, используйте нотифаер
Они не перезапускаются: create вызывается один раз; чтобы обновить данные, перемонтируйте провайдер с новым key или перенесите вызов в нотифаер
create создаёт объект и делает провайдер его владельцем: объект строится один раз и уничтожается, когда провайдер уходит из дерева. .value отдаёт уже существующий объект и никогда его не уничтожает; это делает тот, кто его создал.
**Новый объект → create**: .value(value: CartNotifier()) внутри build создаёт новый экземпляр на каждую перестройку и ни один не уничтожает
**Существующий объект → .value**: чтобы передать его в диалог или открытый маршрут, в элемент ListView.builder, подставить фейк в тесте
**Никогда create: (_) => existing: при размонтировании провайдер уничтожит объект, которым не владеет, а настоящий владелец продолжит работать с мёртвым нотифаером
Два create дают два объекта**: если экран товаров и экран корзины каждый оборачивают себя в ChangeNotifierProvider(create: ...), корзин две, и товары не появляются на другом экране. Общее состояние предоставляйте один раз, выше обоих экранов
**Обычный Provider**: сам ничего не уничтожает; чтобы закрыть базу или клиент, передайте колбэк dispose:
Оба подписываются на одно значение, вычисленное из провайдера, и перестраивают только когда это значение меняется, а Consumer и watch перестраивают на каждый notifyListeners(), что бы ни изменилось.
**context.select<T, R>(fn)**: прямо в build; перестраивается весь виджет, но только при изменении выбранного значения
**Selector<T, R>**: вариант-виджет; добавляет child, который никогда не перестраивается, и необязательный shouldRebuild
Как они сравнивают: через DeepCollectionEquality, поэтому новый List или Map с теми же элементами считается неизменённым; объект вашего класса без == всегда считается изменённым
Несколько полей: выбирайте запись (record) (c.count, c.total): записи сравниваются по значению
Селектор должен быть дешёвым: он выполняется каждый раз, когда провайдер уведомляет зависимых, так что не сортируйте и не фильтруйте в нём большой список
**Когда select не спасает**: большинство полей меняются вместе, значит нотифаер слишком крупный; разделите его
add() меняет тот же List на месте, а select сравнивает список, который вернул в прошлый раз, с тем, что возвращает сейчас. Это один и тот же объект, поэтому они всегда равны, и CartList не перестраивается.
**Как решает select**: хранит значение с прошлого build и при каждом уведомлении сравнивает его с новым через DeepCollectionEquality; изменённый экземпляр сравнивается сам с собой
**Почему watch это скрывал**: watch перестраивает виджет на каждый notifyListeners(), поэтому баг всплывает, только когда кто-то переходит на select или Selector, чтобы сэкономить перестройки
Исправление: заменять список, а не менять его: _items = [..._items, item]; старое значение остаётся ['milk'], а новое становится ['milk', 'bread'], и они различаются
Закройте лазейку: считайте состояние неизменяемым: каждое изменение присваивает новый список, словарь или объект; модели freezed с неизменяемыми коллекциями заставят add() на месте упасть
Как поймать: виджет-тест, который вызывает add(), делает pump и ожидает, что find.text найдёт новый элемент
ProxyProvider строит значение из других провайдеров и снова вызывает update, когда меняется любой из них. Для нотифаера update должен перенастроить экземпляр previous: новый экземпляр заменит старый, а старый будет уничтожен вместе со своим состоянием.
**create вызывается один раз, а update вызывается много раз**: update получает зависимости и предыдущее значение previous
**Обычный ProxyProvider**: возвращать новый неизменяемый объект, например ApiService(token), нормально: в нём нет состояния, которое жалко потерять
**ChangeNotifierProxyProvider**: верните previous!..api = api; новый экземпляр теряет корзину, а виджеты, которые взяли старый через read, продолжают вызывать уже уничтоженный нотифаер
Пронумерованные варианты: ProxyProvider2 … ProxyProvider6 принимают от двух до шести зависимостей
Порядок важен: в MultiProvider зависимость должна стоять выше прокси
**Не вызывайте notifyListeners() внутри update**: он выполняется во время build и вызывает ассерт «called during build»; к тому же возврат того же экземпляра не уведомляет зависимых. Если изменение должно дойти до UI, пусть нотифаер уведомит сам (из своего асинхронного метода или после кадра).
Кто-то вызвал notifyListeners() у нотифаера, у которого уже отработал dispose(): почти всегда это асинхронный колбэк, завершившийся после того, как экран закрыли.
Типичные источники: запрос, который завершился после ухода пользователя с экрана, или подписка либо таймер, запущенные в конструкторе и не отменённые
**Отменяйте в dispose()**: все подписки и таймеры, до super.dispose()
**Проверяйте после каждого await**: ставьте приватный флаг _disposed в dispose() и выходите, если он поднят; публичного геттера у ChangeNotifier для этого нет
Один владелец: ChangeNotifierProvider(create:) сам вызывает dispose, поэтому не вызывайте его ещё и вручную
Сообщение только в debug: в release вызов молча ничего не делает, а утёкший таймер за ним продолжает работать
load() вызывает notifyListeners() синхронно, ещё до первого await. Это помечает грязным провайдер над страницей, пока сама страница ещё строится, а Flutter запрещает помечать предка грязным во время build.
Вызывайте там, где нотифаер создаётся: ChangeNotifierProvider(create: (_) => FeedNotifier(repo)..load()): пока никто не подписан, первое уведомление безвредно
Или после кадра: в initState через WidgetsBinding.instance.addPostFrameCallback; прямой вызов в initState вызовет тот же ассерт, потому что initState тоже выполняется во время build
**Никогда из build: он выполняется много раз за жизнь экрана, так что даже без падения вы бы перезапрашивали данные на каждой перестройке
То же правило для ProxyProvider.update**: там присваивайте поля, но не уведомляйте
Диалог представляет собой новый маршрут, который строит оверлей Navigator, а не виджет, вызвавший showDialog, поэтому его контекст не находится под провайдером, который вы разместили внутри страницы.
С маршрутами то же самое: showModalBottomSheet и Navigator.push тоже добавляют маршрут, поэтому открытый экран не видит нотифаер экрана, который его открыл
**useRootNavigator по умолчанию true: диалог попадает в самый верхний Navigator, ещё дальше от вложенного
Лучшее решение для глобального состояния**: предоставить его выше Navigator: над MaterialApp или в MaterialApp.builder, где child и есть Navigator
Для состояния экрана: возьмите объект через context.read<T>() до открытия маршрута, а диалог оберните в ChangeNotifierProvider<T>.value(value: captured)
**.value, а не create:**: create создал бы второй экземпляр и уничтожил бы его при закрытии
Вне build: подпишитесь через addListener в initState и реагируйте на переходы между состояниями или представьте событие как данные, которые виджет обработает один раз. build должен оставаться чистой функцией от состояния.
**Не в build и не в Consumer**: они выполняются при каждой перестройке, диалог откроется несколько раз, а showSnackBar во время build падает с ассертом «called during build»
**Не в didChangeDependencies**: он срабатывает на любое изменение унаследованных данных: MediaQuery, Theme, локаль
Способ 1: слушатель: addListener в initState, removeListener в dispose, и сравнение с предыдущим значением, чтобы реагировать на изменение, а не на каждое уведомление
Способ 2: события как данные: Stream событий или nullable-поле, которое виджет очищает после обработки
Простое правило: errorMessage описывает состояние, которое рисуют; «покажи ошибку сейчас» описывает событие
dispose() ищет нотифаер через context, но к этому моменту элемент уже деактивирован и не может искать предков. Поиск падает, removeListener так и не вызывается, и нотифаер продолжает дёргать _onChanged у мёртвой страницы.
Ошибка: в debug: «Looking up a deactivated widget's ancestor is unsafe»; в release поиск ничего не находит, и падает уже сам read
Почему: элемент деактивируется до вызова dispose(), и при деактивации он сбрасывает свою таблицу inherited-виджетов, так что context.read искать не в чем
Утечка: _onChanged остаётся подписанным; следующее уведомление запустит _onChanged у размонтированного State, и context.read / ScaffoldMessenger.of(context) снова упадёт
Исправление: получить нотифаер один раз в initState (или в didChangeDependencies), сохранить в поле и в dispose() использовать это поле
Правило: никаких поисков через context в dispose(): всё, что там нужно, сохраняется заранее
На двух уровнях: нотифаер отдельно, как обычный Dart-код с моками зависимостей, и экран в виджет-тесте с нотифаером, подставленным через ChangeNotifierProvider.value.
Юнит-тест нотифаера: вызываете методы и проверяете поля; без дерева виджетов и BuildContext, самая быстрая обратная связь
Виджет-тест экрана: оберните его в ChangeNotifierProvider<T>.value(value: notifier) над MaterialApp, сделайте pump и проверьте, что отрисовалось
Лучше настоящий нотифаер с моками сервисов: через mocktail или mockito; замоканный нотифаер тестирует заглушку, а не вашу логику
**pumpAndSettle(): после асинхронного действия, чтобы дерево успокоилось до проверок
Несколько провайдеров**: соберите в тесте тот же MultiProvider с фейками, чтобы проверялась и связка между провайдерами
Автор тот же: Riverpod представляет собой переработку, в которой поиск зависимостей больше не идёт через дерево виджетов. Провайдеры являются глобальными типизированными объектами, поэтому нет ProviderNotFoundException и не нужен BuildContext. Для нового кода берите Riverpod; в работающем приложении на Provider оставайтесь.
Что исправляет Riverpod
Поиск во время выполнения: context.read<T>() выбрасывает ProviderNotFoundException только при запуске; провайдер Riverpod объявлен как типизированный объект верхнего уровня, который не может «потеряться»
**BuildContext нужен везде**: у сервисов и чистых Dart-тестов его нет; Riverpod читает через Ref или ProviderContainer
Два провайдера одного типа: Provider ищет ближайший по типу, и второй Repository перекрывает первый; Riverpod различает провайдеры по самому объекту
Ручное время жизни: в Provider состояние живёт, пока смонтирован его виджет; autoDispose освобождает провайдер, как только на него никто не подписан
Комбинирование и асинхронность: цепочка ProxyProvider превращается в ref.watch внутри другого провайдера, а AsyncValue даёт загрузку, ошибку и данные одним типом; family добавляет провайдеры с параметром
Когда Provider всё ещё подходит
Небольшое приложение, простое состояние: несколько нотифаеров, и команда их знает
Существующий код: миграция идёт экран за экраном, а не поиском с заменой, и на это время оба пакета могут жить рядом
watch<T>() регистрирует ваш элемент как зависимого от inherited-элемента провайдера, а тот сам подписан на значение. Когда значение уведомляет, элемент провайдера перестраивается в следующем кадре и помечает грязным каждого зависимого.
Поиск за O(1): dependOnInheritedWidgetOfExactType читает словарь inherited-элементов, который Flutter копирует вниз при монтировании; по дереву никто не ходит
Точный тип: ChangeNotifierProvider(create: (_) => AuthNotifierImpl()) регистрируется как AuthNotifierImpl, поэтому чтение AuthNotifier выбрасывает ProviderNotFoundException; пишите ChangeNotifierProvider<AuthNotifier>(...)
**read<T>(): тот же поиск, но без регистрации зависимости, поэтому он никогда вас не перестраивает
watch только в build**: из обработчика нажатия срабатывает ассерт provider, а из initState срабатывает ассерт Flutter о том, что inherited-поиск должен ждать didChangeDependencies
**select: регистрирует зависимость с селектором; при уведомлении провайдер перезапускает его и пропускает зависимых, у которых срез не изменился или которые уже грязные
Много уведомлений дают одну перестройку**: элемент провайдера помечается грязным один раз за кадр, сколько бы раз значение ни уведомило
Уничтожьте поддерево, которое ими владеет, вместо того чтобы сбрасывать каждый нотифаер вручную: положите провайдеры пользователя под виджет с ключом из id пользователя, и новый id размонтирует старые, вызовет у них dispose и заново запустит create.
Почему состояние выживает: create вызывается один раз на элемент провайдера, а dispose вызывается, только когда провайдер уходит из дерева, а над MaterialApp этого не происходит никогда
Корень держите независимым от сессии: HTTP-клиент, нотифаер авторизации, аналитика, конфиг
Граница сессии: виджет, который оборачивает вошедшую в аккаунт часть приложения, с KeyedSubtree(key: ValueKey(userId)) вокруг пользовательских провайдеров
Куда это поставить: в MaterialApp.builder (builder: (context, child) => SignedInScope(child: child!)), чтобы оно оказалось выше Navigator; смена ключа ещё и перемонтирует Navigator и очищает стек маршрутов, что при выходе из аккаунта как раз нужно
**Почему ручной reset() хуже: это чек-лист, и один забытый нотифаер протащит данные прошлого аккаунта
Запросы в полёте**: нотифаеры должны проверять флаг _disposed после await, ведь ключ может смениться посреди запроса
Provider чистит только память: токены, закешированные ответы, локальные базы и кеш изображений нужно очищать отдельно
Обычно одну перестройку на каждого зависимого: уведомление только помечает элементы грязными, а грязный элемент перестраивается один раз в следующем кадре. Настоящая цена складывается из десяти синхронных проходов по всем слушателям, и не все слушатели являются виджетами.
**notifyListeners() синхронный: каждый слушатель выполняется до конца, в порядке регистрации, прежде чем вызов вернёт управление
Почему виджеты перестраиваются один раз**: слушатель провайдера только помечает его элемент грязным; повторные вызовы сразу выходят, а зависимые и селекторы select обрабатываются один раз, когда этот элемент перестраивается
Остальные слушатели выполнятся десять раз: колбэк из addListener, аналитика, сохранение или синхронизация могут записать или отправить данные десять раз
Они ещё и видят полуобновлённое состояние: после третьего уведомления поля с четвёртого по десятое ещё старые, и слушатель может отреагировать на несогласованный снимок
Правило: измените всё, а потом уведомите один раз в конце, или замените один неизменяемый объект состояния одним присваиванием
Открытие маршрута не размонтирует экраны под ним. С maintainState: true (по умолчанию) они остаются смонтированными и подписанными, поэтому каждый тик перестраивает каждый подписанный экран в стеке, а не только видимый.
Скрытый маршрут ещё не размонтирован: Overlay хранит запись на каждый маршрут; закрытый маршрут не рисуется и его тикеры на паузе, но его элементы всё ещё в дереве
Build не учитывает видимость: изменение inherited-зависимости помечает эти элементы грязными, и фаза build перестраивает каждый грязный элемент
Поэтому стоимость растёт с глубиной: на пятом тяжёлом экране в глубине стека нотифаер с частотой 1 Гц запускает пять дорогих методов build в секунду; если он привязан к анимации, то запускает их каждый кадр
Почему это всплывает поздно: в короткой тестовой сессии в стеке один-два экрана, а реальные пользователи уходят на пять-шесть уровней вглубь
Решения по порядку: сузить подписку через select, ограничить тикающий нотифаер маршрутом, которому он нужен, разделить слишком крупный нотифаер, и только для тяжёлого маршрута, который дёшево перестроить, рассмотреть maintainState: false