Start Debugging

Исправление: StreamProvider в Riverpod 3.0 перестаёт эмитить, потому что обновления фильтруются по ==

В Riverpod 3.0 каждый провайдер фильтрует уведомления слушателей по ==, а не по идентичности. StreamProvider, который повторно эмитит один и тот же изменяемый объект, перестаёт перестраивать UI после первого кадра. Здесь разбирается, почему так происходит, и три способа исправить это. Проверено на flutter_riverpod 3.3.2, Flutter 3.44, Dart 3.x.

Если вы обновились до Riverpod 3.0 и StreamProvider вдруг перестраивает ваш виджет ровно один раз, а затем затихает, причина кроется в единственной строке из заметок о миграции, которую легко пропустить: в 3.0 каждый провайдер фильтрует уведомления слушателей по ==, а не по идентичности. Когда ваш поток эмитит один и тот же экземпляр объекта дважды (изменяемый список, который вы меняете на месте, модель на основе контроллера, которую вы отправляете снова), Riverpod сравнивает новое значение с предыдущим, находит их равными и отбрасывает уведомление. Поток по-прежнему срабатывает. Ваша StreamSubscription за пределами Riverpod всё так же видела бы каждое событие. Но ref.watch никогда не перестраивается, потому что с точки зрения Riverpod ничего не изменилось. Исправление состоит в том, чтобы каждый раз эмитить новое, не равное значение, либо переопределить updateShouldNotify. Этот пост проверен на flutter_riverpod 3.3.2 (июнь 2026), Flutter 3.44 и Dart 3.x.

Что на самом деле изменилось в 3.0

До версии 3.0 Riverpod был непоследователен в том, как он решал, оправдывает ли новое значение уведомление слушателей. Некоторые типы провайдеров сравнивали по ==, некоторые использовали identical, а у нескольких была своя особая логика. StreamProvider находился на стороне идентичности этой линии: любое событие, которое производил поток, отправлялось слушателям, потому что только что доставленное событие потока на практике считалось новым.

Riverpod 3.0 свёл всё это к одному правилу. Из официального руководства по миграции на 3.0: “all providers now use == to filter updates.” В руководстве названы провайдеры, которых это изменение затронет с наибольшей вероятностью: “The most likely way for you to be impacted by this change is when using StreamProvider/StreamNotifier, as stream values will now be filtered by ==.”

Это хорошее изменение для согласованности. Оно означает, что провайдер, который пересчитывает значение, равное предыдущему, не станет без нужды перестраивать каждый виджет ниже по дереву, а это та же оптимизация, к которой вы иначе прибегли бы с помощью select. Проблема в том тихом режиме отказа, который оно вводит для паттерна, совершенно нормального в 2.x: эмитить изменяемый объект, менять его и эмитить снова.

Минимальное воспроизведение

Вот самое малое, что ломается. Репозиторий хранит List<int>, дописывает в него и отправляет тот же список через StreamController после каждого добавления.

// flutter_riverpod 3.3.2, Dart 3.x
import 'dart:async';

class CounterRepository {
  final _values = <int>[];
  final _controller = StreamController<List<int>>.broadcast();

  Stream<List<int>> get stream => _controller.stream;

  void add(int value) {
    _values.add(value);
    _controller.add(_values); // same List instance every time
  }
}

Подключите его к StreamProvider и наблюдайте за ним:

// flutter_riverpod 3.3.2
final repositoryProvider = Provider((ref) => CounterRepository());

final valuesProvider = StreamProvider<List<int>>((ref) {
  return ref.watch(repositoryProvider).stream;
});

