To BLoC or not to BLoC, или как iOS-разработчики во Flutter искали Router
Разработка

To BLoC or not to BLoC, или как iOS-разработчики во Flutter искали Router

Ярослав Магин
Разработчик

Оглавление

Создаём сервисы в сферах HR-Tech, образования, девелопмента.

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

Прежде наша команда в своих проектах использовала преимущественно паттерны MVVM и VIPER. Приступив к изучению архитектурных практик во Flutter, мы обнаружили, что здесь пишут несколько иначе. Тем, кто тоже интересуется этой темой, рекомендую изучить репозиторий, где представлены различные варианты реализации одного и того же небольшого приложения «TODO-лист». Не будем рассматривать каждую архитектуру подробно, поскольку это тянет на отдельный цикл статей. Скажу лишь, что мы остановились на BLoC-архитектуре, предложенной Google, поскольку нам она показалась наиболее интересной. Подробнее про BLoC вы можете почитать в нашей предыдущей статье. Сейчас же речь пойдет о том, какие нюансы в применении этой архитектуры могут помешать взаимодействию ваших Bloc-ов с UI, если вы смотрите на него сквозь призму опыта другой архитектуры.

Первый взгляд на BLoC

Казалось бы, все звучит довольно просто. Вот UI, вот Bloc, который в этой архитектуре выступает в качестве презентера. Берем потоки, подписываемся на них и посылаем данные туда-сюда. Однако у нас, привыкших к VIPER-у, все время возникало ощущение, что чего-то здесь не хватает. Поэтому мы попытались адаптировать некоторые идеи VIPER-а к BLoC-у.

Главный вопрос, который нас занимал — кто в этой архитектуре исполняет обязанности Router-а. Раньше у нас была такая последовательность действий: нажали кнопку во View, Presenter обработал нажатие, затем либо передал запрос Interactor-у, либо попросил Router открыть новый экран. Здесь у нас в качестве Presenter-а выступает сам Bloc. То есть переход на новый экран должен осуществляться из Bloc-а? Нет, не все так просто.

Дело в том, что во Flutter вам жизненно необходима такая сущность, как BuildContext. Хотите перерисовать виджет? Нужен BuildContext. Хотите показать другой экран или диалоговое окно? Показывайте с помощью BuildContext. Хотите локализовать строку с помощью библиотеки (хотя она не единственная, но очень популярная)? Вы знаете, куда идти. И этот BuildContext доступен только на уровне UI в момент обращения к виджету.

То есть выполняете вы какой-то асинхронный запрос и вдруг обнаруживаете, что вам срочно нужно пользователю показать сообщение, скажем, о возникшей ошибке. Ошибка приходит в ваш Bloc. Вы хотите показать ииии... не показываете. Почему? Правильно. Потому что у вас контекста нет, так как вы находитесь на уровне бизнес-логики, где виджеты отсутствуют. А уровень UI, где виджеты есть, не должен знать о других модулях, на которые выполняется переход.

Мы придумали два способа для выхода из этой ситуации. Способ первый — просто отдавать в Bloc контекст из виджета. В теле метода виджета initState () мы обращались к нашему Bloc-у, вызывали у него метод setContext (context) и сохраняли соответствующую ссылку, после чего использовали ее для показа диалога или другого экрана. Просто и надежно. Способ второй — каждый раз передавать контекст виджета аргументом в метод, который может потребовать от вас работу с UI. Хотя автор второго способа все равно пришел к тому же первому решению, когда пытался наладить ту самую обработку асинхронно пришедшей ошибки.

Почему нельзя помещать BuildContext в Bloc

Сохранив BuildContext внутри Bloc-а, мы, казалось бы, добились своего. Теперь мы в любой момент в Bloc-е можем вызвать код, который осуществит переход на другой экран, покажет диалог или выполнит другое необходимое действие. Этот код может быть вынесен в отдельный класс, скажем, MyBlocRouter. И каждый раз, когда вам нужно уйти на другой экран, вы пишете MyBlocRouter.goToNextScreen (_context), и внутри этого метода уже пишете всю логику, связанную с созданием и настройкой нового экрана. Тогда это казалось более-менее приемлемым компромиссным решением (особенно в условиях мягко подкрадывающегося дедлайна по проекту). Догадываетесь, почему на самом деле решение было не очень хорошим?

