Start Debugging

FutureBuilder/StreamBuilder против AsyncValue из Riverpod во Flutter: что выбрать?

Используйте FutureBuilder или StreamBuilder для самодостаточного, одноразового асинхронного виджета. Переходите на AsyncValue из Riverpod, как только результат становится общим, кешируется или мутирует. Здесь решение, подводные камни и исполняемый код для обоих. Проверено на Flutter 3.44 и flutter_riverpod 3.3.1.

Если вы выбираете между встроенными FutureBuilder / StreamBuilder из Flutter и AsyncValue из Riverpod, короткий ответ такой: оставьте билдеры для одного самодостаточного виджета, который владеет одноразовым асинхронным результатом, и переходите на AsyncValue из Riverpod в тот момент, когда этот результат становится общим между экранами, кешируется, обновляется или мутирует. Билдеры — это не “версия для новичков” того же самого. Это UI-примитив, который подписывается на один асинхронный объект. AsyncValue — это модель состояния, которая живёт вне дерева виджетов. Это руководство проверено на Flutter 3.44 (стабильная версия, 2026-05-18), Dart 3.12 и flutter_riverpod 3.3.1 (линейка 3.0 вышла 2025-09-10).

Они решают пересекающиеся задачи на разных уровнях

FutureBuilder и StreamBuilder — это виджеты. Вы передаёте каждому Future или Stream, и он отдаёт вашему колбэку builder объект AsyncSnapshot<T>, описывающий текущее состояние соединения (waiting, active, done) плюс последние данные или ошибку. Виджет подписывается при вставке, отписывается при удалении и переподписывается, если вы передаёте другой экземпляр Future/Stream. Это весь контракт. Нет ни кеширования, ни совместного использования, ни памяти о результате после того, как виджет покидает дерево.

AsyncValue<T> из Riverpod вообще не виджет. Это запечатанное объединение с тремя подтипами (AsyncData, AsyncLoading, AsyncError), которое провайдер предоставляет в качестве своего значения. Асинхронная работа выполняется внутри провайдера, живущего вне дерева виджетов, поэтому любой виджет может её прочитать, несколько виджетов могут читать один и тот же экземпляр, а результат переживает перестроения и навигацию. Вы рендерите его через value.when(...) или switch из Dart 3, точно так же, как рендерите AsyncSnapshot, но источником истины является провайдер, а не поле виджета.

Поэтому настоящий вопрос не в том, “что лучше рендерит три состояния”. Оба рендерят три состояния нормально. Вопрос в том, где должен жить асинхронный результат и сколько вещей должны его видеть.

Матрица возможностей

АспектFutureBuilder / StreamBuilder (Flutter 3.44)AsyncValue из Riverpod (flutter_riverpod 3.3.1)
Что этоВиджет, подписывающийся на один Future/StreamЗапечатанный тип состояния, предоставляемый провайдером
Где живёт результатВ виджете, умирает при размонтировании виджетаВ провайдере, вне дерева, переживает навигацию
Совместное использование между экранамиНет, каждый билдер заново выполняет свою работуДа, один провайдер читается из многих виджетов
Кеширование / дедупликацияНет, вы мемоизируете Future самиВстроено, провайдер кеширует до инвалидации
Срабатывание при каждом перестроенииДа, если Future создаётся в buildНет, build провайдера выполняется один раз до инвалидации
Загрузка + предыдущие данныеВручную, snapshot теряет data во время ожиданияvalue.isLoading сохраняет value во время обновления
Мутации / обновлениеПереприсвоить Future и setStateref.invalidate или AsyncValue.guard в notifier
Тестирование без виджетаСложно, нужен pumpWidgetЛегко, читаете провайдер в обычном ProviderContainer
ЗависимостиНоль, идёт вместе с SDKПакет flutter_riverpod
Строки шаблонного кода для разового случаяМинимумБольше настройки для одного одноразового вызова

