Comment tirer parti de BLoC pour le partage de code dans Flutter et AngularDart
Publié: 2022-03-11Au milieu de l'année dernière, je voulais porter une application Android sur iOS et sur le Web. Flutter était le choix pour les plates-formes mobiles, et je réfléchissais à ce qu'il fallait choisir pour le côté Web.
Bien que je sois tombé amoureux de Flutter au premier regard, j'avais encore quelques réserves : lors de la propagation de l'état dans l'arborescence des widgets, InheritedWidget
ou Redux de Flutter, avec toutes ses variantes, feront l'affaire, mais avec un nouveau framework comme Flutter, vous attendez-vous à ce que la couche de vue soit un peu plus réactive, c'est-à-dire que les widgets seraient eux-mêmes sans état et changeraient en fonction de l'état dans lequel ils sont alimentés de l'extérieur, mais ils ne le sont pas. De plus, Flutter ne prend en charge qu'Android et iOS, mais je voulais publier sur le Web. J'ai déjà beaucoup de logique métier dans mon application et je voulais la réutiliser autant que possible, et l'idée de changer le code à au moins deux endroits pour un seul changement de logique métier était inacceptable.
J'ai commencé à chercher comment surmonter cela et je suis tombé sur BLoC. Pour une introduction rapide, je vous recommande de regarder Flutter/AngularDart – Partage de code, mieux ensemble (DartConf 2018) quand vous avez le temps.
Modèle BLoC
BLoC est un mot fantaisiste inventé par Google qui signifie « composants de la logique commerciale ». L'idée du modèle BLoC est de stocker autant de votre logique métier que possible dans du code Dart pur afin qu'elle puisse être réutilisée par d'autres plates-formes. Pour y parvenir, il y a des règles à respecter :
- Communiquez en couches. Les vues communiquent avec la couche BLoC, qui communique avec les référentiels, et les référentiels communiquent avec la couche de données. Ne sautez pas les couches lors de la communication.
- Communiquez via des interfaces. Les interfaces doivent être écrites en code Dart pur et indépendant de la plate-forme. Pour plus d'informations, consultez la documentation sur les interfaces implicites.
- Les BLoC n'exposent que les flux et les puits. Les entrées/sorties d'un BLoC seront discutées plus tard.
- Gardez les vues simples. Gardez la logique métier hors des vues. Ils ne doivent afficher que des données et répondre à l'interaction de l'utilisateur.
- Rendre les BLoCs indépendants de la plate-forme. Les BLoC sont du pur code Dart et ne doivent donc contenir aucune logique ou dépendance spécifique à la plate-forme. Ne branchez pas le code conditionnel de la plate-forme. Les BLoC sont une logique implémentée dans Dart pur et traitent au-dessus de la plate-forme de base.
- Injectez des dépendances spécifiques à la plate-forme. Cela peut sembler contradictoire avec la règle ci-dessus, mais écoutez-moi. Les BLoC eux-mêmes sont indépendants de la plate-forme, mais que se passe-t-il s'ils doivent communiquer avec un référentiel spécifique à la plate-forme ? Injectez-le. En assurant la communication sur les interfaces et en injectant ces référentiels, nous pouvons être sûrs que, que votre référentiel soit écrit pour Flutter ou AngularDart, le BLoC s'en moque.
Une dernière chose à garder à l'esprit est que l'entrée d'un BLoC doit être un puits, tandis que la sortie passe par un flux. Ceux-ci font tous deux partie du StreamController
.
Si vous respectez strictement ces règles lors de l'écriture de votre application Web (ou mobile !), la création d'une version mobile (ou Web !) peut être aussi simple que de créer les vues et les interfaces spécifiques à la plate-forme. Même si vous venez de commencer à utiliser AngularDart ou Flutter, il est toujours facile de créer des vues avec une connaissance de base de la plate-forme. Vous pouvez finir par réutiliser plus de la moitié de votre base de code. Le motif BLoC garde tout structuré et facile à entretenir.
Construire une application AngularDart et Flutter BLoC Todo
J'ai créé une application todo simple dans Flutter et AngularDart. L'application utilise Firecloud comme back-end et une approche réactive pour afficher la création. L'application comporte trois parties :
-
bloc
-
todo_app_flutter
-
todoapp_dart_angular
Vous pouvez choisir d'avoir plus de parties, par exemple, l'interface de données, l'interface de localisation, etc. La chose à retenir est que chaque couche doit communiquer avec l'autre via une interface.
Le code BLoC
Dans le répertoire bloc/
:
-
lib/src/bloc
: Les modules BloC sont stockés ici comme de pures bibliothèques Dart contenant la logique métier. -
lib/src/repository
: Les interfaces vers les données sont stockées dans le répertoire. -
lib/src/repository/firestore
: Le référentiel contient l'interface FireCloud aux données avec son modèle, et comme il s'agit d'un exemple d'application, nous n'avons qu'un seul modèle de donnéestodo.dart
et une interface aux donnéestodo_repository.dart
; cependant, dans une application du monde réel, il y aura plus de modèles et d'interfaces de référentiel. -
lib/src/repository/preferences
contientpreferences_interface.dart
, une interface simple qui stocke les noms d'utilisateur connectés avec succès sur le stockage local sur le Web ou les préférences partagées sur les appareils mobiles.
//BLOC abstract class PreferencesInterface{ //Preferences final DEFAULT_USERNAME = "DEFAULT_USERNAME"; Future initPreferences(); String get defaultUsername; void setDefaultUsername(String username); }
Les implémentations Web et mobiles doivent l'implémenter dans le magasin et obtenir le nom d'utilisateur par défaut à partir du stockage/des préférences locales. L'implémentation d'AngularDart ressemble à :
// ANGULAR DART class PreferencesInterfaceImpl extends PreferencesInterface { SharedPreferences _prefs; @override Future initPreferences() async => _prefs = await SharedPreferences.getInstance(); @override void setDefaultUsername(String username) => _prefs.setString(DEFAULT_USERNAME, username); @override String get defaultUsername => _prefs.getString(DEFAULT_USERNAME); }
Rien de spectaculaire ici, il met en œuvre ce dont il a besoin. Vous remarquerez peut-être la méthode asynchrone initPreferences()
qui renvoie null
. Cette méthode doit être implémentée côté Flutter car l'obtention de l'instance SharedPreferences
sur mobile est asynchrone.
//FLUTTER @override Future initPreferences() async => _prefs = await SharedPreferences.getInstance();
Restons un peu avec le répertoire lib/src/bloc. Toute vue qui gère une logique métier doit avoir son composant BLoC. Dans ce répertoire, vous verrez view BLoCs base_bloc.dart
, endpoints.dart
et session.dart
. Le dernier est responsable de la connexion et de la déconnexion de l'utilisateur et de la fourniture de points de terminaison pour les interfaces du référentiel. La raison pour laquelle l'interface de session existe est que les firebase
et firecloud
ne sont pas les mêmes pour le Web et le mobile et doivent être implémentés en fonction de la plate-forme.
// BLOC abstract class Session implements Endpoints { //Collections. @protected final String userCollectionName = "users"; @protected final String todoCollectionName = "todos"; String userId; Session(){ _isSignedIn.stream.listen((signedIn) { if(!signedIn) _logout(); }); } final BehaviorSubject<bool> _isSignedIn = BehaviorSubject<bool>(); Stream<bool> get isSignedIn => _isSignedIn.stream; Sink<bool> get signedIn => _isSignedIn.sink; Future<String> signIn(String username, String password); @protected void logout(); void _logout() { logout(); userId = null; } }
L'idée est de garder la classe de session globale (singleton). Basé sur son getter _isSignedIn.stream
, il gère le basculement de l'application entre la vue connexion/liste de tâches et fournit des points de terminaison aux implémentations du référentiel si l'ID utilisateur existe (c'est-à-dire que l'utilisateur est connecté).
base_bloc.dart
est la base de tous les BLoC. Dans cet exemple, il gère l'affichage de l'indicateur de charge et de la boîte de dialogue d'erreur selon les besoins.
Pour l'exemple de logique métier, nous allons jeter un œil à todo_add_edit_bloc.dart
. Le nom long du fichier explique son objectif. Il a une méthode void privée _addUpdateTodo(bool addUpdate)
.
// BLOC void _addUpdateTodo(bool addUpdate) { if(!addUpdate) return; //Check required. if(_title.value.isEmpty) _todoError.sink.add(0); else if(_description.value.isEmpty) _todoError.sink.add(1); else _todoError.sink.add(-1); if(_todoError.value >= 0) return; final TodoBloc todoBloc = _todo.value == null ? TodoBloc("", false, DateTime.now(), null, null, null) : _todo.value; todoBloc.title = _title.value; todoBloc.description = _description.value; showProgress.add(true); _toDoRepository.addUpdateToDo(todoBloc) .doOnDone( () => showProgress.add(false) ) .listen((_) => _closeDetail.add(true) , onError: (err) => error.add( err.toString()) ); }
L'entrée de cette méthode est bool addUpdate
et c'est un écouteur de final BehaviorSubject<bool> _addUpdate = BehaviorSubject<bool>()
. Lorsqu'un utilisateur clique sur le bouton de sauvegarde dans l'application, l'événement envoie la valeur vraie de ce puits de sujet et déclenche cette fonction BLoC. Ce morceau de code flutter fait la magie du côté de la vue.
// FLUTTER IconButton(icon: Icon(Icons.done), onPressed: () => _todoAddEditBloc.addUpdateSink.add(true),),
_addUpdateTodo
vérifie que le titre et la description ne sont pas vides et modifie la valeur de _todoError
BehaviorSubject en fonction de cette condition. L'erreur _todoError
est responsable du déclenchement de l'affichage de l'erreur de vue sur les champs d'entrée si aucune valeur n'est fournie. Si tout va bien, il vérifie s'il faut créer ou mettre à jour le TodoBloc
et enfin _toDoRepository
fait l'écriture sur FireCloud.
La logique métier est là mais notez :
- Seuls les flux et les puits sont publics dans BLoC.
_addUpdateTodo
est privé et n'est pas accessible depuis la vue. -
_title.value
et_description.value
sont remplis par l'utilisateur saisissant la valeur dans la saisie de texte. La saisie de texte sur l'événement de modification de texte envoie sa valeur aux récepteurs respectifs. De cette façon, nous avons un changement réactif des valeurs dans le BLoC et leur affichage dans la vue. -
_toDoRepository
dépend de la plate-forme et est fourni par injection.
Découvrez le code de la todo_list.dart
BLoC _getTodos()
. Il écoute un instantané de la collection de tâches et diffuse les données de la collection à répertorier dans sa vue. La liste de vues est redessinée en fonction du changement de flux de collecte.

