Bases de l'architecture d'application Flutter: vanille, modèle de portée, BLoC


(article original publié en anglais sur Medium )


Flutter fournit un cadre réactif moderne, un large éventail de widgets et d'outils. Mais, malheureusement, la documentation ne ressemble en rien à un guide de l'architecture recommandée de l'application Android .


Il n'y a pas d'architecture universelle idéale qui pourrait répondre à toutes les exigences techniques imaginables, mais admettons que la plupart des applications mobiles sur lesquelles nous travaillons ont les fonctionnalités suivantes:


  1. Demander et télécharger des données.
  2. Transformation et préparation des données pour l'utilisateur.
  3. Écriture et lecture de données à partir d'une base de données ou d'un système de fichiers.

Compte tenu de tout cela, j'ai créé une application de démonstration qui résout le même problème en utilisant différentes approches de l'architecture.


Initialement, l'utilisateur affiche un écran avec un bouton «Charger les données utilisateur» situé au centre. Lorsque l'utilisateur clique sur le bouton, un chargement de données asynchrone se produit et le bouton est remplacé par un indicateur de chargement. Une fois le téléchargement des données terminé, l'indicateur de téléchargement est remplacé par des données.


Commençons donc.



Les données


Pour simplifier la tâche, j'ai créé la classe Repository , qui contient la méthode getUser() . Cette méthode simule le chargement asynchrone des données du réseau et renvoie Future<User> .


Si vous n'êtes pas familier avec Futures et la programmation asynchrone dans Dart, nous pouvons en savoir plus ici et lire la documentation de la classe Future .


 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


Développons l'application, comme le ferait un développeur s'il lisait la documentation Flutter sur le site officiel.


Ouvrez l'écran VanillaScreen l'aide du Navigator


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

Étant donné que l'état d'un widget peut changer plusieurs fois au cours de son cycle de vie, nous devons hériter de StatefulWidget . Pour implémenter votre widget avec état, vous avez également besoin de la classe State . Les bool _isLoading et User _user de la classe _VanillaScreenState représentent l'état du widget. Les deux champs sont initialisés avant que la méthode build(BuildContext context) soit appelée pour la première fois.


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

Une fois l'objet d'état du widget créé, la méthode build(BuildContext context) est appelée pour construire l'interface utilisateur. Toutes les décisions concernant le widget à afficher à l'écran sont prises directement dans le code de déclaration de l'interface utilisateur.


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

Afin d'afficher un indicateur de progression, lorsque l'utilisateur clique sur le bouton «Charger les détails de l'utilisateur», nous procédons comme suit.


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

De la documentation (traduction):


Un appel à la méthode setState () informe le framework que l'état interne de cet objet a changé et peut affecter l'interface utilisateur dans la sous-arborescence. C'est la raison pour laquelle le framework appelle la méthode de génération sur cet objet d'état.

Cela signifie qu'après avoir appelé la méthode setState() , le framework appellera à nouveau la méthode build(BuildContext context) , qui recréera l'arborescence entière du widget . Étant donné que la valeur du champ _isLoading true , au lieu de la méthode _buildBody() , la méthode _buildLoading() sera appelée et un indicateur de progression s'affichera à l'écran.
Exactement la même chose se produit lorsque nous recevons un rappel de getUser() et appelons la méthode
setState() pour affecter de nouvelles valeurs aux champs _isLoading et _user .


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

Avantages


  1. Seuil d'entrée bas.
  2. Aucune bibliothèque tierce partie requise.

Inconvénients


  1. Lorsque l'état du widget change, l'arborescence du widget est entièrement recréée à chaque fois.
  2. Viole le principe de la responsabilité exclusive. Le widget est responsable non seulement de la création de l'interface utilisateur, mais également du chargement des données, de la logique métier et de la gestion des états.
  3. Les décisions sur la façon d'afficher l'état actuel sont prises directement dans le code de l'interface utilisateur. Si l'état devient plus complexe, la lisibilité du code diminuera considérablement.

Modèle de portée


Scoped Model est une bibliothèque tierce . Voici comment les développeurs le décrivent:


