Why Choosing BLoC Is Not Enough

We are currently working with RnD hypotheses. Before any feature is developed, its usefulness and relevance must be tested. That is why we create applications that test the hypotheses of our UX researchers.

The apps are used by real taxi drivers and taxi passengers. Using analytics of the use of these apps, researchers confirm or refute the need to develop various features for Atom.

Because of this format of work, we have a whole “zoo” of applications that will most likely have one design system and, possibly, the same parts of functionality. And all these applications require regular improvements and changes based on the results of research.

When our team was still gathering in the middle of last year, we faced the fact that the existing applications were practically unrelated to each other. They were written by different people using different architectural and technical solutions, so maintaining and developing all these applications was too labor-intensive.

Having analyzed the situation, we realized that it would be right to immediately lay down the possibility of reusing the functionality of one application in another – from a small widget to a whole flow with data. It was also necessary to provide the ability to quickly switch between data sources. We came to the conclusion that we needed to lay down a new architecture right away. Below I will tell you what we did.

I would also like to express my gratitude to my colleagues from the Atom company – the Development leader of our team Georgy Savatkov for the idea of ​​this architecture, as well as Flutter developers Samir Gezalov and Artem Kushnir – for their joint work on its implementation.

We have BLoC, why isn't it enough?

In native mobile development languages, such as Swift or Kotlin, classic architectural approaches have already been formed: MVC, MVVM, VIPER. In the case of Flutter, developers usually argue about approaches to State Management. But we decided that even the beloved BLoC is not enough to just drag and form an architecture on it right away. It only provides us with a set of classes and widgets for encapsulating business logic. By and large, BLoC is a simplified Redux without a global state, which is an adaptation to the realities of Flutter.

Just like in Redux, State stores the state, and BLoC handles Event-to-State mapping via Reducer-like methods, encapsulating the Middleware functionality as well.

BLoC helps us work with just one widget or screen, but it doesn't solve many important development problems, such as:

  • data source unknown;

  • no interaction with navigation;

  • again, no abstractions, which means BLoC is not universal;

  • does not solve the issue of synchronizing data or state between screens.

What do we need from the code?

We have identified the following as must-have items:

  • clear separation of areas of responsibility. Separate data layer, business logic layer, UI layer;

  • dependence only on abstractions, contracts;

  • a consequence of the second is independence from implementation when working with data or localization;

  • business logic should be a self-contained black box;

  • independent parts of the application must interact using a single mechanism;

  • any part of the application should be able to receive data from a single source;

  • if necessary, we can reuse widgets or screens not only in another place of the current application, but also in another application;

  • data must be reactive, but only data.

As mentioned above, BLoC remains a State Management solution for architecture. And the abbreviation stands for Business Logic Component helped us come up with the name – Component-Based Architecture of Flutter Applications.

The unit of architecture is a component

So, let's start with the unit of architecture – the component itself. What is it?

A component is a widget “on steroids” that encapsulates certain business logic and UI. Moreover, a component is a self-sufficient and autonomous unit that interacts with the outside world exclusively according to its own contract.

We decided to make each component a separate package. The component consists of the following mandatory layers:

  • Data

  • Domain

  • Localization

  • UI.

Data Layer

The Data layer contains an abstract class with the component repository contract. It specifies what external data is required for the component to work and what methods it can use to work with them.

Domain Layer

The Domain layer contains the component's business logic management. We decided to use the classic Bloc with events and state. Bloc interacts with the component repository from the Data layer according to the following scheme:

Working with the component repository

Working with the component repository

Processing events at the first steps is also no different from the classic one. But after the state changes, we also get the opportunity to emit Action.

Processing events

Processing events

What is Action?

Action — is the same Event, only looking outward and not participating in Event-to-State mapping, but on the contrary, being its product. That is, Event generates Actionboth with and without issuing a new one State.

It is implied that if Event must be processed within its own block, then Actionon the contrary, should be handled outside of our component and should affect some other part of the application, be it navigation or another component. Thus, Action responsible for outgoing communication, from the component to the outside, and Event — for the incoming one, from the component itself into itself.

Subscription to actions and their processing

Subscription to actions and their processing

Why localization at the BLoC level?

