Start Debugging

Переход с Riverpod 2.x на Riverpod 3.0 во Flutter

Пошаговое обновление с flutter_riverpod 2.x до 3.x: поднимите версии пакетов, перенесите StateProvider и его собратьев в legacy-импорт, откажитесь от ref-типов AutoDispose и Family, обработайте оборачивание в ProviderException и автоматический повтор, а также исправьте фильтрацию уведомлений по ==, которая молча теряет события StreamProvider. Проверено на Flutter 3.44, Dart 3.x, flutter_riverpod 3.3.2.

Обновление реального приложения с flutter_riverpod 2.x до 3.x обычно занимает полдня, и большая часть работы — это механический поиск и замена. Линейка 3.0 вышла в сентябре 2025 года, а текущий релиз — 3.3.2 (июнь 2026); это руководство проверено на этой версии с Flutter 3.44 (stable, май 2026) и Dart 3.x. Что действительно ломается: StateProvider, StateNotifierProvider и ChangeNotifierProvider переезжают за импорт legacy.dart, каждый подтип Ref (FutureProviderRef, AutoDisposeNotifier, FamilyNotifier) сворачивается в один тип, ошибки провайдеров теперь приходят обёрнутыми в ProviderException, а сами провайдеры теперь фильтруют уведомления по ==. Последние два пункта вызывают сюрпризы во время выполнения, а не ошибки компиляции, поэтому читайте эти разделы внимательно. Если ваша кодовая база уже использует генерацию кода (@riverpod), менять придётся меньше, чем при написанных вручную объявлениях провайдеров.

Зачем вообще обновляться

Riverpod 2.x всё ещё работает, поэтому доводы в пользу перехода должны быть конкретными:

Что ломается

ОбластьИзменениеСерьёзность
Legacy-провайдерыStateProvider, StateNotifierProvider, ChangeNotifierProvider требуют legacy.dartвысокая
Подтипы RefFutureProviderRef, StreamProviderRef и т. д. все становятся единым Refвысокая
Типы AutoDispose / FamilyAutoDisposeNotifier, FamilyNotifier удалены; используйте модификаторы на Notifierвысокая
Распространение ошибокЧтения перебрасывают ProviderException, оборачивающий вашу исходную ошибкувысокая
Фильтрация уведомленийВсе провайдеры используют ==, чтобы решить, нужно ли уведомлять слушателейсредняя
Автоматический повторУпавшие провайдеры по умолчанию повторяются с выдержкойсредняя
ProviderObserverКолбэки принимают единый ProviderObserverContextсредняя
AsyncValue.valueOrNullПереименован в value; старый геттер value, бросавший исключение, убраннизкая

Предполётный чеклист

Прежде чем трогать хоть один провайдер:

  1. Закоммитьте или спрячьте всё в stash. Эта миграция затрагивает много файлов, и вам нужен чистый git diff для ревью.
  2. Убедитесь, что ваш Dart SDK версии 3.x. Riverpod 3.0 этого требует. Запустите dart --version и проверьте.
  3. Если вы используете генерацию кода, убедитесь, что build_runner сначала чисто отрабатывает на текущем коде 2.x. Вам не нужно отлаживать ошибки генератора и ошибки миграции одновременно.
  4. Отметьте, используете ли вы riverpod_lint. Он поставляет правила линтинга и помощник миграции dart fix, который автоматизирует несколько шагов ниже, так что его установка экономит ручные правки.

Шаг 1: Поднимите версии пакетов

Обновите каждый пакет Riverpod в pubspec.yaml до линейки 3.x за один раз. Смесь пакетов 2.x и 3.x не разрешится.

# pubspec.yaml -- flutter_riverpod 3.3.2, Dart 3.x
dependencies:
  flutter_riverpod: ^3.3.2
  riverpod_annotation: ^3.3.2   # only if you use code-generation

dev_dependencies:
  riverpod_generator: ^3.3.2    # only if you use code-generation
  riverpod_lint: ^3.3.2
  custom_lint: ^0.8.0
  build_runner: ^2.4.0

Затем разрешите зависимости:

# Flutter 3.44
flutter pub get

Проверка: flutter pub deps | grep riverpod показывает каждый пакет Riverpod на версии 3.x. Если pub жалуется на конфликт версий, значит транзитивная зависимость всё ещё удерживает riverpod на 2.x; запустите flutter pub deps, чтобы найти виновника.

Шаг 2: Сначала запустите автоматические исправления

Riverpod 3.0 поставляет правила миграции dart fix через riverpod_lint. Запустите их прежде, чем делать что-либо вручную, потому что они справляются с нудными механическими переписываниями (переименования подтипов Ref, удаление префикса AutoDispose) сразу по всем файлам.

# preview the changes without writing them
dart fix --dry-run

# apply them
dart fix --apply