class ValuesView extends ConsumerWidget {
  const ValuesView({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final async = ref.watch(valuesProvider);
    return async.when(
      data: (values) => Text('Count: ${values.length}'),
      loading: () => const CircularProgressIndicator(),
      error: (e, _) => Text('Error: $e'),
    );
  }
}

На 2.x это показывает Count: 1, затем Count: 2, затем Count: 3 по мере вызова add. На 3.0 показывается Count: 1, после чего обновления больше никогда не происходят. Виджет застрял на первой эмиссии.

Почему == возвращает true здесь, хотя данные изменились

Ловушка в том, что _values является одним и тем же объектом при каждой эмиссии. Когда вы вызываете _controller.add(_values) во второй раз, поток доставляет ту же самую ссылку List. Riverpod оборачивает каждое событие потока в AsyncData<List<int>> и спрашивает, равно ли новое AsyncValue предыдущему.

AsyncValue реализует равенство по значению, и два экземпляра AsyncData равны, когда равны содержащиеся в них значения. Для вашего списка == проваливается до стандартного равенства List, которое для обычного List является равенством по ссылке: список равен только самому себе. Поскольку это буквально один и тот же объект, previous == next равно true. Riverpod заключает, что значение не изменилось, и подавляет уведомление. Мутация, которую вы выполнили между эмиссиями, невидима для сравнения, потому что нет никакого “предыдущего снимка”, с которым можно было бы сравнить. Есть только один список, и он всегда равен самому себе.

Именно эту часть руководство по миграции недооценивает. Issue на GitHub, посвящённый ровно этому поведению, описывает его как тихий отказ, который стоил трёх дней отладки: прямые колбэки stream.listen по-прежнему получают каждое событие, поэтому в изоляции поток выглядит здоровым, но слой провайдера тихо устраняет дубликаты. Именно несоответствие между “поток срабатывает” и “UI не перестраивается” делает эту проблему такой трудной для обнаружения.

Исправление 1: каждый раз эмитить новый экземпляр

Самое прямое исправление, и то, которое вам почти всегда нужно, состоит в том, чтобы перестать переиспользовать один и тот же изменяемый объект. Эмитьте неизменяемый снимок, чтобы каждое событие было отдельным значением, не равным по == предыдущему.

// flutter_riverpod 3.3.2, Dart 3.x
void add(int value) {
  _values.add(value);
  _controller.add(List<int>.unmodifiable(_values)); // fresh instance each emit
}

List<int>.unmodifiable(_values) выделяет новый список, содержащий текущие элементы. Это объект, отличный от предыдущей эмиссии, поэтому previous == next равно false, и Riverpod уведомляет. В качестве бонуса вы больше не пропускаете изменяемый список в дерево виджетов, а это была скрытая ошибка независимо от версии Riverpod: любой потребитель мог изменить внутреннее состояние вашего репозитория через полученную ссылку.

Это правило не специфично для Riverpod. Отправка одной и той же изменяемой коллекции через поток и её изменение на месте является хрупкой с любым потребителем, который делает снимки или сравнивает значения. Неизменяемые эмиссии являются надёжным исправлением.

Исправление 2: намеренно использовать равенство по значению, и тогда всё просто работает

Иногда вы хотите, чтобы == сравнивал содержимое, потому что вы эмитите класс-модель и хотите, чтобы UI пропускал перестроения, когда ничего значимого не изменилось. В этом случае наделите ваш эмитируемый тип настоящим равенством по значению, и поведение 3.0 становится преимуществом, а не багом.

// Dart 3.x records give you value equality for free
final positionProvider = StreamProvider<({double lat, double lng})>((ref) {
  return locationStream(); // each event is a new record
});

Записи Dart сравниваются структурно, поэтому две записи с одинаковыми полями равны по ==. Это означает, что поток GPS, который эмитит одни и те же координаты дважды, корректно пропустит перестроение, а тот, что эмитит новую позицию, вызовет его. То же верно для класса со сгенерированными ==/hashCode из freezed или с написанным вручную operator ==. Правило большого пальца: если значение неизменяемо и обладает равенством по значению, 3.0 автоматически делает правильную вещь. Оно ведёт себя неправильно только тогда, когда вы протаскиваете изменяемый объект мимо проверки равенства, сохраняя ту же ссылку.

Исправление 3: переопределить updateShouldNotify на StreamNotifier

Если вы действительно не можете изменить то, что эмитит поток (сторонний источник, устаревший репозиторий, которым вы не владеете), вы можете переопределить сравнение. Это доступно только в API на основе классов, поэтому вы преобразуете функциональный StreamProvider в StreamNotifierProvider и переопределяете updateShouldNotify.

// flutter_riverpod 3.3.2 with riverpod_annotation 3.x
@riverpod
class Values extends _$Values {
  @override
  Stream<List<int>> build() {
    return ref.watch(repositoryProvider).stream;
  }

