Architecture de l'application Flutter 101: Vanilla, Scoped Model, BLoC


(initialement publié sur Medium )


Flutter fournit un cadre moderne de style réactif, une riche collection de widgets et des outils, mais il n'y a rien de similaire au guide Android de l' architecture des applications .


En effet, il n'y a pas d'architecture ultime qui répondrait à toutes les exigences possibles, mais avouons-le, la plupart des applications mobiles sur lesquelles nous travaillons ont au moins certaines des fonctionnalités suivantes:


  1. Demander / télécharger des données depuis / vers le réseau.
  2. Cartographier, transformer, préparer des données et les présenter à l'utilisateur.
  3. Mettez / obtenez des données vers / depuis la base de données.

En tenant compte de cela, j'ai créé un exemple d'application qui résout exactement le même problème en utilisant trois approches différentes de l'architecture.


L'utilisateur se voit présenter un bouton «Charger les données utilisateur» au centre de l'écran. Lorsque l'utilisateur clique sur le bouton, le chargement des données asynchrones est déclenché et le bouton est remplacé par un indicateur de chargement. Une fois les données chargées, l'indicateur de chargement est remplacé par les données.


Commençons.



Les données


Pour des raisons de simplicité, j'ai créé une classe Repository qui contient la méthode getUser() qui émule un appel réseau asynchrone et renvoie un objet Future<User> avec des valeurs codées en dur.


Si vous n'êtes pas familier avec Futures et la programmation asynchrone dans Dart, vous pouvez en apprendre plus à ce sujet en suivant ce tutoriel et en lisant un document .


 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; } 

Vanille


Construisons l'application comme le feraient la plupart des développeurs après avoir lu la documentation officielle de Flutter.


Navigation vers l'écran VanillaScreen l'aide de Navigator


 Navigator.push( context, MaterialPageRoute( builder: (context) => VanillaScreen(_repository), ), ); 

Comme l'état du widget peut changer plusieurs fois pendant la durée de vie du widget, nous devons étendre StatefulWidget . L'implémentation d'un widget avec état nécessite également d'avoir une classe State . Les champs bool _isLoading et User _user dans la classe _VanillaScreenState représentent l'état du widget. Les deux champs sont initialisés avant l' build(BuildContext context) méthode build(BuildContext context) .


 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(), ); } } 

Lorsque l'objet d'état du widget est créé, la méthode build(BuildContext context) est appelée pour construire l'interface utilisateur. Toutes les décisions concernant les widgets qui doivent être créés pour représenter l'état actuel sont prises dans le code de déclaration de l'interface utilisateur.


 body: SafeArea( child: _isLoading ? _buildLoading() : _buildBody(), ) 

Pour afficher l'indicateur de progression lorsque l'utilisateur clique sur le bouton «Charger les détails de l'utilisateur», nous procédons ci-dessous.


 setState(() { _isLoading = true; }); 

L'appel de setState () informe le framework que l'état interne de cet objet a changé d'une manière qui pourrait avoir un impact sur l'interface utilisateur dans cette sous-arborescence, ce qui oblige le framework à planifier une génération pour cet objet State.

Cela signifie qu'après avoir appelé la méthode setState() méthode build(BuildContext context) est à nouveau appelée par le framework et l'arborescence entière du widget est reconstruite . Comme _isLoading est désormais défini sur true méthode _buildLoading() est appelée à la place de _buildBody() et l'indicateur de chargement s'affiche à l'écran. Exactement la même chose se produit lorsque nous gérons le rappel de getUser() et appelons setState() pour réaffecter les champs _isLoading et _user .


 widget._repository.getUser().then((user) { setState(() { _user = user; _isLoading = false; }); }); 

Avantages


  1. Facile à apprendre et à comprendre.
  2. Aucune bibliothèque tierce n'est requise.

Inconvénients


  1. L'arborescence entière du widget est reconstruite à chaque changement d'état du widget.
  2. Cela brise le principe de la responsabilité unique. Widget n'est pas seulement responsable de la construction de l'interface utilisateur, il est également responsable du chargement des données, de la logique métier et de la gestion des états.
  3. Les décisions sur la façon dont l'état actuel doit être représenté sont prises dans le code de déclaration de l'interface utilisateur. Si nous avions un peu plus de lisibilité du code d'état complexe, cela diminuerait.

Modèle de portée


Scoped Model est un package tiers qui n'est pas inclus dans le framework Flutter. Voici comment les développeurs de Scoped Model le décrivent:


