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

Анимация: Пример страницы оплаты с помощью Stripe
Как мы видим, при нажатии кнопки "Pay (Оплатить)" появляется индикатор загрузки. И сама платежная страница также показывает индикатор загрузки, до тех пор, пока не будут доступны способы оплаты.
Если платеж не проходит по какой-либо причине, надо показать пользователю UI с ошибкой, чтобы проинформировать его об этом.
Давайте попробует вникнуть в суть, чтобы узнать, как можно решить эти проблемы в наших приложениях Flutter.
Состояния загрузки и ошибки с использованием StatefulWidget
Состояния загрузки и ошибки очень распространены, и мы должны их обрабатывать на каждой странице или виджете, который работает асинхронно.
Для примера предположим, что у нас есть кнопка PaymentButton, которую можно использовать для совершения платежа:
class PaymentButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
// note: this is a *custom* button class that takes an extra `isLoading` argument
return PrimaryButton(
text: 'Pay',
// this will show a spinner if loading is true
isLoading: false,
onPressed: () {
// use a service locator or provider to get the checkout service
// make the payment
},
);
}
}
Можно сделать этот виджет стейтфул и добавить две переменные состояния:
class _PaymentButtonState extends State<PaymentButton> {
// loading and error state variables
bool _isLoading = false;
String _errorMessage = '';
Future<void> pay() async {
// make payment, update state variables, and show an alert on error
}
@override
Widget build(BuildContext context) {
// same as before,
return PrimaryButton(
text: 'Pay',
// use _isLoading variable defined above
isLoading: _isLoading,
onPressed: _isLoading ? null : pay,
);
}
}
Этот подход будет работать, но он весьма рутинный и чреват ошибками.
В конце концов, мы же не хотим сделать все наши виджеты стейтфул и повсюду добавлять переменные состояния, верно?
Делаем состояния загрузки и ошибки все более DRY
Что нам действительно нужно, так это последовательный способ управления состояниями загрузки и ошибки во всем приложении.
Для этого мы воспользуемся AsyncValue и StateNotifier из пакета Riverpod.
Когда мы закончим, то сможем отображать любой UI загрузки и ошибки с помощью нескольких строк кода, как показано здесь:
class PaymentButton extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
// error handling
ref.listen<AsyncValue<void>>(
paymentButtonControllerProvider,
(_, state) => state.showSnackBarOnError(context),
);
final paymentState = ref.watch(paymentButtonControllerProvider);
// note: this is a *custom* button class that takes an extra `isLoading` argument
return PrimaryButton(
text: 'Pay',
// show a spinner if loading is true
isLoading: paymentState.isLoading,
// disable button if loading is true
onPressed: paymentState.isLoading
? null
: () => ref.read(paymentButtonControllerProvider.notifier).pay(),
);
}
}
Но давайте будем действовать поэтапно.
Базовая настройка: виджет PaymentButton
Начнем с базового виджета PaymentButton, который был представлен ранее:
import 'package:flutter_riverpod/flutter_riverpod.dart';
// note: this time we subclass from ConsumerWidget so that we can get a WidgetRef below
class PaymentButton extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
// note: this is a custom button class that takes an extra `isLoading` argument
return PrimaryButton(
text: 'Pay',
isLoading: false,
onPressed: () => ref.read(checkoutServiceProvider).pay(),
);
}
}
Когда кнопка нажата, мы вызываем ref.read(), чтобы с помощью сервиса оформления заказа совершить оплату.
Если вы не знакомы с
ConsumerWidgetи синтаксисомref.read(), обратитесь к моему Essential Guide to Riverpod.
Для справки, вот как можно имплементировать CheckoutService и соответствующий провайдер:
// sample interface for the checkout service
abstract class CheckoutService {
// this will succeed or throw an error
Future<void> pay();
}
final checkoutServiceProvider = Provider<CheckoutService>((ref) {
// return some concrete implementation of CheckoutService
});
Это работает, но метод pay() может затянуться на несколько секунд, и у нас нет никакого UI загрузки или ошибки.
Давайте разберемся с этим.
Управление состояниями загрузки и ошибки с помощью AsyncValue
В нашем примере UI должен управлять тремя возможными состояниями:
не загружается (по умолчанию)
загрузка
ошибка
Для представления этих состояний мы можем использовать класс AsyncValue, поставляемый вместе с пакетом Riverpod.
Для справки, вот как определяется этот класс:
@sealed
@immutable
abstract class AsyncValue<T> {
const factory AsyncValue.data(T value) = AsyncData<T>;
const factory AsyncValue.loading() = AsyncLoading<T>;
const factory AsyncValue.error(Object error, {StackTrace? stackTrace}) =
AsyncError<T>;
}
Обратите внимание, что данный класс является абстрактным и что мы можем инстанцировать его только с помощью одного из существующих фабричных конструкторов.
А под капотом эти конструкторы реализованы с помощью следующих конкретных классов:
class AsyncData<T> implements AsyncValue<T>
class AsyncLoading<T> implements AsyncValue<T>
class AsyncError<T> implements AsyncValue<T>
Самое главное, что мы можем использовать AsyncValue для представления трех состояний, которые нас так интересуют:
не загружено → AsyncValue.data
загрузка → AsyncValue.loading
ошибка → AsyncValue.error
Но где мы должны разместить нашу логику?
Для этого необходимо определить подкласс StateNotifier, который будет использовать AsyncValue<void> в качестве состояния.
Подкласс StateNotifier
Сначала мы определим класс PaymentButtonController, который использует CheckoutService в качестве зависимости и устанавливает состояние по умолчанию:
class PaymentButtonController extends StateNotifier<AsyncValue<void>> {
PaymentButtonController({required this.checkoutService})
// initialize state
: super(const AsyncValue.data(null));
final CheckoutService checkoutService;
}
Примечание: AsyncValue.data() обычно используется для передачи некоторых данных с помощью обобщенного аргумента <T>.
Но в нашем случае нет данных, поэтому можно использовать AsyncValue<void> для определения нашего StateNotifier и AsyncValue.data(null) при установке начального значения.
Затем добавляем метод pay(), который будет вызываться из класса виджета:
Future<void> pay() async {
try {
// set state to `loading` before starting the asynchronous work
state = const AsyncValue.loading();
// do the async work
await checkoutService.pay();
} catch (e) {
// if the payment failed, set the error state
state = const AsyncValue.error('Could not place order');
} finally {
// set state to `data(null)` at the end (both for success and failure)
state = const AsyncValue.data(null);
}
}
}
Обратите внимание, как состояние устанавливается несколько раз, чтобы наш виджет мог соответствующим образом перестраивать и обновлять UI.
Чтобы сделать PaymentButtonController доступным для нашего виджета, определяем StateNotifierProvider следующим образом:
final paymentButtonControllerProvider =
StateNotifierProvider<PaymentButtonController, AsyncValue<void>>((ref) {
final checkoutService = ref.watch(checkoutServiceProvider);
return PaymentButtonController(checkoutService: checkoutService);
});
Обновленный виджет PaymentButton
Теперь, когда у нас есть PaymentButtonController, его можно использовать в нашем классе виджета:
class PaymentButton extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
// 1. listen for errors
ref.listen<AsyncValue<void>>(
paymentButtonControllerProvider,
(_, state) => state.whenOrNull(
error: (error) {
// show snackbar if an error occurred
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(error)),
);
},
),
);
// 2. use the loading state in the child widget
final paymentState = ref.watch(paymentButtonControllerProvider);
final isLoading = paymentState is AsyncLoading<void>;
return PrimaryButton(
text: 'Pay',
isLoading: isLoading,
onPressed: isLoading
? null
// note: this was previously using the checkout service
: () => ref.read(paymentButtonControllerProvider.notifier).pay(),
);
}
}
Несколько замечаний:
мы используем
ref.listen()иstate.whenOrNull()для показа снэкбара, если найдено состояние ошибкимы проверяем, является ли состояние платежа инстансом
AsyncLoading<void>(помните:AsyncLoadingявляется подклассомAsyncValue)мы передаем переменную
isLoadingвPrimaryButton, который позаботится о демонстрации правильного UI.
Если вы не знакомы с листенерами в Riverpod, см. Раздел «Прослушивание изменений состояния провайдера» в моем основном руководстве по Riverpod.
Это работает, но можем ли мы получить такой же результат с меньшим количеством шаблонного кода?
Расширения Dart спешат на помощь
Давайте определим расширение для AsyncValue<void>, чтобы было легче проверять состояние загрузки и показывать снэкбар при ошибке:
extension AsyncValueUI on AsyncValue<void> {
// isLoading shorthand (AsyncLoading is a subclass of AsycValue)
bool get isLoading => this is AsyncLoading<void>;
// show a snackbar on error only
void showSnackBarOnError(BuildContext context) => whenOrNull(
error: (error, _) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(error.toString())),
);
},
);
}
С помощью этих изменений упрощаем наш класс виджета:
class PaymentButton extends ConsumerWidget {
const PaymentButton({Key? key}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
// 1. listen for errors
ref.listen<AsyncValue<void>>(
paymentButtonControllerProvider,
(_, state) => state.showSnackBarOnError(context),
);
// 2. use the loading state in the child widget
final paymentState = ref.watch(paymentButtonControllerProvider);
return PrimaryButton(
text: 'Pay',
isLoading: paymentState.isLoading,
onPressed: paymentState.isLoading
? null
: () => ref.read(paymentButtonControllerProvider.notifier).pay(),
);
}
}
При этом, для данной страницы, состояния загрузки и ошибки обрабатываются надлежащим образом:

