Разделение бизнес-логики и UI во Flutter с помощью 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_nameController = StreamController(); // описываем геттер для входного потока (sink) Sink get inName =>_nameController.sink; // описываем геттер для выходного потока (stream) Stream get outName =>_nameController.stream; Отдельные геттеры удобно делать по следующим причинам. Во-первых, так гораздо проще, чем каждый раз писать nameController.sink, во-вторых, вы даете входу/выходу осмысленное имя, что позволяет легче понять его назначение и тем самым улучшает читабельность. Код метода build для вашего виджета может выглядеть так: @override Widget build(BuildContext context) { return StreamBuilder ( stream: someBloc.outName, builder: (BuildContext context, AsyncSnapshotsnapshot) => Text(snapshot.data) ); } Здесь мы создаем специальный виджет StreamBuilder. Первым аргументом указываем поток, который поставляет данные нашему виджету, вторым — функцию, «собирающую» виджет на основе отрисовочного контекста и данных, асинхронно приходящих из потока. Для реализации BLoC часто используются уже готовые библиотеки, например, эта. Нашей же команде она показалась слишком тяжеловесной, поэтому мы воспользовались кастомной, упрощенной реализацией, описанной в этой статье. Рассмотрим ее основные положения. Каждый Bloc поддерживает общий интерфейс BlocBase, содержащий в себе только один обязательный к реализации метод dispose. Он нужен, чтобы не забыть реализовать освобождение ресурсов, занятых Bloc-ом, например, закрыть активные потоки. Выглядит он так: abstract class BlocBase { void dispose(); } Для того чтобы вы могли получить доступ к Bloc-у из любого узла дерева виджетов, корнем этого дерева становится такая сущность, как BlocProvider. Рассмотрим ее код: class BlocProviderextends StatefulWidget { BlocProvider({ Key key, @required this.child, @required this.bloc, }): super(key: key); final T bloc; final Widget child; @override _BlocProviderStatecreateState() => _BlocProviderState(); static T of(BuildContext context){ final type = _typeOf>(); BlocProvider provider = context.ancestorWidgetOfExactType(type); return provider.bloc; } static Type _typeOf() => T; } class _BlocProviderState extends State>{ @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 _counterController = StreamController(); final StreamController _eventController = StreamController(); Sink get _inCounter => _counterController.sink; Stream get outCounter => _counterController.stream; Sink get inEvent => _eventController.sink; Stream 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 StatecreateState() => _MainScreenState(); }- class _MainScreenState extends State { @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: [ Text( ’You have pushed the button this many times:’, ), StreamBuilder( stream: bloc.outCounter, builder: (BuildContext context, AsyncSnapshot 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 { @override Widget build(BuildContext context) { // создаем Bloc и экран для него final MainBloc bloc = MainBloc(); final MainScreen screen = MainScreen(); // на их основе создаем BlocProvider return BlocProvider( child: screen, bloc: bloc, ); } } Запускаем приложение и проверяем его работоспособность. Код проекта вы можете посмотреть здесь. Итак, алгоритм работы при создании новых экранов с использованием BLoC-архитектуры выглядит следующим образом: Разумеется, никто не заставляет вас ограничивать себя исключительно StreamBuilder-ами. Более того, в случае, когда для отрисовки виджета вам нужно просто один раз синхронно получить данные из Bloc-а, «плодить» stream-ы даже нерационально. А для разовой подгрузки данных по сети можно использовать, например, FutureBuilder. Поэтому изучайте, экспериментируйте, создавайте. В следующей статье мы расскажем, как наша команда переходила от привычного паттерна VIPER к BLoC и с какими сложностями мы при этом столкнулись.
Знакомство с BlocProvider
Пример реализации
Чек-лист по работе с BLoC-архитектурой