Как обрабатывать состояния загрузки и ошибки с помощью StateNotifier и AsyncValue во Flutter

от автора

Состояния загрузки и ошибки очень часто встречаются в приложениях, работающих асинхронно.

Если мы не отобразим пользовательский интерфейс (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/