Состояния загрузки и ошибки очень часто встречаются в приложениях, работающих асинхронно.
Если мы не отобразим пользовательский интерфейс (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 для создания трехмерных сцен. Регистрация здесь.