Basics of Flutter Application Architecture: Vanilla, Scoped Model, BLoC


    (the original article in English is published on Medium )


    Flutter provides a modern jet framework, a large set of widgets and bodies. Unfortunately, there is nothing in the documentation that looks like a guide to the recommended architecture of the Android application .


    There is no ideal, universal architecture that could fit any conceivable requirements of the technical specification, but let's recognize that most of the mobile applications we are working on have the following functionality:


    1. Request and download data.
    2. Transformation and preparation of data for the user.
    3. Write and read data from a database or file system.

    Given all this, I created a demo application that solves the same problem using different approaches to architecture.


    Initially, the user is shown a screen with the “Load user data” button located in the center. When the user clicks a button, asynchronous data loading occurs, and the button is replaced with a loading indicator. When data loading is complete, the load indicator is replaced with data.


    So, let's begin.



    Data


    To simplify the task, I created a class Repositorythat contains a method getUser(). This method simulates asynchronous data download from the network and returns Future<User>.


    If you are not familiar with Futures and asynchronous programming in Dart, you can read more about it here and get acquainted with the documentation of the Future class .


    class Repository {
      Future<User> getUser() async {
        await Future.delayed(Duration(seconds: 2));
        return User(name: 'John', surname: 'Smith');
      }
    }

    class User {
      User({
        @required this.name,
        @required this.surname,
      });
      final String name;
      final String surname;
    }

    Vanilla


    Let's develop the application, as a developer would do if he read the Flutter documentation on the official website.


    We open the screen VanillaScreenwithNavigator


    Navigator.push(
      context,
      MaterialPageRoute(
        builder: (context) => VanillaScreen(_repository),
      ),
    );

    Since the state of the widget can change several times during its life cycle, we need to inherit from StatefulWidget. To implement your stateful widget, you also need a class State. Fields bool _isLoadingand User _userin the class _VanillaScreenStaterepresent the state of the widget. Both fields are initialized before the method build(BuildContext context)is called for the first time.


    class VanillaScreen extends StatefulWidget {
      VanillaScreen(this._repository);
      final Repository _repository;
      @override
      State<StatefulWidget> createState() => _VanillaScreenState();
    }
    class _VanillaScreenState extends State<VanillaScreen> {
      bool _isLoading = false;
      User _user;
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          appBar: AppBar(
            title: const Text('Vanilla'),
          ),
          body: SafeArea(
            child: _isLoading ? _buildLoading() : _buildBody(),
          ),
        );
      }
      Widget _buildBody() {
        if (_user != null) {
          return _buildContent();
        } else {
          return _buildInit();
        }
      }
      Widget _buildInit() {
        return Center(
          child: RaisedButton(
            child: const Text('Load user data'),
            onPressed: () {
              setState(() {
                _isLoading = true;
              });
              widget._repository.getUser().then((user) {
                setState(() {
                  _user = user;
                  _isLoading = false;
                });
              });
            },
          ),
        );
      }
      Widget _buildContent() {
        return Center(
          child: Text('Hello ${_user.name} ${_user.surname}'),
        );
      }
      Widget _buildLoading() {
        return const Center(
          child: CircularProgressIndicator(),
        );
      }
    }

    After the widget's state object is created, the method is called build(BuildContext context)to construct the UI. All decisions about which widget should be displayed at the moment on the screen are made directly in the code of the UI declaration.


    body: SafeArea(
      child: _isLoading ? _buildLoading() : _buildBody(),
    )

    In order to display the progress indicator when the user clicks the “Load user details” button, we do the following.


    setState(() {
      _isLoading = true;
    });

    From the documentation (translation):


    Calling the setState () method notifies the framework that the internal state of this object has changed, and may affect the user interface in the subtree. This causes the framework to call the build method of this state object.

    This means that after calling the method, the setState()framework will call the method again build(BuildContext context), which will re-create the entire tree of widgets . Since the field value _isLoadinghas changed to true, the method _buildBody()will be called instead of the method _buildLoading(), and the progress indicator will be displayed on the screen.
    Exactly the same thing will happen when we get a callback from getUser()and call the method
    setState()to assign new values ​​to the _isLoadingand fields _user.


    widget._repository.getUser().then((user) {
      setState(() {
        _user = user;
        _isLoading = false;
      });
    });

    pros


    1. Low threshold of entry.
    2. No third party libraries are required.

    Minuses


    1. When the state of the widget changes, the widget tree is re-created each time.
    2. Violates the principle of sole responsibility. The widget is responsible not only for creating the UI, but also for loading the data, business logic and state management.
    3. Decisions about exactly how to display the current state are made directly in the UI code. If the state becomes more complex, then the readability of the code will greatly decrease.

    Scoped Model


    Scoped Model is a third-party library . Here's how the developers describe it:


    A set of utilities that allow you to transfer the data model of the ancestor widget to all its descendants. In addition to this, when these models change, all descendants that use the model will be re-created. This library was originally taken from the Fuchsia project code .

    Let's create the same screen as in the last example, but using the Scoped Model. First we need to add the Scoped Model library to the project. Add the dependency scoped_modelto the file pubspec.yamlin the section dependencies.


    scoped_model: ^1.0.1

    Let's look at the code UserModelScreenand compare it with the previous example, in which we did not use the Scoped Model. To make our model available for the descendants of a widget, you need to wrap the widget and model in ScopedModel.


    class UserModelScreen extends StatefulWidget {
      UserModelScreen(this._repository);
      final Repository _repository;
      @override
      State<StatefulWidget> createState() => _UserModelScreenState();
    }
    class _UserModelScreenState extends State<UserModelScreen> {
      UserModel _userModel;
      @override
      void initState() {
        _userModel = UserModel(widget._repository);
        super.initState();
      }
      @override
      Widget build(BuildContext context) {
        return ScopedModel(
          model: _userModel,
          child: Scaffold(
            appBar: AppBar(
              title: const Text('Scoped model'),
            ),
            body: SafeArea(
              child: ScopedModelDescendant<UserModel>(
                builder: (context, child, model) {
                  if (model.isLoading) {
                    return _buildLoading();
                  } else {
                    if (model.user != null) {
                      return _buildContent(model);
                    } else {
                      return _buildInit(model);
                    }
                  }
                },
              ),
            ),
          ),
        );
      }
      Widget _buildInit(UserModel userModel) {
        return Center(
          child: RaisedButton(
            child: const Text('Load user data'),
            onPressed: () {
              userModel.loadUserData();
            },
          ),
        );
      }
      Widget _buildContent(UserModel userModel) {
        return Center(
          child: Text('Hello ${userModel.user.name} ${userModel.user.surname}'),
        );
      }
      Widget _buildLoading() {
        return const Center(
          child: CircularProgressIndicator(),
        );
      }
    }

    In the previous example, each time the widget changes state, the widget tree is completely re-created. But do we really need to re-create the widget tree entirely (the whole screen)? For example, AppBar does not change at all, and there is no point in recreating it. Ideally, it is worth recreating only those widgets that should change in accordance with the change of state. And Scoped Model can help us in this task.


    The widget is ScopedModelDescendant<UserModel>used to find UserModelin the tree of widgets. It will automatically be recreated every time it UserModelnotifies that there has been a change.


    Another improvement is that it is UserModelScreenno longer responsible for managing the state, business logic, and data loading.


    Let's look at the class code UserModel.


    class UserModel extends Model {
      UserModel(this._repository);
      final Repository _repository;
      bool _isLoading = false;
      User _user;
      User get user => _user;
      bool get isLoading => _isLoading;
      void loadUserData() {
        _isLoading = true;
        notifyListeners();
        _repository.getUser().then((user) {
          _user = user;
          _isLoading = false;
          notifyListeners();
        });
      }
      static UserModel of(BuildContext context) =>
          ScopedModel.of<UserModel>(context);
    }

    Now UserModelcontains and manages the state. In order to notify listeners (and re-create descendants) that a change has occurred, you must call the method notifyListeners().


    pros


    1. State management, business logic, and data loading are separated from UI code.
    2. Low threshold of entry.

    Minuses


    1. Dependence on a third-party library.
    2. If the model becomes complicated enough, it will be hard to keep track of when it is really necessary to call a method notifyListeners()in order to avoid unnecessary re-creation.

    BLoC


    BLoC ( B usiness Lo gic C omponents) is a pattern recommended by Google developers. Streams are used for state management and for state notification.


    For Android developers: You can imagine what Blocit is ViewModel, and StreamControllerthis LiveData. This will make the following code easy to understand, since you are already familiar with the fundamentals.


    class UserBloc {
      UserBloc(this._repository);
      final Repository _repository;
      final _userStreamController = StreamController<UserState>();
      Stream<UserState> get user => _userStreamController.stream;
      void loadUserData() {
        _userStreamController.sink.add(UserState._userLoading());
        _repository.getUser().then((user) {
          _userStreamController.sink.add(UserState._userData(user));
        });
      }
      void dispose() {
        _userStreamController.close();
      }
    }
    class UserState {
      UserState();
      factory UserState._userData(User user) = UserDataState;
      factory UserState._userLoading() = UserLoadingState;
    }
    class UserInitState extends UserState {}
    class UserLoadingState extends UserState {}
    class UserDataState extends UserState {
      UserDataState(this.user);
      final User user;
    }

    You can see from the code that it is no longer necessary to call additional methods to notify about state changes.


    I created 3 classes to represent possible states:


    UserInitState for the state when the user opens the screen with a button in the center.


    UserLoadingState for the state when the load indicator is displayed while data is being loaded.


    UserDataState for the state when the data is already loaded and shown on the screen.


    Passing the state in this way allows us to completely get rid of the logic in the UI code. In the example with the Scoped Model, we still checked whether the value of the field is _isLoadingtrueor falseto determine which widget to create. In the case of BLoC, we pass a new state to the stream, and the only task of the widget is to UserBlocScreencreate a UI for the current state.


    class UserBlocScreen extends StatefulWidget {
      UserBlocScreen(this._repository);
      final Repository _repository;
      @override
      State<StatefulWidget> createState() => _UserBlocScreenState();
    }
    class _UserBlocScreenState extends State<UserBlocScreen> {
      UserBloc _userBloc;
      @override
      void initState() {
        _userBloc = UserBloc(widget._repository);
        super.initState();
      }
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          appBar: AppBar(
            title: const Text('Bloc'),
          ),
          body: SafeArea(
            child: StreamBuilder<UserState>(
              stream: _userBloc.user,
              initialData: UserInitState(),
              builder: (context, snapshot) {
                if (snapshot.data is UserInitState) {
                  return _buildInit();
                }
                if (snapshot.data is UserDataState) {
                  UserDataState state = snapshot.data;
                  return _buildContent(state.user);
                }
                if (snapshot.data is UserLoadingState) {
                  return _buildLoading();
                }
              },
            ),
          ),
        );
      }
      Widget _buildInit() {
        return Center(
          child: RaisedButton(
            child: const Text('Load user data'),
            onPressed: () {
              _userBloc.loadUserData();
            },
          ),
        );
      }
      Widget _buildContent(User user) {
        return Center(
          child: Text('Hello ${user.name} ${user.surname}'),
        );
      }
      Widget _buildLoading() {
        return const Center(
          child: CircularProgressIndicator(),
        );
      }
      @override
      void dispose() {
        _userBloc.dispose();
        super.dispose();
      }
    }

    The widget code is UserBlocScreeneven simpler than the previous examples. StreamBuilder is used to listen to state changes . StreamBuilderthis StatefulWidget, which creates itself in accordance with the last value (Snapshot) of the stream ( Stream ).


    pros


    1. No third party libraries are required.
    2. Business logic, state management, and data loading are separated from UI code.
    3. Reactivity. There is no need to call additional methods, as in the Scoped Model example notifyListeners().

    Minuses


    1. The threshold of entry is slightly higher. Need experience in working with threads or rxdart.

    Links


    You can view the full code by downloading it from my repository on github .


    Original article published on Medium


    Also popular now: