- Flutter App Architecture 101: Vanilla, Scoped Model, BLoC
- Vadims Savjolovs
Flutter provides a modern, responsive framework with a rich set of components and tools, but there is nothing like the Android application architecture guide.
It’s true that there is no ultimate architecture solution that meets all of our needs, but let’s face it, most of the mobile applications we’re developing have at least some of the following features:
- Request data from/upload data to the network.
- Traverse, transform, prepare and present the data to the user.
- Send data to/get data from the database.
With this in mind, I created a sample application that uses three different architectural approaches to solve the exact same problem.
The Load User Data button is displayed to the user in the center of the screen. When the user clicks the button, the data is loaded asynchronously and the button is replaced with a load indicator. When the data is loaded, the load indicator is replaced with data.
Let’s get started.
data
For simplicity, I create a Repository class that contains a method getUser() that simulates an asynchronous network call and returns a Future
object with hard-coded values. If you are not familiar with Futures and asynchronous programming in Dart, you can learn more about it through this tutorial or by reading the documentation.
class Repository {
Future<User> getUser() async {
await Future.delayed(Duration(seconds: 2));
return User(name: 'John', surname: 'Smith'); }}Copy the code
class User {
User({
@required this.name,
@required this.surname,
});
final String name;
final String surname;
}
Copy the code
Vanilla
Let’s build our application the way most developers would after reading the Official Flutter documentation.
Use Navigator to navigate to the Vanilla Creen page.
Because the state of a component can change many times during its lifetime, we should inherit the StatefulWidget. Implementing stateful components also requires class State. Fields bool _isLoading and User _user in class _VanillaScreenState indicate the state of the component. Both fields are initialized before the build(BuildContext Context) method is called. After the component state object is created, the Build (BuildContext Context) method is called to build the UI. All decisions about how to build the current state of the representation component are made in the UI declaration code.
body: SafeArea(
child: _isLoading ? _buildLoading() : _buildBody(),
)
Copy the code
To display a progress indicator when the user clicks the Load User Details button, we do the following.
setState(() {
_isLoading = true;
});
Copy the code
Calling setState() notifies the framework that the internal State of the object has changed, potentially affecting the user interface in the subtree, which causes the framework to schedule a build for the State object.
This means that after calling the setState() method, the framework calls the Build (BuildContext Context) method again and rebuilds the entire component tree. Since _isLoading is now set to true, _buildLoading() is called instead of _buildBody() and the load indicator is displayed on the screen. Same as when we handle a callback from getUser() and call setState() to reassign the _isLoading and _user fields.
widget._repository.getUser().then((user) {
setState(() {
_user = user;
_isLoading = false;
});
});
Copy the code
advantages
- Learning is simple and easy to understand.
- No third-party libraries are required.
disadvantages
- Each change in a component’s state recreates the entire component tree.
- It breaks down the principle of single responsibility. Components are not only responsible for building the UI, but also for data loading, business logic, and state management.
- The decision about how to represent the current state is made in the UI declaration code. If our state were more complex, the code would be less readable.
Scoped Model
Scoped Model is a third-party package that is not included in the Flutter framework. Here’s how the Scoped Model developer described it:
A set of utilities that allow you to easily pass data models from a parent component to its descendants. In addition, it rebuilds all children using the model when the model is updated. The library was originally extracted from the Fuchsia code base.
Let’s build the same page using the Scoped Model. First, we need to install the Scoped Model package by adding the scoped_model dependency under Dependencies in pubspec.yaml.
scoped_model: ^1.01.
Copy the code
Let’s take a look at the UserModelScreen component and compare it to an earlier example built without the Scoped Model. Since we want our model to be usable for all component descendants, we should wrap it with a generic ScopedModel and provide components and models.
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 constCenter( child: CircularProgressIndicator(), ); }}Copy the code
In the previous example, the entire component tree was rebuilt when the state of the component changed. But do we really need to rebuild the entire page? For example, AppBar shouldn’t change at all, so it doesn’t make sense to rebuild it. Ideally, we should only rebuild those components that are updated. Scoped Model can help us solve this problem.
The ScopedModelDescendant
component is used to find the UserModel in the component tree. As soon as the UserModel notifies you of a change, it automatically rebuilds.
Another improvement is that UserModelScreen is no longer responsible for state management and business logic.
Let’s look at the UserModel code.
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);
}
Copy the code
Now UserModel saves and manages the state. To notifyListeners(and recreate future generations) that changes have occurred, the notifyListeners() method should be called.
advantages
- Separation of business logic, state management and UI code.
- Easy to learn.
disadvantages
- Third-party libraries are required.
- As the model becomes more complex, call in
notifyListeners()
Is hard to track.
BLoC
BLoC (Business Logic Components) is the pattern recommended by the Google developers. It leverages streaming capabilities to manage and broadcast state changes.
For Android developers: You can treat the Bloc object as a ViewModel and the StreamController as a LiveData. This will make the following code very simple, since you are already familiar with the concepts.
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));
});
}
voiddispose() { _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;
}
Copy the code
No additional method calls are required to notify subscribers when state changes.
I created three classes to represent the possible states of the page:
- When the user opens a page with a button in the center, the state is
UserInitState
. - When the load data shows the load indicator, the status is
UserLoadingState
. - When the data is loaded and displayed on the page, the state is
UserDataState
.
Broadcasting state changes in this way allows us to get rid of all the logic in the UI declaration code. In the example using the Scoped Model, we are still checking whether _isLoading is true in the UI declaration code to determine which component we should render. In the example of BLoC, we are broadcasting the state of the page, and the only responsibility of the UserBlocScreen component is to render the UI for that 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(); }}Copy the code
The UserBlocScreen code is much simpler than the previous example. We use StreamBuilder to listen for state changes. StreamBuilder is a StatefulWidget that builds itself based on the latest snapshot of its interaction with the Stream.
advantages
- No third-party libraries are required.
- Separation of business logic, state management and UI logic.
- This is reactive. No additional calls are required, just like the Scoped Model’s
notifyListeners()
The same.
disadvantages
- Experience with STREAM or RXDART is required.
The source code
You can view the source code for the example above in the Github repo.
If you find any mistakes in the translation or other areas that need to be improved, please advise.