Привет, Хабр! Меня зовут Никита Королев, я тимлид Flutter‑команды в компании IBS. Уже год я работаю на проекте компании «Атом» — разработчика российского электромобиля. На данный момент наша команда занимается разработкой приложений для направления «Такси».

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

Сейчас мы работаем с RnD‑гипотезами. Прежде чем какая‑либо фича окажется в разработке, необходимо проверить ее полезность и актуальность. Поэтому мы создаем приложения, которые проверяют гипотезы наших UX‑исследователей.

Приложениями пользуются реальные таксисты и пассажиры такси. С помощью аналитики использования этих приложений исследователи подтверждают или опровергают необходимость разработки различных фич для «Атома».

Из‑за такого формата работы у нас собрался целый «зоопарк» приложений, которые, скорее всего, будут иметь одну дизайн‑систему и, возможно, одинаковые части функционала. И все эти приложения требуют регулярных доработок и изменений по итогам исследований.

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

Проанализировав ситуацию, мы поняли, что было бы правильно сразу заложить возможность переиспользования функциональности одного приложения в другом — от небольшого виджета до целого флоу с данными. Также необходимо было обеспечить возможность быстрого переключения между источниками данных. Мы пришли к выводу, что нужно сразу заложить новую архитектуру. Дальше расскажу, что мы сделали.

Также хочется выразить благодарность коллегам из компании Атом — Development‑лидеру нашей команды Георгию Саватькову за идею этой архитектуры, а также Flutter‑разработчикам Самиру Гезалову и Артему Кушниру — за совместную работу по ее реализации.

У нас есть BLoC, почему его не достаточно?

В нативных языках мобильной разработки, таких как Swift или Kotlin, уже сформировались классические архитектурные подходы: MVC, MVVM, VIPER. В случае с Flutter разработчики, как правило, спорят о подходах к State Management. Но мы посчитали, что даже многими любимый BLoC недостаточно просто затянуть и сходу сформировать на нем архитектуру. Он только предоставляет нам набор классов и виджетов для инкапсуляции бизнес-логики. По большому счету BLoC — это упрощенный Redux без глобального состояния, что является адаптацией под реалии Flutter.

Так же как и в Redux, State хранит состояние, а BLoC занимается Event-to-State маппингом — посредством Reducer-подобных методов, инкапсулируя в себе и функциональность Middleware. 

BLoC помогает нам работать только с одним виджетом или экраном, но не решает многих важных проблем разработки, например:

  • неизвестен источник данных;

  • никакого взаимодействия с навигацией; 

  • снова никаких абстракций, а значит, BLoC не является универсальным;

  • не решает вопрос синхронизации данных или состояния между экранами.

Что нам нужно от кода?

Из обязательных хотелок мы выделили следующие:

  • явное разделение зон ответственности. Отдельный слой данных, слой бизнес-логики, слой UI;

  • зависимость только от абстракций, контрактов;

  • следствие из второго — независимость от реализации при работе с данными или локализацией;

  • бизнес-логика должна быть автономным черным ящиком;

  • независимые друг от друга части приложения должны взаимодействовать по единому механизму;

  • любая часть приложения должна иметь возможность получать данные из единого источника;

  • при надобности мы можем переиспользовать виджеты или экраны не только в другом месте текущего приложения, но и в другом приложении;

  • данные должны быть реактивными, но только данные.

Как уже было сказано выше, BLoC остается State Management решением для архитектуры. И расшифровка его аббревиатуры Business Logic Component помогла нам придумать название — Компонентная архитектура Flutter-приложений.

Единица архитектуры — компонент

Итак, начнем с единицы архитектуры — самого компонента. Что он из себя представляет?

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

Мы решили делать каждый компонент отдельным пакетом (package). Компонент состоит из следующих обязательных слоев:

  • Data

  • Domain

  • Localization

  • UI.

Слой Data

В слое Data находится абстрактный класс с контрактом репозитория компонента. В нем прописывается то, какие внешние данные необходимы для работы компонента и по каким методам он может с ними работать.

Слой Domain

В слое Domain находится управление бизнес-логикой компонента. Мы решили использовать для этих целей классический Bloc с событиями и состоянием. Bloc взаимодействует с репозиторием компонента из слоя Data по следующей схеме:

Работа с репозиторием компонента
Работа с репозиторием компонента