Когда FutureBuilder или StreamBuilder — правильный выбор

Берите встроенные билдеры, когда асинхронный результат действительно принадлежит одному виджету и больше никому не нужен.

Вот правильная форма. Future создаётся один раз в initState, а не в build, поэтому виджет не выполняет повторный запрос при каждом перестроении родителя.

// Flutter 3.44, Dart 3.12
class UserCard extends StatefulWidget {
  const UserCard({super.key, required this.id});
  final String id;

  @override
  State<UserCard> createState() => _UserCardState();
}

class _UserCardState extends State<UserCard> {
  late Future<User> _user;

  @override
  void initState() {
    super.initState();
    _user = api.fetchUser(widget.id); // created ONCE, not in build
  }

  @override
  Widget build(BuildContext context) {
    return FutureBuilder<User>(
      future: _user,
      builder: (context, snapshot) {
        return switch (snapshot) {
          AsyncSnapshot(connectionState: ConnectionState.waiting) =>
            const CircularProgressIndicator(),
          AsyncSnapshot(hasError: true, :final error) =>
            Text('Failed: $error'),
          AsyncSnapshot(hasData: true, :final data?) =>
            Text(data.name),
          _ => const SizedBox.shrink(),
        };
      },
    );
  }
}

Самая частая ошибка с этим виджетом — создавать Future встроенно, как future: api.fetchUser(widget.id) прямо в build. Каждое перестроение тогда выделяет новый Future, FutureBuilder видит новую идентичность и перезапускается из состояния загрузки. Этот режим сбоя достаточно распространён, чтобы иметь собственную статью: смотрите почему FutureBuilder пересоздаёт свой Future при каждом перестроении для полного воспроизведения и всех вариантов, которые его вызывают.

Когда AsyncValue из Riverpod — правильный выбор

Переходите на AsyncValue, когда асинхронный результат перестаёт быть приватной деталью одного виджета.

Тот же рендеринг трёх состояний, теперь с источником в провайдере:

// Flutter 3.44, Dart 3.12, flutter_riverpod 3.3.1
final userProvider = FutureProvider.family<User, String>((ref, id) {
  return api.fetchUser(id); // runs once, cached per id, shared everywhere
});

class UserCard extends ConsumerWidget {
  const UserCard({super.key, required this.id});
  final String id;

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final user = ref.watch(userProvider(id));
    return switch (user) {
      AsyncData(:final value) => Text(value.name),
      AsyncError(:final error) => Text('Failed: $error'),
      _ => const CircularProgressIndicator(),
    };
  }
}

Два виджета, вызывающие ref.watch(userProvider('42')), разделяют один запрос и один кешированный результат. Нет initState, нет сохранённого поля и нет дисциплины “создать Future один раз”, о которой нужно помнить, потому что провайдер уже выполняет свой build ровно один раз на аргумент, пока не будет инвалидирован. Для полного набора состояний, мутаций через AsyncValue.guard и сохранения предыдущих данных при обновлении смотрите как показывать состояния загрузки и ошибки с AsyncValue.

Поведение перестроения и повторного запроса, которое на самом деле решает

Производительность здесь не ось. Оба подхода рендерят с одинаковой частотой кадров. Различается то, сколько раз выполняется ваша асинхронная работа, и это вопрос корректности и стоимости, а не чистой скорости.

Поместите счётчик внутрь асинхронного вызова и понаблюдайте, что происходит, когда окружающий виджет перестраивается (переключение темы, открывающаяся клавиатура, setState родителя):

Если ваша асинхронная работа — это дешёвое локальное чтение, ничего из этого не имеет значения, и билдер выигрывает в простоте. Если это сетевой вызов, запрос к базе данных или что-либо со стоимостью или ограничением частоты, кеширование — это вся причина, по которой существует AsyncValue, и ручное воспроизведение того же поведения вокруг FutureBuilder реализует худшую версию кеша провайдера из Riverpod.

