Riverpod Notifier vs AsyncNotifier vs StreamNotifier во Flutter: какой класс наследовать?
Выбор определяется типом возврата build(): T означает Notifier, FutureOr<T> означает AsyncNotifier, Stream<T> означает StreamNotifier. Здесь матрица выбора, иерархия типов, которая это объясняет, и подводные камни фильтрации по == и перезаписи состояния. Проверено на flutter_riverpod 3.4.2 и Flutter 3.44.2.
Выбор между Notifier, AsyncNotifier и StreamNotifier определяется одной вещью: типом возврата вашего метода build(). Если он возвращает T, наследуйте Notifier<T>. Если он возвращает Future<T> или обычный T, который вы, возможно, захотите позже сделать асинхронным, наследуйте AsyncNotifier<T>. Если ваш источник данных продолжает присылать новые значения после первого, наследуйте StreamNotifier<T>. Всё остальное (методы мутации, ref.watch внутри build, семейства, автоматическое освобождение) во всех трёх работает одинаково. Всё в этой статье проверено на flutter_riverpod 3.4.2 и Flutter 3.44.2 (stable, 2026-06-10) с Dart 3.12.2, а для раздела о генерации кода использован riverpod_generator 4.0.4.
Матрица выбора
Notifier<T> | AsyncNotifier<T> | StreamNotifier<T> | |
|---|---|---|---|
build() возвращает | T | FutureOr<T> | Stream<T> |
| Провайдер отдаёт | T | AsyncValue<T> | AsyncValue<T> |
| Класс провайдера | NotifierProvider | AsyncNotifierProvider | StreamNotifierProvider |
| Состояние загрузки | никогда | сначала AsyncLoading | сначала AsyncLoading |
| Значения после первого | пишете вы | пишете вы | пишет поток |
Модификатор .future | нет | да | да |
Помощник update() | нет | да | да |
Сигнатура updateShouldNotify | (T, T) | (AsyncValue<T>, AsyncValue<T>) | (AsyncValue<T>, AsyncValue<T>) |
| Заменяет (Riverpod 2.x) | StateNotifier, StateProvider | FutureProvider + методы | StreamProvider + методы |
Именно на последней строке чаще всего спотыкаются. AsyncNotifier не является “асинхронной версией Notifier” в смысле надмножества. Это FutureProvider, у которого есть куда положить методы мутации. StreamNotifier это StreamProvider с тем же дополнением. Если методы мутации вам не нужны, обычный FutureProvider или StreamProvider остаётся более компактным ответом.
Почему тип возврата и есть всё правило
Это не стилистическое соглашение. Это навязано иерархией классов в riverpod 3.4.2. Каждый из трёх публичных классов объявляет абстрактный build() с фиксированным типом возврата:
// package:riverpod/src/providers/notifier/orphan.dart, riverpod 3.4.2
abstract class Notifier<ValueT> extends $Notifier<ValueT> {
@visibleForOverriding
ValueT build();
}
// package:riverpod/src/providers/async_notifier/orphan.dart
abstract class AsyncNotifier<StateT> extends $AsyncNotifier<StateT> {
@visibleForOverriding
FutureOr<StateT> build();
}
// package:riverpod/src/providers/stream_notifier/orphan.dart
abstract class StreamNotifier<ValueT> extends $StreamNotifier<ValueT> {
@visibleForOverriding
Stream<ValueT> build();
}
При неверном выборе вы получите ошибку компиляции, а не сюрприз во время выполнения. Вот точные диагностики flutter analyze на Flutter 3.44.2:
error - 'WrongOne.build' ('Future<int> Function()') isn't a valid override of
'Notifier.build' ('int Function()') - invalid_override
error - 'WrongTwo.build' ('Stream<int> Function()') isn't a valid override of
'AsyncNotifier.build' ('FutureOr<int> Function()') - invalid_override
error - 'Ok' doesn't conform to the bound 'AsyncNotifier<int>' of the type
parameter 'NotifierT' - type_argument_not_matching_bounds
Третья ошибка это несовпадение пары: подкласс Notifier, переданный в AsyncNotifierProvider. Класс notifier и класс провайдера связаны обобщённым ограничением, так что смешать их не получится.
Когда выбирать Notifier
Берите Notifier<T>, когда начальное состояние доступно синхронно и ничто за пределами ваших собственных методов его не меняет.
// flutter_riverpod 3.4.2, Flutter 3.44.2, Dart 3.12.2
class Counter extends Notifier<int> {
@override
int build() => 0;
void increment() => state++;
}
final counterProvider = NotifierProvider<Counter, int>(Counter.new);
ref.watch(counterProvider) даёт вам int, а не AsyncValue<int>. Нет ветки загрузки, которую надо отрисовывать, нет и ветки ошибки, и в этом весь смысл: выбранный фильтр, флаг изменённости формы, индекс выбранной вкладки, корзина покупок в памяти. Если вы ловите себя на том, что пишете AsyncData(...) вокруг значения, которое у вас уже есть, вы выбрали не тот базовый класс.
Что удивляет тех, кто пришёл со StateNotifier: build() может выполниться повторно. Если вы внутри него делаете ref.watch другого провайдера, изменение выше по цепочке заново выполняет build() и сбрасывает ваше состояние. Сам экземпляр notifier при этом сохраняется, поэтому поля экземпляра выживают:
// Verified: constructed once, built twice after the dependency changed.
expect(Instanced.built, 2); // build() re-ran
expect(Instanced.constructed, 1); // the object was not recreated
Когда выбирать AsyncNotifier
Берите AsyncNotifier<T>, когда начальное состояние приходит из Future, а каждое следующее значение приходит из ваших собственных методов мутации.
// flutter_riverpod 3.4.2
class AsyncCounter extends AsyncNotifier<int> {
@override
Future<int> build() async {
await Future<void>.delayed(const Duration(milliseconds: 10));
return 0;
}
Future<void> increment() async {
final current = await future; // resolves to the latest non-loading value
state = AsyncData(current + 1);
}
}
final asyncCounterProvider =
AsyncNotifierProvider<AsyncCounter, int>(AsyncCounter.new);
Геттер future внутри notifier и модификатор .future у провайдера оба приходят из миксина $AsyncClassModifier. Оттуда же и update(), эргономичная версия приведённого выше чтения-изменения-записи:
Future<void> increment() => update((current) => current + 1);
Одна деталь стоит внимания, потому что она меняет то, что ваш виджет отрисует на первом кадре: build() возвращает FutureOr<T>, поэтому вернуть значение синхронно допустимо, и в этом случае провайдер никогда не проходит через AsyncLoading.
class SyncishAsync extends AsyncNotifier<int> {
@override
int build() => 42; // legal: FutureOr<int> accepts int
}
// Verified: the very first read is AsyncData(42), not AsyncLoading.
expect(container.read(syncishProvider), isA<AsyncData<int>>());
Это делает AsyncNotifier разумным вариантом по умолчанию для состояния, которое сегодня синхронно, но которое вы планируете позже спрятать за сетевым вызовом. Платой становится обёртка AsyncValue, которую придётся разворачивать в каждом виджете, поэтому для индекса вкладки я бы её не использовал. Механика аккуратной отрисовки этой обёртки та же, что описана в статье про отображение состояний загрузки и ошибки через AsyncValue.
Когда выбирать StreamNotifier
Берите StreamNotifier<T>, когда источник продолжает присылать данные. Слушатель снимков Firestore, WebSocket, Stream из плагина, периодический таймер.
// flutter_riverpod 3.4.2
class Ticker extends StreamNotifier<int> {
@override
Stream<int> build() {
final controller = StreamController<int>();
var i = 0;
final timer = Timer.periodic(const Duration(milliseconds: 5), (_) {
controller.add(i++);
});
ref.onDispose(() {
timer.cancel();
controller.close();
});
return controller.stream;
}
}
final tickerProvider = StreamNotifierProvider<Ticker, int>(Ticker.new);
Отличительное поведение в том, что состояние продолжает меняться без вашей записи в state. Если подписаться на такой провайдер и собрать эмиссии, получится [0, 1, 2, ...], тогда как AsyncNotifier выдал бы ровно один AsyncData и на этом остановился.
Riverpod управляет подпиской за вас. Когда build() выполняется повторно из-за изменения отслеживаемой зависимости, предыдущая подписка отменяется до подписки на новый поток:
// Verified with a StreamController whose onCancel increments a counter.
expect(Feed.subscribes, 2); // build re-ran, new stream
expect(Feed.cancels, 1); // Riverpod cancelled the old subscription
Приведённый выше ref.onDispose всё равно нужен для ресурсов, которыми сам поток не владеет, например для Timer. Riverpod отменяет свою подписку на ваш поток; про таймер, который этот поток питает, он ничего не знает. Дисциплина здесь та же, что и при освобождении контроллеров во Flutter во избежание утечек памяти.
AsyncNotifier и StreamNotifier это братья, а не родитель и потомок
В dartdoc StreamNotifier назван “вариантом AsyncNotifier”, что читается как наследование. Это не так. Оба наследуют одну и ту же внутреннюю базу и различаются лишь одним обобщённым аргументом:
// package:riverpod/src/providers/async_notifier.dart, riverpod 3.4.2
abstract class $AsyncNotifier<ValueT> extends $AsyncNotifierBase<ValueT>
with $AsyncClassModifier<ValueT, FutureOr<ValueT>> {}
// package:riverpod/src/providers/stream_notifier.dart
abstract class $StreamNotifier<ValueT> extends $AsyncNotifierBase<ValueT>
with $AsyncClassModifier<ValueT, Stream<ValueT>> {}
$AsyncNotifierBase<ValueT> в обоих случаях наследует AnyNotifier<AsyncValue<ValueT>, ValueT>, поэтому оба отдают AsyncValue<T> и оба получают future и update(). Единственное различие в CreatedT: FutureOr<ValueT> против Stream<ValueT>. При этом $Notifier<StateT> наследует $SyncNotifierBase<StateT>, который наследует AnyNotifier<StateT, StateT>, поэтому у него тип состояния и тип значения совпадают.
Практическое следствие в том, что проверка типа на AsyncNotifier не сработает для StreamNotifier, и обобщённый вспомогательный код с if (notifier is AsyncNotifier) молча пропустит ваши провайдеры на основе потоков:
// Verified on riverpod 3.4.2
expect(Ticker(), isNot(isA<AsyncNotifier<int>>()));
expect(AsyncCounter(), isNot(isA<StreamNotifier<int>>()));
Фильтрация по == задевает все три класса
В Riverpod 3.0 решение о том, уведомлять ли слушателей, унифицировали через ==. Обычно об этом пишут как о проблеме Notifier, потому что классический симптом это изменение List на месте без перестроения UI. Это не проблема Notifier. Она касается AsyncNotifier и StreamNotifier тоже, потому что AsyncValue.operator == сравнивает обёрнутое значение через ==:
// package:riverpod/src/core/async_value.dart, riverpod 3.4.2
@override
bool operator ==(Object other) {
return runtimeType == other.runtimeType &&
other is AsyncValue<ValueT> &&
other._loading == _loading &&
other.valueFilled == valueFilled &&
other._errorFilled == _errorFilled;
}
Поэтому оборачивание того же экземпляра List в свежий AsyncData даёт значение, которое == предыдущему состоянию, и уведомление отбрасывается:
// Verified: both of these are silent no-ops for listeners.
class AsyncTodoList extends AsyncNotifier<List<String>> {
@override
List<String> build() => <String>[];
void addMutating(String v) {
final list = state.requireValue..add(v);
state = AsyncData(list); // same list instance, == is true
}
void addReplacing(String v) =>
state = AsyncData([...state.requireValue, v]); // new list, notifies
}
final list = ['x'];
expect(AsyncData(list) == AsyncData(list), isTrue);
expect(AsyncData(['x']) == AsyncData(['x']), isFalse);
Решение одинаково во всех трёх классах: всегда присваивайте новый экземпляр коллекции вместо изменения и повторного присваивания. Аварийный выход тоже одинаков, но обратите внимание, что сигнатура меняется вместе с базовым классом, потому что updateShouldNotify принимает тип состояния, а не тип значения:
// Notifier<List<String>>
@override
bool updateShouldNotify(List<String> previous, List<String> next) => true;
// AsyncNotifier<List<String>> or StreamNotifier<List<String>>
@override
bool updateShouldNotify(
AsyncValue<List<String>> previous,
AsyncValue<List<String>> next,
) => true;
Если вы попали сюда после того, как поток загадочно перестал обновлять UI, та же первопричина подробнее разобрана в статье про события StreamProvider, отфильтрованные по равенству в Riverpod 3.0.
Подводный камень StreamNotifier: ваши записи перезаписываются
StreamNotifier наследует сеттер state, так что ничто не мешает вам туда присвоить. Но поток по-прежнему живой, и следующее событие побеждает:
// Verified against a StreamNotifier whose build() emits every 5ms.
container.read(tickerProvider.notifier).poke(); // state = AsyncData(999)
expect(container.read(tickerProvider).value, 999); // holds, briefly
await Future<void>.delayed(const Duration(milliseconds: 20));
expect(container.read(tickerProvider).value, isNot(999)); // the stream won
Это не баг и не повод избегать методов мутации у StreamNotifier. Это повод сделать мутацию оптимистичной и дать потоку её подтвердить. Пишите в state ради немедленного отклика UI, отправляйте изменение на бэкенд и позвольте пришедшему обратно событию потока стать источником истины:
// flutter_riverpod 3.4.2
Future<void> send(String message) async {
state = AsyncData([...(state.value ?? const []), message]); // optimistic
await _api.post(message); // the server echoes this back down the stream
}
Если поток не возвращает ваши мутации обратно, ваша задача не имеет формы потока. Возьмите AsyncNotifier и управляйте состоянием сами.
Генерация кода выбирает за вас
С riverpod_generator вы вообще не называете базовый класс. Вы ставите аннотацию @riverpod, наследуете сгенерированный _$Foo, а генератор читает тип возврата build(). Вот три класса, различающиеся только этим типом возврата, и соответствующие сгенерированные объявления от riverpod_generator 4.0.4:
// gen.dart
@riverpod
class Counter extends _$Counter {
@override
int build() => 0;
}
@riverpod
class AsyncCounter extends _$AsyncCounter {
@override
Future<int> build() async => 0;
}
@riverpod
class Ticker extends _$Ticker {
@override
Stream<int> build() => Stream.value(0);
}
// gen.g.dart, generated
final class CounterProvider extends $NotifierProvider<Counter, int> { ... }
abstract class _$Counter extends $Notifier<int> { ... }
final class AsyncCounterProvider
extends $AsyncNotifierProvider<AsyncCounter, int> { ... }
abstract class _$AsyncCounter extends $AsyncNotifier<int> { ... }
final class TickerProvider extends $StreamNotifierProvider<Ticker, int> { ... }
abstract class _$Ticker extends $StreamNotifier<int> { ... }
Замените Future<int> build() на Stream<int> build(), перезапустите builder, и базовый класс сменится под вами без единой другой правки. Это самый весомый практический аргумент в пользу генерации кода именно в этом вопросе.
Одну асимметрию сгенерированный вывод делает наглядной: сгенерированные провайдеры освобождаются автоматически, написанные вручную нет.
// gen.g.dart: every generated provider passes isAutoDispose: true
CounterProvider._() : super(..., isAutoDispose: true, ...);
// Hand-written, verified on riverpod 3.4.2:
expect(counterProvider.isAutoDispose, isFalse);
expect(asyncCounterProvider.isAutoDispose, isFalse);
expect(tickerProvider.isAutoDispose, isFalse);
Для StreamNotifier эта разница обходится дорого: написанный вручную потоковый провайдер держит подписку открытой навсегда, как только кто-то его прочитал, потому что NotifierProvider, AsyncNotifierProvider и StreamNotifierProvider по умолчанию ставят isAutoDispose в false. Передайте NotifierProvider(..., isAutoDispose: true), если хотите поведение как у сгенерированного, но без генерации.
Ещё одна оговорка про версии
На Flutter 3.44.2 самые свежие пакеты сейчас не разрешаются вместе. flutter_riverpod 3.4.2 плюс любая версия riverpod_generator проваливает разрешение версий из-за matcher 0.12.19 и test_api 0.7.11, которые этот Flutter SDK фиксирует через flutter_test. Чисто разрешается комбинация flutter_riverpod 3.3.2 с riverpod_annotation 4.0.3 и riverpod_generator 4.0.4, именно из неё получен приведённый выше сгенерированный вывод. В правиле выбора класса между 3.3.2 и 3.4.2 нет никакой разницы, но при генерации кода ожидайте отставания на одну минорную версию от рантайм-пакета, пока ограничение SDK не догонит.
Рекомендация
По умолчанию берите AsyncNotifier для всего, что затрагивает ввод-вывод, Notifier для всего остального, а StreamNotifier только тогда, когда источник действительно присылает больше одного значения. Цена выбора AsyncNotifier там, где хватило бы Notifier, это немного шума с разворачиванием AsyncValue в виджетах. Цена выбора Notifier там, где данные асинхронны, это поле late, LateInitializationError и ручной булев флаг загрузки, что строго хуже. А если вы используете генерацию кода, перестаньте об этом думать вовсе: пишите тот build(), который вам нужен, и пусть генератор выбирает.
Похожие статьи
- Какой пакет Riverpod ставить: riverpod, flutter_riverpod или hooks_riverpod
- FutureBuilder и StreamBuilder в сравнении с AsyncValue из Riverpod
- Полное руководство по миграции с Riverpod 2.x на 3.0
- Перевод StatefulWidget с setState на Notifier из Riverpod
- Превращение FutureBuilder в AsyncNotifier из Riverpod
Источники
- Что нового в Riverpod 3.0, про объединение notifier-классов и переход на
==при фильтрации уведомлений. - riverpod 3.4.2 на pub.dev, источник процитированных выше объявлений
Notifier,AsyncNotifierиStreamNotifier. - flutter_riverpod 3.4.2 на pub.dev.
- riverpod_generator 4.0.4 на pub.dev, генератор, чей вывод показан в разделе о генерации кода.
Comments
Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.