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 classeMainActivity
; - 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 :
- Android Studio 4.0 ou version ultérieure, que vous devez savoir utiliser. Assurez-vous qu'Android Studio est à jour, ainsi que votre SDK et Gradle.
- Un appareil ou un émulateur Android.
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.
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.
- Ouvrez Android Studio, puis cliquez sur Start a new Android Studio project (Démarrer un nouveau projet Android Studio).
- Dans la fenêtre "Create New Project" (Créer un projet), sélectionnez Empty Activity (Activité vide), puis cliquez sur Next (Suivant).
- 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.
- Dans Android Studio, cliquez sur l'onglet "Projects" (Projets) et développez le dossier "Gradle Scripts" (Scripts Gradle).
Ouvrez build.gradle
(Module: app).
- Appliquez le plug-in Kotlin de processeur d'annotations
kapt
en l'ajoutant après la section des plug-ins définie en haut de votre fichierbuild.gradle
(Module: app).
apply plugin: 'kotlin-kapt'
- Ajoutez le bloc
packagingOptions
dans le blocandroid
pour exclure le module de fonctions atomiques du package et éviter les avertissements. - Certaines des API que vous utiliserez requièrent la version 1.8 de
jvmTarget
. Ajoutez-la également au blocandroid
.
android {
// other configuration (buildTypes, defaultConfig, etc.)
packagingOptions {
exclude 'META-INF/atomicfu.kotlin_module'
}
kotlinOptions {
jvmTarget = "1.8"
}
}
- 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.
- 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 :
Room vous permet de créer des tables via une Entity. C'est parti.
- Créez un fichier de classe Kotlin appelé
Word
contenant la classe de donnéesWord
. 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.
- 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.
- Créez un fichier de classe Kotlin appelé
WordDao
. - 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.
- 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 deRoomDatabase.
. - 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 objetRoomDatabase
dans le contexte de l'application à partir de la classeWordRoomDatabase
. 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.
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éthodegetAlphabetizedWords
pour renvoyerFlow
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.
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ètreWordRepository
et représente une extension deViewModel
. 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 FlowallWords
du Repository. Vous avez ensuite converti Flow en LiveData en appelantasLiveData().
. - Vous avez créé une méthode
insert()
de type wrapper qui appelle la méthodeinsert()
du Repository. De cette manière, l'implémentation deinsert()
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éeviewModelScope
, 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 deWordViewModel
: leWordRepository
.
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 :
- Cliquez sur le module d'application dans la fenêtre Project (Projet).
- Sélectionnez File > New > Android Resource File (Fichier > Nouveau > Fichier de ressources Android).
- Dans les qualificatifs disponibles, sélectionnez Dimension.
- Nommez votre fichier "dimens".
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 :
- Sélectionnez File > New > Vector Asset (Fichier > Nouveau > Élément vectoriel).
- Cliquez sur l'icône du robot Android dans le champ Clip Art (Image clipart).
- Effectuez une recherche sur "add" (ajouter) et sélectionnez l'élément "+". Cliquez sur OK.
- Dans la fenêtre Asset Studio, cliquez sur Next (Suivant).
- Vérifiez que le chemin d'accès à l'icône est
main > drawable
et cliquez sur Finish (Terminer) pour ajouter l'élément. - 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 deListAdapter
; - une classe
DiffUtil.ItemCallback
imbriquée dans la classeWordListAdapter.
; - 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 à unTextView
. La classe expose une fonctioncreate()
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 leWordViewHolder
dansonCreateViewHolder
et l'associe dansonBindViewHolder
.
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.
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 :
- Sélectionnez File > New > Activity > Empty Activity (Fichier > Nouveau > Activité > Activité vide).
- Saisissez
NewWordActivity
comme nom d'activité. - 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 :
Les composants de l'application sont les suivants :
MainActivity
: affiche les mots dans une liste à l'aide duRecyclerView
et duWordListAdapter
. DansMainActivity
, unObserver
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 deFlow
àLiveData
en appelantflow.toLiveData()
.Repository:
gère une ou plusieurs sources de données. LeRepository
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écuterSELECT * 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êtesFlow
, lorsque vous souhaitez être informé des modifications dans la base de données. Word
: est une classe d'entité contenant un seul mot.Views
etActivities
(etFragments
) n'interagissent qu'avec les données viaViewModel
. 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 :
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.