This solution may seem strange, but it is due to the localization of errors. If we need to show an error state that came from an external data source, we do not make the UI layer aware of possible errors, but only pass it the text.

So we had to build an add-on to the classic Blocin which generics are transmitted Event And Stateto work in the component with Action And 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 layer

We divide the UI into two levels. This is due, again, to the desire to delimit areas of responsibility, thereby reducing the volume and complexity of the code, and making it more flexible.

Task Widget – communication with Blocthat is, receiving State through BlocBuilder / BlocConsumer and challenges Event at the right moments. Widget does not draw UI as such, limiting itself to perhaps Scaffold and similar things, and delegates it LayoutWidgetonly when deciding how the UI should react to the received 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,
        );
      },
    );
  }
}

Task LayoutWidget — rendering the UI itself with data received directly from Widget. It does not depend on the State Management solutions used in the project, does not know anything about the block and how to interact with it and, therefore, can be applied with any architectural solution.

LayoutWidget receives data from Widgetand communicates back with raw callbacks: a button was pressed, the value of a TextField changed, and so on.

And this is what the UI layer looks like in code:

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 — is an abstract sealed class that has factories, one factory for each possible state. Typically, this is .loading(), .error() And .content()but their number may not be limited and depends on the needs of the component.

Respectively, Widget receives State from Blocunderstands how the UI should react to the current state, and calls the necessary factory LayoutWidgetpassing there a set of data defined by the factory itself.

Implementations

Component implementations exist in the target application domain. They typically consist of four files:

I don’t see any point in focusing on the implementation of contracts, but here’s what I mean View And Coordinator It's worth talking about separately.

View

This is the entry point of almost any component. In it, we initialize the component's BLoC, passing dependency implementations to it from DI. As a rule, there are two of them – a repository contract and a localization contract. The resulting initialized block is passed further along the tree using the standard InheritedWidget mechanism, implemented using the native BLoC mechanism – 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 as its name suggests, it coordinates work with one or more components within the framework of one semantic screen, that is, Viewthat's why they named it that.

Here we listen Action blocks of our components that exist within the current Viewand somehow react to them: call navigation methods, send Event to other blocks, we call analytics events, and so on.

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.
  }
}

Result

As a result, the diagram of the interaction of the parts of the architecture may look like this:

Scheme of work on one View

Scheme of work on one View

If our application has several screens, they will not interact. Their implementations will be at the Application level, but without any connection to each other. As, for example, in the diagram below.

Scheme of work with multiple screens

Scheme of work with multiple screens

Are everything components?

A component is not the be-all and end-all. It would be wrong to say that our entire application consists only of components, because many parts of our application do not interact with the user or external data.

The decision on whether a screen/widget will be a component always remains on the developer's side. This decision is made at the moment of analyzing the possible interaction with the screen/widget according to the following parameters:

If the screen or widget:

  • receives data from an external source;

  • the screen/widget is where the user action is handled and it can be encapsulated inside that screen/widget.

Then this screen, widget should be a component.

By analyzing the task statements and frequently changing requirements in the RnD format, we try to anticipate possible changes on different screens, so even the simplest screens are usually components for us.

Conclusion

With the help of component architecture, we tried to solve many typical problems of Flutter applications:

  • we use dependency only on abstractions, not on implementations;

  • created a single entry point for data in the component – the component repository – to clearly regulate possible data sources and have full control over incoming data;

  • we made a clear division of layers into areas of responsibility – according to all the canons of Clean Architecture;

  • were able to reuse any component between different applications by creating only a few files for each of them, specifying the implementation details. This made it possible to assemble new applications much faster, embedding existing components into them, like in a constructor.

We have already used this architecture on 4 different applications in our project over the last year and we can confidently say that we will continue to use it in the future.

Of course, our solution has its challenges that we had to overcome. First of all, it is the generation of components, as well as the use of code generation (in our case, the frozen package, which we are gradually getting rid of) and maintaining the relevance of all dependencies. We have overcome all the problems at different stages, but we will talk about this in more detail in the following articles.

We are planning a series of articles on different parts of our architecture. Stay tuned. So far we have covered only the main unit of the architecture, but there is still much to cover – data acquisition and management, navigation, DI, code generation, and so on.

Thanks to everyone who checked it out, see you in the next part!

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *