Le découpage de nos Widget, c'est important ?

le 30/05/2024 par Rémi Dormoy
Tags: Software Engineering, Mobile

Depuis bientôt 10 ans que je fais du développement mobile, j’ai toujours beaucoup entendu parler (et j’en ai pas mal parlé moi même), du choix de l’architecture logicielle à mettre en place. Cette architecture logicielle a pour rôle de structurer notre code pour rendre les couches de code “métier” et “infrastructure” séparées, maintenables et testables. La couche de “vue” étant quant à elle simplifiée au maximum.

D’abord sur mes débuts en Android avec du MVP, puis l'apparition de la clean architecture, en passant par le MVVM et les Architecture Components que Google nous a créés. Puis en passant à Flutter il y a quelques années, ces discussions ont continué, elles étaient même essentielles pour pouvoir se lancer sérieusement dans une technologie nouvelle chez OCTO. Impossible de déplacer une architecture Android sur une application Flutter, il fallait inventer (ou plutôt trouver quelque part dans la communauté) une architecture répondant à nos besoins.

Et souvent, ce choix s’accompagne d’une autre décision, celle de la librairie qui nous permettra d’implémenter cette architecture. En Flutter, on appelle souvent ces librairies des solutions de state-management. Ces décisions auront un impact, c’est évident, et je vous laisse consulter cet article pour choisir de la manière la plus éclairée possible. Mais au fur et à mesure du perfectionnement de cette architecture des couches métier, une nouvelle question est apparue peu à peu : comment découper efficacement notre partie visuelle ?

Pourquoi ce découpage est-il devenu central ?

Avant de jeter un œil aux différentes manières de découper nos Widgets, et aux impacts que cela peut avoir sur notre projet, essayons de comprendre pourquoi cette question qui n’était que peu présente il y a quelques années sur mobile a gagné progressivement en importance.

La généralisation des Design Systems

Ces dernières années, que ce soit dans le mobile ou dans le web, une grande partie des fronts se sont munis d’un design system. Il existe toujours une multitude de maturité en fonction des entreprises et de leurs différents produits mais de manière générale, on peut observer à minima une plus grande cohérence dans les fronts au niveau des couleurs, des composants, des tailles, des styles de police, etc …
S' il y a toujours eu de la réutilisation dans les vues côté mobile, cela s’est beaucoup accéléré ces dernières années grâce aux design system. Nous avons vu émerger un “dictionnaire commun” entre les designeur•euse•s et le developpeur•euse•s, pour nommer les différents composants. Et l’ajout d’un nouveau mot dans ce dictionnaire fait l’objet d’un débat. Nous ne développons un nouveau composant que si notre Design System n’en comporte pas qui remplisse le même rôle.
À force d’implémenter des écrans similaires les uns aux autres, il est normal d’investir du temps et de l’énergie pour les rendre le plus réutilisable possible.

L'émergence des Back-For-Front

De la même manière que pour les design system, de plus en plus d’applications mobiles utilisent un back-for-front (BFF). C'est-à-dire un back-end proposant des web-services sur mesure pour notre application. Dans le passé, une application appelait la plupart du temps des web-services REST qui pouvaient être répartis dans plusieurs backends différents et il était donc fréquent d'exécuter plusieurs de ces appels réseau au chargement d’un écran.

Aujourd’hui, ce sont les BFF qui vont faire cette agrégation et permettre donc à votre application de ne faire qu’un seul appel au chargement de la page et d’avoir un contenu sur mesure qui lui est retourné. Certains BFF vont même jusqu’à formater les différentes informations à afficher.

Ce qui entraîne tout naturellement la réduction du temps de développement nécessaire au développement des fonctionnalités du côté de l’application mobile, surtout dans les couches métier. Et nous permet donc de consacrer plus de temps à la partie graphique de notre code.

L’apparition de la UI déclarative

Pour ceux qui ne sont pas familier avec le terme, rappelons rapidement que la “UI déclarative”, c’est le fait d’écrire la partie graphique de nos applications sous forme de code et non plus dans des fichiers séparés et statiques.
En Flutter, le fait de faire de la UI déclarative signifie donc que notre code, qu’il traite d’affichage ou de logique métier, sera toujours écrit en Dart. Notons que ce mouvement vers la UI déclarative n’est pas propre à Flutter. On a vu l’apparition et surtout la démocratisation de Swift UI sur iOS et de Jetpack Compose sur Android, qui sont devenus peu à peu les standards sur leurs plateformes.

