(originally published on Medium)
Flutter provides a modern react-style framework, rich widget collection and tooling, but there’s nothing similar to Android’s guide to app architecture.
Indeed, there’s no ultimate architecture that would meet all the possible requirements, yet let’s face the fact that most of the mobile apps we are working on have at least some of the following functionality:
- Request/upload data from/to the network.
- Map, transform, prepare data and present it to the user.
- Put/get data to/from the database.
Taking this into account I have created a sample app that is solving exactly the same problem using three different approaches to the architecture.
User is presented with a button “Load user data” in the center of the screen. When user clicks the button asynchronous data loading is triggered and the button is replaced with a loading indicator. After data is loaded loading indicator is replaced with the data.
Let’s get started.
Data
For the purpose of simplicity I have created Repository
class that contains getUser()
method that emulates an asynchronous network call and returns Future<User>
object with hardcoded values.
If you are not familiar with Futures and asynchronous programming in Dart you can learn more about it by following this tutorial and reading a doc.
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 build the app in the way most developers would do after reading official Flutter documentation.
Navigating to VanillaScreen
screen using Navigator
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => VanillaScreen(_repository),
),
);
As the state of the widget could change several times during the lifetime of the widget we should extend StatefulWidget
. Implementing a stateful widget also requires to have a State
class. Fields bool _isLoading
and User _user
in _VanillaScreenState
class represent the state of the widget. Both fields are initialised before the build(BuildContext context)
method is called.
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(),
);
}
}
When the widget state object is created build(BuildContext context)
method is called to build the UI. All the decisions about the widgets that should be built to represent the current state are made in the UI declaration code.
body: SafeArea(
child: _isLoading ? _buildLoading() : _buildBody(),
)
In order do display progress indicator when user click “Load user details” button we do following.
setState(() {
_isLoading = true;
});
Calling setState() notifies the framework that the internal state of this object has changed in a way that might impact the user interface in this subtree, which causes the framework to schedule a build for this State object.
That means that after calling setState()
method build(BuildContext context)
method is called by the framework again and the whole widget tree is rebuilt. As _isLoading
is now set to true
method _buildLoading()
is called instead of _buildBody()
and loading indicator is displayed on the screen. Exactly the same happens when we handle callback from getUser()
and call setState()
to reassign _isLoading
and _user
fields.
widget._repository.getUser().then((user) {
setState(() {
_user = user;
_isLoading = false;
});
});
Pros
- Easy to learn and understand.
- No third-party libraries are required.
Cons
- The whole widget tree is rebuilt every time widget state changes.
- It’s breaking the single responsibility principle. Widget is not only responsible for building the UI, it’s also responsible for data loading, business logic and state management.
- Decisions about how the current state should be represented are made in the UI declaration code. If we would have a bit more complex state code readability would decrease.
Scoped Model
Scoped Model is a third-party package that is not included into Flutter framework. This is how the developers of Scoped Model describe it:
A set of utilities that allow you to easily pass a data Model from a parent Widget down to its descendants. In addition, it also rebuilds all of the children that use the model when the model is updated. This library was originally extracted from the Fuchsia codebase.
Let’s build the same screen using Scoped Model. First, we need to install Scoped Model package by adding scoped_model
dependency to pubspec.yaml
under dependencies
section.
scoped_model: ^1.0.1
Let’s take a look at UserModelScreen
widget and compare it with the previous example that was built without using Scoped Model. Let’s take a look at UserModelScreen widget and compare it with the previous example that was built without using Scoped Model. As we want to make our model available to all the widget’s descendants we should wrap it with generic ScopedModel and provide a widget and a model.
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 the whole widget tree was rebuilt when widget’s state changed. But do we actually require to rebuild the whole screen? For example AppBar shouldn’t change at all so there’s no point in rebuilding it. Ideally, we should rebuild only those widgets that are updated. Scoped Model can help us to solve that.
ScopedModelDescendant<UserModel>
widget is used to find UserModel
in the Widget tree. It will be automatically rebuilt whenever the UserModel
notifies that change has taken place.
Another improvement is that UserModelScreen
is not anymore responsible for the state management and business logic.
Let’s take a look at 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);
}
Now UserModel
holds and manages the state. In order to notify listeners (and rebuild descendants) that the change took place notifyListeners()
method should be called.
Pros
- Business logic, state management and UI code separation.
- Easy to learn.
Cons
- Requires third-party library.
- As model gets more and more complex it’s hard to keep track when you should call
notifyListeners()
.
BLoC
BLoC (Business Logic Components) is a pattern recommended by Google developers. It leverages streams functionality in order to manage and propagate state changes.
For Android developers: You can think of Bloc
object as a ViewModel
and of StreamController
as a LiveData
. This will make the following code very straightforward as you’re 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));
});
}
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;
}
No additional method calls are required to notify subscribers when the state changes.
I have created 3 classes to represent possible states of the screen:
UserInitState
for the state, when user opens a screen with a button in the center.
UserLoadingState
for the state, when loading indicator is displayed while data is being loaded.
UserDataState
for the state, when data is loaded and displayed on the screen.
Propagating state changes in this way allows us to get rid of all the logic in the UI declaration code. In example with Scoped Model we still were checking if _isLoading
is true
in the UI declaration code to decide which widget we should render. In case with BLoC we are propagating the state of the screen and the only responsibility of UserBlocScreen
widget is to render the UI for this 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();
}
}
UserBlocScreen
code got even simpler in comparison to the previous examples. To listen to the state changes changes we are using StreamBuilder. StreamBuilder
is a StatefulWidget
that builds itself based on the latest snapshot of interaction with a Stream.
Pros
No third-party libraries needed.
Business logic, state management and UI logic separation.
It’s reactive. No additional calls are needed like in case with Scoped Model’s notifyListeners()
.
Cons
Experience working with streams or rxdart is required.
Links
You can checkout the source code of the examples above form this github repo.
Originally article is published on Medium