Flutter. BlOC, Provider, async - Shelf Architecture

Introduction


When you try to write an application, the first thing you come across is how to organize the architecture of the application. And when it comes to Flutter, the head can completely go round what Google gives out - Vanilla, Scoped Model, BLoC, MVP, MVC, MVVM, MVI, etc. Suppose you decide to go in the most fashionable way (the one that Google advised in 2018) and use BLOC. What is it? How to use it? Or maybe Redux or RxDart? - although the stop is about the ā€œotherā€ ... But still, what's next? What libraries to connect? Bloc, Flutter_bloc, bloc_pattern, etc.?

Such a number of architecture options and tools for their implementation can really delay the selection stage for a long time.

For whom the article


The article will be primarily useful to those who are just starting to learn Flutter and do not know where to start. I will show one of the options for implementing the application on Flutter. This will allow you to ā€œfeelā€ Flutter, and then decide for yourself how and using which you will write your applications.

Patterns and tools. Brief and simple


So, let's begin. The first thing worth noting is that there is an application architecture (pattern, template, some construction concept) - this is exactly the same: BLoC, MVP, MVC, MVVM, MVI, etc. Many of these architectures are used not only in Flutter, but also in other programming languages. Question - what to choose from? In my opinion, you need to choose what you yourself know well, but only if this implies reactivity and a strict separation of business logic from the interface (yes, yes - ā€œa car can be any color if it is blackā€). As for the separation of interface and business logic, I think there is no need to explain, but as for reactivity - try, if you havenā€™t tried - in the end it is really very convenient and ā€œbeautifulā€. If you canā€™t choose it yourself, then let us allow it to be done for us by not the most stupid guys from Google - BLOC. We figured out the architecture.

Now the tools - there are ready-made libraries - Bloc, Flutter_bloc, bloc_pattern - which is better? I donā€™t know - everyone is good. You can choose and compare for a long time, but here again, as in the army - itā€™s better to make a wrong decision for now than not to make any. And for now, I propose to go back in the wake of the fashion and use the Provider (what the same guys recommend using in 2019).

All this will allow us to make both global bloc and local bloc, as needed. A lot has been written about the architecture of BLoC (namely, a pattern, not libraries), I think you should not dwell on it again in detail. I note only one point in this article, not classic BLoC will be used, but slightly modified - in BLoC actions (events) will not be transmitted through Sinks, but BLoC functions will be called. Simply, at the moment, I do not see the benefits of using Sinks - and since they are not there, then why complicate your life?

Asynchrony and Parallel Computing in Dart


It's also worth a little clarification of the concept of asynchrony in Dart, since we're talking about reactivity. Very often, at the first stages of acquaintance with Dart, the meaning of asynchronous functions (async) is not correctly understood. You should always remember that ā€œby defaultā€ the program runs in one thread, and asynchrony only allows you to change the sequence of commands, rather than execute them in parallel. That is, if you simply run the function with large calculations just by marking it async, then the interface will be blocked. Async does NOT start a new thread. How async and await work there is a lot of information on the Internet, so I will not dwell on this either.

If you need to make some big calculations and at the same time not block the interface, you need to use the compute function (for special hardcore you can use isolates). This will really start a separate thread of execution, which will also have its own separate memory area (which is very sad and sad). You can communicate with such streams only through messages that can contain simple data types, their lists.

Let's get down to practice


Formulation of the problem


Let's try to write the simplest application - let it be some kind of telephone directory. We will use Firebase as storage - this will allow us to make a "cloud" application. Iā€™ll skip how to connect Firebase to the project (more than one article has been written on this topic and I donā€™t see the point of repeating. Note: Cloud Firestore is used in this project.).

It should be like this:





Application description


Our application will externally contain:

  1. Firebase authorization window (the logic of this window will be contained in MainBloc).
  2. Information window - will display information about the user under whom the program is authorized (the logic of this window will also be contained in MainBloc).
  3. Directory window in the form of a list of telephones (the logic of this window will be contained in a separate PhonebookBloc).
  4. Application menu that will switch screens.

