Android Room et ViewModel – Kotlin

L'objectif des composants d'architecture est de fournir 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 vous aident à 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 (bugs de code, erreurs grammaticales, formulation peu claire, etc.) 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 de programmation.

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 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 les MVP ou MVC. Cet atelier de programmation implémente l'architecture définie dans le Guide to app architecture (Guide de l'architecture des applications).

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.

Si vous ne connaissez pas Kotlin, vous trouverez ici une version de cet atelier dans le langage de programmation Java.

Objectifs de l'atelier

Dans cet atelier de programmation, vous apprendrez à utiliser les composants d'architecture Room, ViewModel et LiveData pour concevoir et créer une application qui:

  • implémente notre architecture recommandée à l'aide des composants d'architecture Android ;
  • utilise une base de données pour obtenir et enregistrer les données, et pré-remplit la base de données avec quelques mots ;
  • affiche tous les mots dans un RecyclerView, dans MainActivity ;
  • ouvre une deuxième activité lorsque l'utilisateur appuie sur le bouton "+". Lorsque l'utilisateur saisit un mot, il l'ajoute à la base de données et à la liste.

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.

L'utilisation des composants d'architecture et l'implémentation de l'architecture recommandée supposent de passer par de nombreuses étapes. Le plus important est de créer un modèle mental illustrant ce qui se passe, et de comprendre la relation entre les divers éléments ainsi que le mécanisme du flux des données. Au cours de cet atelier de programmation, ne vous contentez pas de copier et coller le code, mais essayez de développer cette compréhension intrinsèque.

Pour expliquer la terminologie, voici une brève présentation des composants d'architecture et de leur fonctionnement conjoint. Notez que cet atelier de programmation se concentre sur un sous-ensemble de composants, à savoir LiveData, ViewModel et Room. Chaque composant est expliqué plus en détail lorsque vous l'utilisez.

Ce schéma présente une forme de base de l'architecture :

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

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.

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.

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

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.

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.

Présentation de l'architecture RoomWordSample

Le schéma suivant présente 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.

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

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 les autres plug-ins définis 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.
android {
    // other configuration (buildTypes, defaultConfig, etc.)

    packagingOptions {
        exclude 'META-INF/atomicfu.kotlin_module'
    }
}
  1. Ajoutez le code suivant à la fin du bloc dependencies.
// Room components
implementation "androidx.room:room-runtime:$rootProject.roomVersion"
kapt "androidx.room:room-compiler:$rootProject.roomVersion"
androidTestImplementation "androidx.room:room-testing:$rootProject.roomVersion"

// Lifecycle components
implementation "androidx.lifecycle:lifecycle-extensions:$rootProject.archLifecycleVersion"
kapt "androidx.lifecycle:lifecycle-compiler:$rootProject.archLifecycleVersion"
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$rootProject.archLifecycleVersion"

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

// Material design
implementation "com.google.android.material:material:$rootProject.materialVersion"

// Testing
testImplementation 'junit:junit:4.12'
androidTestImplementation "androidx.arch.core:core-testing:$rootProject.coreTestingVersion"
  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 {
    roomVersion = '2.2.5'
    archLifecycleVersion = '2.2.0'
    coreTestingVersion = '2.1.0'
    materialVersion = '1.1.0'
    coroutines = '1.3.4'
}

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

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 l'annoter. Les annotations identifient la relation entre chaque partie de cette classe et une entrée de la base de données. Room utilise ces informations 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. Vous pouvez ainsi annoter vos requêtes avec le modificateur suspend, puis les appeler à 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, vous souhaitez généralement effectuer une action, 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.

Selon la manière dont les données sont stockées, cela peut être délicat. L'observation de modifications apportées aux données au niveau de plusieurs composants de votre application peut créer des chemins de dépendance explicites et rigides entre les composants. Cela complique, entre autres, les tests et le débogage.

LiveData, une classe Bibliothèque de cycle de vie pour l'observation de données, résout ce problème. Utilisez une valeur de retour de type LiveData dans la description de votre méthode. Room génère alors tout le code nécessaire pour mettre à jour le LiveData 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 LiveData.

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

