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 и setState | ref.invalidate или AsyncValue.guard в notifier |
| Тестирование без виджета | Сложно, нужен pumpWidget | Легко, читаете провайдер в обычном ProviderContainer |
| Зависимости | Ноль, идёт вместе с SDK | Пакет flutter_riverpod |
| Строки шаблонного кода для разового случая | Минимум | Больше настройки для одного одноразового вызова |
Когда FutureBuilder или StreamBuilder — правильный выбор
Берите встроенные билдеры, когда асинхронный результат действительно принадлежит одному виджету и больше никому не нужен.
- Самодостаточный листовой виджет. Диалог, загружающий одну запись, плитка, разрешающая размер изображения, строка настроек, читающая одну-единственную настройку. Работа начинается, когда виджет появляется, и неактуальна, как только он исчезает. Оборачивать это в провайдер — церемония без выгоды.
- Поток, которым вы уже владеете и хотите рендерить напрямую. Если у вас есть
Streamот плагина (поток позиции отGeolocator, поток статуса отconnectivity_plus) и вы показываете его только в одном месте,StreamBuilder— самый прямой путь.StreamBuilderиз Flutter 3.44 управляет жизненным циклом подписки/отписки за вас. - Ноль добавленных зависимостей. Небольшое приложение, пример кода, пример пакета или экран в кодовой базе, которая намеренно избегала библиотеки управления состоянием. Билдеры являются частью SDK, поэтому добавлять нечего.
- Вы обучаете или прототипируете. Билдеры делают сопоставление асинхронности с UI видимым в одном месте. Эта ясность стоит многого, когда цель — понять жизненный цикл, а не выпустить функцию.
Вот правильная форма. 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, когда асинхронный результат перестаёт быть приватной деталью одного виджета.
- Результат является общим. Два экрана показывают один и тот же профиль пользователя, или заголовок и тело оба читают текущую корзину. С билдерами каждый подписчик заново выполняет запрос. С провайдером работа выполняется один раз, и оба виджета читают один и тот же
AsyncValue. - Вам нужно кеширование и дедупликация. Riverpod кеширует значение провайдера, пока что-то его не инвалидирует. Уйдите навигацией и вернитесь, и данные всё ещё на месте вместо мелькания спиннера. Линейка 3.0 даже добавляет
AsyncValue.isFromCache, поэтому UI может отличать серверные данные от данных, сохранённых офлайн. - Вы мутируете и обновляете. Pull-to-refresh, оптимистичное обновление, повторная попытка.
ref.invalidate(provider)заново выполняет загрузку, и во время этой перезагрузкиvalue.isLoadingравноtrue, покаvalue.hasValueостаётсяtrue, поэтому вы продолжаете показывать старые данные вместо очистки экрана. Сделать это сFutureBuilderозначает жонглировать сохранённымFuture,setStateи собственной логикой “сохранить предыдущие данные”. - Вы хотите тестировать без подъёма виджета. Логику провайдера можно проверить в обычном
ProviderContainerбезWidgetTester, безpumpWidgetи без фиктивногоBuildContext.
Тот же рендеринг трёх состояний, теперь с источником в провайдере:
// 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 родителя):
- Future создан в
buildс FutureBuilder: запрос срабатывает при каждом перестроении. Экран, который перестраивается десять раз во время прокрутки, делает десять сетевых вызовов. Это ошибка по умолчанию, а не крайний случай. - Future вынесен в
initStateс FutureBuilder: запрос срабатывает один раз на экземпляр виджета. Уйдите навигацией и вернитесь, виджет перестраивается с нуля и снова выполняет запрос, потому что старогоStateбольше нет. - FutureProvider с AsyncValue: запрос срабатывает один раз на аргумент провайдера и кешируется. Перестроения не выполняют его заново. Уход навигацией и возврат читают кеш. Он выполняется заново только когда вы его инвалидируете или меняются его зависимости.
Если ваша асинхронная работа — это дешёвое локальное чтение, ничего из этого не имеет значения, и билдер выигрывает в простоте. Если это сетевой вызов, запрос к базе данных или что-либо со стоимостью или ограничением частоты, кеширование — это вся причина, по которой существует 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.