The internal application will be constructed as follows: each screen will contain a file with screen widgets, a bloc file (with the corresponding bloc class), an actions file (contains simple classes describing events that affect the bloc state), a states file (contains simple classes that reflect bloc status ), the data_model file containing the repository class (responsible for receiving data) and the data class (stores bloc business logic data).

The application will function like this - when the screen is opened, the corresponding bloc is initialized with the initial state value and, if necessary, some initial action is called in the bloc constructor. The screen is built / rebuilt based on state, which returns bloc. The user performs some actions in the application that have corresponding actions. Actions are passed to the bloc class, where they are processed in the mapEventToState function and bloc returns the new state back to the screen, based on which the screen is rebuilt.

File structure


First of all, we create an empty Flutter project and make the project structure of this kind (I note that in the demo project some files will eventually remain empty):



Authorization window. Mainbloc


Now you need to implement authorization in Firebase.
Let's start by creating event classes (itā€™s convenient to transfer data through events in bloc) and states for Main bloc:

file MainBloc \ actions

abstract class MainBlocAction{
  String get password => null;
  String get email => null;
}

file MainBloc \ states

abstract class MainBlocState{
  bool busy;
  MainBlocState({this.busy = false});
  copy(bool busy) {
    return null;
  }
}

The busy flag in the state class is used to display progress_hud in the interface and exclude unnecessary data reads from the database when scrolling through the list. Before all operations in the block begin, a new state of the old type with the busy flag set is issued to the output stream - this way the interface receives a notification that the operation has begun. At the end of the operation, a new state is sent to the stream with the busy flag cleared.

The heirs of the MainBlocState class describe the state of the main application Bloc. The heirs of MainBlocAction describe the events that occur in it.

The MainBloc class contains 4 main elements - the function of "converting" events to states (Future mapEventToState), the Bloc state is _blocState, the bloc state repository is the repo, and the "output" state stream (which interface elements track) is blocStream. Basically, these are all elements that provide bloc-a functionality. Sometimes it is advisable to use 2 output streams in one bloc - such an example will be lower. I will not list it here - you can see it by downloading the project.

The bloc repository class contains the logic for working with Firebase and an object (data) that stores the data necessary for the business logic that this bloc implements.

File MainBloc \ data_model

class MainRepo{

  final MainData data = MainData();

  FirebaseAuth get firebaseInst => MainData.firebaseInst;

  FirebaseUser _currentUser;

  Future<bool> createUserWithEmailAndPassword(
      String email, String password) async {
    var dataUser;
      try {
        dataUser =
            (await firebaseInst.createUserWithEmailAndPassword(
                email: email, password: password))
                .user;
      } catch (e) {
        print(Error.safeToString(e));
        print(e.code);
        print(e.message);
      }
      if (dataUser == null){
        data.setState(IsNotLogged());
        return false;
      }

      _currentUser = dataUser;
      data.setState(IsLogged(),
          uid: _currentUser.uid,
          email: _currentUser.email);
    return true;
  }

  ...}

class MainData {
  static final firebaseInst = FirebaseAuth.instance;
  static MainBlocState _authState = IsNotLogged();
  static MainBlocState get authState => _authState;
  static String _uid;
  static String get uid => _uid;
  static String _email;
  static String get email => _email;

  void setState(MainBlocState newState,
      {String uid = '', String email = ''}) {
    _authState = newState;
    _uid = uid;
    _email = email;
  }
}


The MainData class also stores state, but the authorization state in Firebase, and not the Bloc state.

We wrote the logic for the main bloc, now we can begin to implement the authorization / registration screen.

MainBloc is initialized in the main file:

The main file

class MyApp extends StatelessWidget {
  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return Provider(
        create: (context) => MainBloc(),
        dispose: (context, value) => value.dispose(),
        child: MaterialApp(
          routes: menuRoutes,
        ));
  }
}

It's time to make a small digression about StreamBuilder, Provider, StreamProvider, Consumer and Selector.

Retreat about Providers


Provider - only transfers the stored value down the tree. And you can access it only after the child build, i.e. you need to build a sub widget. Not responsible for updating widgets.

