Séparation des préoccupations dans les applications de flottement
Engineering at ClickUp

Séparation des préoccupations dans les applications de flottement

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'écran DetailScreen 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 et operator == !

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 chargement
  • LoadedDetailState : les données ont été chargées avec succès et sont associées à une mesure
  • NoDataDetailState : les données ont été chargées, mais il n'y a pas de données disponibles
  • UnknownErrorDetailState : l'opération a échoué en raison d'une erreur 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 ou map.

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 un DetailState 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 !