Une des implications derrière ce changement est qu’il est maintenant beaucoup plus facile de découper entièrement nos composants pour les rendre indépendants les uns des autres. Bien sûr, cela était déjà possible auparavant, et certains d’entre vous ont peut-être déjà créé des custom view sur Android. Mais la pratique était plus complexe et beaucoup moins répandue qu'aujourd'hui.
D’ailleurs, sur Flutter, extraire son code sous forme de Widget n’est pas juste pratique en termes de maintenabilité de notre code et de réutilisabilité, mais c’est aussi une bonne pratique en termes de performance.

L’accélération des développements mobiles

Nous avons déjà cité au-dessus les design system, les BFF, l’UI déclarative. Si nous ajoutons également toutes les améliorations des différents frameworks comme le hot reload, il est aisé de comprendre pourquoi nous constatons ces dernières années une accélération de la vitesse de développement sur mobile.
Et cette accélération se traduit par une chose : nous écrivons plus souvent de la vue, et nous implémentons plus d’écrans qu’avant. Il est donc normal que la question du découpage de notre partie graphique et de sa structure prenne une importance de plus en plus centrale.

Quels sont les différents niveaux de découpage à appliquer ?

Quand on parle de découper le code de notre partie graphique, on peut parler de beaucoup de choses différentes. On peut penser à de petites classes privées pour ne pas avoir des classes de 600 lignes, très complexes à lire. On peut imaginer de petits widgets réutilisables un peu partout dans notre application. On peut enfin y voir des séparations entre les différents états de nos écrans ou entre les différentes sous-parties de ces écrans. Ici nous allons parler de … tout ça !
Chaque niveau, ou chaque raison, de découper sa vue est important, et c’est la combinaison de tous ces niveaux qui fait que l’on aura un partie graphique performante, lisible et réutilisable.

Dans la suite de cet article nous prendrons l’exemple très fictif d’une application de streaming musical dont nous chercherons à structurer la partie graphique de l’écran d’accueil, dont nous voyons ici une capture d’écran.

Écran de l'application de streaming musical que l'on va étudier dans la suite de l'article

1 - Découper sa vue en suivant les fonctionnalités

Lorsque l’on regarde cet écran, on voit vite apparaître différentes parties, représentant des fonctionnalités que l’on peut facilement séparer en plusieurs Widgets :
Découpage en widget selon les fonctionnalités affichées à l'écran

Notre premier niveau de découpage apparaît rapidement. Personnellement je vous recommande de nommer vos composants avant même de les implémenter, cela évite d’implémenter votre écran et de devoir le redécouper ensuite, ce qui risque de vous faire sauter certains niveaux.
Dans tous les cas, nous avons ici notre écran qui est maintenant découpé en 6 Widgets. Nous pouvons maintenant répéter l’opération “à l’intérieur” de chacun de ces Widgets ce qui nous donnera ces 5 Widgets supplémentaires (en considérant que nous ne découperons pas plus “CurrentMusic” et “SectionTitle”)

Les nouveaux sous-widgets que l'on a créé

Ce premier niveau de découpage nous amène plusieurs avantages :

  • Nous nous forçons à découpler chacun de ces composants
  • Chacun de ces sous composants comporte une complexité plus faible que l’écran au total, et on a donc un chemin d’implémentation “pas à pas”
  • Dans le cas où vous suivez (ou construisez un design system) c’est l’occasion d’aligner vos noms avec ceux de vos designers (avec optionnellement l’ajout d’un suffixe si cela risque de faire des doublons avec vos noms d’objets et/ou variables métier. Ex: CurrentMusicWidget au lieu de juste CurrentMusic)

Vu comme ça, rien de bien compliqué à priori. Mais si on oublie de se poser la question dès le début de notre implémentation, et que nous pensons et faisons ce découpage seulement une fois que notre écran est devenu compliqué, il sera beaucoup plus long et douloureux de rendre les choses faciles. Et puis ce n’est pas parce que quelque chose est simple que ce n’est pas important !