Обработка эвентов на первых шагах тоже ничем не отличается от классической. Но после изменения состояния мы также получаем возможность эмитить Action.

Обработка эвентов
Обработка эвентов

Что такое Action?

Action — это тот же Event, только смотрящий наружу и не участвующий в Event-to-State маппинге, а наоборот, являющийся его продуктом. То есть Event порождает Action, причем как с, так и без эмита нового State

Подразумевается, что если Event должен обрабатываться внутри своего блока, то Action, напротив, должен быть обработан вне нашего компонента и должен влиять на какую-либо другую часть приложения, будь то навигация или другой компонент. Таким образом, Action ответственен за исходящую коммуникацию, из компонента вовне, а Event — за входящую, из самого компонента в самого себя.   

Подписка на экшны и их обработка
Подписка на экшны и их обработка

Почему локализация на уровне BLoC?

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

Таким образом, нам пришлось сделать надстройку над классическим Bloc, в который дженериками передаются Event и State, чтобы работать в компоненте с Action и Localization.

import 'dart:async';

import 'package:atom_dart_core/atom_dart_core.dart';
import 'package:bloc/bloc.dart';

abstract class BaseBloc<Event, State, Action, Localization> extends Bloc<Event, State> with DisposableHolderMixin {
  BaseBloc({
    required State baseState,
    required this.localization,
  }) : super(baseState);

  late final _actionsSubject = StreamController<Action>.broadcast().addToDisposableHolder(disposableHolder);

  final Localization localization;

  Stream<Action> get actions => _actionsSubject.stream;

  void emitAction(Action action) {
    if (_actionsSubject.isClosed) return;
    _actionsSubject.sink.add(action);
  }

  @override
  Future<void> close() {
    disposableHolder.dispose();
    return super.close();
  }
}

Слой UI

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

Задача Widget — коммуникация с Bloc, то есть получение State через BlocBuilder / BlocConsumer и вызовы Event в нужные моменты. Widget не рисует UI как таковой, ограничиваясь разве что Scaffold и подобными вещами, а делегирует это LayoutWidget, лишь только принимая решение, как UI должен отреагировать на полученный State.

import 'package:atom_flutter_core/atom_flutter_core.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:my_cool_screen/src/domain/bloc/my_cool_screen_bloc.dart';
import 'package:my_cool_screen/src/ui/my_cool_screen_layout_widget.dart';

class MyCoolScreenWidget extends StatefulWidget {
  const MyCoolScreenWidget({
    super.key,
  });

  @override
  State<MyCoolScreenWidget> createState() => _MyCoolScreenState();
}

class _MyCoolScreenState extends BaseState<MyCoolScreenWidget> {
  late final MyCoolScreenBloc _bloc = bloc();

  @override
  Widget build(BuildContext context) {
    return BlocBuilder<MyCoolScreenBloc, MyCoolScreenState>(
      builder: (context, state) {
        if (state.failure != null) {
          final failure = state.failure!;
          return MyCoolScreenLayoutWidget.error(
            failure: failure,
          );
        } else if (state.isLoading) {
          return MyCoolScreenLayoutWidget.loading();
        }
        return MyCoolScreenLayoutWidget.content(
          localization: _bloc.localization,
        );
      },
    );
  }
}

Задача LayoutWidget — отрисовка самого UI с данными, полученными напрямую от Widget. Он не зависит от используемых в проекте State Management решений, ничего не знает о блоке и как с ним взаимодействовать и, следовательно, может быть применим с любым архитектурным решением.

LayoutWidget получает данные от Widget, а обратно общается сырыми callback: нажата кнопка, изменилось значение TextField и тому подобное.

А вот так слой UI выглядит в коде:

import 'package:atom_flutter_core/atom_flutter_core.dart';
import 'package:flutter/material.dart';
import 'package:my_cool_screen/src/localization/my_cool_screen_localization_contract.dart';

abstract class MyCoolScreenLayoutWidget extends BaseStatelessWidget {
  const MyCoolScreenLayoutWidget._({
    super.key,
  });

  factory MyCoolScreenLayoutWidget.loading({
    Key? key,
  }) =>
      _MyCoolScreenLayoutWidgetLoading._(
        key: key,
      );

