Android Room et ViewModel – Kotlin

La collection de composants d'architecture Android fournit des conseils sur l'architecture des applications, ainsi que des bibliothèques pour les tâches courantes telles que la gestion du cycle de vie et la persistance des données. Les composants d'architecture peuvent vous aider à structurer votre application d'une manière fiable, testable et gérable avec moins de code récurrent.

Les bibliothèques de composants d'architecture font partie d'Android Jetpack.

Il s'agit ici de la version en Kotlin de l'atelier de programmation. La version en langage de programmation Java est disponible ici.

Si vous rencontrez des problèmes tels que des bugs de code, des erreurs grammaticales ou du contenu prêtant à confusion au cours de cet atelier de programmation, veuillez les signaler via le lien Signaler une erreur situé dans l'angle inférieur gauche de l'atelier.

Conditions préalables

Vous devez être familiarisé avec Kotlin, les concepts de la conception orientée objet et les principes de base du développement Android, en particulier :

  • RecyclerView et les adaptateurs ;
  • la base de données SQLite et le langage de requête SQLite ;
  • les coroutines de base (si vous ne maîtrisez pas les coroutines, vous pouvez commencer par consulter l'article Using Kotlin Coroutines in your Android App (Utiliser des coroutines Kotlin dans votre application Android)).

Il est également utile de se familiariser avec les modèles d'architecture logicielle qui séparent les données de l'interface utilisateur, tels que Model-View-Presenter (MVP) ou Model-View-Controller (MVC). Cet atelier de programmation implémente l'architecture définie dans le Guide de l'architecture des applications destiné aux développeurs Android.

Cet atelier de programmation porte sur les composants d'architecture Android. Des concepts et du code hors sujet vous sont fournis pour vous permettre de les copier-coller.

Objectifs de l'atelier

Vous apprendrez à concevoir et créer une application à l'aide des composants d'architecture Room, ViewModel et LiveData. Cette application pourra :

  • implémenter l'architecture recommandée à l'aide des composants d'architecture Android ;
  • utiliser une base de données pour obtenir et enregistrer les données, et pré-remplir la base de données avec des exemples de mots ;
  • afficher tous les mots dans un RecyclerView, dans la classe MainActivity ;
  • ouvrir une deuxième activité lorsque l'utilisateur appuiera sur le bouton "+". Lorsqu'il saisira un mot, celui-ci sera ajouté à la base de données et s'affichera dans la liste RecyclerView.

L'application est simple, mais suffisamment complexe pour que vous puissiez vous en servir de modèle. Voici un aperçu :

Ce dont vous avez besoin :

Cet atelier de programmation fournit tout le code nécessaire pour créer l'application complète.

Le diagramme ci-dessous présente les composants d'architecture et comment ils fonctionnent ensemble. Notez que cet atelier de programmation se concentre sur un sous-ensemble de composants, à savoir LiveData, ViewModel et Room. Chaque composant est décrit en détail lorsque vous l'utilisez dans votre application.

8e4b761713e3a76b.png

LiveData : classe de conteneur de données qui peut être observée. Elle conserve et met en cache la dernière version des données de façon systématique, et notifie les observateurs en cas de changement. LiveData tient compte du cycle de vie. Les composants de l'UI observent simplement les données pertinentes, et ne terminent pas, ni ne reprennent l'observation. LiveData gère automatiquement tout cela, car elle tient compte des changements pertinents concernant l'état du cycle de vie du projet pendant l'observation.

ViewModel : fait office de centre de communication entre le Repository (données) et l'UI. L'UI n'a plus besoin de s'inquiéter de l'origine des données. Les instances ViewModel survivent à la recréation d'une activité ou d'un fragment.

Repository : classe que vous créez et qui sert principalement à gérer plusieurs sources de données.

Entity : classe annotée qui décrit une table de base de données lors de l'utilisation de Room.

Base de données Room : simplifie la gestion de la base de données et sert de point d'accès à la base de données SQLite sous-jacente (masque SQLiteOpenHelper)). La base de données Room utilise le DAO pour envoyer des requêtes à la base de données SQLite.

Base de données SQLite : sur l'espace de stockage de l'appareil. La bibliothèque de persistance de Room crée et gère cette base de données à votre place.

DAO : objet d'accès aux données. Mappage entre des requêtes SQL et des fonctions. Lorsque vous utilisez un DAO, vous appelez les méthodes, et Room s'occupe du reste.

Présentation de l'architecture RoomWordSample

Le diagramme suivant schématise les interactions entre tous les éléments de l'application. Chacun des encadrés (sauf pour la base de données SQLite) représente une classe que vous allez créer.

a70aca8d4b737712.png

  1. Ouvrez Android Studio, puis cliquez sur Start a new Android Studio project (Démarrer un nouveau projet Android Studio).
  2. Dans la fenêtre "Create New Project" (Créer un projet), sélectionnez Empty Activity (Activité vide), puis cliquez sur Next (Suivant).
  3. Sur l'écran suivant, nommez l'application "RoomWordSample", puis cliquez sur Finish (Terminer).

9b6cbaec81794071.png

Vous devez ensuite ajouter les bibliothèques de composants à vos fichiers Gradle.

  1. Dans Android Studio, cliquez sur l'onglet "Projects" (Projets) et développez le dossier "Gradle Scripts" (Scripts Gradle).

Ouvrez build.gradle (Module: app).

  1. Appliquez le plug-in Kotlin de processeur d'annotations kapten l'ajoutant après la section des plug-ins définie en haut de votre fichier build.gradle (Module: app).
apply plugin: 'kotlin-kapt'
  1. Ajoutez le bloc packagingOptions dans le bloc android pour exclure le module de fonctions atomiques du package et éviter les avertissements.
  2. Certaines des API que vous utiliserez requièrent la version 1.8 de jvmTarget. Ajoutez-la également au bloc android.
android {
    // other configuration (buildTypes, defaultConfig, etc.)

    packagingOptions {
        exclude 'META-INF/atomicfu.kotlin_module'
    }

    kotlinOptions {
        jvmTarget = "1.8"
    }

}
  1. Remplacez le bloc dependencies par :
dependencies {
    implementation "androidx.appcompat:appcompat:$rootProject.appCompatVersion"
    implementation "androidx.activity:activity-ktx:$rootProject.activityVersion"

    // Dependencies for working with Architecture components
    // You'll probably have to update the version numbers in build.gradle (Project)

    // Room components
    implementation "androidx.room:room-ktx:$rootProject.roomVersion"
    kapt "androidx.room:room-compiler:$rootProject.roomVersion"
    androidTestImplementation "androidx.room:room-testing:$rootProject.roomVersion"

    // Lifecycle components
    implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$rootProject.lifecycleVersion"
    implementation "androidx.lifecycle:lifecycle-livedata-ktx:$rootProject.lifecycleVersion"
    implementation "androidx.lifecycle:lifecycle-common-java8:$rootProject.lifecycleVersion"

    // Kotlin components
    implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
    api "org.jetbrains.kotlinx:kotlinx-coroutines-core:$rootProject.coroutines"
    api "org.jetbrains.kotlinx:kotlinx-coroutines-android:$rootProject.coroutines"

    // UI
    implementation "androidx.constraintlayout:constraintlayout:$rootProject.constraintLayoutVersion"
    implementation "com.google.android.material:material:$rootProject.materialVersion"

    // Testing
    testImplementation "junit:junit:$rootProject.junitVersion"
    androidTestImplementation "androidx.arch.core:core-testing:$rootProject.coreTestingVersion"
    androidTestImplementation ("androidx.test.espresso:espresso-core:$rootProject.espressoVersion", {
        exclude group: 'com.android.support', module: 'support-annotations'
    })
    androidTestImplementation "androidx.test.ext:junit:$rootProject.androidxJunitVersion"
}

Gradle peut signaler une version manquante ou non définie à ce stade. Elle devrait être corrigée à l'étape suivante.

  1. Dans votre fichier build.gradle (Project: RoomWordsSample), ajoutez les numéros de version à la fin du fichier, comme indiqué dans le code ci-dessous.
ext {
    activityVersion = '1.1.0'
    appCompatVersion = '1.2.0'
    constraintLayoutVersion = '2.0.2'
    coreTestingVersion = '2.1.0'
    coroutines = '1.3.9'
    lifecycleVersion = '2.2.0'
    materialVersion = '1.2.1'
    roomVersion = '2.2.5'
    // testing
    junitVersion = '4.13.1'
    espressoVersion = '3.1.0'
    androidxJunitVersion = '1.1.2'
}

Les données de cette application sont des mots, et vous aurez besoin d'un tableau simple pour contenir ces valeurs :

3821ac1a6cb01278.png

Room vous permet de créer des tables via une Entity. C'est parti.

  1. Créez un fichier de classe Kotlin appelé Word contenant la classe de données Word. Cette classe décrit l'Entity (qui représente la table SQLite) pour vos mots. Chaque propriété de la classe représente une colonne de la table. Room utilisera ensuite ces propriétés pour créer la table et instancier des objets à partir des lignes de la base de données.

Voici le code :

data class Word(val word: String)

Pour rendre la classe Word pertinente pour une base de données Room, vous devez créer une association entre la classe et la base de données à l'aide d'annotations Kotlin. Vous utiliserez des annotations spécifiques pour identifier la relation entre les différentes parties de cette classe et les entrées de la base de données. Room utilise ces informations supplémentaires pour générer du code.

Si vous saisissez vous-même les annotations (au lieu de les coller), Android Studio importera automatiquement les classes d'annotation.

  1. Mettez à jour votre classe Word avec des annotations, comme indiqué dans ce code :
@Entity(tableName = "word_table")
class Word(@PrimaryKey @ColumnInfo(name = "word") val word: String)

Voyons à quoi servent ces annotations :

  • @Entity(tableName = "word_table") Chaque classe @Entity représente une table SQLite. Annotez votre déclaration de classe pour indiquer qu'il s'agit d'une entité. Vous pouvez spécifier le nom de la table si vous voulez qu'il soit différent du nom de la classe. Ici, le nom de la table est "word_table".
  • @PrimaryKey Chaque entité a besoin d'une clé primaire. Pour simplifier les choses, chaque mot agit comme une clé primaire.
  • @ColumnInfo(name = "word") Indique le nom de la colonne dans la table si vous souhaitez qu'il soit différent du nom de la variable de membre. Ici, le nom de la colonne est "word" (mot).
  • Chaque propriété stockée dans la base de données doit avoir une visibilité publique, qui est la valeur par défaut Kotlin.

Vous trouverez une liste complète d'annotations dans la documentation de référence sur le récapitulatif des packages Room.

Qu'est-ce que le DAO ?

Dans le DAO (objet d'accès aux données), spécifiez des requêtes SQL et associez-les à des appels de méthode. Le compilateur vérifie le langage SQL et génère des requêtes à partir d'annotations pratiques pour les requêtes courantes, telles que @Insert. Room utilise le DAO afin de créer une API propre pour votre code.

Le DAO doit être une interface ou une classe abstraite.

Par défaut, toutes les requêtes doivent être exécutées sur un thread distinct.

Room est compatible avec les coroutines Kotlin. Cela permet d'annoter les requêtes avec le modificateur suspend, puis d'appeler cette fonction à partir d'une coroutine ou d'une autre fonction de suspension.

Implémenter le DAO

Écrivons un DAO qui fournit les requêtes suivantes pour :

  • obtenir tous les mots par ordre alphabétique ;
  • insérer un mot ;
  • supprimer tous les mots.
  1. Créez un fichier de classe Kotlin appelé WordDao.
  2. Copiez et collez le code suivant dans WordDao, puis corrigez les importations si nécessaire pour le compiler.
@Dao
interface WordDao {

    @Query("SELECT * FROM word_table ORDER BY word ASC")
    fun getAlphabetizedWords(): List<Word>

    @Insert(onConflict = OnConflictStrategy.IGNORE)
    suspend fun insert(word: Word)

    @Query("DELETE FROM word_table")
    suspend fun deleteAll()
}

Voyons cela de plus près :

  • WordDao est une interface. Les DAO doivent être des interfaces ou des classes abstraites.
  • L'annotation @Dao l'identifie en tant que classe DAO pour Room.
  • suspend fun insert(word: Word) : déclare une fonction de suspension pour insérer un mot.
  • L'annotation @Insert est une annotation de méthode DAO spéciale dans laquelle vous n'avez pas besoin d'avoir recours à SQL. Il existe également des annotations @Delete et @Update pour supprimer et mettre à jour des lignes, mais vous ne les utilisez pas dans cette application.
  • onConflict = OnConflictStrategy.IGNORE : la stratégie "onConflict" sélectionnée ignore un nouveau mot s'il est identique à un mot déjà présent dans la liste. Pour en savoir plus sur les stratégies de conflit disponibles, consultez la documentation.
  • suspend fun deleteAll() : déclare une fonction de suspension pour supprimer tous les mots.
  • Il n'existe pas d'annotation pratique pour supprimer plusieurs entités. Elle est donc annotée avec le code générique, @Query.
  • @Query("DELETE FROM word_table") : @Query exige que vous fournissiez une requête SQL en tant que paramètre de chaîne de l'annotation, ce qui permet d'effectuer des requêtes de lecture complexes et d'autres opérations.
  • fun getAlphabetizedWords(): List<Word> : méthode permettant d'obtenir tous les mots et de renvoyer une liste (List) de mots (Words).
  • @Query("SELECT * FROM word_table ORDER BY word ASC") : requête qui renvoie une liste de mots triés par ordre croissant.

Lorsque vous modifiez des données, c'est généralement pour effectuer certaines actions, comme afficher les données mises à jour dans l'UI. Vous devez donc observer les données afin de pouvoir réagir face aux changements.

Pour observer les modifications des données, vous utiliserez le Flow de kotlinx-coroutines. Utilisez une valeur de retour de type Flow dans la description de votre méthode. Room génère alors tout le code nécessaire pour mettre à jour le Flow lorsque la base de données est mise à jour.

Dans WordDao, modifiez le prototype de la méthode getAlphabetizedWords() pour que le code List<Word> renvoyé soit encapsulé avec Flow.

   @Query("SELECT * FROM word_table ORDER BY word ASC")
   fun getAlphabetizedWords(): Flow<List<Word>>

Nous transformerons le Flow en LiveData dans le ViewModel dans la suite de cet atelier de programmation. Nous aborderons ces composants plus en détail une fois qu'ils seront implémentés.

Qu'est-ce qu'une base de données Room**?**

  • Room est une couche de base de données située au-dessus d'une base de données SQLite.
  • Room gère les tâches routinières que vous effectuiez auparavant avec un SQLiteOpenHelper.
  • Room utilise le DAO pour envoyer des requêtes à sa base de données.
  • Par défaut, pour éviter de mauvaises performances de l'UI, Room ne vous autorise pas à émettre des requêtes sur le thread principal. Lorsque les requêtes Room renvoient Flow, elles sont automatiquement exécutées de manière asynchrone sur un thread d'arrière-plan.
  • Room permet de contrôler le temps de compilation des instructions SQLite.

Implémenter la base de données Room

Votre classe de base de données Room doit être abstraite et représenter une extension de RoomDatabase. En règle générale, vous n'avez besoin que d'une instance de base de données Room pour l'ensemble de l'application.

Nous allons en créer une maintenant.

  1. Créez un fichier de classe Kotlin appelé WordRoomDatabase et ajoutez-y le code suivant :
// Annotates class to be a Room Database with a table (entity) of the Word class
@Database(entities = arrayOf(Word::class), version = 1, exportSchema = false)
public abstract class WordRoomDatabase : RoomDatabase() {

   abstract fun wordDao(): WordDao

   companion object {
        // Singleton prevents multiple instances of database opening at the
        // same time.
        @Volatile
        private var INSTANCE: WordRoomDatabase? = null

        fun getDatabase(context: Context): WordRoomDatabase {
            // if the INSTANCE is not null, then return it,
            // if it is, then create the database
            return INSTANCE ?: synchronized(this) {
                val instance = Room.databaseBuilder(
                        context.applicationContext,
                        WordRoomDatabase::class.java,
                        "word_database"
                    ).build()
                INSTANCE = instance
                // return instance
                instance
            }
        }
   }
}

Passons en revue le code :

  • La classe de base de données Room doit être abstract et représenter une extension de RoomDatabase..
  • Vous annotez la classe en tant que base de données Room avec @Database et vous utilisez les paramètres d'annotation pour déclarer les entités appartenant à la base de données et définir le numéro de version. Chaque entité correspond à une table qui sera créée dans la base de données. Les migrations de base de données n'entrent pas dans le cadre de cet atelier de programmation. Par conséquent, exportSchema a été défini sur "false" afin d'éviter un avertissement de compilation. Dans une application réelle, envisagez de définir un répertoire permettant à Room d'exporter le schéma afin de vérifier le schéma actuel dans votre système de contrôle des versions.
  • La base de données présente les DAO via une méthode "getter" abstraite pour chaque @Dao.
  • Vous avez défini un singleton, WordRoomDatabase, pour empêcher l'ouverture simultanée de plusieurs instances de la base de données.
  • getDatabase renvoie le singleton. La base de données est créée lors du premier accès, à l'aide du générateur de bases de données de Room, via un objet RoomDatabase dans le contexte de l'application à partir de la classe WordRoomDatabase. Elle s'appelle "word_database".

Qu'est-ce qu'un Repository ?

Une classe Repository (ce qui signifie "dépôt") donne accès à plusieurs sources de données. Elle ne fait pas partie des bibliothèques de composants d'architecture, mais il s'agit d'une bonne pratique recommandée pour la séparation du code et de l'architecture. Une classe Repository fournit une API propre pour l'accès aux données dans le reste de l'application.

cdfae5b9b10da57f.png

Pourquoi utiliser un Repository ?

Un Repository gère les requêtes et vous permet d'utiliser plusieurs backends. Dans l'exemple le plus courant, le Repository implémente la logique permettant de décider s'il faut récupérer les données d'un réseau ou utiliser les résultats mis en cache dans une base de données locale.

Implémenter le Repository

Créez un fichier de classe Kotlin appelé WordRepository et collez-y le code suivant :

// Declares the DAO as a private property in the constructor. Pass in the DAO
// instead of the whole database, because you only need access to the DAO
class WordRepository(private val wordDao: WordDao) {

    // Room executes all queries on a separate thread.
    // Observed Flow will notify the observer when the data has changed.
    val allWords: Flow<List<Word>> = wordDao.getAlphabetizedWords()

    // By default Room runs suspend queries off the main thread, therefore, we don't need to
    // implement anything else to ensure we're not doing long running database work
    // off the main thread.
    @Suppress("RedundantSuspendModifier")
    @WorkerThread
    suspend fun insert(word: Word) {
        wordDao.insert(word)
    }
}

Principaux points à retenir :

  • Le DAO est transmis au constructeur du Repository, et non à l'ensemble de la base de données. En effet, le constructeur n'a besoin que d'un accès au DAO, car celui-ci contient toutes les méthodes de lecture et d'écriture de la base de données. Il n'est pas nécessaire d'exposer l'intégralité de la base de données au Repository.
  • La liste de mots est une propriété publique. Elle est initialisée en obtenant la liste Flow de mots de Room. Pour ce faire, vous avez défini la méthode getAlphabetizedWords pour renvoyer Flow lors de l'étape "Observer les modifications apportées à la base de données". Room exécute toutes les requêtes sur un thread séparé.
  • Le modificateur suspend indique au compilateur que cette méthode doit être appelée à partir d'une coroutine ou d'une autre fonction de suspension.
  • Room exécute des requêtes de suspension en dehors du thread principal.

Qu'est-ce qu'un ViewModel ?

Le rôle de ViewModel consiste à fournir des données à l'UI et à survivre aux modifications de configuration. Un ViewModel fait office de centre de communication entre le Repository et l'UI. Vous pouvez également utiliser un ViewModel pour partager des données entre fragments. Le ViewModel fait partie de la bibliothèque de cycle de vie.

72848dfccfe5777b.png

Pour consulter le guide d'introduction à ce sujet, consultez ViewModel Overview ou l'article de blog ViewModels: A Simple Example (ViewModel : un exemple simple).

Pourquoi utiliser un ViewModel ?

Un ViewModel contient les données d'UI de votre application en tenant compte de la notion de cycle de vie et survit aux modifications de configuration. La séparation des données d'UI de vos classes Activity et Fragment vous permet de mieux respecter le principe de responsabilité unique : vos activités et fragments sont responsables de la visualisation des données à l'écran, et votre ViewModel peut s'occuper de la préservation et du traitement de toutes les données nécessaires à l'UI.

LiveData et ViewModel

LiveData est un conteneur de données observable. Vous pouvez être averti chaque fois que des données sont modifiées. Contrairement à Flow, LiveData tient compte du cycle de vie, c'est-à-dire qu'il respecte le cycle de vie d'autres composants tels que Activity ou Fragment. LiveData arrête ou reprend automatiquement l'observation en fonction du cycle de vie du composant qui écoute les modifications. LiveData est le composant idéal à utiliser avec des données modifiables que l'UI utilisera ou affichera.

Le ViewModel transforme les données du Repository, de Flow à LiveData, et expose la liste de mots LiveData à l'UI. Ainsi, chaque fois que les données changent dans la base de données, vous avez l'assurance que votre UI est automatiquement mise à jour.

viewModelScope

Dans Kotlin, toutes les coroutines s'exécutent dans un élément CoroutineScope. Une "scope" ou "portée" permet de contrôler la durée de vie des coroutines tout au long de sa tâche. Lorsque vous annulez la tâche d'une portée, cette action annule toutes les coroutines démarrées dans celle-ci.

La bibliothèque AndroidX lifecycle-viewmodel-ktx ajoute un viewModelScope en tant que fonction d'extension de la classe ViewModel, ce qui vous permet de travailler avec des portées.

Pour en savoir plus sur l'utilisation des coroutines dans ViewModel, reportez-vous à l'étape 5 de l'atelier de programmation Utiliser des coroutines Kotlin dans votre application Android ou à l'article de blog Easy Coroutines in Android: viewModelScope (Coroutines simples dans Android : viewModelScope).

Implémenter le ViewModel

Créez un fichier de classe Kotlin pour WordViewModel et ajoutez-y le code suivant :

class WordViewModel(private val repository: WordRepository) : ViewModel() {

    // Using LiveData and caching what allWords returns has several benefits:
    // - We can put an observer on the data (instead of polling for changes) and only update the
    //   the UI when the data actually changes.
    // - Repository is completely separated from the UI through the ViewModel.
    val allWords: LiveData<List<Word>> = repository.allWords.asLiveData()

    /**
     * Launching a new coroutine to insert the data in a non-blocking way
     */
    fun insert(word: Word) = viewModelScope.launch {
        repository.insert(word)
    }
}

class WordViewModelFactory(private val repository: WordRepository) : ViewModelProvider.Factory {
    override fun <T : ViewModel> create(modelClass: Class<T>): T {
        if (modelClass.isAssignableFrom(WordViewModel::class.java)) {
            @Suppress("UNCHECKED_CAST")
            return WordViewModel(repository) as T
        }
        throw IllegalArgumentException("Unknown ViewModel class")
    }
}

Examinons ce code en détail :

  • Vous avez créé une classe appelée WordViewModel qui récupère le paramètre WordRepository et représente une extension de ViewModel. Le Repository est la seule dépendance requise par le ViewModel. Si d'autres classes étaient requises, elles auraient également été transmises dans le constructeur.
  • Vous avez ajouté une variable de membre LiveData publique pour mettre en cache la liste de mots.
  • Vous avez initialisé LiveData avec le Flow allWords du Repository. Vous avez ensuite converti Flow en LiveData en appelant asLiveData()..
  • Vous avez créé une méthode insert() de type wrapper qui appelle la méthode insert() du Repository. De cette manière, l'implémentation de insert() est encapsulée à partir de l'UI. Nous lançons une nouvelle coroutine et appelons l'élément "insert" du Repository, qui est une fonction de suspension. Comme mentionné précédemment, les ViewModels comportent une portée de coroutine basée sur le cycle de vie et appelée viewModelScope, que vous utiliserez ici.
  • Vous avez créé le ViewModel et implémenté un ViewModelProvider.Factory qui récupère en tant que paramètre les dépendances nécessaires à la création de WordViewModel : le WordRepository.

Si vous utilisez viewModels et ViewModelProvider.Factory, le framework se chargera du cycle de vie du ViewModel. Il survit aux modifications de configuration et, même si l'activité est recréée, vous obtenez toujours la bonne instance de la classe WordViewModel.

Vous devez ensuite ajouter la mise en page XML pour la liste et les éléments.

Cet atelier de programmation suppose que vous maîtrisiez la création de mises en page au format XML. C'est pourquoi nous vous fournissons simplement le code.

Créez le matériel du thème de votre application en définissant le parent de AppTheme sur Theme.MaterialComponents.Light.DarkActionBar. Ajoutez un style pour les éléments de la liste dans values/styles.xml :

<resources>

    <!-- Base application theme. -->
    <style name="AppTheme" parent="Theme.MaterialComponents.Light.DarkActionBar">
        <!-- Customize your theme here. -->
        <item name="colorPrimary">@color/colorPrimary</item>
        <item name="colorPrimaryDark">@color/colorPrimaryDark</item>
        <item name="colorAccent">@color/colorAccent</item>
    </style>

    <!-- The default font for RecyclerView items is too small.
    The margin is a simple delimiter between the words. -->
    <style name="word_title">
        <item name="android:layout_marginBottom">8dp</item>
        <item name="android:paddingLeft">8dp</item>
        <item name="android:background">@android:color/holo_orange_light</item>
        <item name="android:textAppearance">@android:style/TextAppearance.Large</item>
    </style>
</resources>

Créez un fichier de ressources de dimension :

  1. Cliquez sur le module d'application dans la fenêtre Project (Projet).
  2. Sélectionnez File > New > Android Resource File (Fichier > Nouveau > Fichier de ressources Android).
  3. Dans les qualificatifs disponibles, sélectionnez Dimension.
  4. Nommez votre fichier "dimens".

aa5895240838057.png

Ajoutez les ressources de dimension suivantes à values/dimens.xml :

<dimen name="big_padding">16dp</dimen>

Ajoutez une mise en page layout/recyclerview_item.xml :

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="wrap_content">

    <TextView
        android:id="@+id/textView"
        style="@style/word_title"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:background="@android:color/holo_orange_light" />
</LinearLayout>

Dans le fichier layout/activity_main.xml, remplacez le TextView par un RecyclerView et ajoutez un bouton d'action flottant (FAB). Votre mise en page devrait se présenter comme suit :

<androidx.constraintlayout.widget.ConstraintLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/recyclerview"
        android:layout_width="0dp"
        android:layout_height="0dp"
        tools:listitem="@layout/recyclerview_item"
        android:padding="@dimen/big_padding"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <com.google.android.material.floatingactionbutton.FloatingActionButton
        android:id="@+id/fab"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_margin="16dp"
        android:contentDescription="@string/add_word"/>

</androidx.constraintlayout.widget.ConstraintLayout>

L'apparence du FAB doit correspondre à l'action disponible. Vous allez donc remplacer l'icône par le symbole "+".

Vous devez d'abord ajouter un nouvel élément vectoriel :

  1. Sélectionnez File > New > Vector Asset (Fichier > Nouveau > Élément vectoriel).
  2. Cliquez sur l'icône du robot Android dans le champ Clip Art (Image clipart). 8d935457de8e7a46.png
  3. Effectuez une recherche sur "add" (ajouter) et sélectionnez l'élément "+". Cliquez sur OK. 758befc99c8cc794.png
  4. Dans la fenêtre Asset Studio, cliquez sur Next (Suivant). 672248bada3cfb25.png
  5. Vérifiez que le chemin d'accès à l'icône est main > drawable et cliquez sur Finish (Terminer) pour ajouter l'élément. ef118084f96c6176.png
  6. Toujours dans le fichier layout/activity_main.xml, modifiez le FAB afin d'inclure le nouveau drawable :
<com.google.android.material.floatingactionbutton.FloatingActionButton
        android:id="@+id/fab"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_margin="16dp"
        android:contentDescription="@string/add_word"
        android:src="@drawable/ic_add_black_24dp"/>

Vous allez afficher les données dans un RecyclerView, ce qui est un peu plus pratique que de se contenter de les générer dans un TextView. Cet atelier de programmation suppose que vous connaissiez le fonctionnement de RecyclerView, RecyclerView.ViewHolder et ListAdapter.

Vous devrez créer :

  • la classe WordListAdapter qui représente une extension de ListAdapter ;
  • une classe DiffUtil.ItemCallback imbriquée dans la classe WordListAdapter. ;
  • le ViewHolder qui affichera chaque mot de la liste.

Voici le code :

class WordListAdapter : ListAdapter<Word, WordViewHolder>(WordsComparator()) {

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): WordViewHolder {
        return WordViewHolder.create(parent)
    }

    override fun onBindViewHolder(holder: WordViewHolder, position: Int) {
        val current = getItem(position)
        holder.bind(current.word)
    }

    class WordViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
        private val wordItemView: TextView = itemView.findViewById(R.id.textView)

        fun bind(text: String?) {
            wordItemView.text = text
        }

        companion object {
            fun create(parent: ViewGroup): WordViewHolder {
                val view: View = LayoutInflater.from(parent.context)
                    .inflate(R.layout.recyclerview_item, parent, false)
                return WordViewHolder(view)
            }
        }
    }

    class WordsComparator : DiffUtil.ItemCallback<Word>() {
        override fun areItemsTheSame(oldItem: Word, newItem: Word): Boolean {
            return oldItem === newItem
        }

        override fun areContentsTheSame(oldItem: Word, newItem: Word): Boolean {
            return oldItem.word == newItem.word
        }
    }
}

Vous obtenez :

  • La classe WordViewHolder, qui nous permet de lier un texte à un TextView. La classe expose une fonction create() statique qui permet de gonfler la mise en page.
  • Le WordsComparator définit comment effectuer le calcul si deux mots sont identiques ou si le contenu est identique.
  • Le WordListAdapter crée le WordViewHolder dans onCreateViewHolder et l'associe dans onBindViewHolder.

Ajoutez le RecyclerView à la méthode onCreate() de MainActivity.

Dans la méthode onCreate() après setContentView :

   val recyclerView = findViewById<RecyclerView>(R.id.recyclerview)
   val adapter = WordListAdapter()
   recyclerView.adapter = adapter
   recyclerView.layoutManager = LinearLayoutManager(this)

Exécutez votre application pour vous assurer que tout fonctionne correctement. Vous ne verrez aucun élément, car vous n'avez pas encore associé les données.

79cb875d4296atie.png

Vous souhaitez n'utiliser qu'une seule instance de la base de données et du Repository dans votre application. Pour ce faire, un moyen simple consiste à les créer l'une et l'autre en tant que membres de la classe Application. Ainsi, il vous suffit de les récupérer depuis l'application au besoin, au lieu de devoir les créer à chaque fois.

Créez une classe appelée WordsApplication qui représente une extension de la classe Application. Voici le code :

class WordsApplication : Application() {
    // Using by lazy so the database and the repository are only created when they're needed
    // rather than when the application starts
    val database by lazy { WordRoomDatabase.getDatabase(this) }
    val repository by lazy { WordRepository(database.wordDao()) }
}

Voici ce que vous avez fait :

  • Vous avez créé une instance de base de données.
  • Vous avez créé une instance Repository, basée sur le DAO de la base de données.
  • Étant donné que ces objets ne doivent être créés que lors de leur première utilisation, et non au démarrage de l'application, vous utilisez la délégation de propriété de Kotlin : by lazy.

Maintenant que vous avez créé la classe Application, mettez à jour le fichier AndroidManifest et définissez WordsApplication en tant que application android:name.

Le tag d'application doit se présenter comme suit :

<application
        android:name=".WordsApplication"
        android:allowBackup="true"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@style/AppTheme">
...

Pour le moment, la base de données ne contient aucune donnée. Vous pouvez ajouter des données de deux manières : ajoutez des données lorsque vous créez la base de données, ou ajoutez Activity pour ajouter des mots.

Pour supprimer tout le contenu et remplir à nouveau la base de données chaque fois que l'application est créée, vous devrez créer un RoomDatabase.Callback et remplacer onCreate(). Étant donné que vous ne pouvez pas effectuer d'opérations de base de données Room sur le thread UI, onCreate() lance une coroutine sur le coordinateur d'E/S.