Подводный камень, который решает за вас

Несколько ограничений решают вопрос независимо от вкуса.

Вы уже используете Riverpod. Если в приложении есть провайдеры, не подмешивайте FutureBuilder в экран, который их читает. Прочитать данные провайдера, а затем обернуть второй FutureBuilder вокруг другого асинхронного вызова — значит получить два несвязанных жизненных цикла на одном экране и два места, где “loading” может быть true. Предоставьте второй вызов тоже как провайдер и рендерите оба через AsyncValue. Согласованность здесь предотвращает класс ошибок, когда половина экрана устаревает.

Результат должен пережить виджет. Всё, что запрошено в initState, умирает вместе со State. Если пользователь переходит вперёд и обратно, а вы не хотите свежий спиннер и свежий сетевой вызов каждый раз, вам нужен кеш, который живёт над виджетом. Это провайдер. FutureBuilder не может дать вам сохранность между маршрутами, как бы вы это ни устраивали.

Вы обращаетесь к ref после await. Это специфичная для Riverpod ловушка, а не причина его избегать: если вы делаете await внутри notifier, а затем читаете ref после того, как виджет, вызвавший его, исчез, вы попадаете на Cannot use "ref" after the widget was disposed. Решение — захватить то, что вам нужно, до await. Это стоит знать, прежде чем вы остановитесь на нём, и это разобрано в решении для использования ref после уничтожения.

Вы явно хотите ноль зависимостей. Пример пакета pub, случай воспроизведения или командная политика против библиотек управления состоянием вынуждают билдеры. Это законное ограничение, и билдеры вполне способны на самодостаточный асинхронный UI.

У StreamBuilder есть одна дополнительная тонкость

Всё вышеперечисленное относится к работе с Future. Потоки добавляют жизненный цикл подписки, и это склоняет решение чуть дальше в сторону Riverpod для всего нетривиального. StreamBuilder переподписывается, когда вы передаёте ему новый экземпляр Stream, и отписывается, когда покидает дерево, но он не выполняет multicast: два StreamBuilder на одном потоке с одной подпиской выбросят ошибку, потому что Stream с одной подпиской допускает только одного слушателя. StreamProvider из Riverpod находится перед потоком, поэтому несколько виджетов читают один AsyncValue, не борясь за подписку, а последнее значение кешируется для поздних подписчиков. Если поток показывается ровно в одном месте, StreamBuilder подходит. Если он нужен более чем одному виджету, StreamProvider полностью устраняет проблему единственного слушателя.

Рекомендация, со всем контекстом за ней

По умолчанию используйте AsyncValue из Riverpod для любого асинхронного результата, который является общим, кешируется, обновляется или мутирует, что в реальном приложении составляет большинство из них. Вы получаете один запрос вместо N, бесплатное кеширование между навигациями, isLoading, который сохраняет предыдущие данные при обновлении, и логику, которую можно тестировать без виджета. Оставьте FutureBuilder и StreamBuilder для действительно самодостаточного, одноразового асинхронного UI: листовой виджет, который загружает одну вещь, показывает её и забывает при размонтировании, особенно в приложениях, которые не несут никакой зависимости управления состоянием. Билдеры — это не страховочные колёса, которые перерастают. Это правильный инструмент, когда у асинхронного результата аудитория из одного, и неправильный инструмент в тот момент, когда у него аудитория из двух. Выбирайте по владению, а не по знакомству.

Если вы всё ещё выбираете подход к управлению состоянием в более широком смысле, компромиссы между пакетами разобраны в Provider против Riverpod против Bloc для управления состоянием во Flutter в 2026. А если ваш асинхронный UI продолжает обнажать сбои, как изящно обрабатывать сетевые ошибки в приложении Flutter разбирает превращение брошенных исключений в чистое состояние ошибки в обеих моделях.

Источники

Comments

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

< Назад