
Пример получения данных с сервера с использованием библиотеки 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; FuturefetchData(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.
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 и запрос улетит в репозиторий.
Изменив время задержки в репозитории, больше чем время таймаута, можно проверить отображение ошибки.
Комментарии к статье: BLoC во Flutter, отличие от Cubit и пример использования.
С обновлением блока до его текущей версии события теперь обрабатываются параллельно, как и в Кубите. Я думаю стоит обновить статью