BLoC во Flutter, отличие от Cubit и пример использования.

BLoC во Flutter, отличие от Cubit и пример использования.

Пример получения данных с сервера с использованием библиотеки Bloc, а так же основные отличия от Cubit.

Я уже писал о использовании Cubit и библиотеке bloc и flutter_bloc.

Пример приложения на Flutter с Provider, Bloc, Cubit и Freezed.

Bloc расширяет класс Cubit и является более продвинутым. Bloc получает события для запуска изменения состояния в отличии от Cubit, в котором мы могли использовать для этого функции.

Важным отличием Bloc от Cubit является то, что состояния будут возвращаться в порядке запроса событий. То есть, если в Cubit объявить функцию, которая обратиться куда то асинхронно, и вызвать ее дважды, то нет гарантии что мы получим ответы в том же порядке.

class MyCubit extends Cubit<int> {
  MyCubit(): super(0);
  int delay = 2;

  Future fetchData(int id) async {
    print("fetchData(id: $id)");
    await Future.delayed(Duration(seconds: delay--));
    emit(id);
  }
}

void main() {
  final cubit = MyCubit();
  cubit.listen((int data) {
    print("Response(id: $data)");
  });
  print("Вызов ID 1");
  cubit.fetchData(1);
  print("Вызов ID 2");
  cubit.fetchData(2);
}

Запустив этот код в консоле увидим:

Вызов ID 1
fetchData(id: 1)
Вызов ID 2
fetchData(id: 2)
Response(id: 2)
Response(id: 1)

то есть сначала мы вызвали первый раз fetchData с ID = 1, потом второй с ID = 2. Но, допустим, первый запрос выполнялся дольше второго. В данном примере первый запрос выполняется 2 секунды, а второй 1 секунду. В результате мы получаем сначала результат для второго запроса, а потом для первого. Это может повлечь, например, неверное отображение или не актуальную информацию в UI.

Вариант с Bloc:

class MyBlocEvent {}
class FetchMyBlocEvent extends MyBlocEvent {
  final int id;
  FetchMyBlocEvent(this.id): assert(id != null);
}

class MyBloc extends Bloc {
  MyBloc(): super(0);
  int delay = 2;

  @override
  Stream mapEventToState(MyBlocEvent event) async* {
    if (event is FetchMyBlocEvent) {
      print("fetchData(id: ${event.id})");
      await Future.delayed(Duration(seconds: delay--));
      yield event.id;
    }
  }
}

void main() {
  final bloc = MyBloc();
  bloc.listen((int data) {
    print("Response(id: $data)");
  });
  print("Вызов ID 1");
  bloc.add(FetchMyBlocEvent(1));
  print("Вызов ID 2");
  bloc.add(FetchMyBlocEvent(2));
  bloc.close();
}

Результат совершенно другой:

Вызов ID 1
Вызов ID 2
fetchData(id: 1)
Response(id: 1)
fetchData(id: 2)
Response(id: 2)

Теперь все события обрабатываются в порядке очереди.

Рассмотрим схему работы Bloc.

bloc_flow

onEvent - вызывается сразу, как только событие будет добавлено.

transformEvents - тут можно манипулировать входящими событиями. По умолчанию используется asyncExpand, то есть все события обрабатываются в порядке их поступления.

Вы можете, например, пропускать только уникальные события с помощью distinct().

  
  @override
  Stream<Transition<MyBlocEvent, int>> transformEvents(events, transitionFn) {
    return super.transformEvents(events.distinct(), transitionFn);
  }

Теперь вызвав два раза одно и то же событие, в mapEventToState попадет только первое.

Или, с помощью debounceTime, обрабатывать события, только если с поступления прошлого прошло определенное время.

mapEventToState - вызывается для каждого события пришедшего с transformEvents. Новое состояние можно вернуть из этой функции с помощью yield.

transformTransitions - может быть использован для манипулирования возвращением состояний. Например, с помощью debounceTime отбрасывать состояния, если между изменением прошло слишком мало времени.

onTransition - вызывается прямо перед тем как state у Bloc будет обновлен. Можно использовать для логирования.

В статье Стейт-менеджер с ChangeNotifier и ChangeNotifierProvider во Flutter я хранил список товаров в магазине в переменной, созданной в функции main.

Добавим в тот код репозиторий, который будет получать список товаров с сервера и хранить где то в кеше, например в переменной. В реальном проекте хранение возможно будет в локальной БД.

В качестве сервера мы используем просто заглушку эмулирующую получения данных с сервера с задержкой.

class Item {
  final String name;
  final double price;
  Item({ @required this.name, @required this.price }):
        assert(name != null), assert(price != null);
}

