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:
- Request and download data.
- Transformation and preparation of data for the user.
- 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 Repository
that 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 VanillaScreen
withNavigator
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 _isLoading
and User _user
in the class _VanillaScreenState
represent 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 _isLoading
has 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 _isLoading
and fields _user
.
widget._repository.getUser().then((user) {
setState(() {
_user = user;
_isLoading = false;
});
});
pros
- Low threshold of entry.
- No third party libraries are required.
Minuses
- When the state of the widget changes, the widget tree is re-created each time.
- 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.
- 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_model
to the file pubspec.yaml
in the section dependencies
.
scoped_model: ^1.0.1
Let's look at the code UserModelScreen
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 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 UserModel
in the tree of widgets. It will automatically be recreated every time it UserModel
notifies that there has been a change.
Another improvement is that it is UserModelScreen
no 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 UserModel
contains 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
- State management, business logic, and data loading are separated from UI code.
- Low threshold of entry.
Minuses
- Dependence on a third-party library.
- 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 Bloc
it is ViewModel
, and StreamController
this 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 _isLoading
true
or false
to 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 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 is UserBlocScreen
even simpler than the previous examples. StreamBuilder is used to listen to state changes . StreamBuilder
this StatefulWidget
, which creates itself in accordance with the last value (Snapshot) of the stream ( Stream ).
pros
- No third party libraries are required.
- Business logic, state management, and data loading are separated from UI code.
- Reactivity. There is no need to call additional methods, as in the Scoped Model example
notifyListeners()
.
Minuses
- 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