📜 ⬆️ ⬇️

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 the Repository class, which contains the getUser() method. 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.


Open VanillaScreen with Navigator


 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 the State class. The bool _isLoading and User _user fields in the _VanillaScreenState class represent the widget state. Both fields are initialized before the build(BuildContext context) method 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 status object is created, the build(BuildContext context) method is called 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 setState() method, the framework will again call the build(BuildContext context) method, which will re-create the entire widget tree . Since the _isLoading field _isLoading changed to true , the _buildBody() method will be called instead of the _buildBody() method, and the progress indicator will be displayed on the screen.
Exactly the same thing happens when we get a callback from getUser() and call the method
setState() to assign new values ​​to the _isLoading and _user fields.


 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 previous example, but using the Scoped Model. First we need to add the Scoped Model library to the project. Add the scoped_model dependency to the scoped_model file in the dependencies section.


 scoped_model: ^1.0.1 

Let's look at the UserModelScreen code and compare it with the previous example, in which we did not use the Scoped Model. To make our model available for the descendants of the widget, it is necessary 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 ScopedModelDescendant<UserModel> widget is used to find UserModel in the widget tree. It will automatically be recreated every time the UserModel reports that there has been a change.


Another improvement is that UserModelScreen no longer responsible for managing state, business logic, and data loading.


Let's look at the code of the class 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 UserModel contains and manages the state. In order to notify listeners (and re-create descendants) that a change has occurred, you must call the notifyListeners() method.


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 the notifyListeners() method to prevent 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 that Bloc is a ViewModel , and StreamController is 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 a state in this way allows us to completely get rid of logic in the UI code. In the Scoped Model example, we still checked whether the value of the _isLoading field _isLoading true or false to determine which widget to create. In the case of BLoC, we pass the new state to the stream, and the only task of the UserBlocScreen widget UserBlocScreen create 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 of the UserBlocScreen even simpler than the previous examples. StreamBuilder is used to listen to state changes . StreamBuilder is a StatefulWidget that 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 notifyListeners() example.

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



Source: https://habr.com/ru/post/438574/