Pour lancer une coroutine, vous devez disposer d'un CoroutineScope. Mettez à jour la méthode getDatabase de la classe WordRoomDatabase pour obtenir également une portée de coroutine en tant que paramètre :

fun getDatabase(
       context: Context,
       scope: CoroutineScope
  ): WordRoomDatabase {
...
}

Le remplissage de la base de données n'est pas lié au cycle de vie de l'UI. Par conséquent, vous ne devez pas utiliser un CoroutineScope tel que viewModelScope. Il est lié au cycle de vie de l'application. Vous allez mettre à jour le WordsApplication pour qu'il contienne un applicationScope, puis le transmettre à WordRoomDatabase.getDatabase.

class WordsApplication : Application() {
    // No need to cancel this scope as it'll be torn down with the process
    val applicationScope = CoroutineScope(SupervisorJob())

    // Using by lazy so the database and the repository are only created when they're needed
    // rather than when the application starts
    val database by lazy { WordRoomDatabase.getDatabase(this, applicationScope) }
    val repository by lazy { WordRepository(database.wordDao()) }
}

Dans WordRoomDatabase, vous créerez une implémentation personnalisée du RoomDatabase.Callback(), qui obtient également un CoroutineScope en tant que paramètre constructeur. Ensuite, vous remplacerez la méthode onOpen pour insérer des données dans la base de données.

Voici le code permettant de créer le rappel au sein de la classe WordRoomDatabase :

private class WordDatabaseCallback(
    private val scope: CoroutineScope
) : RoomDatabase.Callback() {

    override fun onCreate(db: SupportSQLiteDatabase) {
        super.onCreate(db)
        INSTANCE?.let { database ->
            scope.launch {
                populateDatabase(database.wordDao())
            }
        }
    }

    suspend fun populateDatabase(wordDao: WordDao) {
        // Delete all content here.
        wordDao.deleteAll()

        // Add sample words.
        var word = Word("Hello")
        wordDao.insert(word)
        word = Word("World!")
        wordDao.insert(word)

        // TODO: Add your own words!
    }
}

Enfin, ajoutez le rappel à la séquence de compilation de la base de données juste avant d'appeler .build() sur le Room.databaseBuilder() :

.addCallback(WordDatabaseCallback(scope))

Le code final devrait se présenter comme suit :

@Database(entities = arrayOf(Word::class), version = 1, exportSchema = false)
abstract class WordRoomDatabase : RoomDatabase() {

   abstract fun wordDao(): WordDao

   private class WordDatabaseCallback(
       private val scope: CoroutineScope
   ) : RoomDatabase.Callback() {

       override fun onCreate(db: SupportSQLiteDatabase) {
           super.onCreate(db)
           INSTANCE?.let { database ->
               scope.launch {
                   var wordDao = database.wordDao()

                   // Delete all content here.
                   wordDao.deleteAll()

                   // Add sample words.
                   var word = Word("Hello")
                   wordDao.insert(word)
                   word = Word("World!")
                   wordDao.insert(word)

                   // TODO: Add your own words!
                   word = Word("TODO!")
                   wordDao.insert(word)
               }
           }
       }
   }

