Publié par

Il y a 1 mois -

Temps de lecture 7 minutes

Des coroutines et Flow puis LiveData pour une architecture de code Android au top

Toujours à la recherche d’une architecture de code parfaite pour vos applications Android ? Restons simple avec une Activité, un ViewModel, un repository et des services de donnée.

Dans cet article nous allons explorer une application qui doit afficher les données provenant de deux services de donnée.

Nous allons aussi limiter l’utilisation de LiveData à l’activity et au ViewModel, afin de ne pas avoir besoin de LiveData pour tester notre repository.

Nous ferons l’usage de Flow, la nouvelle librairie expérimentale Kotlin coroutines et basée sur les coroutines pour gérer de façon asynchrone et réactive nos flux de données.

Présentation de l’app

L’année dernière, nous vous avions présenté une application pour afficher des stations de Ski. La lecture de ce premier post est donc recommandé avant celui-ci.

L’application téléchargeait une liste depuis un service et sauvegardait les données reçues dans une base de données locale via Room, puis les affichait via un LiveData.

Depuis l’application a évolué, deux fonctionnalités ont été ajoutées :

  1. Un système de favoris en hors-ligne uniquement.
  2. L’affichage de la météo qui n’est jamais stocké en base de données.

La complexité consiste donc à « mélanger » les données distantes et de la base de données locale.

Pour ce faire, 3 modèles de données sont créés :

  • SkiResortUiModel : UI Model utilisé pour l’affichage (dans l’adapter, view holder, activity, …)
  • SkiResortRemoteModel : Remote Model utilisé pour les données venant du serveur (contient le mapping avec le Json)
  • SkiResortLocalModel : Local Model utilisé pour la base de données (entité Room)

Le Repository entre ces couches de données s’occupe de la logique de « mélange ».

Choix technique

Flow est utilisé dans le repository, la Dao (base de données Room). Ce Flow est converti en LiveData dans le ViewModel afin de communiquer avec l’activity.

Flow est un flux froid. Cela signifie que les éléments sont émis uniquement en présence d’un receveur. Lors d’un appel à un de ses constructeurs, il retourne une instance de calcul qui va émettre un flux d’éléments.

L’avantage de ne pas utiliser de LiveData associé au ViewModel est de pouvoir supprimer la dépendance à la notion de cycle de vie Android. En plus des critères d’isolation de responsabilité et de lisibilité, cela permet de meilleurs tests unitaires.

Les flux de données sont matérialisés suivant ce graphe :

 

Dans les paragraphes suivants les couches vont être décrites en partant des données jusqu’à l’affichage (de droite à gauche du graphe) :

  1. Room
  2. Retrofit
  3. Repo
  4. ViewModel
  5. Activity
  6. (Bonus) Tests Unitaires

1. Room

Voici les informations stockées en base de données :

Voici la classe générant l’entité Room correspondante :

@Entity(tableName = "ski_resorts")
data class SkiResortLocalModel(@PrimaryKey @field:SerializedName("ski_resort_id") val skiResortId: Int,
                               @field:SerializedName("name") val name: String = "",
                               @field:SerializedName("country") val country: String = "",
                               @field:SerializedName("mountain_range") val mountainRange: String = "",
                               @field:SerializedName("slope_km") val slopeKm: Int = 0,
                               @field:SerializedName("lifts") val lifts: Int = 0,
                               @field:SerializedName("slopes") val slopes: Int = 0,
                               @field:SerializedName("is_fav") var isFav: Boolean = false)

La DAO expose une méthode getAllSkiResorts() qui retourne un Flow de liste de SkiResortLocalModel, grâce à une requête SELECT.

//Get all the ski resorts
@Query("SELECT skiResortId, name, country, mountainRange, slopeKm, lifts, slopes, isFav FROM ski_resorts")
fun getAllSkiResorts(): Flow<List<SkiResortLocalModel>>

2. Retrofit

L’interface SkiResortListService contient une méthode suspendue getSkiResorts() qui renvoie une liste de SkiResortRemoteModel.

@GET("v0/b/ski-resort-be7dc.appspot.com/o/resort-weather.json?alt=media&token=f40092bf-2e06-4077-a84e-0906b834d487")
suspend fun getSkiResorts(): List<SkiResortRemoteModel>

3. Repository

Le repository contient la logique de « mélange » des données en fonction de leur provenance en créant un model de donnée presentable par l’application. Au centre de l’application, il va poster des mises à jour en fonction des résultats des fournisseurs de données à travers le temps.

Le repository va faire le lien entre les deux précédentes classes.

Cette méthode retourne un Flow provenant de l’interface Retrofit, puis les insère en base de données. Elle commence par émettre une liste vide, qui correspond à un état loading.