Проверка: перезапустите dart fix --dry-run и убедитесь, что специфичные для Riverpod исправления пропали из списка. Затем сделайте git diff и прочитайте, что изменилось. Инструмент хорош, но не всеведущ, поэтому оставшиеся шаги — это части, которые он не может вывести сам.

Шаг 3: Перенесите legacy-провайдеры за legacy-импорт

StateProvider, StateNotifierProvider и ChangeNotifierProvider всё ещё существуют, но теперь живут в отдельной библиотеке, чтобы основная поверхность импорта показывала только современный API. Если вы импортируете их из основного пакета, вы получите ошибку “undefined name”.

// Riverpod 3.x
// Add this import wherever you still use the legacy providers:
import 'package:flutter_riverpod/legacy.dart';

// StateProvider itself is unchanged in behaviour:
final counterProvider = StateProvider<int>((ref) => 0);

Это осознанный толчок, а не предупреждение об устаревании, которое можно игнорировать вечно. Долгосрочный шаг — переписать каждый StateProvider как Notifier, а каждый StateNotifierProvider как Notifier или AsyncNotifier, та же целевая форма, к которой вы придёте при переходе с пакета provider. Но делать это переписывание во время подъёма версии не обязательно. Добавьте импорт, добейтесь зелёного результата и конвертируйте позже.

Проверка: приложение компилируется. Сделайте grep по legacy.dart и убедитесь, что каждый файл, использующий legacy-провайдер, имеет импорт, и ни один файл не импортирует его без надобности (линтер отметит неиспользуемый импорт).

Шаг 4: Сверните подтипы Ref и варианты Notifier

В 2.x провайдер, сгенерированный из кода, выдавал вам типизированный ref вроде CounterRef, а написанные вручную провайдеры использовали FutureProviderRef<T>, StreamProviderRef<T> и так далее. В 3.0 есть единый Ref. Проход dart fix обычно справляется с этим, но написанные вручную объявления, которые инструмент пропустил, нуждаются в правке.

// Riverpod 2.x
int example(ExampleRef ref) => 0;

Future<User> user(UserRef ref) async => fetchUser();
// Riverpod 3.x -- one Ref type for everything
int example(Ref ref) => 0;

Future<User> user(Ref ref) async => fetchUser();

То же объединение затрагивает нотификаторы на основе классов. AutoDisposeNotifier, FamilyNotifier и комбинаторный взрыв имён между ними исчезли. Вы выражаете то же поведение с помощью модификаторов на базовом Notifier:

// Riverpod 2.x
class TodosNotifier extends AutoDisposeAsyncNotifier<List<Todo>> {
  @override
  Future<List<Todo>> build() => fetchTodos();
}
final todosProvider =
    AutoDisposeAsyncNotifierProvider<TodosNotifier, List<Todo>>(
  TodosNotifier.new,
);
// Riverpod 3.x -- autoDispose is a modifier, not a base class
class TodosNotifier extends AsyncNotifier<List<Todo>> {
  @override
  Future<List<Todo>> build() => fetchTodos();
}
final todosProvider =
    AsyncNotifierProvider.autoDispose<TodosNotifier, List<Todo>>(
  TodosNotifier.new,
);

Параметры Family, которые раньше жили в FamilyNotifier, теперь приходят как обычные аргументы build (кодогенерация) или через модификатор .family (вручную). Если вы используете @riverpod, генератор берёт на себя всю обвязку, и вам нужно только перезапустить его.

Проверка (для тех, кто использует кодогенерацию): удалите сгенерированные файлы и пересоберите.

dart run build_runner build --delete-conflicting-outputs

Убедитесь, что ошибок генератора нет и что перегенерированные файлы .g.dart ссылаются на Ref, а не на старые типизированные ref.

Шаг 5: Обработайте оборачивание в ProviderException

Это изменение с наибольшей вероятностью проскользнёт мимо проверки компиляции и сломается во время выполнения. В 3.0, когда build провайдера бросает исключение, а другой участок кода читает провайдер императивно, чтение не перебрасывает ваше исходное исключение. Оно перебрасывает ProviderException, который оборачивает его. Любой блок on MyException catch, который ловил исходный тип, перестаёт срабатывать.

// Riverpod 2.x -- this used to work
try {
  final user = await ref.read(userProvider.future);
} on NotFoundException catch (e) {
  showNotFound();
}
// Riverpod 3.x -- catch the wrapper, inspect .exception
try {
  final user = await ref.read(userProvider.future);
} on ProviderException catch (e) {
  if (e.exception is NotFoundException) {
    showNotFound();
  } else {
    rethrow;
  }
}

Развёрнутый путь — это AsyncValue. Когда вы рендерите провайдер через .when(error: ...) или сопоставляете с образцом AsyncError, ошибка, которую вы там получаете, — это ваше исходное исключение, а не обёртка. Так что UI-код, читающий состояние реактивно, не затронут; изменения нужны только императивному ref.read(...future) внутри try/catch. Отдельная статья про ProviderException в Riverpod 3.0 разбирает угловые случаи.