Ответ прост — все та же изоляция слоев по практикам Clean Architecture. Поместив context в Bloc, вы тем самым жестко завязываете вашу бизнес-логику на конкретную UI-имплементацию, что лишает возможности повторно использовать компонент в других местах. А ведь именно это является одним из назначений Bloc-а.

Отделяем навигацию от UI и бизнес-логики

Вспомним, что BLoC — прежде всего реактивный паттерн. Вы можете организовать отдельный поток для событий. На вход этого потока поступают данные из Bloc-а (это могут быть строковые ключи, числовые константы или значения enum-ов). На выходе есть некто, кто слушает эти сообщения и в зависимости от запрошенного события вызывает ваш Router, передавая ему свой контекст. Назовем эту сущность BlocContext. Ее основное назначение — помогать Bloc-у в тех операциях, которые он не может самостоятельно выполнить без контекста. К таким операциям относятся навигация между экранами и показ диалогов.

Проиллюстрируем взаимоотношения классов следующей диаграммой:

Диаграмма взаимоотношений классов

Child (он же виджет) использует Bloc для подписки на потоки с данными и отправки сообщений Bloc-у. BlocContext использует Bloc для подписки на события навигации и показа диалогов. Сам же Bloc делает ровно то, что должен — инкапсулирует бизнес-логику, ничего не зная про UI, который его использует.

Код BlocContext может выглядеть следующим образом:

abstract class BlocContextBase <T extends BlocBase>  {


  // обязательный к реализации метод
 void subscribe(T bloc, BuildContext context);

 // общие обработчики диалогов, которые могут пригодиться каждому bloc

 void showInfoAlert(BuildContext context, String message) {
    // ...
}

 void showError(BuildContext context, String message) {
   // ...
 }

 void showProgressDialog(BuildContext context) {
   // ...
 }
}

Метод subscribe нам нужен для того, чтобы наш BlocContext мог подписаться на события, которые посылает связанный с ним Bloc. Зная, какое событие произошло, и имея при этом ссылку на BuildContext, мы всегда можем показать новый диалог или перейти на другой экран.

Чтобы получить доступ к контексту, немного дополним реализацию BlocProvider из предыдущей статьи:

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

 final T bloc;
 final Widget child;
 final BlocContextBase<T> blocContext;

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

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

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

class _BlocProviderState<T> extends State<BlocProvider<BlocBase>> {
 @override
 void initState() {
   super.initState();
   widget.blocContext.subscribe(widget.bloc, context);
 }

 @override
 void dispose() {
   widget.bloc.dispose();
   super.dispose();
 }

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

Рассмотрим внесенные изменения. В BlocProvider появился третий параметр — собственно BlocContext. Также появилась реализация метода initState. Поскольку BlocProvider является виджетом, у него есть BuildContext. Благодаря этому мы можем в методе initState вызвать subscribe BlocContext-а, передавая сам Bloc и контекст, взятый из BlocProvider. Подписка на события из Bloc-а должна происходить только в методе initState, и никак не в методе build. Последний изначально проектировался как метод без side-эффектов. Вы не можете контролировать, когда и кто его вызовет. Например, когда вы добавляете в навигационный стек новый экран через Navigator.push (...), то все виджеты в стеке автоматически перерисовываются. И если у вас где-то есть build, который завязан на подписку, или на загрузку данных, или нечто подобное, то ждите удивительных ошибок и увлекательного дебага.

Реализуя конкретных наследников BlocContext, вы можете сами определить, как и на какие события вы будете подписываться, какие именно диалоги вы будете показывать и каким Router-ам переадресовывать навигацию. Заметим, что BlocContext должен сразу выполнять код, связанный с показом диалога/экрана. В его обязанности не входит проверять какие-либо условия, например, заполнение пользователем обязательных текстовых полей. Это часть бизнес-логики, которая относится к задачам Bloc-а. Он выполняет всю обработку, проверяет все условия, валидирует данные и только тогда посылает в поток событие на показ чего бы то ни было.

Пример навигации между двумя экранами

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

class BlocEvent<T> {
 T type;
 Map<String, dynamic> parameters;

 BlocEvent({@required this.type, this.parameters});
}

// Общий интерфейс для всех bloc-ов
abstract class BlocBase<T> {
 StreamController<BlocEvent<T>> _eventsController =
     StreamController<BlocEvent<T>>();

 @protected
 Sink<BlocEvent<T>> get inEvents => _eventsController.sink;

 Stream<BlocEvent<T>> get outEvents => _eventsController.stream;

