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

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

Комментарии (0)