Заключение
Вот законченная имплементация для расширения AsyncValueUI:
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
// Bonus: define AsyncValue<void> as a typedef that we can
// reuse across multiple widgets and state notifiers
typedef VoidAsyncValue = AsyncValue<void>;
extension AsyncValueUI on VoidAsyncValue {
bool get isLoading => this is AsyncLoading<void>;
void showSnackBarOnError(BuildContext context) => whenOrNull(
error: (error, _) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(error.toString())),
);
},
);
}
Благодаря методам расширения AsyncValueUI мы можем легко обрабатывать состояния загрузки и ошибки в нашем приложении.
Фактически, для каждой страницы, которая выполняется асинхронно, нам нужно сделать два шага:
добавить подкласс
StateNotifier<VoidAsyncValue>, являющийся посредником между классом виджета и указанными выше классами сервиса или хранилищамодифицировать метод
build()виджета, обрабатывая состояние ошибки черезref.listen()и проверяя состояние загрузки по мере необходимости.
Хотя для того, чтобы настроить все подобным образом, потребуется немного предварительной работы, полученные преимущества оправдывают затраченное время:
мы можем обрабатывать состояния загрузки и ошибки с помощью небольшого количества кода в наших виджетах;
мы можем перенести всю логику управления состояниями из наших виджетов в отдельные классы контроллеров.
Материал подготовлен в рамках курса «Flutter Mobile Developer».
Всех желающих приглашаем на бесплатный двухдневный интенсив «Flutter engine, анимация и ее оптимизация». На этом интенсиве мы рассмотрим самые глубокие механизмы Flutter Engine и научимся создавать сложные и плавные анимации как на мобильных платформах, так и веб-версии, использовать инструменты профилирования для исключения "замерзания" интерфейса. Также мы затронем тему использования WebGL в веб-приложениях на Flutter для создания трехмерных сцен. Регистрация здесь.