   companion object {
       @Volatile
       private var INSTANCE: WordRoomDatabase? = null

       fun getDatabase(
           context: Context,
           scope: CoroutineScope
       ): WordRoomDatabase {
            // if the INSTANCE is not null, then return it,
            // if it is, then create the database
            return INSTANCE ?: synchronized(this) {
                val instance = Room.databaseBuilder(
                        context.applicationContext,
                        WordRoomDatabase::class.java,
                        "word_database"
                )
                 .addCallback(WordDatabaseCallback(scope))
                 .build()
                INSTANCE = instance
                // return instance
                instance
        }
     }
   }
}

Ajoutez les ressources de chaîne suivantes dans values/strings.xml :

<string name="hint_word">Word...</string>
<string name="button_save">Save</string>
<string name="empty_not_saved">Word not saved because it is empty.</string>
<string name="add_word">Add word</string>

Ajoutez cette ressource de couleur dans value/colors.xml :

<color name="buttonLabel">#FFFFFF</color>

Ajoutez une ressource de dimension min_height dans values/dimens.xml :

<dimen name="min_height">48dp</dimen>

Créez une activité Activity Android vide avec le modèle d'activité vide :

  1. Sélectionnez File > New > Activity > Empty Activity (Fichier > Nouveau > Activité > Activité vide).
  2. Saisissez NewWordActivity comme nom d'activité.
  3. Vérifiez que la nouvelle activité a bien été ajoutée au fichier manifeste Android.