StreamBuilder - a widget that monitors the stream and is completely rebuilt when it receives a new object from the stream.

StreamProvider - a widget that monitors the stream and upon receipt of a new object, signals that the child widgets (those that are declared as a separate class with the build method) should be rebuilt.

Consumer and Selector are ā€œsyntactic sugarā€, i.e. this is actually a ā€œwrapperā€ that contains build and hides the widget underneath. In Selector-e, you can do additional filtering of updates.

Thus, when you need to rebuild most of the screen at each event, you can use the option with Provider and StreamBuilder. When it is necessary to rebuild parts of the widget tree close to the leaves, it is advisable to use StreamProvider in combination with Consumer and Selector to exclude unnecessary rebuilds of the tree.

Authorization Continuation


When entering the application, the user must get to the authorization / registration window, and at that moment the application menu should not be available to him yet. The second point - to partially refresh this screen does not make much sense, so we can use StreamBuilder to build the interface. And the third point in the project is using Navigator to navigate between screens. Upon receipt of an event of successful authorization, it is necessary to call the transition to the information screen. But just inside build StreamBuilder, this will not work - there will be an error. To get around this, you can use the auxiliary wrapper class StreamBuilderWithListener (Eugene Brusov - stackoverflow.com ).

Now the listing of this screen is auth_screen itself (I will give here in part):

File auth_screen

Widget build(BuildContext context) {
  var bloc = Provider.of<MainBloc>(context, listen: false);
  return StreamBuilderWithListener<MainBlocState>(
      stream: bloc.blocStream.stream,
      listener: (value) {
        //not allowed call navigator push in build
        if (value is IsLogged) {
          Navigator.of(context).pushReplacementNamed(InfoScreen.nameMenuItem);
        }
      },
      initialData: bloc.state,
      builder: (context, snappShot) {
        if (snappShot.data is IsLoggedOnStart) {
          return LoggedWidget();
        } else if (snappShot.data is IsLogged) {
          //not allowed call navigator push in build
          return ModalProgressHUD(
              inAsyncCall: true,
          child: Text(''),);
        } else if (snappShot.data is IsNotLogged) {
          return SignInAndSignUpWidget();
        }
        return Scaffold(body: Text("                Unknown event"));
      });
}

First, a StreamBuilderWithListener is created to listen to the stream from bloc. And based on the current state, either the LoggedWidget widget (if the user is already authorized) or SignInAndSignUpWidget (if the user is not authorized yet) is called. If bloc returns the IsLogged state, switching to a new screen using the Navigator does not occur in the builder (which would lead to an error), but in the listener. In the underlying widgets, the interface is built based on the data returned here. Here, the Provider + StreamBuilder bundle is actually used, because when the state of the block changes, virtually the entire interface changes.

To transfer data to bloc, TextEditingController and action parameters are used:

auth_screen file

class _SignUpWidgetWidgetState extends State {
  String _email, _password;

  final TextEditingController _emailController = TextEditingController();
  final TextEditingController _passwordController = TextEditingController();

  @override
  void initState() {
    super.initState();
    _emailController.addListener(_onEmailChanged);
    _passwordController.addListener(_onPasswordChanged);
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      children: <Widget>[
        TextFormField(
          controller: _emailController,
          decoration: InputDecoration(
              labelText: 'email'),
        ),
        TextFormField(
          controller: _passwordController,
          obscureText: true,
          decoration: InputDecoration(
              labelText: 'password'),
        ),
        RaisedButton(
            child: Text('sign up'),
            onPressed: () {
              Provider.of<MainBloc>(context, listen: false).mapEventToState(
                  Registration(_email, _password));
            })
      ],
    );
  }

  @override
  void dispose() {
    _emailController.dispose();
    _passwordController.dispose();
    super.dispose();
  }

  void _onEmailChanged() {
    _email = _emailController.text;
  }

  void _onPasswordChanged() {
    _password = _passwordController.text;
  }
}
 

PhoneBookScreen Window


