Récemment, j'ai dû mettre en place des parcours d'intégration pour les personnes suivantes ClickUp nouveaux arrivants ! Il s'agissait d'une tâche très importante car beaucoup de nouveaux utilisateurs étaient sur le point de découvrir la plate-forme avec la publicité incroyablement drôle que nous avons diffusée en avant-première lors du Super Bowl ! ✨
via ClickUp
Le walkthrough permet à nos nombreux nouveaux utilisateurs, qui ne connaissent peut-être pas encore ClickUp, de comprendre rapidement comment utiliser plusieurs fonctions de l'application. Il s'agit d'un effort continu, tout comme les nouveaux.. ClickUp University ressource que nous poursuivons ! 🚀
Heureusement, l'architecture logicielle derrière l'application mobile ClickUp Flutter m'a permis de mettre en œuvre cette fonction assez rapidement, même en réutilisant les vrais widgets de l'application ! Cela signifie que le walkthrough est dynamique, réactif, et correspond exactement aux écrans réels de l'application - et continuera à le faire, même lorsque les widgets évolueront.
J'ai également pu mettre en œuvre la fonction grâce à la bonne séparation des préoccupations.
Voyons ce que je veux dire par là. 🤔
Séparation des préoccupations
La conception d'une architecture logicielle est l'un des sujets les plus complexes pour les équipes d'ingénieurs. Parmi toutes les responsabilités, il est toujours difficile d'anticiper les évolutions futures des logiciels. C'est pourquoi la création d'une architecture bien stratifiée et découplée peut vous aider, vous et vos coéquipiers, à bien des égards !
Le principal avantage de la création de petits systèmes découplés est sans aucun doute la testabilité ! Et c'est ce qui m'a aidé à créer une alternative de démonstration des écrans existants de l'application !
Un guide étape par étape
Maintenant, comment pourrions-nous appliquer ces principes à une application Flutter ?
Nous allons partager quelques techniques que nous utilisons pour construire ClickUp avec un exemple simple de walkthrough.
L'exemple est si simple qu'il peut ne pas éclairer tous les avantages qui se cachent derrière, mais croyez-moi, il vous aidera à créer beaucoup plus d'applications Flutter maintenables avec des bases de code complexes. 💡
L'application
À titre d'exemple, nous allons créer une application qui affiche la population des États-Unis pour chaque année.
via ClickUp
Nous avons deux écrans ici :
HomeScreen
: liste simplement toutes les années de 2000 à aujourd'hui. Lorsque l'utilisateur clique sur une année, il accède à l'écranDetailScreen
dont l'argument de navigation est paramétré sur l'année sélectionnée.DetailScreen
: obtient l'année à partir de l'argument de navigation, appelle la fonctiondatausa.io API pour cette année, et analyse les données JSON pour extraire la valeur de la population associée. Si les données sont disponibles, un libellé est affiché avec la population.
Nous allons nous concentrer sur l'implémentation de DetailScreen
car c'est la plus intéressante avec son appel asynchrone.
Étape 1. Approche naïve
/$$$img/ https://clickup.com/blog/wp-content/uploads/2022/03/image-5-1400x607.png Approche naïve Stateful Widget /$$$img/
via ClickUp
L'implémentation la plus évidente pour notre application est l'utilisation d'un seul StatefulWidget
pour toute la logique.
Code source et démo de Dart
Accéder à l'argument de navigation year
Pour accéder à l'année demandée, nous lisons les RouteSettings
du widget hérité ModalRoute
.
void didChangeDependencies() {
super.didChangeDependencies() ;
final year = ModalRoute.of(context) !.settings.arguments as int ;
// ...
}
Appel HTTP
Cet exemple invoque la fonction get
du paquetage http
pour obtenir les données du fichier
datausa.io API
analyse le JSON résultant avec la méthode jsonDecode
de la bibliothèque dart:convert
, et garde Future
comme partie de l'état avec une propriété nommée _future
.
late Future<Map<dynamique, dynamique>?> _future ;
void didChangeDependencies() {
super.didChangeDependencies() ;
final year = ModalRoute.of(context) !.settings.arguments as int ;
if (_year != year) {
_future = _loadMeasure(year) ;
}
}
Future<Map<dynamique, dynamique>?> _loadMeasure(int year) async {
_year = year ;
final uri = Uri.parse(
'https://datausa.io/api/data?drilldowns=Nation&measures=Population&year=$year') ;
final result = await get(uri) ;
final body = jsonDecode(result.body) ;
final data = body['data'] as List<dynamic> ;
if (data.isNotEmpty) {
return data.first ;
}
return null ;
Rendu
Pour créer l'arbre des widgets, nous utilisons un FutureBuilder
, qui se reconstruit lui-même en fonction de l'état actuel de notre appel asynchrone _future
.
@override
Widget build(BuildContext context) {
return Scaffold(
appBar : AppBar(
titre : Texte('Année $_year'),
),
body : FutureBuilder<Map<dynamique, dynamique>?>(
future : _future,
builder : (context, snapshot) {
switch (snapshot.connectionState) {
case ConnectionState.Terminé :
final error = snapshot.error ;
if (error != null) {
// return "error" tree.
}
final data = snapshot.data ;
if (data != null) {
// renvoie l'arbre "résultat".
}
// renvoie des données "vides" tree.case ConnectionState.none :
case ConnectionState.waiting :
case ConnectionState.active :
// renvoie l'arbre de données "loading".
}
},
),
) ;
}
Review
D'accord, la mise en œuvre est courte et n'utilise que des widgets intégrés, mais pensez maintenant à notre intention initiale : construire des alternatives de démonstration (ou des tests) pour cet écran. Il est très difficile de contrôler le résultat de l'appel HTTP pour forcer l'application à effectuer un rendu dans un certain état.
C'est là que le concept d'inversion de contrôle va nous aider. 🧐
Étape 2. Inversion de contrôle
/$$$img/ https://clickup.com/blog/wp-content/uploads/2022/03/image-6-1400x607.png Inversion du contrôle vers le fournisseur, prestataire et le client de l'API /$$$img/
via ClickUp
Ce principe peut être difficile à comprendre pour les nouveaux développeurs (et aussi difficile à expliquer), mais l'idée générale est de extraire les préoccupations en dehors de nos composants - de sorte qu'ils ne soient pas responsables du choix du comportement - et de le déléguer à la place.
Dans une situation plus courante, cela consiste simplement à créer des abstractions et à injecter des implémentations dans nos composants afin que leur implémentation puisse être modifiée ultérieurement si nécessaire.
Mais ne vous inquiétez pas, cela aura plus de sens après notre prochain exemple ! 👀 Code source et démo de Dart
Création d'un objet client API
Afin de commander l'appel HTTP à notre API, nous avons isolé notre implémentation dans une classe dédiée DataUsaApiClient
. Nous avons également créé une classe Measure
pour faciliter la manipulation et la maintenance des données.
class DataUsaApiClient {
const DataUsaApiClient({
this.endpoint = 'https://datausa.io/api/data',
}) ;
final String endpoint ;
Future<Mesure?> getMeasure(int year) async {
final uri =
Uri.parse('$endpoint?drilldowns=Nation&mesures=Population&year=$year') ;
final result = await get(uri) ;
final body = jsonDecode(result.body) ;
final data = body['data'] as List<dynamic> ;
if (data.isNotEmpty) {
return Measure.fromJson(data.first as Map<String, Object?>) ;
}
return null ;
}
Fournir un client API
Pour notre exemple, nous utilisons l'API bien connue
fournisseur, prestataire
pour injecter une instance de DataUsaApiClient
à la racine de notre arbre.
Fournisseur, prestataire<DataUsaApiClient>(
create : (context) => const DataUsaApiClient(),
child : const MaterialApp(
accueil : HomePage(),
),
)
Utilisation du client API
Le fournisseur permet à n'importe quel widget descendant (comme notre _DetailScreen_
)_ de lire le DataUsaApiClient
supérieur le plus proche dans l'arbre. Nous pouvons alors utiliser sa méthode getMeasure
pour démarrer notre Future
, à la place de l'implémentation HTTP réelle.
@overridevoid didChangeDependencies() {
super.didChangeDependencies() ;
final year = ModalRoute.of(context) !.settings.arguments as int ;
if (_year != year) {
_year = year ;
final api = context.read<DataUsaApiClient>() ;
_future = api.getMeasure(year) ;
}
}
Démonstration du client API
Nous pouvons maintenant en tirer parti !
Au cas où vous ne le sauriez pas : toutes les classes
en dart définissent aussi implicitement une interface associée
. Cela nous permet de fournir une implémentation alternative de DataUsaApiClient
qui renvoie toujours la même instance à partir de ses appels à la méthode getMeasure
.
Cette méthode
class DemoDataUsaApiClient implements DataUsaApiClient {
const DemoDataUsaApiClient(this.measure) ;
mesure finale mesure ;
@overrideString get endpoint => '' ;
@override
Future<Mesure?> getMeasure(int year) {
return Future.value(measure) ;
}
Affichage d'une page de démonstration
Nous avons maintenant toutes les clés pour afficher une instance de démonstration de la DetailPage
!
Nous surchargeons simplement l'instance de DataUsaApiClient
actuellement fournie en enveloppant notre DetailScreen
dans un fournisseur qui crée une instance de DemoDataUsaApiClient
à la place !
Et voilà, notre DetailScreen
lit cette instance de démo à la place, et utilise nos données demoMeasure
au lieu d'un appel HTTP.
ListTile(
title : const Texte('Ouvrir la démo'),
onTap : () {
const demoMeasure = Measure(
year : 2022,
population : 425484,
nation : "États-Unis",
) ;
Navigateur.push(
contexte,
MaterialPageRoute(
paramètres : RouteSettings(arguments : demoMeasure.year),
builder : (context) {
return Fournisseur<DataUsaApiClient>(
create : (context) =>
const DemoDataUsaApiClient(demoMeasure),
child : const DetailScreen(),
) ;
},
),
) ;
},
)
Revue de presse
Notre widget DetailScreen
n'est plus responsable de la logique d'obtention des données, mais la délègue à un objet client dédié. Et nous sommes maintenant capables de créer des instances de démonstration de l'écran, ou d'implémenter des tests de widgets pour notre écran ! Génial ! 👏
Mais nous pouvons faire encore mieux !
Puisque nous ne sommes pas en mesure de simuler un état de chargement, par exemple, nous n'avons pas le contrôle total de tout changement d'état au niveau de notre widget.
Étape 3. Gestion de l'état
/$$$imgg/ https://clickup.com/blog/wp-content/uploads/2022/03/image-7-1400x607.png Gestion des états dans les applications de flottement /$$$img/
via ClickUp
C'est un sujet d'actualité dans Flutter !
Je suis sûr que vous avez déjà lu de longs fils de personnes qui tentent d'élire la meilleure solution de state-management pour Flutter. Et pour être clair, ce n'est pas ce que nous allons faire dans cet article. Selon nous, tant que vous séparez votre logique entreprise de votre logique visuelle, tout va bien ! Créer ces couches est vraiment important pour la maintenance. Notre exemple est simple, mais dans les applications réelles, la logique peut rapidement devenir complexe et une telle séparation permet de retrouver beaucoup plus facilement vos algorithmes de logique pure. Ce sujet est souvent résumé par la gestion des états.
Dans cet exemple, nous utilisons un ValueNotifier
basique aux côtés d'un Provider
. Mais nous aurions également pu utiliser
flutter_bloc
ou
nacelle
(ou une autre solution)_, et cela aurait très bien fonctionné aussi. Les principes restent les mêmes, et tant que vous avez séparé vos états et votre logique, il est même possible de porter votre base de code à partir d'une des autres solutions.
Cette séparation nous aide également à contrôler n'importe quel état de nos widgets afin que nous puissions les simuler de toutes les manières possibles ! Code source et démo de Dart
Création d'un état dédié
Au lieu de s'appuyer sur le AsyncSnapshot
du framework, nous représentons maintenant l'état de notre écran sous la forme d'un objet DetailState
.
Il est également important d'implémenter les méthodes hashCode
et operator ==
pour rendre notre objet comparable par la valeur. Cela nous permet de détecter si deux instances doivent être considérées comme différentes.
💡 Les equatable ou gelé sont d'excellentes options pour vous aider à implémenter les méthodes
hashCode
etoperator ==
!
abstract class DetailState {
const DetailState(this.year) ;
final int year ;
@overridebool operator ==(Object other) =>
identique(this, other) ||
(autre est DetailState &&
runtimeType == other.runtimeType &&
year == other.year) ;
@overrideint get hashCode => runtimeType.hashCode ^ year ;
Notre état est toujours associé à un year
, mais nous avons également quatre états possibles distincts concernant ce que nous voulons montrer à l'utilisateur :
NotLoadedDetailState
: la mise à jour des données n'a pas encore commencéLoadingDetailState
: les données sont en cours de chargementLoadedDetailState
: les données ont été chargées avec succès et sont associées à unemesure
NoDataDetailState
: les données ont été chargées, mais il n'y a pas de données disponiblesUnknownErrorDetailState
: l'opération a échoué en raison d'uneerreur
inconnue
class NotLoadedDetailState extends DetailState {
const NotLoadedDetailState(int year) : super(year) ;
}
class LoadedDetailState extends DetailState {
const LoadedDetailState({
obligatoire int year,
requis this.measure,
}) : super(year) ;
mesure finale mesure ;
@overridebool operator ==(Object other) =>
identique(this, other) ||
(other is LoadedDetailState && measure == other.measure) ;
@overrideint get hashCode => runtimeType.hashCode ^ measure.hashCode ;
}
class NoDataDetailState extends DetailState {
const NoDataDetailState(int year) : super(year) ;
}
class LoadingDetailState extends DetailState {
const LoadingDetailState(int year) : super(year) ;
}
class UnknownErrorDetailState extends DetailState {
const UnknownErrorDetailState({
requis int year,
required this.error,
}) : super(year) ;
erreur dynamique finale ;
@overridebool operator ==(Object other) =>
identique(this, other) ||
(other is UnknownErrorDetailState &&
year == other.year &&
erreur == other.error) ;
@overrideint get hashCode => Object.hash(super.hashCode, error.has
Ces états sont plus clairs qu'un AsyncSnapshot
, puisqu'ils représentent réellement nos cas d'utilisation. Et encore une fois, cela rend notre code plus maintenable !
💡 Nous recommandons fortement l'utilisation de l'outil Les types d'union du paquet gelé pour représenter vos états logiques ! Il ajoute de nombreux utilitaires comme les méthodes
copyWith
oumap
.
Mettre la logique dans un Notificateur
Maintenant que nous avons une représentation de notre état, nous avons besoin d'en stocker une instance quelque part - c'est le but du DetailNotifier
. Il gardera l'instance courante de DetailState
dans sa propriété value
et fournira des méthodes pour mettre à jour l'état.
Nous fournissons un état initial NotLoadedDetailState
, et une méthode refresh
pour charger des données depuis l'API et mettre à jour la valeur courante value
.
class DetailNotifier extends ValueNotifier<DetailState> {
DetailNotifier({
obligatoire int year,
requis this.api,
}) : super(DetailState.notLoaded(year)) ;
final DataUsaApiClient api ;
int get year => valeur.année ;
Future<void> refresh() async {
if (value is ! LoadingDetailState) {
value = DetailState.loading(year) ;
try {
final result = await api.getMeasure(year) ;
if (result != null) {
value = DetailState.loaded(
year : année,
measure : result,
) ;
} else {
value = DetailState.noData(year) ;
}
} catch (erreur) {
value = DetailState.unknownError(
year : année,
error : erreur,
) ;
}
}
}
Fournir un état pour l'affiche
Pour instancier et observer l'état de notre écran, nous nous appuyons également sur le fournisseur et son ChangeNotifierProvider
. Ce type de fournisseur recherche automatiquement tout ChangeListener
créé et va déclencher une reconstruction de la part du Consumer
à chaque fois qu'il est notifié d'un changement _(lorsque la valeur de notre notifier est différente de la précédente)
class DetailScreen extends StatelessWidget {
const DetailScreen({
Clé : clé,
}) : super(key : clé) ;
@override
Widget build(BuildContext context) {
final year = ModalRoute.of(context) !.settings.arguments as int ;
return ChangeNotifierProvider<DetailNotifier>(
create : (context) {
final notifier = DetailNotifier(
year : année,
api : context.read<DataUsaApiClient>(),
) ;
notifier.refresh() ;
return notifier ;
},
child : Consumer<DetailNotifier>(
builder : (context, notifier, child) {
final state = notifier.value ;
// ...
},
),
) ;
}
}
Revue de presse
C'est très bien ! L'architecture de notre application commence à avoir fière allure. Tout est séparé en couches bien définies, avec des préoccupations spécifiques ! 🤗
Il manque cependant une chose pour la testabilité, nous voulons définir l'état courant DetailState
pour contrôler l'état de notre DetailScreen
associé.
Étape 4. Widget visuel de disposition dédié
/$$$img/ https://clickup.com/blog/wp-content/uploads/2022/03/image-8-1400x607.png Widgets visuels dédiés à la disposition dans les applications flutter /$$$img/
via ClickUp
Dans la dernière étape, nous avons donné un peu trop de responsabilités à notre widget DetailScreen
: il était responsable de l'instanciation du DetailNotifier
. Et comme nous l'avons vu précédemment, nous essayons d'éviter toute responsabilité logique au niveau de la couche de vue !
Nous pouvons facilement résoudre ce problème en créant une autre couche pour notre widget screen : nous allons diviser notre widget DetailScreen
en deux :
DetailScreen
est responsable du paramètre des différentes dépendances de notre écran à partir de l'état actuel de l'application (navigation, notifiers, état, services, ...),DetailLayout
convertit simplement unDetailState
en un arbre de widgets dédié.
En combinant les deux, nous serons capables de créer simplement des instances de démo/test de DetailLayout
, mais en ayant DetailScreen
pour les cas d'utilisation réels dans notre application.
Code source et démo de Dart
Disposition dédiée
Pour obtenir une meilleure séparation des préoccupations, nous avons déplacé tout ce qui se trouve sous le widget Consumer
vers un widget dédié DetailLayout
. Ce nouveau widget ne consomme que des données et n'est responsable d'aucune instanciation. Il se contente de convertir l'état de lecture en une arborescence de widgets spécifique.
L'appel ModalRoute.of
et l'instance ChangeNotifierProvider
restent dans le DetailScreen
, et ce widget retourne simplement le DetailLayout
avec un arbre de dépendance pré-configuré !
Cette amélioration mineure est spécifique à l'utilisation des fournisseurs, mais vous remarquerez que nous avons également ajouté un ProxyProvider
afin que tout widget descendant puisse consommer directement un DetailState
. Cela facilitera l'imitation des données.
class DetailScreen extends StatelessWidget {
const DetailScreen({
Clé : clé,
}) : super(key : clé) ;
@override
Widget build(BuildContext context) {
final year = ModalRoute.of(context) !.settings.arguments as int ;
return ChangeNotifierProvider<DetailNotifier>(
create : (context) {
final notifier = DetailNotifier(
year : année,
api : context.read<DataUsaApiClient>(),
) ;
notifier.refresh() ;
return notifier ;
},
child : child : ProxyProvider<DetailNotifier, DetailState>(
update : (context, value, previous) => value.value,
child : const DetailLayout(),
),
) ;
}
}
class DetailLayout extends StatelessWidget {
const DetailLayout({
Clé : clé,
}) : super(key : clé) ;
@override
Widget build(BuildContext context) {
return Consumer<DetailState>(
builder : (context, state, child) {
return Scaffold(
appBar : AppBar(
titre : Texte('Année ${state.year}'),
),
body : () {
// ...
}(),
) ;
},
) ;
}
Extraction des widgets en tant que classes dédiées
N'hésitez jamais à extraire un arbre de widgets dans une classe dédiée ! Elle d'améliorer les performances et de rendre le code plus facile à maintenir.
Dans notre exemple, nous avons créé un widget de disposition visuelle pour chacun des types d'état associés :
if (state is NotLoadedDetailState || state is LoadingDetailState) {
return const LoadingDetailLayout() ;
}
if (state is LoadedDetailState) {
return LoadedDetailLayout(state : state) ;
}
if (state is UnknownErrorDetailState) {
return UnknownErrorDetailLayout(state : state) ;
}
return const NoDataDetailLayout() ;
Instances de démonstration
Nous avons maintenant un contrôle total sur ce que nous pouvons simuler et afficher sur notre écran !
Il nous suffit d'envelopper un DetailLayout
avec un Provider<DetailState>
pour simuler l'état de la disposition.
ListTile(
titre : const Texte('Ouvrir la démo "chargée"'),
onTap : () {
Navigator.push(
context,
MaterialPageRoute(
builder : (context) {
return Provider<DetailState>.value(
valeur : const DetailState.loaded(
année : 2022,
measure : Measure(
année : 2022,
population : 425484,
nation : 'United States',
),
),
child : const DetailLayout(),
) ;
},
),
) ;
},
),
Tuile-Liste(
titre : const Texte('Ouvrir la démo "chargement"'),
onTap : () {
Navigator.push(
context,
MaterialPageRoute(
builder : (context) {
return Provider<DetailState>.value(
valeur : const DetailState.loading(2022),
child : const DetailLayout(),
) ;
},
),
) ;
},
),
Conclusion
Créer une architecture logicielle maintenable n'est définitivement pas facile ! Anticiper les scénarios futurs peut demander beaucoup d'efforts, mais j'espère que les quelques conseils que j'ai partagés vous aideront à l'avenir !
Les exemples peuvent sembler simples - on pourrait même croire que nous faisons de l'ingénierie à outrance - mais au fur et à mesure que la complexité de votre application augmentera, l'existence de ces normes vous sera d'une grande aide ! 💪
Amusez-vous bien avec Flutter, et suivez le blog pour obtenir plus d'articles techniques comme celui-ci ! Restez à l'écoute !