<activity android:name=".NewWordActivity"></activity>

Mettez à jour le fichier activity_new_word.xml du dossier des mises en page avec le code suivant :

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <EditText
        android:id="@+id/edit_word"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:minHeight="@dimen/min_height"
        android:fontFamily="sans-serif-light"
        android:hint="@string/hint_word"
        android:inputType="textAutoComplete"
        android:layout_margin="@dimen/big_padding"
        android:textSize="18sp" />

    <Button
        android:id="@+id/button_save"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:background="@color/colorPrimary"
        android:text="@string/button_save"
        android:layout_margin="@dimen/big_padding"
        android:textColor="@color/buttonLabel" />

</LinearLayout>

Mettez à jour le code pour l'activité :

class NewWordActivity : AppCompatActivity() {

    private lateinit var editWordView: EditText

    public override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_new_word)
        editWordView = findViewById(R.id.edit_word)

        val button = findViewById<Button>(R.id.button_save)
        button.setOnClickListener {
            val replyIntent = Intent()
            if (TextUtils.isEmpty(editWordView.text)) {
                setResult(Activity.RESULT_CANCELED, replyIntent)
            } else {
                val word = editWordView.text.toString()
                replyIntent.putExtra(EXTRA_REPLY, word)
                setResult(Activity.RESULT_OK, replyIntent)
            }
            finish()
        }
    }

    companion object {
        const val EXTRA_REPLY = "com.example.android.wordlistsql.REPLY"
    }
}

La dernière étape consiste à connecter l'UI à la base de données en enregistrant les nouveaux mots saisis par l'utilisateur et en affichant le contenu actuel de la base de données de mots dans RecyclerView.

Pour afficher le contenu actuel de la base de données, ajoutez un observateur qui observe LiveData dans ViewModel.

Chaque fois que les données sont modifiées, le rappel onChanged() est invoqué. Il appelle la méthode setWords() de l'adaptateur pour mettre à jour les données mises en cache de l'adaptateur et actualiser la liste affichée.

Dans MainActivity, créez le ViewModel :

private val wordViewModel: WordViewModel by viewModels {
    WordViewModelFactory((application as WordsApplication).repository)
}

Pour créer le ViewModel, vous avez utilisé le délégué viewModels, en transmettant une instance de notre classe WordViewModelFactory. Cette construction est basée sur le Repository récupéré à partir de WordsApplication.

Toujours dans onCreate(), ajoutez un observateur pour la propriété "allWords"LiveData à partir de WordViewModel.

La méthode onChanged() (méthode par défaut pour notre lambda) se déclenche lorsque les données observées changent et que l'activité est au premier plan :

wordViewModel.allWords.observe(this, Observer { words ->
            // Update the cached copy of the words in the adapter.
            words?.let { adapter.submitList(it) }
})

Vous souhaitez que NewWordActivity s'ouvre en cas de pression sur le bouton d'action flottant et, une fois de retour dans MainActivity, qu'il soit possible d'insérer un nouveau mot dans la base de données ou d'afficher un Toast.

Pour ce faire, commencez par définir un code de requête :

private val newWordActivityRequestCode = 1

Dans MainActivity, ajoutez le code onActivityResult() pour NewWordActivity.

Si l'activité renvoie RESULT_OK, insérez le mot renvoyé dans la base de données en appelant la méthode insert() du WordViewModel :

override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
    super.onActivityResult(requestCode, resultCode, data)

    if (requestCode == newWordActivityRequestCode && resultCode == Activity.RESULT_OK) {
        data?.getStringExtra(NewWordActivity.EXTRA_REPLY)?.let {
            val word = Word(it)
            wordViewModel.insert(word)
        }
    } else {
        Toast.makeText(
            applicationContext,
            R.string.empty_not_saved,
            Toast.LENGTH_LONG).show()
    }
}

Dans MainActivity, démarrez NewWordActivity lorsque l'utilisateur appuie sur le bouton d'action flottant. Dans MainActivity onCreate, trouvez le FAB et ajoutez un onClickListener contenant le code suivant :