private fun getAllRemoteResorts(): Flow<List<SkiResortRemoteModel>> = flow {
    emit(emptyList())
    try {
        val networkResult = skiResortListService.getSkiResorts()
        emit(networkResult)
        skiResortDao.insertAll(prepareInsertWithFavStatus(toDbModel(networkResult)))
    } catch (throwable: Throwable) {
		//TOBEDONE manage network error
    }
}

Cette méthode appelle la précédente méthode et skiResortDao.getAllSkiResorts() pour les combiner en un seul flow. Elle est appelée par le ViewModel. La documentation sur combine.

fun getAllSkiResorts(): Flow<List<SkiResortUiModel>> =
    skiResortDao.getAllSkiResorts().combine(getAllRemoteResorts()) { local, remote ->
        toUiModel(local, remote)
    }

La méthode toUiModel crée une liste de SkiResortUiModel avec une liste de SkiResortLocalModel et une liste de SkiResortRemoteModel :

  1. Si la liste provenant du service est vide, la liste de SkiResortUiModel est grace aux informations contenu en base de données (toUiModelFromDb()).
  2. Sinon la liste de SkiResortUiModel est crée à partir du contenu reçu par le service et enrichi du status favori par la méthode getFavFromList().
fun toUiModel(skiResortLocalModelListDb: List<SkiResortLocalModel>,
              skiResortRemoteModelListService: List<SkiResortRemoteModel>):
        List<SkiResortUiModel> {
    return if (skiResortRemoteModelListService.isEmpty()) {
        toUiModelFromDb(skiResortLocalModelListDb)
    } else {
        skiResortRemoteModelListService.map {
            SkiResortUiModel(
                    it.skiResortId,
                    it.name,
                    it.country,
                    it.mountainRange,
                    it.slopeKm,
                    it.lifts,
                    it.slopes,
                    getFavFromList(skiResortLocalModelListDb, it.skiResortId),
                    weather = getDrawableForString(it.weather)
            )
        }
    }
}

4. ViewModel

Le ViewModel transforme simplement le flow provenant du repository en LiveData afin qu’il soit observé dans l’activity. Ce flow contient les données mélangées par le repository.

//list of all the ski resorts
val skiResortUiModelList: LiveData<List<SkiResortUiModel>> =
    skiResortRepo.getAllSkiResorts().asLiveData(viewModelScope.coroutineContext)

La méthode asLiveData fait partie du package androidx.lifecycle.

Il peut prendre en paramètre un context de coroutine, dans l’exemple viewModelScope.coroutineContext pour être dans le scope du ViewModel.

5. Activity

Dans l’activity les changements communiqués à l’adapteur, ce dernier affiche la liste :

viewModelSkiResortList.skiResortUiModelList.observe(this, Observer<List<SkiResortUiModel>> {
    adapter.submitList(it)
})

6. Tests unitaires

Le repository concentre la logique de merge des deux sources de donnée, nous concentrons nos effort dessus.

Le test suivant vérifie que dans l’ordre :

  1. Les données hors-ligne sont émises
  2. Les données hors-ligne et en ligne sont émises

Quand la méthode getAllSkiResorts() est appelée.

@Test
fun `should respond offline data then mixed data when service and db are valid`() = runBlockingTest {
    // Given
    every {
        skiResortDao.getAllSkiResorts()
    } returns flowOf(createExpectedDbData())

    coEvery {
        skiResortListService.getSkiResorts()
    } returns createExpectedRemoteData()

    coEvery {
        skiResortDao.insertAll(createExpectedDbData())
    } returns Unit

    // When
    val result = SkiResortRepo(skiResortListService, skiResortDao).getAllSkiResorts().take(2).toList()

    // Then
    assertEquals(createExpectedResultFirst(), result[0])
    assertEquals(createExpectedResultSecond(), result[1])
}

take(2).toList() permet de limiter aux 2 premiers résultats du Flow sous forme de liste.

createExpectedResultFirst() renvoie une liste sans la météo (résultats hors-ligne).

createExpectedResultSecond() renvoie des objet avec des favoris et la météo (résultats hors ligne et en ligne combiné).

Le fichier de test complet.

Conclusion

Voilà, nous avons parcouru le code de l’application de ses couches de donnée jusqu’à l’affichage.

La modification du status des favoris est écoutée par le même flow quand une modification est demandée et la base de données modifiée. Pour explorer vous même le code est disponible sur Github.

Pour aller plus loin :

Publié par

Publié par Alexandre Genet

Alexandre est développeur Android chez Xebia.

Commentaire

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *

Nous recrutons

Être un Sapient, c'est faire partie d'un groupe de passionnés ; C'est l'opportunité de travailler et de partager avec des pairs parmi les plus talentueux.