2 - Définir le niveau d’abstraction de nos composants

Maintenant que le découpage de nos Widgets est fait, nous devons nous demander quand et comment nous allons les réutiliser, de manière à pouvoir adapter leur interface. Pour certains, la réponse sera peut être évidente.
Prenons par exemple le composant MyTab. Nous pouvons penser qu’il sera réutilisé un peu partout ou nous aurons des onglets, ce qui nous donnera probablement l’interface suivante :


class MyTab extends StatelessWidget {

  final bool selected;

  final String label;

  final void Function() onTap;



  MyTab({

    required this.selected,

    required this.label,

    required this.onTap,

  });



  @override

  Widget build(BuildContext context) {...}

}

Pour le Widget que l’on a appelé AlbumItem auparavant, nous pouvons par contre nous demander s' il ne sera pas utilisé sous une autre forme. Imaginons par exemple que nous ayons une liste horizontale similaire mais qui ne contiendrait non pas des albums mais n’importe quel type de liste de musiques, albums ou playlist.

Éléments ayant un design similaire à AlbumItem

On voit ici un léger changement de design (il n’y a plus de sous titre) mais on pourrait très bien choisir de réutiliser le même composant que pour les albums, notamment s'il est défini de cette manière dans notre design system. Dans sa version générique, on pourrait l’appeler HorizontalSliderItem. Les deux interfaces donneraient :


class HorizontalSliderItem extends StatelessWidget {

  final String image;

  final String title;

  final String? subtitle;

  final void Function() onTap;



  HorizontalSliderItem({

    required this.image,

    required this.title,

    required this.subtitle,

    required this.onTap,

  });



  @override

  Widget build(BuildContext context) { ... }

}


Et dans le cas ou le composant est réservé simplement aux albums :


class AlbumSectionItem extends StatelessWidget {

  final Album album;



  AlbumSectionItem(this.album);



  @override

  Widget build(BuildContext context) { ... }

}

Chacun des deux choix aura ses avantages et ses inconvénients. Dans le cas d’un Widget générique (HorizontalSliderItem) on y gagnera :

  • Une interface avec des types simples
  • La possibilité de l’utiliser dans n’importe quel contexte
  • Une plus grande variété de configurations, avec des paramètres optionnels

Dans le cas de notre AlbumItem les avantages seront :

  • Une interface sur mesure, avec des types qui ont plus de sens
  • Certaines fonctionnalités peuvent être encapsulées dans le Widget (comme le comportement au click)
  • Un nom qui est plus parlant dans le contexte de notre écran

3 - Quel degré d’indépendance pour nos Widgets ?

Laissons de côté pour le moment les Widgets les plus abstraits pour nous concentrer sur des ensembles plus grands et qui représentent une fonctionnalité offerte à l’utilisateur. Des ensembles qui peuvent avoir un sens, même si on enlève les autres éléments de l’écran. Nous aurons par exemple les Widgets vus au-dessus CurrentMusic, AlbumSection et PlaylistSection. Pour pouvoir afficher correctement ces Widgets, nous aurons besoin à un moment ou un autre de faire appel à notre couche “métier” et de récupérer une liste d’albums, une liste de playlists, ainsi que la musique en cours de lecture.
Suivant le fonctionnement de cette couche métier, différentes situations sont possibles mais choisissons d’en retenir deux :

  • Tous les éléments de la page sont retournés par le même appel réseau (sur un BFF par exemple)
  • Chaque partie vient d’une source de données différente : la musique en cours vient de données locales, la liste des playlistes d’un premier appel et la liste des albums d’un autre appel réseau.

Dans le premier cas, il sera tentant de partager toutes les données à l’intérieur d’un seul state, et d’injecter ce state à la racine de notre écran. On aurait donc une structure de cette forme :


class HomePageState {

  final List<Playlist> playlists;

  final List<Album> albums;

  final CurrentMusic? currentMusic;



  HomePageState({

    required this.playlists,

    required this.albums,

    this.currentMusic,

  });

}



class MyHomePage extends StatelessWidget {

  @override

  Widget build(BuildContext context) {

    return MonInjectorDeState(

      child: MonBuilderUtilisantLeState(builder: (BuildContext context, HomePageState state) {

        return HomePageLayout(

          horizontalColumn: [

            PlaylistSection(state.playlists),

            AlbumSection(state.albums),

          ],

          floatingBottomBar: CurrentMusicItem(state),

        );

      }),

    );

  }

}

Dans le second cas, il est bien sûr possible de ne garder qu’un seul state et d’assembler les deux appels réseau et la donnée locale dans notre partie métier. Mais il est aussi possible de découper notre HomePage en trois state différents et de les injecter de manière séparée dans notre écran :


class MyHomePage extends StatelessWidget {

  @override

  Widget build(BuildContext context) {

    return HomePageLayout(

      horizontalColumn: [

        PlaylistSection(),

        AlbumSection(),

      ],

      floatingBottomBar: CurrentMusicItem(),

    );

  }

}



class PlaylistState {

  final List<Playlist> playlists;



  PlaylistState(this.playlists);

}



class PlaylistSection extends StatelessWidget {

  @override

  Widget build(BuildContext context) {

    return MonInjecteurDeState(

      child: MonBuilderUtilisantLeState(

        builder: (BuildContext context, PlaylistState state) {

          return GridView.builder(...);

        }

      ),

    );

  }

}

Si tout va bien, ces deux implémentations auront le même rendu sur l’écran de votre utilisateur et des performances similaires. Mais chacune des deux solutions a des implications différentes dans votre code, sa maintenabilité et sa réutilisabilité :

  • Dans le cas d’un widget indépendant avec son propre state, il sera réutilisable dans n’importe quel écran sans presque aucun coût de développement. Vous voulez ajouter un widget affichant la musique en cours sur un autre écran ? Facile : vous ajoutez le Widget CurrentMusicItem et il s’injecte son state, fait ses appels réseau ou local, applique ses règles de gestion tout seul comme un grand !
  • Dans le cas d’un state commun, il est généralement plus facile de partager des informations d’un widget à l’autre. Vous souhaitez afficher un logo à côté de la playlist qui contient la musique en cours de lecture ? L’information est déjà présente dans votre state. Vous souhaitez mettre à jour le logo en cas d’un appui sur le bouton pause ? Vos Widget ont le même ancêtre commun et vous pouvez facilement passer de l’un à l’autre avec une callback.

De la même manière qu’il n’y a pas que ces deux possibilités au niveau des sources de données, plein d’autres découpages de nos states et de nos Widgets sont possibles. Sur des cas plus complexes que vous pouvez rencontrer sur vos applications il est possible (probable ?) que vous hésitiez, et que vous ne puissiez pas départager deux découpages.

Il n’y a pas forcément de solution magique, mais tant que vous prenez le temps de chercher le meilleur découpage et que vous le faites dès le début de votre implémentation, il y a de très fortes chances que vous trouviez une bonne solution.

Conclusion

Avec les framework de développement mobiles modernes, de nombreux choix s’offrent à nous pour structurer notre partie graphique. De la même manière que pour le code métier, ces choix amèneront des conséquences bénéfiques ou non sur la maintenabilité de notre code et sur la vitesse de développement de notre équipe.

Il est donc important de se poser les bonnes questions sur quel composant est dépendant de quel composant, de savoir à quelle fréquence nous allons les réutiliser et de les découper et les désigner en conséquence.

Si votre application s’est déchargée de la majeure partie de sa logique métier dans un BFF, si votre équipe de design a créé un design system qui limite le nombre de “vrais” nouveaux composants, cela ne veut pas dire qu’il n’y a plus besoin de réfléchir et que la seule activité qu’il nous restera en tant que développeur•euse mobile est de multiplier les copier-coller. Nous allons simplement avoir plus de temps à notre partie graphique pour créer de nouveaux écrans plus rapidement et de meilleure qualité.
Ce qui tombe plutôt bien, car si la partie métier de nos applications s’écrit plus rapidement, de nombreuses fonctionnalités sont apparues de l’autre côté : les thèmes sombres, les écrans pliables, et courant 2025, la nécessité d’un score d’accessibilité de 100 % ! Ce temps gagné nous sera bien utile pour y répondre.