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