Un ensemble d'utilitaires qui vous permettent de transférer le modèle de données du widget ancêtre à tous ses descendants. De plus, lorsque les données du modèle changent, tous les descendants qui utilisent le modèle seront recréés. Cette bibliothèque a été initialement extraite du code du projet Fuchsia .

Créons le même écran que dans l'exemple précédent, mais en utilisant le modèle Scoped. Tout d'abord, nous devons ajouter la bibliothèque Scoped Model au projet. Ajoutez la dépendance scoped_model fichier scoped_model dans la section des dependencies .


 scoped_model: ^1.0.1 

Regardons le code UserModelScreen et comparons-le avec l'exemple précédent, dans lequel nous n'avons pas utilisé le modèle Scoped. Pour rendre notre modèle accessible aux descendants du widget, nous devons envelopper le widget et le modèle dans 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(), ); } } 

Dans l'exemple précédent, chaque fois qu'un widget change d'état, l'arborescence des widgets était complètement recréée. Mais avons-nous vraiment besoin de recréer la totalité de l'arborescence des widgets (plein écran)? Par exemple, l'AppBar ne change pas du tout et il est inutile de le recréer. Idéalement, vous ne devez recréer que les widgets qui doivent changer en fonction du changement d'état. Et le modèle de portée peut nous aider à résoudre ce problème.


Le ScopedModelDescendant<UserModel> est utilisé pour rechercher le UserModel dans l'arborescence des widgets. Il sera automatiquement recréé chaque fois que l' UserModel notifiera qu'il y a eu un changement.


Une autre amélioration est que UserModelScreen n'est plus responsable de la gestion des états, de la logique métier et du chargement des données.


Regardons le code de la classe 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); } 

UserModel contient et gère désormais l'état. Afin d'avertir les écouteurs (et recréer des descendants) qu'un changement s'est produit, vous devez appeler la méthode notifyListeners() .


Avantages


  1. La gestion des états, la logique métier et le chargement des données sont distincts du code de l'interface utilisateur.
  2. Seuil d'entrée bas.

Inconvénients


  1. Dépendance de bibliothèque tierce.
  2. Si le modèle devient suffisamment complexe, il sera difficile de savoir quand il est vraiment nécessaire d'appeler la méthode notifyListeners() pour éviter des recréations inutiles.

BLoC


BLoC ( B usiness Logic C omponents) est un modèle recommandé par les développeurs de Google. Les flux sont utilisés pour gérer l'état et pour notifier un changement d'état.


Pour les développeurs Android: vous pouvez imaginer que Bloc est un ViewModel et StreamController est un LiveData . Cela rendra le code suivant facile à comprendre, car vous connaissez déjà les principes de base.


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

Le code montre qu'il n'est plus nécessaire d'appeler des méthodes supplémentaires pour notifier les changements d'état.


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


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


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


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


Passer l'état de cette manière nous permet de nous débarrasser complètement de la logique du code de l'interface utilisateur. Dans l'exemple Scoped Model, nous avons toujours vérifié si la valeur du champ _isLoading true ou false pour déterminer le widget à créer. Dans le cas de BLoC, nous transmettons le nouvel état au flux, et la seule tâche du widget UserBlocScreen créer une interface utilisateur pour l'état actuel.


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

Le code du widget UserBlocScreen est encore plus simple que dans les exemples précédents. Afin d'écouter les changements d'état, StreamBuilder est utilisé. StreamBuilder est un StatefulWidget qui se crée conformément à la dernière valeur (Snapshot) du flux ( Stream ).


Avantages


  1. Aucune bibliothèque tierce partie requise.
  2. La logique métier, la gestion des états et le chargement des données sont distincts du code de l'interface utilisateur.
  3. Réactivité Il n'est pas nécessaire d'appeler des méthodes supplémentaires, comme dans l'exemple avec Scoped Model notifyListeners() .

Inconvénients


  1. Le seuil d'entrée est légèrement supérieur. Besoin d'expérience avec les flux ou rxdart.

Les liens


Vous pouvez lire le code complet en le téléchargeant depuis mon dépôt github .


Article original publié sur Medium

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


All Articles