 void dispose() {
   _eventsController.close();
 }
}
Сюда также добавляется новый параметризованный класс BlocEvent, хранящий тип события (для этого мы будем использовать перечисления) и словарь параметров. Объекты этого класса мы будем пробрасывать от Bloc-а к контексту.

Перейдем к главному экрану. Немного изменим верстку: добавим туда текст, по нажатию на который будет открываться новый экран. Найдем в коде верстки виджет Column, где располагается основной контент, и поместим туда следующий элемент:

InkWell(
 child: Text("Checkout next screen",
     style: TextStyle(color: Colors.blue)),
 onTap: () => bloc.onGoToNextScreenButton(),
)

Также нам потребуется реализовать соответствующий метод. Обратите внимание: в отличие от предыдущей, в этой статье не используется реактивность для передачи событий на сторону Bloc-а. Перечисляемый тип MainBlocEvent теперь нужен для того, чтобы передавать события от Bloc-а к его контексту. Код в файле main_bloc.dart теперь выглядит следующим образом:

import 'dart:async';

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

enum MainBlocEvent {
// события навигации для bloc-а
 goToNextScreen
}

class MainBloc extends BlocBase< MainBlocEvent> {

 int _counter = 0;

 final StreamController<int> _counterController = StreamController<int>();

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

 MainBloc();

 void onIncrementButton() {
   _handleIncrementCounterEvent();
 }

 void onGoToNextScreenButton() {
   inEvents.add(BlocEvent(type: MainBlocEvent.goToNextScreen));
 }

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

   // не забываем вызвать метод суперкласса
   super.dispose();
 }

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

Здесь мы изменили перечисление MainBlocEvent и добавили обработчик для перехода на следующий экран.

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

class SecondScreen extends StatelessWidget {
 @override
 Widget build(BuildContext context) {
   return Scaffold(
     appBar: AppBar(title: Text("Second screen")),
     body: Center(
       child: Text("Some content will be there"),
     ),
   );
 }
}

Для перехода нам понадобится соответствующий Router:

class SecondScreenRouter {
 static void openModule(BuildContext context) {
   Navigator.push(context,
       MaterialPageRoute(builder: (BuildContext context) => SecondScreen()));
 }
}

Теперь реализуем MainBlocContext:

class MainBlocContext extends BlocContextBase {
 @override
 void subscribe(MainBloc bloc, BuildContext context) {
   bloc.outEvents.listen((BlocEvent<MainBlocEvent> event) {
     switch (event.type) {
       case MainBlocEvent.goToNextScreen:
         SecondScreenRouter.openModule(context);
         break;
       default:
         assert(false, "Should never reach there");
         break;
     }
   });
 }
}

Итак, все готово. Осталось только внести завершающие штрихи в main.dart:

class _MyHomePageState extends State<MyHomePage> {

 // кешируем ссылки на Bloc и его контекст
 // чтобы избежать их повторного создания и проблем с подпиской на потоки Bloc-а
 final MainBloc bloc = MainBloc();
 final MainBlocContext blocContext = MainBlocContext();

 @override
 Widget build(BuildContext context) {
   return BlocProvider(
     child: MainScreen(),
     bloc: bloc,
     blocContext: blocContext,
   );
 }
}
Готово! Наш переход между экранами работает:
Переход между экранами
Исходный код проекта можно найти здесь.

Summary: полученный результат

Итак, чего мы сегодня добились?

  • Изолировали бизнес-логику от UI. Теперь наши Bloc-и ничего не знают про BuildContext и не нуждаются в нем для показа экрана/диалога.
  • Вынесли общий функционал, связанный с показом сообщений об ошибках, информационных диалогов и других вещей в отдельный класс. Вам не придется реализовывать эти методы отдельно для каждого Bloc-а.
  • Вам не нужно думать о том, чтобы не забыть подписать ваш BlocContext на Bloc. Сама подписка выполняется внутри BlocProvider. Ваша задача — реализовать соответствующие классы и передать их BlocProvider-у.

Таким образом, мы адаптировали идеи Clean Architecture к BLoC во Flutter и получили то разделение ответственностей, к которому стремились. А в следующей статье мы расскажем, как работать с платформенными сервисами вроде камеры, галереи или телефонной книги.

Оцените эту статью

0 0
Спасибо за оценку!

Оставьте заявку

Расскажите о проекте — мы его реализуем

Мы свяжемся с вами в течение 4 рабочих часов: обсудим цели проекта, требования к нему и составим план сотрудничества

* – поля обязательные для заполнения