  factory MyCoolScreenLayoutWidget.error({
    required Failure failure,
    Key? key,
  }) =>
      _MyCoolScreenLayoutWidgetError._(
        failure: failure,
        key: key,
      );

  factory MyCoolScreenLayoutWidget.content({
    required MyCoolScreenLocalizationContract localization,
    Key? key,
  }) =>
      _MyCoolScreenLayoutWidgetContent._(
        localization: localization,
        key: key,
      );
}

class _MyCoolScreenLayoutWidgetLoading extends MyCoolScreenLayoutWidget {
  const _MyCoolScreenLayoutWidgetLoading._({
    super.key,
  }) : super._();

  @override
  Widget build(BuildContext context) {
    return const Center(
      child: CircularProgressIndicator(),
    );
  }
}

class _MyCoolScreenLayoutWidgetError extends MyCoolScreenLayoutWidget {
  const _MyCoolScreenLayoutWidgetError._({
    required Failure failure,
    super.key,
  })  : _failure = failure,
        super._();

  final Failure _failure;

  @override
  Widget build(BuildContext context) {
    return Center(
      child: Text(
        _failure.toString(),
      ),
    );
  }
}

class _MyCoolScreenLayoutWidgetContent extends MyCoolScreenLayoutWidget {
  const _MyCoolScreenLayoutWidgetContent._({
    required MyCoolScreenLocalizationContract localization,
    super.key,
  })  : _localization = localization,
        super._();

  final MyCoolScreenLocalizationContract _localization;

  @override
  Widget build(BuildContext context) {
    //Основная фабрика для верстки экрана
    return const SizedBox();
  }
}

LayoutWidget — это абстрактный sealed класс, который имеет фабрики, по фабрике на каждое возможное состояние. Обычно это .loading(), .error() и .content(), но их количество может быть не ограничено и зависит от потребностей компонента.

Соответственно, Widget получает State от Bloc, понимает, как на текущее состояние должен отреагировать UI, и вызывает нужную фабрику у LayoutWidget, прокидывая туда набор данных, определяемый самой фабрикой. 

Реализации

Реализации компонентов существуют в домене целевого приложения. Как правило, они состоят из четырех файлов:

  • Реализация контракта репозитория компонента

  • Реализация контракта локализации

  • Coordinator

  • View.

Акцентировать внимание на реализациях контрактов не вижу смысла, а вот про View и Coordinator стоит поговорить отдельно.

View

Это входная точка практически любого компонента. В ней мы инициализируем BLoC компонента, передавая в него из DI реализации зависимостей. Как правило, их две — контракт репозитория и контракт локализации. Полученный инициализированный блок пробрасывается дальше по дереву, используя стандартный InheritedWidget механизм, реализованный с помощью нативного для BLoC механизма — BlocProvider.

import 'package:atom_flutter_core/atom_flutter_core.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:meta/meta.dart';
import 'package:my_cool_screen/my_cool_screen.dart';
import 'package:app/src/components/my_cool_screen/data/repository/my_cool_screen_component_repository.dart';
import 'package:app/src/components/my_cool_screen/localization/my_cool_screen_localization.dart';
import 'package:app/src/components/my_cool_screen/my_cool_screen_coordinator.dart';

class MyCoolScreenView extends BaseStatelessWidget {
  const MyCoolScreenView({
    super.key,
  });

  @override
  Widget build(BuildContext context) {
    return MultiBlocProvider(
      providers: [
        BlocProvider(create: _createMyCoolScreenBloc),
      ],
      child: const MyCoolScreenCoordinator(
        child: MyCoolScreenWidget(),
      ),
    );
  }

  MyCoolScreenBloc _createMyCoolScreenBloc(BuildContext context) {
    return MyCoolScreenBloc(
      repository: MyCoolScreenComponentRepository(),
      localization: MyCoolScreenLocalization(),
    );
  }
}

Coordinator

Coordinator же, исходя из его названия, координирует работу с одним или несколькими компонентами в рамках одного смыслового экрана, то есть View, поэтому так и назвали его.

Тут мы слушаем Action блоков наших компонентов, которые существуют в рамках текущего View, и как-то реагируем на них: вызываем методы навигации, посылаем Event в другие блоки, вызываем события аналитики и так далее.

import 'package:atom_flutter_core/atom_flutter_core.dart';
import 'package:flutter/material.dart';
import 'package:my_cool_screen/my_cool_screen.dart';