// BLOC void _getTodos(){ showProgress.add(true); _toDoRepository.getToDos() .listen((todosList) { todosSink.add(todosList); showProgress.add(false); }, onError: (err) { showProgress.add(false); error.add(err.toString()); }); }
La chose importante à savoir lors de l'utilisation de flux ou d'un équivalent rx est que les flux doivent être fermés. Nous le faisons dans la méthode dispose()
de chaque BLoC. Disposez le BLoC de chaque vue dans sa méthode dispose/destroy.
// FLUTTER @override void dispose() { widget.baseBloc.dispose(); super.dispose(); }
Ou dans un projet AngularDart :
// ANGULAR DART @override void ngOnDestroy() { todoListBloc.dispose(); }
Injecter des référentiels spécifiques à la plate-forme
Nous avons dit précédemment que tout ce qui vient dans un BLoC doit être un simple Dart et rien de dépendant de la plate-forme. TodoAddEditBloc
besoin de ToDoRepository
pour écrire sur Firestore. Firebase a des packages dépendant de la plate-forme et nous devons avoir des implémentations distinctes de l'interface ToDoRepository
. Ces implémentations sont injectées dans les applications. Pour Flutter, j'ai utilisé le package flutter_simple_dependency_injection
et il ressemble à ceci :
// FLUTTER class Injection { static Firestore _firestore = Firestore.instance; static FirebaseAuth _auth = FirebaseAuth.instance; static PreferencesInterface _preferencesInterface = PreferencesInterfaceImpl(); static Injector injector; static Future initInjection() async { await _preferencesInterface.initPreferences(); injector = Injector.getInjector(); //Session injector.map<Session>((i) => SessionImpl(_auth, _firestore), isSingleton: true); //Repository injector.map<ToDoRepository>((i) => ToDoRepositoryImpl(injector.get<Session>()), isSingleton: false); //Bloc injector.map<LoginBloc>((i) => LoginBloc(_preferencesInterface, injector.get<Session>()), isSingleton: false); injector.map<TodoListBloc>((i) => TodoListBloc(injector.get<ToDoRepository>(), injector.get<Session>()), isSingleton: false); injector.map<TodoAddEditBloc>((i) => TodoAddEditBloc(injector.get<ToDoRepository>()), isSingleton: false); } }
Utilisez ceci dans un widget comme celui-ci :
// FLUTTER TodoAddEditBloc _todoAddEditBloc = Injection.injector.get<TodoAddEditBloc>();
AngularDart a intégré l'injection via des fournisseurs.
// ANGULAR DART @GenerateInjector([ ClassProvider(PreferencesInterface, useClass: PreferencesInterfaceImpl), ClassProvider(Session, useClass: SessionImpl), ExistingProvider(Endpoints, Session) ])
Et dans un composant :
// ANGULAR DART providers: [ overlayBindings, ClassProvider(ToDoRepository, useClass: ToDoRepositoryImpl), ClassProvider(TodoAddEditBloc), ExistingProvider(BaseBloc, TodoAddEditBloc) ],
Nous pouvons voir que Session
est globale. Il fournit la fonctionnalité de connexion/déconnexion et les points de terminaison utilisés dans ToDoRepository
et BLoCs. ToDoRepository
besoin d'une interface de points de terminaison qui est implémentée dans SessionImpl
et ainsi de suite. La vue ne devrait voir que son BLoC et rien de plus.
Vues
Les vues doivent être aussi simples que possible. Ils affichent uniquement ce qui vient du BLoC et envoient l'entrée de l'utilisateur au BLoC. Nous y reviendrons avec le widget TodoAddEdit
de Flutter et son équivalent web TodoDetailComponent
. Ils affichent le titre et la description de la tâche sélectionnée et l'utilisateur peut ajouter ou mettre à jour une tâche.
Battement:
// FLUTTER _todoAddEditBloc.todoStream.first.then((todo) { _titleController.text = todo.title; _descriptionController.text = todo.description; });
Et plus tard dans le code…
// FLUTTER StreamBuilder<int>( stream: _todoAddEditBloc.todoErrorStream, builder: (BuildContext context, AsyncSnapshot errorSnapshot) { return TextField( onChanged: (text) => _todoAddEditBloc.titleSink.add(text), decoration: InputDecoration(hintText: Localization.of(context).title, labelText: Localization.of(context).title, errorText: errorSnapshot.data == 0 ? Localization.of(context).titleEmpty : null), controller: _titleController, ); }, ),
Le widget StreamBuilder
se reconstruit s'il y a une erreur (rien d'inséré). Cela se produit en écoutant _todoAddEditBloc.todoErrorStream . _todoAddEditBloc.titleSink
_todoAddEditBloc.todoErrorStream . _todoAddEditBloc.titleSink
, qui est un récepteur dans le BLoC qui contient le titre et est mis à jour lorsque l'utilisateur saisit du texte dans le champ de texte.
La valeur initiale de ce champ de saisie (si une tâche est sélectionnée) est remplie en écoutant _todoAddEditBloc.todoStream
qui contient la tâche sélectionnée ou vide si nous ajoutons une nouvelle tâche.
L'attribution d'une valeur à un champ de texte est effectuée par son contrôleur _titleController.text = todo.title;
.
Lorsque l'utilisateur décide d'enregistrer la tâche, il appuie sur l'icône de vérification dans la barre d'application et déclenche _todoAddEditBloc.addUpdateSink.add(true)
. Cela invoque le _addUpdateTodo(bool addUpdate)
nous avons parlé dans la section BLoC précédente et effectue toute la logique métier d'ajout, de mise à jour ou d'affichage de l'erreur à l'utilisateur.
Tout est réactif et il n'est pas nécessaire de gérer l'état du widget.
Le code AngularDart est encore plus simple. Après avoir fourni au composant son BLoC, à l'aide de fournisseurs, le code du fichier todo_detail.html
se charge d'afficher les données et de renvoyer l'interaction de l'utilisateur au BLoC.
// AngularDart <material-input #title label="{{titleStr}}" ngModel="{{(todoAddEditBloc.titleStream | async) == null ? '' : (todoAddEditBloc.titleStream | async)}}" (inputKeyPress)="todoAddEditBloc.titleSink.add($event)" [error]="(todoAddEditBloc.todoErrorStream | async) == 0 ? titleErrString : ''" autoFocus floatingLabel type="text" useNativeValidation="false" autocomplete="off"> </material-input> <material-input #description label="{{descriptionStr}}" ngModel="{{(todoAddEditBloc.descriptionStream | async) == null ? '' : (todoAddEditBloc.descriptionStream | async)}}" (inputKeyPress)="todoAddEditBloc.descriptionSink.add($event)" [error]="(todoAddEditBloc.todoErrorStream | async) == 1 ? descriptionErrString : ''" autoFocus floatingLabel type="text" useNativeValidation="false" autocomplete="off"> </material-input> <material-button animated raised role="button" class="blue" (trigger)="todoAddEditBloc.addUpdateSink.add(true)"> {{saveStr}} </material-button> <base-bloc></base-bloc>
Semblable à Flutter, nous ngModel=
la valeur du flux de titre, qui est sa valeur initiale.
// AngularDart (inputKeyPress)="todoAddEditBloc.descriptionSink.add($event)"
L'événement de sortie inputKeyPress
renvoie les caractères saisis par l'utilisateur dans la saisie de texte à la description du BLoC. L'événement du bouton matériel (trigger)="todoAddEditBloc.addUpdateSink.add(true)"
envoie l'événement d'ajout/mise à jour du BLoC qui déclenche à nouveau la même _addUpdateTodo(bool addUpdate)
dans le BLoC. Si vous regardez le code todo_detail.dart
du composant, vous verrez qu'il n'y a presque rien à part les chaînes qui sont affichées sur la vue. Je les ai placés là et non dans le HTML à cause d'une éventuelle localisation qui peut se faire ici.
Il en va de même pour tous les autres composants : les composants et les widgets n'ont aucune logique métier.
Un autre scénario mérite d'être mentionné. Imaginez que vous ayez une vue avec une logique de présentation de données complexe ou quelque chose comme un tableau avec des valeurs qui doivent être formatées (dates, devises, etc.). Quelqu'un pourrait être tenté d'obtenir les valeurs de BLoC et de les formater dans une vue. C'est faux! Les valeurs affichées dans la vue doivent arriver dans la vue déjà formatées (chaînes). La raison en est que le formatage lui-même est également une logique métier. Un autre exemple est lorsque le formatage de la valeur d'affichage dépend d'un paramètre d'application qui peut être modifié lors de l'exécution. En fournissant ce paramètre à BLoC et en utilisant une approche réactive pour afficher l'affichage, la logique métier formatera la valeur et ne redessinera que les parties nécessaires. Le modèle BLoC que nous avons dans cet exemple, TodoBloc
, est très simple. La conversion d'un modèle FireCloud vers le modèle BLoC est effectuée dans le référentiel, mais si nécessaire, elle peut être effectuée dans BLoC afin que les valeurs du modèle soient prêtes à être affichées.
Emballer
Ce bref article couvre les principaux concepts de mise en œuvre du modèle BLoC. C'est la preuve que le partage de code entre Flutter et AngularDart est possible, permettant un développement natif et multiplateforme.
En explorant l'exemple, vous verrez que, lorsqu'il est correctement mis en œuvre, BLoC raccourcit considérablement le temps de création d'applications mobiles/web. Un exemple est ToDoRepository
et son implémentation. Le code d'implémentation est presque identique et même la logique de composition de la vue est similaire. Après quelques widgets/composants, vous pouvez rapidement démarrer la production de masse.
J'espère que cet article vous donnera un aperçu du plaisir et de l'enthousiasme que j'ai à créer des applications Web/mobiles en utilisant Flutter/AngularDart et le modèle BLoC. Si vous cherchez à créer des applications de bureau multiplateformes en JavaScript, lisez Electron : Applications de bureau multiplateformes simplifiées par son collègue Toptaler Stephane P. Pericat.