Un ensemble d'utilitaires qui vous permettent de transmettre facilement un modèle de données d'un widget parent à ses descendants. En outre, il reconstruit également tous les enfants qui utilisent le modèle lorsque le modèle est mis à jour. Cette bibliothèque a été initialement extraite de la base de code Fuchsia.

Construisons le même écran en utilisant le modèle Scoped. Tout d'abord, nous devons installer le package Scoped Model en ajoutant la dépendance pubspec.yaml à pubspec.yaml dans dependencies section des dependencies .


 scoped_model: ^1.0.1 

Jetons un coup d'œil au widget UserModelScreen et comparons-le avec l'exemple précédent qui a été construit sans utiliser le modèle Scoped. Jetons un coup d'œil au widget UserModelScreen et comparons-le avec l'exemple précédent qui a été construit sans utiliser le modèle Scoped. Comme nous voulons rendre notre modèle disponible pour tous les descendants du widget, nous devons l'envelopper avec ScopedModel générique et fournir un widget et un modèle.


 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(), ); } } 

Dans l'exemple précédent, l'ensemble de l'arborescence des widgets a été reconstruit lorsque l'état du widget a changé. Mais avons-nous réellement besoin de reconstruire tout l'écran? Par exemple, AppBar ne devrait pas changer du tout, il est donc inutile de le reconstruire. Idéalement, nous devrions reconstruire uniquement les widgets mis à jour. Le modèle de portée peut nous aider à résoudre ce problème.


ScopedModelDescendant<UserModel> est utilisé pour rechercher UserModel dans l'arborescence Widget. Il sera automatiquement reconstruit chaque fois que le UserModel notifie que le changement a eu lieu.


Une autre amélioration est que UserModelScreen n'est plus responsable de la gestion de l'état et de la logique métier.


Jetons un coup d'œil au 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); } 

Désormais, UserModel détient et gère l'état. Afin d'avertir les écouteurs (et reconstruire les descendants) que la modification a eu lieu, la méthode notifyListeners() doit être appelée.


Avantages


  1. Logique d'entreprise, gestion des états et séparation du code de l'interface utilisateur.
  2. Facile à apprendre.

    Inconvénients

  3. Nécessite une bibliothèque tierce.
  4. Comme le modèle devient de plus en plus complexe, il est difficile de savoir quand vous devez appeler notifyListeners() .

BLoC


BLoC ( B usiness Logic C omponents) est un modèle recommandé par les développeurs de Google. Il exploite la fonctionnalité des flux afin de gérer et de propager les changements d'état.


Pour les développeurs Android: vous pouvez considérer l'objet Bloc comme un ViewModel et StreamController comme un LiveData . Cela rendra le code suivant très simple car vous êtes déjà familier avec les 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; } 

Aucun appel de méthode supplémentaire n'est requis pour informer les abonnés lorsque l'état change.


J'ai créé 3 classes pour représenter les états possibles de l'écran:


UserInitState pour l'état, lorsque l'utilisateur ouvre un écran avec un bouton au centre.


UserLoadingState pour l'état, lorsque l'indicateur de chargement est affiché pendant le chargement des données.


UserDataState pour l'état, lorsque les données sont chargées et affichées à l'écran.


La propagation des changements d'état de cette manière nous permet de nous débarrasser de toute la logique du code de déclaration de l'interface utilisateur. Dans l'exemple avec Scoped Model, nous vérifions toujours si _isLoading est true dans le code de déclaration de l'interface utilisateur pour décider quel widget nous devons rendre. Dans le cas de BLoC, nous propagons l'état de l'écran et la seule responsabilité du widget UserBlocScreen est de rendre l'interface utilisateur pour cet état.


 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 UserBlocScreen est devenu encore plus simple par rapport aux exemples précédents. Pour écouter les changements d'état, nous utilisons StreamBuilder . StreamBuilder est un StatefulWidget qui se construit sur la base du dernier instantané d'interaction avec un Stream .


Avantages


Aucune bibliothèque tierce n'est requise.
Logique d'entreprise, gestion des états et séparation de la logique de l'interface utilisateur.
C'est réactif. Aucun appel supplémentaire n'est nécessaire, comme dans le cas avec notifyListeners du modèle notifyListeners() .


Inconvénients


Une expérience de travail avec les flux ou rxdart est requise.



Vous pouvez extraire le code source des exemples ci-dessus depuis ce dépôt github.


L'article est initialement publié sur Medium

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


All Articles