And now let's talk a little about our PhoneBookScreen window. This is the most interesting window - here the interface is built on the basis of 2 streams from bloc, and there is also a list with scroll and pagination (pagination).

PhonebookScreen \ screen File

class PhonebookTopPart extends StatelessWidget {

  StatefulWidget caseWidget(PhonebookState state) {
    if (state is PhonebookListOpened) {
      return PhonebookList();
    //} else if (data is PhonebookCardToViewOpened) {
    }else ModalProgressHUD(
      inAsyncCall: true,
      child: Text(''),);
    return null;
  }

  @override
  Widget build(BuildContext context) {
    var bloc = Provider.of<PhonebookBloc>(context, listen: false);
    return StreamProvider<PhonebookState>(
        create: (context) => bloc.blocStream.stream,
        initialData: bloc.state,
        child: Selector<PhonebookState,PhonebookState>(
            selector: (_,state)=>state,
            shouldRebuild: (previous, next){return (previous.runtimeType!=next.runtimeType);},
            builder: (_, state, __) { return ModalProgressHUD(
                inAsyncCall: state.busy,
                child: Scaffold(
                  appBar: AppBar(
                    title: Text("Phones list"),
                  ),
                  drawer: MenuWidget(),
                  body: caseWidget(state),
                ));}
        ));
  }
}

The first StreamProvider is needed to switch between different screens of the directory - list, contact card, contact card for editing, etc. The widget for the screen is selected in the caseWidget function (but in this example only the view for the list is implemented - you can try to implement the view for the contact card - this is very simple and will not be a bad start.).

On this screen, a bunch of StreamProvider + Selector / Consumer is already used, because there is a scroll of the list and it is not advisable to rebuild the entire screen with it (i.e. rebuilding widgets comes from the corresponding Selector / Consumer and lower in the tree).

And here is the implementation of the list itself:

PhonebookScreen \ screen file

class _PhonebookListState extends State<PhonebookList> {
  ScrollController _scrollController = ScrollController();

  @override
  void initState() {
    super.initState();
    _scrollController.addListener(_scrollListener);
  }

  @override
  Widget build(BuildContext context) {
    var bloc = Provider.of<PhonebookBloc>(context, listen: false);
    var list = bloc.repo.data.list;
    return Container(
        child: StreamProvider<PhonebookState>(
            create: (context) => bloc.scrollStream.stream,
            initialData: bloc.scrollState,
            child: Consumer<PhonebookState>(
              builder: (_, state, __) {
                return ListView.builder(
                    controller: _scrollController,
                    itemCount: list.length,
                    itemBuilder: (BuildContext context, int index) {
                      return ListTile(
                        title: Text(list[index].data['name']),
                        subtitle: Text(list[index].data['phone']),
                      );
                    });
              },
            )));
  }

  void _scrollListener() {
    double delta = MediaQuery
        .of(context)
        .size
        .height * 3;
    double maxScroll = _scrollController.position.maxScrollExtent;
    double currentScroll = _scrollController.position.pixels;
    if (maxScroll - currentScroll <= delta) {
      Provider.of<PhonebookBloc>(context, listen: false)
          .mapEventToState(ScrollPhonebook());
    }
  }

  @override
  void dispose() {
    _scrollController.removeListener(_scrollListener);
    super.dispose();
  }
}

Here we see the second StreamProvider, which monitors the second stream of bloc, which is responsible for the scroll. Pagination is organized as standard via _scrollListener (controller: _scrollController). Although the window is interesting, but given the detailed description of the first window, there is nothing more to say here. Therefore, thatā€™s all today.

The objective of this article was not to show the perfect code, that is, here you can find many points for optimization - correctly ā€œsplitā€ into files, use instance, mixins and the like somewhere. Also, what "begs" the next step - you can make a contact card. The main task was to structure knowledge, set a certain vector for constructing the application, give explanations on some of the moments of designing an application on Flutter that were not very obvious at the first stages of acquaintance.

The project can be downloaded at (for registration you can use any mail with a password of at least 6 characters. When re-authorizing, the password must be the same as that used during registration).

All Articles