Blog technique sur mes expériences de développeur.
20 septembre 2022
L’introduction de la bibliothèque Jetpack Compose sur Android achangé nos habitudes en programmation et les bibliothèques historiques que l’on utilise ne sont pas toujours compatibles avec ce nouveau paradigme de développement qu’est la declarative UI.
C’est notamment le cas des bibliothèques de type Image Loader dont l’utilisation via des fichiers layout XML marchait sans problème mais qui tardent à se mettre à jour pour une utilisation optimale avec Jetpack Compose. C’est notamment le cas de Glide, Picasso ou encore Fresco qui, au moment d’écrire cet article, ne se prêtent pas à une utilisation avec Jetpack Compose.
De plus en plus populaire, la bibliothèque Coil est aujourd’hui l’unique bibliothèque de type Image Loader supportant officiellement une utilisation et une intégration avec Jetpack Composé. En effet, si Glide et Picasso ont un temps profité d’un support via des développements de Google au sein de ma bibliothèque Accompanist, ces supports ont aujourd’hui été retirés au profil de Coil.
Dans cet article, nous n’allons pas voir comment intégrer et utiliser Coil de manière classique, mais bien comment utiliser, configurer et surcharger la bibliothèque pour une utilisation d’images stockées dans Firebase Storage dans une interface graphique construite à l’aise de Jetpack Compose.
La première étape consiste bien évidemment à ajouter Coil en tant que dépendance du projet afin de pouvoir l’utiliser. Pour ce faire, rendez-vous dans la section dependencies
du fichier build.gradle
du module dans lequel vous souhaitez utiliser la bibliothèque, afin d’y adjoindre la ligne suivante :
implementation("io.coil-kt:coil-compose:2.2.1")
Fetcher
La difficulté de l’utilisation de Coil dans le cas précis de cet article est son utilisation pour afficher des images stockées dans Firebase Storage. En effet, via le SDK de Firebase Storage, il n’est pas si trivial de récupérer l’URL d’un document. En effet, il convient de manipuler un objet StorageReference
sur lequel il convient de récupérer l’URL. Le principal problème se situe ici. Pour charger une image, Coil a besoin d’une URL hors l’objet StorageReference
n’expose pas d’attribut permettant de récupérer une telle URL. La seule façon d’obtenir le fameux graal est de passer par une méthode qui renvoie le résultat de manière asynchrone.
Dans le cas d’un nombre restreint d’images, on pourrait imaginer demander l’ensemble des URLs avant de construire la vue de l’application. Mais ça devient vite périlleux dès lors que l’on souhaite par exemple afficher une liste de plusieurs centaines d’images. Est-ce vraiment optimisé de pré-charger plusieurs centaines d’URLs sans avoir la certitude qu’elles seront vraiment exploitées si l’utilisateur ne parcourt pas l’ensemble de la liste ?
La solution se trouve ailleurs. Fort heureusement, la bibliothèque Coil offre la possibilité d’être entendue afin de créer son propre Fetcher
, c’est-à-dire sa propre classe permettant de surcharger le comportement de Coil.
C’est ce que nous allons faire dans la suite de cet article.
La première étape consiste donc à créer notre propre classe en indiquant les paramètres qui pourront être utiles lors du chargement de l’image. Dans notre cas, les paramètres sont au nombre de trois :
StorageReference
qui fait référence à l’image que l’on souhaite charger depuis Firebase Storage ;Option
permettant éventuellement de personnalisera requête a l’image ;DiskCache
permettant de manipuler le cache de la bibliothèque et ainsi limiter les requêtes réseaux intempestives.On obtient alors la classe suivante :
class FirestoreFetcher(private val storage: StorageReference,
private val options: Options,
private val diskCache: DiskCache?)
: Fetcher
Il convient maintenant de surcharger la méthode fetch
de la classe afin de mettre en place la logique métier qui convient à noté cas d’usage :
override suspend fun fetch(): FetchResult
{
TODO()
}
La première étape consiste à vérifier que notre image n’est pas déjà dans le cache de la bibliothèque pour éviter de consommer le forfait des utilisateurs pour rien :
override suspend fun fetch(): FetchResult
{
val snapshot = readFromDiskCache()
}
private fun readFromDiskCache(): DiskCache.Snapshot? =
diskCache?.get(storage.path)
Nous reviendrons sur la configuration de ce cache un peu plus loin dans la suite de cet article.
Si la réponse du cache est null
c’est que l’image n’a pas été trouvée et qu’elle doit donc être chargée depuis le réseau. Dans le cas contraire, nous pouvons pouvons directement renvoyer un objet de type SourceResult
:
override suspend fun fetch(): FetchResult
{
val snapshot = readFromDiskCache()
try
{
if (snapshot != null)
{
// Return the candidate from the cache if it is eligible.
return SourceResult(source = ImageSource(snapshot.data, diskCache!!.fileSystem, storage.path, snapshot), mimeType = "application/jpg", dataSource = DataSource.DISK)
}
TODO()
}
catch (exception: Exception)
{
Timber.w(exception)
throw exception
}
}
A noter que dans le cadre de cet exemple le mimeType
est en dur dans le code car attendu. Libre à vous mettre en place une logique métier spécifique pour le déduire à partir des informations que vous offres le cache.
Bien évidemment si l’image que l’on souhaite charger ne se trouve pas dans le cache, il convient de la charger depuis le réseau. Pour ce faire, nous allons utiliser les méthodes proposées par Firebase Storage et plus particulièrement la méthode stream
de la classe StorageReference
.
A noter que l’on va en profiter pour stocker l’image en cache afin d’optimiser une utilisation future :
override suspend fun fetch(): FetchResult
{
val snapshot = readFromDiskCache()
try
{
if (snapshot != null)
{
// Return the candidate from the cache if it is eligible.
return SourceResult(source = ImageSource(snapshot.data, diskCache!!.fileSystem, storage.path, snapshot), mimeType = "application/jpg", dataSource = DataSource.DISK)
}
// Slow path: fetch the image from the network.
val response = storage.stream.await()
try
{
// Write the response to the disk cache then open a new snapshot.
val byteOutputStream = ByteArrayOutputStream()
response.stream.use { input ->
byteOutputStream.use { output ->
input.copyTo(output)
}
}
val byteInputStream = ByteArrayInputStream(byteOutputStream.toByteArray())
val byteInputStream2 = ByteArrayInputStream(byteOutputStream.toByteArray())
writeToDiskCache(byteInputStream2)
return SourceResult(dataSource = DataSource.NETWORK, source = ImageSource(byteInputStream.source().buffer(), options.context), mimeType = "application/jpg")
}
catch (exception: Exception)
{
Timber.w(exception)
throw exception
}
}
catch (exception: Exception)
{
Timber.w(exception)
throw exception
}
}
A noter que l’utilisation de la méthode await
sur la méthode stream
est possible grâce à la bibliothèque org.jetbrains.kotlinx:kotlinx-coroutines-play-services
qu’il convient d’ajouter aux dépendances du projet.
Finalement, revenons sur la méthode writeToDiskCache
qu’il convient d’écrire :
private fun writeToDiskCache(source: ByteArrayInputStream)
{
// Return `null` if we're unable to write to this entry.
val editor = diskCache?.edit(storage.path)
if (editor != null)
{
try
{
diskCache?.fileSystem?.write(editor?.data) {
this.writeAll(source.source())
}
editor?.commitAndGet()
}
catch (exception: Exception)
{
Timber.w(exception)
editor?.abort()
throw exception
}
}
}
Keyer
Quand on propose une implémentation personnalisée de Coil comme c’est notre cas actuellement, il convient généralement, en plus du Fetcher
, d’écrire un Keyer
.
Cette interface permet, dans la mécanique de Coil, de définir la clef associée à une image lors de la mise en cache.
Ici, une implémentation très simple est suffisante :
class FirestoreKeyer
: Keyer<StorageReference>
{
override fun key(data: StorageReference, options: Options): String =
data.path
}
Maintenant que nos classes sont ecrites, il convient de configurer Coil afin qu’il les utilise. Ca sera également l’occasion de configurer l’utilisation du cache que l’on souhaite faire au sein de notre application.
Cette confirmation se passe dans la classe Application
du projet. Cette dernière doit alors implémenter l’interface ImageLoaderFactory
à travers l’implémentation de la méthode newImageLoader
.
Mais avant ca, il convient d’écrire une classe supplémentaire de type Factory
permettant de créer l’instance de notre Fetcher
:
class FirebaseFetcherFactory
: Fetcher.Factory<StorageReference>
{
override fun create(storage: StorageReference, options: Options, imageLoader: ImageLoader): Fetcher =
FirestoreFetcher(storage, options, imageLoader.diskCache)
}
Il est alors possible de finaliser la configuration de Coil :
class MyApplication
: Application(), ImageLoaderFactory
{
override fun newImageLoader(): ImageLoader
{
return ImageLoader.Builder(this).apply {
components {
add(FirestoreKeyer())
add(FirestoreFetcherFactory())
}
allowRgb565(true)
memoryCache {
MemoryCache.Builder(this@MyApplication).apply {
allowRgb565(true)
}.build()
}
diskCache {
DiskCache.Builder().apply {
directory(this@MyApplication.cacheDir.resolve("image_cache"))
allowRgb565(true)
}.build()
}
}.build()
}
}
N’oubliez pas de déclarer cette classe dans le fichier AndroidManifest.xml
de votre projet !
Maintenant que tout est configuré, il convient simplement d’utiliser Coil via Jetpack Compose à travers l’utilisation du Composable
AsyncImage
:
@Composable
private fun MyItem(Val photo: String)
{
Card(
shape = RoundedCornerShape(8.dp),
modifier = Modifier.fillMaxWidth(),
elevation = 8.dp,
) {
AsyncImage(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(4 / 3f),
model = ImageRequest.Builder(LocalContext.current)
.data(Firebase.storage.reference.child(photo))
.crossfade(true)
.build(),
contentDescription = "my photo",
contentScale = ContentScale.Crop,
placeholder = ColorPainter(Color.Gray)
)
}
}
L’image devrait alors d’afficher correctement a l’écran.