Un package est en cours de création pour mettre en place une stratégie de cache rapidement et facilement : https://github.com/romgrm/flutter_cache_strategy. La stratégie de cache en flutter est un aspect primordial dans le développement d’une application mobile.
Dans cet article, je vais vous expliquer comment, chez Beapp, on l’implémente.
La stratégie de cache en Flutter
Qu’est-ce que c’est ?
Si vous lisez cet article, je pense que vous savez ce qu’est le cache, mais au cas-où…
Le cache correspond au stockage de données dans la mémoire de votre appareil, quel qu’il soit. L’idée derrière est de manipuler cette donnée.
Pourquoi utiliser du cache ?
- Si votre utilisateur a une mauvaise connexion ou n’est pas connecté à internet.
- Pour limiter les appels réseaux, notamment pour de la donnée qui ne nécessite pas d’être mise à jour régulièrement.
- Pour stocker de la donnée sensible (on reparlera de ce sujet plus tard).
Mais comme une image vaut mille mots :
Comme vous pouvez le voir, le principal objectif d’une stratégie de cache est d’essayer de toujours afficher quelque chose à l’écran.
Concernant les données sensibles évoquées plus haut, je dissocie bien le cache utilisateur du cache réseau pour ces raisons :
- Le cache réseau correspond plutôt aux data dites “métier” de l’application et est donc plus éphémère par nature.
- À l’inverse, le cache utilisateur, qui correspond généralement aux Access Token, Resfresh Token, password etc… doit être plus sécurisé, encrypté et non accessible par l’utilisateur (utilisation de Keychain pour iOS et EncryptedSharedPreferences pour Android, par exemple).
- Concrètement, un refresh token a une plus longue validité qu’un affichage d’un message dans une messagerie, ce qui causerait un appel réseau sur le refresh token inutile.
C’est donc une bonne pratique de séparer ces caches, même si concrètement, ils peuvent être combinés.
Maintenant que nous avons une vision primaire de ce qu’est le cache, rentrons dans le vif du sujet !
Comment mettre en place cette stratégie ?
Au début du projet, l’arborescence de dossiers ressemble à ça :
Dans le sous dossier storage
on crée un fichier storage.dart
qui contient une classe abstraite Storage
.
Cette classe est un contrat dans lequel on déclare des méthodes pour manipuler la data.
Comme je le disais, on va manipuler de la donnée à travers l’application, mais pour ça, il faut au préalable la stocker dans l’appareil.
On va donc utiliser le package Hive, qui est une solution de stockage sur le modèle clef:valeur
.
Pour expliquer brièvement, Hive va créer un dossier dans le stockage interne de l’appareil dans lequel une hiveBox contiendra la donnée.
On peut par la suite facilement ouvrir cette box et accéder à la donnée souhaitée pour la manipuler.
Pour implémenter ces méthodes, on crée un fichier cache_storage.dart
, toujours dans le sous-dossier Storage
.
Le principe est simple :
- On crée une instance
Hive
à l’instanciation deCacheStorage
. - À chaque fois que nous manipulons de la donnée, on ouvre la boxe grâce à
hiveBoxName
et la méthode appelée est exécutée (get, write, delete…). - On accède à la donnée voulue grâce à sa
key
.
Maintenant que l’on peut manipuler la donnée avec ces méthodes, on va mettre en place différentes stratégies qui correspondront aux différents cas d’utilisation de notre application.
On commence par créer un fichier cache_strategy.dart
à la racine de notre dossier cache
. Ce contrat nous permet d’uniformiser l’application des différentes stratégies existantes.
- defaultTTLValue correspond au temps de validité de notre cache. Une fois cette période de validité dépassée, la donnée stockée est considérée comme invalide.
- _storeCacheData() permet de stocker la donnée grâce au
CacheWrapper
. - _isValid() vérifie si le cache récupéré est toujours valide grâce au
defaultTTLValue
. - invokeAsync() va déclencher un appel réseau pour récupérer la donnée en utilisant la méthode fournie par
asyncBloc
. Il appellera ensuite_storeCacheData()
pour stocker cette dernière, puis la renvoyer. - fetchCacheData() permet de récupérer la donnée stockée en cache via sa
key
. Une fois le json reçu et décodé par leCacheWrapper
, si la donnée est toujours valide, on retourne un objet Dart typé et utilisable grâce auserializerBloc
. - applyStrategy() est la méthode qui sera implémentée par nos différentes stratégies pour qu’elles puissent s’exécuter.
Avec ces explications, on peut voir le déroulement de chaque stratégie implémentée :
➜ On appelle applyStrategy()
pour indiquer quelle stratégie est utilisée avec les paramètres requis.
➜ Pour récupérer la donnée en cache, fetchCacheData()
est utilisée, qui elle-même utilise _isValid()
pour vérifier si la donnée est toujours valide, auquel cas la renverra.
➜ Pour récupérer de la donnée via un appel réseau, on utilise invokeAsync()
qui, une fois la donnée récupérée, la stockera en cache via _storeCacheData()
puis la renverra également.
Concernant le CacheWrapper
évoqué plus haut, on crée un fichier cache_wrapper.dart
à la racine de cache
.
CacheWrapper
est une classe qui, comme son nom l’indique, permet de contenir la donnée reçue. Son constructeur prend deux arguments, un type générique data
qui contiendra la donnée de n’importe quel type et cachedDate
qui contiendra l’heure exacte à laquelle la donnée a été stockée.
Les méthodes fromJson()
et toJson()
permettent de convertir la donnée, soit en JSON pour la mise en cache, soit en Map
pour l’utiliser dans le code.
CacheWrapper
peut donc être interprété comme un “wrapper” qui contient la donnée stockée et qui permet d’encoder/décoder cette dernière.
À cette étape de l’article, notre arborescence de fichiers ressemble à ça :
Maintenant que nous avons vu la définition de ce que peuvent faire nos différentes stratégies, passons à leur implémentation.
Dans un nouveau dossier strategy
à la racine de cache
, on va créer autant de fichier qu’il y a de stratégies.
Chaque stratégie est un singleton, ce qui signifie qu’une seule instance de ces dernières sera injectée dans l’application.
On aurait pu utiliser le package get_it pour injecter nos stratégies, mais cela accroît la dépendance à une librairie tierce et ses désavantages que l’on connait, donc il est préférable de les créer soi-même.
Chaque stratégie va implémenter la méthode applyStrategy()
de la classe abstraite CacheStrategy
.
AsyncOrCache
Cette stratégie va déclencher un appel réseau dans le but de récupérer de la donnée. Si de la donnée est récupérée, elle sera stockée et renvoyée repository. Dans le cas o une erreur est retournée suite à notre appel réseau (erreur 401, 403, 404, 500…), la stratégie va récupérer de la data stockée précédemment en cache puis la retourner. S’il n’y a pas de donnée en cache ou qu’elle est invalide, on retourne l’erreur précédemment levée ainsi qu’une valeur null
qui sera gérée dans le gestionnaire d’état.
CacheOrAsync
Cette stratégie est similaire à celle du dessus, mais dans le sens inverse. On va d’abord checker si de la donnée est stockée en cache et, si le retour de fetchCacheData()
est null
, on déclenchera un appel réseau. La suite est identique au comportement de la stratégie AsyncOrCache
.
JustAsync
Cette stratégie déclenche seulement un appel réseau, avec le même comportement qu’AsyncOrCache
.
JustCache
Cette dernière utilise donc uniquement la donnée stockée en cache.
Pour les deux dernières stratégies, un appel réseau/cache aurait suffi pour avoir le résultat souhaité, mais l’uniformisation est une bonne pratique dans le monde du développement.
À présent, on doit créer un point d’entrée pour que nos stratégies soient utilisées.
À la racine de cache
, on crée un fichier cache_manager.dart
.
Ce fichier contient toute la logique et les entrants pour implémenter la stratégie adéquate. Il sera directement utilisé dans notre code métier.
➜ Ce fichier est séparé en deux classes : CacheManager
et StrategyBuilder
.
➜ CacheManager
contient le point d’entrée avec la méthode from()
. StrategyBuilder
contient les autres méthodes qui permettent l’implémentation de la stratégie.
- defaultSessionName permet de donner un nom à notre session cache et de ce fait la manipuler facilement. Par exemple, on peut envisager que dans une application, chaque utilisateur ait une session de cache associée à son compte sur le même appareil. On pourra donc aisément retrouver les données stockées en utilisant
firstName + lastName + id
de l’utilisateur commedefaultSessionName
. - from() créer une instance de
StrategyBuilder
qui prend un type générique (ce qui permet donc de typer la data comme on le souhaite). Unekey
est passée en paramètre afin de créer le nom de notre futurehiveBox
. Une instance deCacheStorage
est également passée afin que leStrategyBuilder
puisse s’en servir. withSession() permet donc de nommer la session de cache actuelle. - clear() permet de vider la session de cache, soit entièrement, soit précisément via le
prefix
qui correspond à lakey
de la donnée stockée.
Une fois que la méthode from()
est appelée, le StrategyBuilder
déclenche les méthodes suivantes :
- withAsync() permet de récupérer l’appel réseau souhaité par l’utilisateur, qui sera utilisé dans la méthode
invokeAsync()
. - withSerializer() correspond au sérialiseur/désérialiseur de notre donnée, qui sera responsable de la transformation du JSON reçu par le cache en objet typé utilisable dans le code et vice-versa.
La sérialisation / désérialisation par défaut dans Dart n’étant pas optimisée pour les objets complexes, Flutter conseille vivement d’utiliser le package json_serializable. Il va automatiquement générer les méthodes toJson()
et fromJson()
pour chacun de nos DTOs, qui seront appelées lorsque le serializerBloc
sera utilisé dans la stratégie de cache.
- withTtl() permet de définir le
defaultTTLValue
pour la durée de vie du cache. - withStrategy() permet de choisir la stratégie souhaitée. L’utilisation d’un Singleton laisse à l’utilisateur la possibilité de créer d’autres types de stratégie à sa guise (plus customisable qu’une
enum
par exemple). - execute() est la dernière méthode appelée qui permet de déclencher
applyStrategy()
et par la suite de retourner la donnée récupérée.
Comment l’utiliser dans notre code ?
Maintenant que nous avons vu comment s’implémente la stratégie de cache, passons du côté métier de notre application pour comprendre comment s’en servir.
Tout d’abord, nous devons injecter le CacheManager
qui est le point d’entrée de notre stratégie.
Je vous conseille de créer un fichier service_locator.dart
à la racine de core
, qui gère l’injection de dépendances dans notre app.
On a donc un singleton de CacheManager
avec l’instance pour le stockage CacheStorage
.
La méthode setupGetIt()
est généralement appelée à l’initialisation de l’application, dans le main.dart
.
En essayant d’appliquer au mieux le principe de Separation of Concerns et le pattern Clean Architecture, notre arborescence ressemble à ça :
Ce qui nous intéresse ici, c’est le repository
, qui fait office de “pont” en recevant un dto
qui provient du datasource
, pour le transformer en entity
, provenant du domain
.
C’est donc à ce moment-là où la donnée reçue en brute sera transformée en objet adapté à notre application, et donc logiquement ici ou la mise en cache s’applique.
Prenons un exemple d’une application qui affiche un travail à faire pour un étudiant.
On va d’abord récupérer les instances de HomeworkDataSource
et CacheManager
afin de pouvoir récupérer respectivement l’appel réseau ainsi que la stratégie de cache.
getHomeworkAssignment
permet de récupérer une liste de HomeworkDto
et de la convertir en liste de HomeworkEntity
.
Dans cette conversion, on implémente notre mise en cache comme telle :
- from() permet de préciser quel est le type du
dto
attendu ainsi que lakey
de la session de cache. - withSerializer() va injecter la méthode de désérialisation pour la donnée.
- withAsync() injecte l’appel réseau nécessaire pour récupérer la donnée.
- withStrategy() permet de choisir quelle stratégie l’on souhaite mettre en place.
- execute() permet de déclencher la stratégie de cache et attend en retour une donnée renvoyée par cette dernière.
Avec cette implémentation, notre stratégie va d’abord appeler fetchHomeworkAssignment()
pour récupérer de la donnée depuis le serveur. Si une erreur est renvoyée, alors un appel sur le cache est déclenché pour récupérer de la donnée mise en cache sur l’appareil. À la fin, soit de la donnée (récente ou non), soit null
sera retourné. Il ne reste plus qu’à appeler le repository
, par exemple avec un gestionnaire d’état, pour pouvoir ensuite gérer l’affichage dans notre UI.
J’espère avoir été assez clair dans ces explications, si l’implémentation de cette stratégie vous paraît trop longue et que vous voulez un résultat rapidement, un package est bientôt disponible !
Merci pour la lecture, tout commentaire/retour est le bienvenu ! 🙏
Romain / Développeur mobile chez Beapp
—