Ludovic ROLAND

Blog technique sur mes expériences de développeur.

Android : Utiliser Coil et Jetpack Compose pour charger des images depuis Firebase Storage

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.

Ajouter Coil au projet

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")

Créer son propre 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 :

  • Le StorageReference qui fait référence à l’image que l’on souhaite charger depuis Firebase Storage ;
  • Une Option permettant éventuellement de personnalisera requête a l’image ;
  • Un 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)

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
  }
}

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
  }
}

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
    }
  }
}

Créer son propre 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

}

Configurer l’utilisation de nos classes personnalisées

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()
  }

}

Utiliser Coil dans Jetpack Compose

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.

Commentaires