val fab = findViewById<FloatingActionButton>(R.id.fab)
fab.setOnClickListener {
  val intent = Intent(this@MainActivity, NewWordActivity::class.java)
  startActivityForResult(intent, newWordActivityRequestCode)
}

Votre code, une fois fini, doit ressembler à ceci :

class MainActivity : AppCompatActivity() {

    private val newWordActivityRequestCode = 1
    private val wordViewModel: WordViewModel by viewModels {
        WordViewModelFactory((application as WordsApplication).repository)
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        val recyclerView = findViewById<RecyclerView>(R.id.recyclerview)
        val adapter = WordListAdapter()
        recyclerView.adapter = adapter
        recyclerView.layoutManager = LinearLayoutManager(this)

        // Add an observer on the LiveData returned by getAlphabetizedWords.
        // The onChanged() method fires when the observed data changes and the activity is
        // in the foreground.
        wordViewModel.allWords.observe(owner = this) { words ->
            // Update the cached copy of the words in the adapter.
            words.let { adapter.submitList(it) }
        }

        val fab = findViewById<FloatingActionButton>(R.id.fab)
        fab.setOnClickListener {
            val intent = Intent(this@MainActivity, NewWordActivity::class.java)
            startActivityForResult(intent, newWordActivityRequestCode)
        }
    }

    override fun onActivityResult(requestCode: Int, resultCode: Int, intentData: Intent?) {
        super.onActivityResult(requestCode, resultCode, intentData)

        if (requestCode == newWordActivityRequestCode && resultCode == Activity.RESULT_OK) {
            intentData?.getStringExtra(NewWordActivity.EXTRA_REPLY)?.let { reply ->
                val word = Word(reply)
                wordViewModel.insert(word)
            }
        } else {
            Toast.makeText(
                applicationContext,
                R.string.empty_not_saved,
                Toast.LENGTH_LONG
            ).show()
        }
    }
}

Maintenant, exécutez votre application. Lorsque vous ajoutez un mot à la base de données dans NewWordActivity, l'UI est automatiquement mise à jour.

Maintenant que vous disposez d'une application opérationnelle, récapitulons ce que vous avez créé. Voici à nouveau la structure de l'application :

a70aca8d4b737712.png

Les composants de l'application sont les suivants :

  • MainActivity : affiche les mots dans une liste à l'aide du RecyclerView et du WordListAdapter. Dans MainActivity, un Observer détecte les mots de la base de données et est notifié lorsqu'ils changent.
  • NewWordActivity: ajoute un nouveau mot à la liste.
  • WordViewModel : fournit des méthodes pour accéder à la couche de données et renvoie LiveData pour que MainActivity puisse configurer la relation d'observation*.
  • LiveData<List<Word>> : autorise les mises à jour automatiques dans les composants de l'UI. Vous pouvez passer de Flow à LiveData en appelant flow.toLiveData().
  • Repository: gère une ou plusieurs sources de données. Le Repository expose les méthodes permettant à la classe ViewModel d'interagir avec le fournisseur de données sous-jacent. Dans cette application, ce backend est une base de données Room.
  • Room : est un wrapper qui permet d'implémenter une base de données SQLite. Room fait une bonne partie du travail à votre place.
  • DAO : mappe les appels de méthode aux requêtes de base de données. Ainsi, lorsque le Repository appelle une méthode telle que getAlphabetizedWords(), Room peut exécuter SELECT * FROM word_table ORDER BY word ASC**.**
  • Le DAO peut exposer les requêtes suspend pour les requêtes de détection unique et les requêtes Flow, lorsque vous souhaitez être informé des modifications dans la base de données.
  • Word : est une classe d'entité contenant un seul mot.
  • Views et Activities (et Fragments) n'interagissent qu'avec les données via ViewModel. Par conséquent, l'origine des données n'a pas d'importance.

Flux de données pour les mises à jour automatiques de l'UI (UI réactive)

La mise à jour automatique est possible, car vous utilisez LiveData. Dans MainActivity, un Observer observe les mots LiveData de la base de données et est notifié lorsqu'il change. En cas de modification, la méthode onChange() de l'observateur est exécutée et met à jour mWords dans WordListAdapter.

Les données peuvent être observées, car il s'agit de LiveData. Le résultat est le LiveData<List<Word>> qui est renvoyé par la propriété WordViewModel allWords.

Le WordViewModel masque toutes les informations relatives au backend dans la couche de l'UI. Il fournit des méthodes permettant d'accéder à la couche de données et renvoie LiveData afin que MainActivity puisse configurer la relation d'observation. Views et Activities (et Fragments) n'interagissent qu'avec les données via ViewModel. Par conséquent, l'origine des données n'a pas d'importance.

Dans ce cas, les données proviennent d'un Repository. Le ViewModel n'a pas besoin de savoir avec quoi ce Repository interagit. Il a seulement besoin de savoir comment interagir avec le Repository, c'est-à-dire via les méthodes exposées par le Repository.

Le Repository gère une ou plusieurs sources de données. Dans l'application WordListSample, ce backend est une base de données Room. Room est un wrapper qui permet d'implémenter une base de données SQLite. Room fait une bonne partie du travail à votre place. Par exemple, Room effectue toutes les opérations que vous effectuiez auparavant avec une classe SQLiteOpenHelper.

Le DAO mappe les appels de méthode aux requêtes de la base de données. Ainsi, lorsque le Repository appelle une méthode telle que getAllWords(), Room peut exécuter SELECT * FROM word_table ORDER BY word ASC.

Comme le résultat renvoyé par la requête correspond à des données LiveData observées, chaque fois que les données dans Room changent, la méthode onChanged() de l'interface Observer est exécutée, et l'UI est mise à jour.

[Facultatif] Télécharger le code de la solution

Si vous ne l'avez pas déjà fait, vous pouvez consulter le code de la solution pour cet atelier de programmation. Vous pouvez consulter le dépôt GitHub ou télécharger le code ici :

Télécharger le code source

Décompressez le fichier ZIP téléchargé. Cela a pour effet de décompresser un dossier racine, android-room-with-a-view-kotlin, qui contient l'application complète.