Plus tard dans cet atelier de programmation, vous suivrez les modifications de données via un Observer dans MainActivity.

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 LiveData, 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 {
            val tempInstance = INSTANCE
            if (tempInstance != null) {
                return tempInstance
            }
            synchronized(this) {
                val instance = Room.databaseBuilder(
                        context.applicationContext,
                        WordRoomDatabase::class.java, 
                        "word_database"
                    ).build()
                INSTANCE = instance
                return 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 bases de données n'entrent pas dans le cadre de cet atelier de programmation. Nous avons donc défini exportSchema sur "false" pour éviter un avertissement de compilation. Dans une application réelle, vous pouvez 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.
  • Nous avons 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. Il crée la base de données la première fois qu'il y a accès, à l'aide du générateur de bases de données de Room afin de créer un objet RoomDatabase dans le contexte de l'application à partir de la classe WordRoomDatabase. Il 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.

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 LiveData will notify the observer when the data has changed.
    val allWords: LiveData<List<Word>> = wordDao.getAlphabetizedWords()
 
    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 LiveData de mots de Room. Pour ce faire, nous avons défini la méthode getAlphabetizedWords pour renvoyer LiveData dans la classe &LiveData. Room exécute toutes les requêtes sur un thread séparé. Ensuite, LiveData avertira l'observateur sur le thread principal lorsque les données auront été modifiées.
  • 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.

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.

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.

Dans ViewModel, utilisez LiveData pour les données modifiables que l'UI utilisera ou affichera. L'utilisation de LiveData présente plusieurs avantages:

  • Vous pouvez placer un observateur sur les données (plutôt que d'interroger les modifications) et ne mettre à jour
    l'UI que lorsque les données changent réellement.
  • Le Repository et l'UI sont complètement séparés par le ViewModel.
  • Il n'y a aucun appel de base de données à partir de ViewModel (cette opération est entièrement gérée dans le Repository), ce qui rend le code plus vérifiable.

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(application: Application) : AndroidViewModel(application) {

    private val repository: WordRepository
    // Using LiveData and caching what getAlphabetizedWords 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>>

    init {
        val wordsDao = WordRoomDatabase.getDatabase(application).wordDao()
        repository = WordRepository(wordsDao)
        allWords = repository.allWords
    }

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

Voici comment nous avons procédé :

  • Nous avons créé une classe appelée WordViewModel qui récupère le paramètre Application et représente une extension de AndroidViewModel.
  • Ajout d'une variable de membre privé pour conserver une référence au dépôt.
  • Nous avons ajouté une variable de membre LiveData publique pour mettre en cache la liste de mots.
  • Création d'un bloc init qui obtient une référence à la WordDao à partir du WordRoomDatabase.
  • Dans le bloc init, construction du WordRepository basé sur le WordRoomDatabase.
  • Dans le bloc init, initialisez les LiveData allWords à l'aide du dépôt.
  • Nous avons 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 ne voulons pas que le thread principal soit inséré. C'est pourquoi nous lançons une nouvelle coroutine et appelons l'insertion du dépôt, 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 nous utilisons ici.

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_width">match_parent</item>
        <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>

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. Nous allons donc remplacer l'icône par le symbole '+.

Nous devons 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).
  3. Recherchez "ajouter" et sélectionnez l'élément "#&99". Cliquez sur OK.
  4. Cliquez ensuite sur Next (Suivant).
  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.
  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.LayoutManager, RecyclerView.ViewHolder et RecyclerView.Adapter.

Notez que la variable words de l'adaptateur met en cache les données. À la prochaine tâche, vous allez ajouter le code qui met automatiquement à jour les données.

Créez un fichier de classe Kotlin pour WordListAdapter afin d'étendre RecyclerView.Adapter. Voici le code :

class WordListAdapter internal constructor(
        context: Context
) : RecyclerView.Adapter<WordListAdapter.WordViewHolder>() {

    private val inflater: LayoutInflater = LayoutInflater.from(context)
    private var words = emptyList<Word>() // Cached copy of words

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

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): WordViewHolder {
        val itemView = inflater.inflate(R.layout.recyclerview_item, parent, false)
        return WordViewHolder(itemView)
    }

    override fun onBindViewHolder(holder: WordViewHolder, position: Int) {
        val current = words[position]
        holder.wordItemView.text = current.word
    }

    internal fun setWords(words: List<Word>) {
        this.words = words
        notifyDataSetChanged()
    }

    override fun getItemCount() = words.size
}

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(this)
   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.

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 la base de données est ouverte, 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 démarrée, vous devez créer un RoomDatabase.Callback et remplacer onOpen(). Étant donné que vous ne pouvez pas effectuer d'opérations de base de données Room sur le thread UI, onOpen() 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 {
...
}

Mettez à jour l'initialiseur de récupération de la base de données dans le bloc init de WordViewModel pour transmettre également le champ d'application:

val wordsDao = WordRoomDatabase.getDatabase(application, viewModelScope).wordDao()

Dans WordRoomDatabase, nous créons une implémentation personnalisée du RoomDatabase.Callback(), qui obtient également un CoroutineScope en tant que paramètre constructeur. Ensuite, nous remplaçons 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 onOpen(db: SupportSQLiteDatabase) {
        super.onOpen(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 onOpen(db: SupportSQLiteDatabase) {
           super.onOpen(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>

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

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

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. Définissez le nom du fichier : "dimens".

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

<dimen name="small_padding">8dp</dimen>
<dimen name="big_padding">16dp</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 une variable de membre pour ViewModel:

private lateinit var wordViewModel: WordViewModel

Utilisez ViewModelProvider pour associer votre ViewModel à votre Activity.

Lorsque Activity commence, ViewModelProviders crée ViewModel. Lorsque l'activité est détruite, par exemple en modifiant une configuration, ViewModel persiste. Lorsque l'activité est recréée, ViewModelProviders renvoie la valeur ViewModel existante. Pour en savoir plus, consultez ViewModel.

Dans onCreate(), sous le bloc de code RecyclerView, obtenez un ViewModel à partir de ViewModelProvider:

wordViewModel = ViewModelProvider(this).get(WordViewModel::class.java)

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.setWords(it) }
})

Nous voulons 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, commençons 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 const val newWordActivityRequestCode = 1
   private lateinit var wordViewModel: WordViewModel

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

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

       wordViewModel = ViewModelProvider(this).get(WordViewModel::class.java)
       wordViewModel.allWords.observe(this, Observer { words ->
           // Update the cached copy of the words in the adapter.
           words?.let { adapter.setWords(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, 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()
       }
   }
}

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 :

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 observe les mots LiveData de la base de données et est notifié lorsqu'il change.
  • 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. Dans MainActivity, un Observer observe les mots LiveData de la base de données et est notifié lorsqu'il change.
  • 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.
  • 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 nous utilisons 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.