class ApiProvider {
  Future<List<Item>> fetchItems() async {
    await Future.delayed(Duration(seconds: 1));
    return [
      Item(name: "Ноутбук", price: 100.0),
      Item(name: "Телефон", price: 200.0),
      Item(name: "Холодильник", price: 150.0),
      Item(name: "Телевизор", price: 120.0),
      Item(name: "Утюг", price: 60.0),
    ];
  }
}

class ItemsRepository {
  final ApiProvider apiProvider;
  UnmodifiableListView<Item> _items;
  ItemsRepository({ @required this.apiProvider }): assert(apiProvider != null);

  Future<UnmodifiableListView<Item>> fetch() async {
    if (_items == null) {
      _items = UnmodifiableListView(await apiProvider.fetchItems());
    }
    return _items;
  }
}

И создадим Bloc с состояниями и событиями.

abstract class ItemsEvent {}
class FetchItemsEvent extends ItemsEvent {}

abstract class ItemsState {}
class InitialItemsState extends ItemsState {}
class ErrorItemsState extends ItemsState {}
class LoadingItemsState extends ItemsState {}
class LoadedItemsState extends ItemsState {
  final UnmodifiableListView<Item> items;
  LoadedItemsState(this.items): assert(items != null);
}

class ItemsBloc extends Bloc<ItemsEvent, ItemsState> {
  final ItemsRepository _itemsRepository;
  ItemsBloc({ @required ItemsRepository itemsRepository }):
      assert(itemsRepository != null), _itemsRepository = itemsRepository,
      super(InitialItemsState());

  @override
  Stream<ItemsState> mapEventToState(ItemsEvent event) async* {
    if (event is FetchItemsEvent) {
      yield LoadingItemsState();
      try {
        final list = await _itemsRepository.fetch()
            .timeout(Duration(seconds: 2));
        yield LoadedItemsState(list);
      } on dynamic catch (_) {
        yield ErrorItemsState();
      }
    }
  }
}

Классы ItemsEvent и ItemsState абстрактные, они используются в качестве интерфейсов. 

Когда в функцию mapEventToState поступит ItemsEvent, мы проверяем какое это именно событие.

На основании типа, решаем какие действия нужно выполнить.

Запись типа:

if (event is FetchItemsEvent) {
  ...
}

проверит является ли event экземпляром FetchItemsEvent и если это так, то внутри блока if переменная event будет неявно приведена к типу FetchItemsEvent и мы сможем получить доступ к ее методам и свойствам. В данном примере нам этого не нужно, но это может быть передача id объекта к которому мы хотим получить доступ, логин и пароль и многое другое. То есть любые параметры которые нужно передать из UI в Bloc.

При запросе получения данных, обновляем стейт на LoadingItemsState. Далее следует запрос в репозиторий, и репозиторий решает, где ему брать данные. В этом примере проверяем есть ли данные в свойстве класса. Если нет, то обращается к api. Эмулируем задержку ответа от сервера и отдаем данные в репозиторий. Репозиторий отдает в Bloc.

В запросе к репозиторию можно добавить timeout. Если за определенное время мы не получим ответа от репозитория, будет вызвано исключение. Исключения можно обрабатывать по отдельности. Я же отлавливаю в данном примере любое исключение и сообщаю об ошибке сменой статуса на ErrorItemsState.

В случае удачного ответа, стейт изменится на LoadedItemsState которые будет содержать в себе полученные данные.

Приложение из прошлой статьи о корзине немного модифицируем, и создадим два экрана. HomePage будет содержать кнопку перехода в магазин. Это нужно только для демонстрации "ленивого" создания ItemsBloc.

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter App',
      theme: ThemeData(
        primarySwatch: Colors.blue,
        visualDensity: VisualDensity.adaptivePlatformDensity,
      ),
      home: HomePage()
    );
  }
}

class HomePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) => Scaffold(
    appBar: AppBar(
      title: Text("Магазин"),
      actions: [
        IconButton(
          icon: Icon(Icons.clear),
          onPressed: () => context.read<Cart>().clear(),
        ),
      ],
    ),
    body: Center(
      child: RaisedButton(
        onPressed: () => Navigator.of(context)
          .push(MaterialPageRoute(builder: (context) => ShopPage())),
        child: Text('Магазин'),
      ),
    ),
  );
}

class ShopPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) => Scaffold(
    appBar: AppBar(
      title: Text("Магазин"),
      actions: [
        IconButton(
          icon: Icon(Icons.clear),
          onPressed: () => context.read<Cart>().clear(),
        ),
      ],
    ),
    body: Column(
      children: [
        CartBar(),
        Expanded(
          child: BlocBuilder<ItemsBloc, ItemsState>(
            builder: (context, state) {
              if (state is LoadedItemsState) {
                return ListView.builder(
                  itemCount: state.items.length,
                  itemBuilder: (context, idx) => ItemView(state.items[idx]),
                );
              } else if (state is ErrorItemsState) {
                return Column(
                  mainAxisAlignment: MainAxisAlignment.center,
                  children: [
                    Center(child: Text('Произошла ошибка'),),
                    RaisedButton.icon(
                      onPressed: () =>
                          context.read<ItemsBloc>().add(FetchItemsEvent()),
                      label: Text("Повторить"),
                      icon: Icon(Icons.refresh),
                    ),
                  ],
                );
              }
              return Center(child: CircularProgressIndicator(),);
            }
          ),
        ),
      ],
    ),
  );
}

class CartBar extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final cart = context.watch<Cart>();
    return Padding(
      padding: const EdgeInsets.all(20.0,),
      child: Row(
        children: [
          Expanded(
              child: Text("Товаров: ${cart.count}")
          ),
          Text("сумму: ${cart.amount}")
        ],
      ),
    );
  }
}

class ItemView extends StatelessWidget {
  final Item item;
  ItemView(this.item): assert(item != null);

  @override
  Widget build(BuildContext context) => InkWell(
    onTap: () => context.read<Cart>().add(item),
    child: Consumer<Cart>(
      builder: (context, cart, child) => Card(
        color: cart.hasItem(item)
            ? Colors.lightGreen
            : Colors.white,
        child: Padding(
          padding: const EdgeInsets.all(20.0),
          child: Row(
            children: [
              Expanded(child: Text(item.name)),
              Text(item.price.toStringAsFixed(2))
            ],
          ),
        ),
      ),
    ),
  );
}

Виджет BlocBuilder<ItemsBloc, ItemsState> принимает Cubit. Как я уже писал, Bloc наследует Cubit. Следовательно нужно передать экземпляр нашего блока. Создадим ItemsBloc в BlocProvider. В отличии от обычного Provider, BlocProvider уже имеет реализацию метода dispose в котором вызывает bloc.close().

Чтобы у нас был доступ к нашим ресурсам, переданным через провайдер с любой страницы навигатора, нужно разместить провайдер выше навигатора. Это я показывал в видео.


Обернем MaterialApp в провайдеры:

import 'dart:async';
import 'dart:collection';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:provider/provider.dart';


void main() {
  runApp(MyProvider());
}

class MyProvider extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Provider<ApiProvider>(
      create: (context) => ApiProvider(),
      child: RepositoryProvider<ItemsRepository>(
        create: (context) =>
            ItemsRepository(apiProvider: context.read<ApiProvider>()),
        child: BlocProvider<ItemsBloc>(
            create: (context) =>
            ItemsBloc(itemsRepository: context.read<ItemsRepository>())
              ..add(FetchItemsEvent()),
            child: ChangeNotifierProvider(
              create: (context) => Cart(),
              child: MyApp(),
            )
        ),
      ),
    );
  }
}

Обратите внимание, в методе create у BlocProvider идет сразу передача события FetchItemsEvent.

Запустив приложение, и нажав кнопку магазин, можно видеть как вращается индикатор загрузки, а потом отображаются предметы. Задержка получения данных установлена в 1 секунду, но сколько не прождав на первом экране, данные не начнут загружаться. Это и есть ленивое создание ресурса в Provider.

Вынеся создание Bloc над навигатором, мы получили экземпляр нашего ItemsBloc через контекст, можно написать BlocBuilder<ItemsBloc, ItemsState>(cubit: context.read<ItemsBloc>, ...). Но библиотека flutter_bloc делает это сама и нам не нужно этого писать. BlocBuilder, как и другие виджеты этой библиотеки, попытаются получить экземпляр указанного типа данных из контекста. Если же в контексте его не будет, нужно создать экземпляр и передать в параметр cubit.

Таким образом, когда код дойдет до BlocBuilder<ItemsBloc>, будет поиск провайдера возвращающего ItemsBloc. И будет вызван метод create, который создаст экземпляр объекта при первом обращении к провайдеру.

Это поведение можно изменить указав lazy: false. Тогда при запуске приложения, сразу будет создан Bloc и запрос улетит в репозиторий.

Изменив время задержки в репозитории, больше чем время таймаута, можно проверить отображение ошибки.

При копировании материалов ссылка на https://terraideas.ru/ обязательна

Комментарии к статье: BLoC во Flutter, отличие от Cubit и пример использования.

Владимир 19 дней назад

С обновлением блока до его текущей версии события теперь обрабатываются параллельно, как и в Кубите. Я думаю стоит обновить статью