class MyCoolScreenCoordinator extends StatefulWidget {
  const MyCoolScreenCoordinator({
    required Widget child,
    super.key,
  }) : _child = child;

  final Widget _child;

  @override
  State<MyCoolScreenCoordinator> createState() => _MyCoolScreenCoordinatorState();
}

class _MyCoolScreenCoordinatorState extends BaseState<MyCoolScreenCoordinator> {
  @override
  void initState() {
    super.initState();
    bloc<MyCoolScreenBloc>().actions.listen(_onMyCoolScreenAction).addToDisposableHolder(disposableHolder);
  }

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

  void _onMyCoolScreenAction(MyCoolScreenAction action) {
    // TODO: Handle Action.
  }
}

Результат

В итоге схема схема взаимодействия частей архитектуры может выглядеть следующим образом:

Схема работы на одной View
Схема работы на одной View

Если в нашем приложении есть несколько экранов, они не будут взаимодействовать. Их реализации будут лежать на уровне Application, но без связи друг с другом. Как, например, на схеме ниже.

Схема работы с несколькими экранами
Схема работы с несколькими экранами

Все ли является компонентами?

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

Принятие решения о том, будет ли экран/виджет компонентом, всегда остается на стороне разработчика. Это решение принимается в момент анализа возможного взаимодействия с экраном/виджетом по следующим параметрам:

Если экран или виджет:

  • получает данные из внешнего источника;

  • на экране / виджете происходит обработка действия пользователя, и его можно инкапсулировать внутри этого экрана / виджета.

Тогда этот экран,виджет должен быть компонентом. 

Анализируя постановки задач и часто меняющиеся в формате RnD требования, мы стараемся предвидеть возможные изменения на разных экранах, поэтому даже самые простые экраны, как правило, у нас являются компонентами.

Заключение

С помощью компонентной архитектуры мы постарались решить многие типичные проблемы Flutter-приложений:

  • используем зависимость только от абстракций, а не от реализаций;

  • создали единую точку входа для данных в компоненте — репозиторий компонента — чтобы четко регламентировать возможные источники данных и иметь полный контроль над поступающими данными;

  • произвели четкое разделение слоев по зонам ответственности — по всем канонам Clean Architecture;

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

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

Конечно, в нашем решении есть и сложности, с которыми нам приходилось бороться. Это в первую очередь генерация компонентов, а также использование кодогенерации (в нашем случае пакет freezed, от использования которого мы постепенно избавляемся) и поддержка актуальности всех зависимостей. На разных этапах со всеми проблемами мы поборолись, но подробнее об этом расскажем в следующих статьях.

Мы планируем выпустить серию статей по разным частям нашей архитектуры. Следите за обновлениями. Пока мы разобрали только основную единицу архитектуры, но еще многое предстоит осветить — получение данных и управление ими, навигацию, DI, генерацию кода и так далее.

Спасибо всем, кто ознакомился, до встречи в следующей части!

Комментарии (4)


  1. undersunich
    17.07.2024 11:48
    +1

    Странный подход.У Вас есть понимание что с "Bloc" что то не так и он не лезет на Ваши задачи.И при этом Вы упорно тащите его в свою архитектуру. Пахнет архитектурным-мазохизмом


    1. nickkorol1994 Автор
      17.07.2024 11:48
      +1

      Это не так. В тексте не говорится, что BLoC не лезет на наши задачи. BLoC позволяет нам управлять состоянием конкретных экранов, тогда как наша задача состояла в формировании архитектуры проекта. BLoC - составная часть этой архитектуры, которую при желании можно заменить на Riverpod, MobX и любой другой State manager.

      Многие разработчики (в первую очередь, начинающие) считают, что для написания нового приложения достаточно выбрать способ управления состоянием, навигации и пр. Именно это заблуждение и направлен развеять данный текст.


    1. AlchemistDark
      17.07.2024 11:48

      Или карго-культом...


    1. tremp
      17.07.2024 11:48

      А что значит не лезет? Блок сам по себе не является полноценной архитектурой мобильного приложения. Причем есть 2 подхода при использовании чистой архитектуры - рассматривать его как компонент доменного слоя или презентационного. Это всего лишь стейт - менеджер. Тут вполне все норм.