Этот сайт использует файлы cookies. Оставаясь на сайте, Вы соглашаетесь с использованием файлов cookies и принимаете Соглашение об использовании сайта.

bLoC

Разделение бизнес-логики и UI во Flutter с помощью BLoC-архитектуры

635

Flutter — динамично развивающийся фреймворк для написания кроссплатформенных приложений. Несмотря на свою молодость (первая стабильная версия Flutter 1.0 была представлена всего лишь 4 декабря 2018 года, то есть чуть больше года назад на момент публикации статьи), он приобрел немало сторонников, и его популярность только растет. Сегодня мы хотели бы рассказать об одной из самых популярных среди Flutter-разработчиков архитектур, которая была разработана в Google, и называется — BLoC.


Оглавление


Что такое BLoC

Давайте разберемся с основной терминологией. BLoC — это акроним от «Business Logic Component» (компонент бизнес-логики). Как следует из названия, это класс, отделяющий бизнес-логику приложения от пользовательского интерфейса. Такой компонент содержит код, который можно повторно использовать где угодно: в другом модуле, на другой платформе, в другом приложении. Если вы до этого работали с архитектурой MVVM, то можете провести аналогию и сравнить Bloc с ViewModel — они похожи по своему назначению.

Обратите внимание на разницу в написании терминов. Здесь и далее употребляется BLoC, когда речь идет об архитектуре, и Bloc – когда о классе в программе.

Отделение UI от бизнес-логики для Flutter жизненно необходимо. Согласитесь, что карабкаться вверх-вниз по дереву виджетов (UI-компонентов) в поисках нужной вам логики — не самая приятная вещь. Особенно, если верстка и так содержит очень много кода и разбросана по разным файлам. Кроме того, если мы следуем заветам Clean Architecture (а мы им следуем, не так ли?), то в нашем UI вообще не должно быть ничего лишнего. Равно как и бизнес-логика ничего не должна знать про UI, который к ней обращается. Благодаря такому тщательному разделению ответственностей мы получаем полностью изолированный компонент, который можно легко тестировать независимо от UI и использовать в другом окружении. Этот компонент и есть наш Bloc.

Одной из отличительных особенностей паттерна является то, что он полностью базируется на реактивности. Что это значит? Реактивное программирование — это программирование с асинхронными потоками данных. В традиционном императивном стиле мы обычно пишем код следующего содержания: вот текстовое поле, назначаем ему обработчик, реагирующий на ввод нового символа, в обработчике добавляем объект, у которого вызываем метод обновления текстового поля в модели. Мы четко описываем каждый шаг. В реактивном подходе все выглядит немного иначе. Текстовое поле связывается с конкретной переменной в модели. И как только пользователь начинает что-то печатать, эта переменная сразу принимает то значение, которое сейчас содержится в текстовом поле.

Для реализации реактивных сценариев язык Dart по умолчанию предлагает класс StreamController. Он позволяет нам моделировать поток данных с помощью двух составляющих — sink (входной поток, куда пользователь добавляет события) и stream (выходной поток, который слушают один или несколько объектов и реагируют на изменения). Пример описания входа-выхода для экрана в коде Bloc-а выглядит следующим образом:

final StreamController<String>_nameController = StreamController<String>();

// описываем геттер для входного потока (sink)
Sink<String> get inName =>_nameController.sink;

// описываем геттер для выходного потока (stream)
Stream<String> get outName =>_nameController.stream;

Отдельные геттеры удобно делать по следующим причинам. Во-первых, так гораздо проще, чем каждый раз писать nameController.sink, во-вторых, вы даете входу/выходу осмысленное имя, что позволяет легче понять его назначение и тем самым улучшает читабельность.

Код метода build для вашего виджета может выглядеть так:

@override
Widget build(BuildContext context) {
 return StreamBuilder<String> (
     stream: someBloc.outName,
     builder: (BuildContext context, AsyncSnapshot<String>snapshot) => Text(snapshot.data)
 );
}

Здесь мы создаем специальный виджет StreamBuilder. Первым аргументом указываем поток, который поставляет данные нашему виджету, вторым — функцию, «собирающую» виджет на основе отрисовочного контекста и данных, асинхронно приходящих из потока.

Для реализации BLoC часто используются уже готовые библиотеки, например, эта. Нашей же команде она показалась слишком тяжеловесной, поэтому мы воспользовались кастомной, упрощенной реализацией, описанной в этой статье. Рассмотрим ее основные положения.

Знакомство с BlocProvider

Каждый Bloc поддерживает общий интерфейс BlocBase, содержащий в себе только один обязательный к реализации метод dispose. Он нужен, чтобы не забыть реализовать освобождение ресурсов, занятых Bloc-ом, например, закрыть активные потоки. Выглядит он так:

abstract class BlocBase {
 void dispose();
}

Для того чтобы вы могли получить доступ к Bloc-у из любого узла дерева виджетов, корнем этого дерева становится такая сущность, как BlocProvider. Рассмотрим ее код:

class BlocProvider<T extends BlocBase>extends StatefulWidget {
 BlocProvider({
   Key key,
   @required this.child,
   @required this.bloc,
 }): super(key: key);

 final T bloc;
 final Widget child;

 @override
 _BlocProviderState<T>createState() => _BlocProviderState<T>();

 static T of<T extends BlocBase>(BuildContext context){
   final type = _typeOf<BlocProvider<T>>();
   BlocProvider<T> provider = context.ancestorWidgetOfExactType(type);
   return provider.bloc;
 }

 static Type _typeOf<T>() => T;
}

class _BlocProviderState<T> extends State<BlocProvider<BlocBase>>{
 @override
 void dispose(){
   widget.bloc.dispose();
   super.dispose();
 }

 @override
 Widget build(BuildContext context){
   return widget.child;
 }
}

Идея заключается в том, что наш провайдер, точно такой же виджет, который состоит из двух слагаемых: Bloc, к которому будут обращаться виджеты, и дерево виджетов, которое мы видим на экране. Для доступа к Bloc-у в методе виджета build вам нужно написать так:

@override
Widget build(BuildContext context) {
 MyBloc bloc = BlocProvider.of(context);
 return Scaffold(body: /*ваша реализация с использованием StreamBuilder*/);
}

Доступ к Bloc-у под капотом реализован очень просто. Мы движемся вверх по иерархии виджетов в рамках BuildContext, пока не встретим BlocProvider. Как только это произошло, мы можем вернуть Bloc этого провайдера.

Небольшое уточнение по поводу оптимизации. Поиск по иерархии виджетов имеет вычислительную сложность O (n). Если ваш провайдер расположен относительно близко к виджету, который запросил Bloc, такая операция не займет много времени. Однако в случае, когда ваш виджет имеет большую глубину вложенности, а доступ к Bloc-у ему требуется часто, это может сказаться на производительности. Тогда есть смысл подумать о том, чтобы кэшировать ссылку на Bloc, например, передавая его как аргумент в конструктор виджета.

Пример реализации

Рассмотрим, как работа с BLoC-архитектурой может выглядеть на практике. Перепишем пример, который по умолчанию генерируется при создании Flutter-проекта. Так как здесь всего один экран, у нас будет один виджет MainScreen с прилагающимся к нему MainBloc.

У MainBloc есть входной поток с UI событиями (в нашем случае нажатия на кнопку «+») и выходной поток со значением счетчика нажатий. Само значение счетчика counter из State должно переехать сюда, поскольку оно — часть бизнес-логики. В результате получаем следующий код для MainBloc:

import 'dart:async';

import 'package:simple_bloc_app/bloc/common/bloc_base.dart';

enum MainBlocEvent {
 incrementCounter
 // ...другие события, которые будет обрабатывать Bloc
}

class MainBloc extends BlocBase {
 // данные Bloc-а
 int _counter = 0;

 // stream controllers
 final StreamController<int> _counterController = StreamController<int>();
 final StreamController<MainBlocEvent> _eventController =
     StreamController<MainBlocEvent>();

 Sink<int> get _inCounter => _counterController.sink;
 Stream<int> get outCounter => _counterController.stream;

 Sink<MainBlocEvent> get inEvent => _eventController.sink;
 Stream<MainBlocEvent> get _outEvent => _eventController.stream;

 MainBloc() {
   // подписываемся на поток
   // здесь обрабатываются события, пришедшие со стороны UI
   _outEvent.listen(_handleEvent);
 }

 // альтернатива потоку с UI-событиями
 void onIncrementButton() {
   _handleIncrementCounterEvent();
 }

 @override
 void dispose() {
   // здесь мы закрываем открытые контроллеры
   _eventController.close();
   _counterController.close();
 }

 void _handleEvent(MainBlocEvent event) {
   switch (event) {
     case MainBlocEvent.incrementCounter:
       _handleIncrementCounterEvent();
       break;
     default:
       // чтобы гарантировать, что мы не пропустим ни один кейс enum-а
       assert(false, "Should never reach there");
       break;
   }
 }

 void _handleIncrementCounterEvent() {
   _inCounter.add(++_counter);
 }
}

Если создание отдельного потока для UI-событий вам кажется излишним, то вы можете написать отдельный метод-обработчик нажатия на кнопку «+» (onIncrementButton) и вызывать его напрямую.

Теперь вынесем код виджета из main.dart в MainScreen и интегрируем в него наш MainBloc:

import 'package:flutter/material.dart';
import 'package:simple_bloc_app/bloc/common/bloc_provider.dart';
import 'package:simple_bloc_app/bloc/main_bloc.dart';

class MainScreen extends StatefulWidget {
 @override
 State<StatefulWidget>createState() => _MainScreenState();
}

class _MainScreenState extends State<MainScreen> {
 @override
 Widget build(BuildContext context) {
   final MainBloc bloc = BlocProvider.of(context);

   return Scaffold(
     appBar: AppBar(
       title: Text("My first BLoC app"),
     ),
     body: Center(
       child: Column(
         mainAxisAlignment: MainAxisAlignment.center,
         children: <Widget>[
           Text(
             'You have pushed the button this many times:',
           ),
           StreamBuilder(
             stream: bloc.outCounter,
             builder: (BuildContext context, AsyncSnapshot<int> snapshot) {
               return Text(
                 "${snapshot.data ?? 0}",
                 style: Theme.of(context).textTheme.display1,
               );
             },
           )
         ],
       ),
     ),
     floatingActionButton: FloatingActionButton(
       // используйте либо поток, либо вызов метода Bloc-а
       onPressed: () => bloc.inEvent.add(MainBlocEvent.incrementCounter),
       //onPressed: () => bloc.onIncrementButton(),
       tooltip: 'Increment',
       child: Icon(Icons.add),
     ), 
   );
 }
}

Обратите внимание, текстовый виджет со значением счетчика теперь находится в StreamBuilder, который подписан на поток из Bloc-а. При нажатии на кнопку в Bloc прокидывается соответствующее событие (на ваше усмотрение, через поток или обработчик). Bloc обрабатывает событие, увеличивая хранящийся в нем счетчик нажатий на 1. После чего значение этого счетчика передается в другой поток, на который подписан виджет на стороне UI. StreamBuilder получает новое состояние из потока (AsyncSnapshot) и перерисовывает свое содержимое с новыми данными.

Наконец, создадим BlocProvider для нашего экрана. Вынесем этот код в main.dart:

import 'package:flutter/material.dart';
import 'package:simple_bloc_app/bloc/common/bloc_provider.dart';
import 'package:simple_bloc_app/bloc/main_bloc.dart';
import 'package:simple_bloc_app/screen/main_screen.dart';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {

 @override
 Widget build(BuildContext context) {
   return MaterialApp(
     title: 'Flutter Demo',
     theme: ThemeData(
       primarySwatch: Colors.blue,
     ),
     home: MyHomePage(),
   );
 }
}

class MyHomePage extends StatefulWidget {
 MyHomePage({Key key}) : super(key: key);

 @override
 _MyHomePageState createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
 @override
 Widget build(BuildContext context) {
   // создаем Bloc и экран для него
   final MainBloc bloc = MainBloc();
   final MainScreen screen = MainScreen();

   // на их основе создаем BlocProvider
   return BlocProvider(
     child: screen,
     bloc: bloc,
   );
 }
}

Запускаем приложение и проверяем его работоспособность.

Код проекта вы можете посмотреть здесь.

Чек-лист по работе с BLoC-архитектурой

Итак, алгоритм работы при создании новых экранов с использованием BLoC-архитектуры выглядит следующим образом:

  • Создать Bloc для конкретного экрана.
  • Описать его входы и выходы в виде потоков.
  • Реализовать требуемую бизнес-логику.
  • Создать новый экран.
  • Поместить в корень дерева виджетов BlocProvider, передать ему экземпляр реализованного Bloc-а и, собственно, виджеты, которые будут отображаться на экране.
  • Реализовать экран, используя по необходимости StreamBuilder-ы, подписанные на выходы из Bloc-а.

Разумеется, никто не заставляет вас ограничивать себя исключительно StreamBuilder-ами. Более того, в случае, когда для отрисовки виджета вам нужно просто один раз синхронно получить данные из Bloc-а, «плодить» stream-ы даже нерационально. А для разовой подгрузки данных по сети можно использовать, например, FutureBuilder. Поэтому изучайте, экспериментируйте, создавайте. В следующей статье мы расскажем, как наша команда переходила от привычного паттерна VIPER к BLoC и с какими сложностями мы при этом столкнулись.