Проверка: сделайте grep по ref.read( в сочетании с .future внутри блоков try, а также по любой конструкции catch, которая называет доменное исключение. Добавьте тест, который заставляет провайдер бросить исключение, и проверяет, что ваш обработчик по-прежнему выполняется.

Шаг 6: Исправьте фильтрацию уведомлений по ==

В 2.x у разных типов провайдеров были разные правила решения о том, когда уведомлять слушателей. В 3.0 все они используют ==. Для состояния Notifier это обычно нормально, но больно бьёт по StreamProvider и StreamNotifier: если ваш поток эмитит объекты, равные по == (например, изменяемые классы, не переопределяющие равенство, или два значения, которые случайно оказываются равными), вторая эмиссия теперь отбрасывается как дубликат.

Проявление сбоя — это UI, который перестаёт обновляться, хотя поток явно эмитит. Исправление в том, чтобы эмитируемый тип имел корректную семантику равенства. Если вы эмитите доменные объекты, дайте им равенство по значению (record, класс Freezed или написанные вручную ==/hashCode). См. записи Dart против классов Freezed, чтобы понять, к чему тянуться.

// Riverpod 3.x -- two distinct ticks that are == would be collapsed.
// A record gives structural equality so each tick is treated as new.
Stream<({int count, DateTime at})> ticks(Ref ref) async* {
  var n = 0;
  await for (final _ in Stream.periodic(const Duration(seconds: 1))) {
    yield (count: n++, at: DateTime.now());
  }
}

Если вам действительно нужно пропускать каждую эмиссию независимо от равенства, переопределите updateShouldNotify на StreamNotifier, чтобы он возвращал true.

Проверка: запустите приложение, понаблюдайте за любым виджетом, работающим от потока, и убедитесь, что он по-прежнему обновляется при каждой ожидаемой эмиссии. Здесь нет сигнала на этапе компиляции, поэтому нужен ручной дымовой тест.

Шаг 7: Определитесь с автоматическим повтором

Упавшие провайдеры теперь повторяются автоматически: начальная задержка 200 мс, удваивающаяся вплоть до 6.4 секунды. Для большинства провайдеров, работающих с сетью, это улучшение. Но если у вас есть провайдер, чей сбой постоянен (ошибка валидации, 404, который никогда не станет 200), тихие повторы тратят вызовы и могут маскировать ошибку в UI на несколько секунд.

Отключите глобально на уровне области или для каждого провайдера:

// Riverpod 3.x -- disable retry everywhere
ProviderScope(
  retry: (retryCount, error) => null, // null delay = do not retry
  child: MyApp(),
)
// Or keep it, but stop retrying non-transient errors
ProviderScope(
  retry: (retryCount, error) {
    if (error is NotFoundException) return null;
    if (retryCount >= 3) return null;
    return Duration(milliseconds: 200 * (1 << retryCount));
  },
  child: MyApp(),
)

Проверка: направьте провайдер на эндпоинт, возвращающий 404, и убедитесь, что он не долбит сервер или что ваш предикат повтора замыкается накоротко, как задумано.

Шаг 8: Обновите ProviderObserver и переименование valueOrNull

Если у вас есть кастомный ProviderObserver (аналитика, логирование), сигнатура его колбэков изменилась. Аргументы контейнера и провайдера слились в единый ProviderObserverContext.

// Riverpod 3.x
class LoggingObserver extends ProviderObserver {
  @override
  void didUpdateProvider(
    ProviderObserverContext context,
    Object? previousValue,
    Object? newValue,
  ) {
    debugPrint('${context.provider.name} -> $newValue');
  }
}

И маленькое: AsyncValue.valueOrNull переименован в value. Старый геттер value (который бросал исключение при загрузке/ошибке) убран. Если вы полагались на поведение с исключением, сопоставляйте с образцом случай AsyncData.

Проверка: анализатор отмечает каждое место вызова valueOrNull; проход dart fix из шага 2 обычно переписывает их, но убедитесь, что ни одного не осталось.

Проверка: полный дымовой тест

После всех шагов пройдите чеклист от начала до конца:

План отката

На практике эта миграция — дверь в одну сторону. Как только вы напишете import 'package:flutter_riverpod/legacy.dart' и примете единый тип Ref, откат означает отмену изменений в каждом файле. Чистый откат — это git, а не код: делайте всю миграцию в ветке, оставьте ветку 2.x нетронутой и сливайте только после того, как дымовой тест пройдёт. Не делайте полумиграцию и не выкатывайте её; кодовая база, где часть файлов на семантике 3.x, а часть — на допущениях 2.x (особенно вокруг оборачивания ошибок), хуже, чем любая из версий по отдельности.

Подводные камни, на которые мы наткнулись

Похожее

Источники

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Назад