  @override
  bool updateShouldNotify(
    AsyncValue<List<int>> previous,
    AsyncValue<List<int>> next,
  ) {
    return true; // always notify, restore the 2.x behavior for this provider
  }
}

Безусловный возврат true восстанавливает для этого одного провайдера поведение “уведомлять при каждой эмиссии”, существовавшее до 3.0, не меняя глобальное значение по умолчанию для остального приложения. Вы также можете сделать это умнее, например сравнивая длины или счётчик версий, если безусловные перестроения слишком агрессивны. Обратите внимание, что у сырого функционального StreamProvider((ref) => ...) нет хука updateShouldNotify, поэтому это исправление требует формы на основе классов. Если вы всё ещё выбираете между функциональным и классовым стилями, руководство по миграции с Riverpod 2.x на 3.0 разбирает, когда каждый из них того стоит.

Как подтвердить, что это ваш баг, а не что-то другое

У симптома (виджет на основе потока, который обновляется один раз и замирает) есть несколько возможных причин, поэтому убедитесь, что это фильтр равенства, прежде чем прибегать к этим исправлениям:

  1. Добавьте print внутри источника потока, прямо перед _controller.add(...). Если он печатается при каждом событии, но виджет не перестраивается, значит события доходят до потока, но фильтруются ниже.
  2. Прикрепите временный сырой слушатель: ref.watch(repositoryProvider).stream.listen((v) => debugPrint('raw: $v')). Если сырой слушатель срабатывает каждый раз, а ref.watch(valuesProvider) не перестраивается, значит слой провайдера устраняет дубликаты, что подтверждает фильтр ==.
  3. Проверьте, является ли эмитируемый объект тем же экземпляром. Если вы отправляете поле, кешированный список или модель-синглтон, вы почти наверняка столкнулись с этим.

Если же сам поток перестаёт срабатывать, это другая проблема: StreamSubscription, которая была отменена, контроллер, который был закрыт, или провайдер, который был уничтожен и создан заново. О стороне жизненного цикла потоков, связанной с уничтожением, см. отмену StreamSubscription в dispose.

Смежные подводные камни в том же релизе 3.0

Фильтр равенства является одним из кластера изменений 3.0, которые проявляются во время выполнения, а не во время компиляции, что и делает их дорогими для отладки. Ещё два, о которых стоит знать до релиза:

А если асинхронные разрывы внутри вашего notifier обращаются к ref после await, защитите их проверкой mounted, описанной в проверке Ref.mounted после асинхронного разрыва.

Однострочное правило, которое стоит запомнить

Riverpod 3.0 перестраивается, когда previous != next. Если ваш StreamProvider переиспользует изменяемый объект, previous и next являются одной и той же ссылкой, поэтому они всегда равны, и он никогда не перестраивается. Эмитьте неизменяемые снимки (или наделите ваш тип значения настоящим равенством), и фреймворк сделает правильную вещь. Прибегайте к updateShouldNotify только тогда, когда вы не можете контролировать эмитируемое значение. Для более широкого взгляда на то, когда StreamProvider и его AsyncValue вообще являются подходящим инструментом по сравнению со старыми виджетами-билдерами, хорошим следующим чтением будет сравнение FutureBuilder и StreamBuilder против AsyncValue от Riverpod.

Источники

Comments

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

< Назад