(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:
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.
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; }
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 methodsetState()
to assign new values to the _isLoading
and _user
fields.
widget._repository.getUser().then((user) { setState(() { _user = user; _isLoading = false; }); });
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.
notifyListeners()
method to prevent unnecessary re-creation.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 ).
notifyListeners()
example.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/