Développement Mobile

Persistance locale avec Room 3 et Jetpack Compose pas à pas

11 min de lecture

Une application mobile qui dépend exclusivement du réseau frustre vite l’utilisateur. Coupez la connexion, l’écran reste vide. Persister localement les données dans une base SQLite locale change cette expérience : ouverture instantanée, lecture des données sans connexion, et synchronisation transparente quand le réseau revient. Room 3.0, sorti en mars 2026, est la couche d’abstraction officielle pour SQLite sur Android. Cette version majeure apporte deux changements structurels : exclusivité KSP (KAPT n’est plus supporté) et compatibilité Kotlin Multiplatform via le package androidx.room3. Ce tutoriel reprend la mise en place complète : dépendances, entités, DAO, base de données, migrations, et intégration au Repository de l’application.

Prérequis

  • Projet Kotlin avec ViewModel + StateFlow et Retrofit configurés
  • KSP activé dans le build (cf. Installer Android Studio Otter 3 étape 5)
  • Compréhension basique de SQLite (table, ligne, colonne, clé primaire)
  • Temps estimé : 90 minutes

Étape 1 — Ajouter les dépendances Room 3.0

Room 3.0 utilise le nouveau coordonnées Maven androidx.room3, distinct de l’ancien androidx.room qui reste en maintenance. Pour un projet neuf, on part sur Room 3.0 directement. La configuration via libs.versions.toml :

[versions]
room = "3.0.0"

[libraries]
androidx-room3-runtime = { group = "androidx.room3", name = "room3-runtime", version.ref = "room" }
androidx-room3-ktx = { group = "androidx.room3", name = "room3-ktx", version.ref = "room" }
androidx-room3-compiler = { group = "androidx.room3", name = "room3-compiler", version.ref = "room" }

Dans app/build.gradle.kts, ajoutez les trois dépendances. room3-runtime contient l’API publique, room3-ktx ajoute les extensions Coroutines/Flow indispensables, et room3-compiler doit être déclaré avec ksp (pas implementation) car c’est un processeur d’annotation.

dependencies {
    implementation(libs.androidx.room3.runtime)
    implementation(libs.androidx.room3.ktx)
    ksp(libs.androidx.room3.compiler)
}

Synchronisez. La tâche Gradle kspDebugKotlin doit apparaître dans le panneau Gradle. Au premier build, Room génère automatiquement les classes d’implémentation des DAO et de la base — vous n’avez à écrire que les interfaces.

Étape 2 — Déclarer une entité Room

Une @Entity est une data class Kotlin qui représente une table SQLite. Chaque champ devient une colonne. La @PrimaryKey indique la colonne servant d’identifiant unique. On peut renommer une colonne avec @ColumnInfo(name = "..."), ajouter un index avec @Entity(indices = [...]), et lier des entités via des relations.

import androidx.room3.ColumnInfo
import androidx.room3.Entity
import androidx.room3.PrimaryKey

@Entity(tableName = "articles")
data class ArticleEntity(
    @PrimaryKey val id: Int,
    val titre: String,
    val contenu: String,
    @ColumnInfo(name = "auteur_id") val auteurId: Int,
    @ColumnInfo(name = "publie_le") val publieLe: Long
)

Choix techniques. id: Int pour la clé primaire — si l’API distante fournit déjà des identifiants stables, on les reprend ; sinon, on utilise @PrimaryKey(autoGenerate = true) val id: Long = 0. Les dates sont stockées en Long (timestamp Unix en millisecondes) pour éviter les complications de format. Pour des valeurs énumérées, on utilise des TypeConverter qu’on déclare au niveau de la base.

Étape 3 — Définir un DAO avec suspend et Flow

Un DAO (Data Access Object) déclare les opérations SQL sous forme de fonctions Kotlin annotées. Room génère l’implémentation à la compilation. Les requêtes lecture renvoient un Flow pour être réactives (l’UI se met à jour automatiquement à chaque écriture). Les écritures sont des suspend fun qui s’exécutent sur un dispatcher IO.

import androidx.room3.Dao
import androidx.room3.Insert
import androidx.room3.OnConflictStrategy
import androidx.room3.Query
import androidx.room3.Update
import kotlinx.coroutines.flow.Flow

@Dao
interface ArticlesDao {

    @Query("SELECT * FROM articles ORDER BY publie_le DESC")
    fun observerArticles(): Flow<List<ArticleEntity>>

    @Query("SELECT * FROM articles WHERE id = :id")
    suspend fun obtenirParId(id: Int): ArticleEntity?

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun upsert(articles: List<ArticleEntity>)

    @Update
    suspend fun mettreAJour(article: ArticleEntity)

    @Query("DELETE FROM articles WHERE id = :id")
    suspend fun supprimer(id: Int)

    @Query("DELETE FROM articles")
    suspend fun vider()
}

Trois éléments à comprendre. OnConflictStrategy.REPLACE remplace une ligne existante quand on insère avec une clé déjà présente — exactement ce dont a besoin un cache rafraîchi depuis l’API. observerArticles(): Flow<List<ArticleEntity>> émet une nouvelle valeur à chaque écriture concernant la table articles, sans qu’on ait à invalider manuellement. Et les paramètres SQL utilisent :nom qui correspond au paramètre Kotlin homonyme.

Étape 4 — Construire la base de données

La RoomDatabase est la classe centrale qui expose les DAO et gère le fichier SQLite. On la déclare comme abstract, Room génère l’implémentation. La construction passe par Room.databaseBuilder et doit être singleton dans l’application — créer plusieurs instances ouvre plusieurs handles de fichier et provoque des verrouillages.

import androidx.room3.Database
import androidx.room3.RoomDatabase

@Database(
    entities = [ArticleEntity::class],
    version = 1,
    exportSchema = true
)
abstract class AppDatabase : RoomDatabase() {
    abstract fun articlesDao(): ArticlesDao
}

object DatabaseProvider {
    @Volatile private var instance: AppDatabase? = null

    fun lire(context: Context): AppDatabase {
        return instance ?: synchronized(this) {
            instance ?: Room.databaseBuilder(
                context.applicationContext,
                AppDatabase::class.java,
                "app.db"
            )
                .fallbackToDestructiveMigration(dropAllTables = false)
                .build()
                .also { instance = it }
        }
    }
}

L’option exportSchema = true exporte le schéma SQL dans app/schemas/ à chaque incrémentation de version — utile pour versionner les migrations dans Git. fallbackToDestructiveMigration(dropAllTables = false) est acceptable pendant le développement (les données locales seront effacées en cas de saut de version sans migration définie), à supprimer en production où chaque montée de version doit être migrée explicitement.

Étape 5 — Brancher Room au Repository

Le Repository devient l’orchestrateur entre Room (cache local) et Retrofit (source distante). Il expose un Flow<List<Article>> qui vient de Room, et lance en parallèle un refresh depuis l’API qui écrit dans Room. L’UI ne voit qu’un seul Flow réactif, instantané à l’ouverture grâce au cache, et frais grâce au refresh asynchrone.

class ArticlesRepository(
    private val api: BlogApi,
    private val dao: ArticlesDao
) {
    fun observerArticles(): Flow<List<Article>> {
        return dao.observerArticles().map { liste ->
            liste.map { it.toArticle() }
        }
    }

    suspend fun rafraichir() {
        try {
            val articlesDistants = api.listerArticles()
            dao.upsert(articlesDistants.map { it.toEntity() })
        } catch (e: Exception) {
            // L'utilisateur conserve les données locales
        }
    }

    private fun ArticleEntity.toArticle() = Article(id, titre, contenu)
    private fun ArticleDto.toEntity() = ArticleEntity(
        id = id,
        titre = title,
        contenu = contenu,
        auteurId = auteurId,
        publieLe = System.currentTimeMillis()
    )
}

Cette architecture s’appelle Single Source of Truth : la base locale est la vérité, le réseau alimente la base. Le ViewModel observe observerArticles() et n’a aucune connaissance du réseau. Quand l’utilisateur tire pour rafraîchir, le ViewModel appelle repository.rafraichir(). La nouvelle écriture dans Room déclenche automatiquement l’émission d’une nouvelle valeur sur le Flow, et l’UI se met à jour. Aucun callback, aucune synchronisation manuelle.

Étape 6 — Gérer les migrations de schéma

Quand une application évolue, son schéma SQLite évolue avec. Ajouter une colonne, renommer une table, créer un index : chaque changement incrémente la version de la base et nécessite une Migration qui décrit comment passer de l’ancienne à la nouvelle structure. Sans migration, Room échoue au démarrage avec une IllegalStateException.

val MIGRATION_1_2 = object : Migration(1, 2) {
    override fun migrate(db: SupportSQLiteDatabase) {
        db.execSQL("ALTER TABLE articles ADD COLUMN nb_vues INTEGER NOT NULL DEFAULT 0")
    }
}

Room.databaseBuilder(context, AppDatabase::class.java, "app.db")
    .addMigrations(MIGRATION_1_2)
    .build()

Pour les migrations complexes (renommage de colonne, déplacement de données entre tables), on utilise les auto-migrations de Room 3 qui génèrent le code SQL à partir des annotations @AutoMigration(from = 1, to = 2) déclarées sur la @Database. Pour les cas les plus tordus, on fournit un AutoMigrationSpec avec des callbacks Kotlin. Toutes les migrations doivent être versionnées dans Git et testées avant release : une migration cassée détruit les données utilisateur.

Étape 7 — Tester les requêtes Room

Room s’appuie sur SQLite Android, donc les tests requièrent l’environnement Android. On utilise androidx.room3.testing pour créer une base en mémoire dans un test instrumenté. Pour les tests JVM purs sans émulateur, Room 3 supporte également Robolectric.

@RunWith(AndroidJUnit4::class)
class ArticlesDaoTest {
    private lateinit var db: AppDatabase
    private lateinit var dao: ArticlesDao

    @Before
    fun setup() {
        val context = ApplicationProvider.getApplicationContext<Context>()
        db = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java)
            .allowMainThreadQueries()
            .build()
        dao = db.articlesDao()
    }

    @After fun teardown() = db.close()

    @Test
    fun upsertEtObservationRenvoieLaListe() = runTest {
        dao.upsert(listOf(ArticleEntity(1, "Test", "Contenu", 1, 0L)))
        val liste = dao.observerArticles().first()
        assertEquals(1, liste.size)
        assertEquals("Test", liste[0].titre)
    }
}

La base inMemory est créée et détruite par test, ce qui isole les cas. Les DAO se testent en quelques millisecondes par cas. Pour le Repository qui combine Room et Retrofit, on injecte un faux BlogApi qui renvoie des DTOs contrôlés. Cette pyramide de tests donne confiance sur la couche données sans dépendre d’un serveur réel.

Étape 8 — Relations entre entités

Une application réelle a souvent plusieurs entités liées : un article appartient à un auteur, un commentaire appartient à un article. Room expose ces relations via @Relation. La table junction n’est pas nécessaire pour des relations un-à-plusieurs simples. Voici un exemple : un auteur et ses articles.

@Entity(tableName = "auteurs")
data class AuteurEntity(
    @PrimaryKey val id: Int,
    val nom: String
)

data class AuteurAvecArticles(
    @Embedded val auteur: AuteurEntity,
    @Relation(
        parentColumn = "id",
        entityColumn = "auteur_id"
    )
    val articles: List<ArticleEntity>
)

@Dao
interface AuteursDao {
    @Transaction
    @Query("SELECT * FROM auteurs WHERE id = :id")
    suspend fun obtenirAvecArticles(id: Int): AuteurAvecArticles?
}

L’annotation @Transaction garantit que les deux requêtes générées (auteur + articles) s’exécutent dans une même transaction SQLite — sinon, entre les deux, un autre thread pourrait écrire et rendre le résultat incohérent. Pour les relations plusieurs-à-plusieurs, on utilise @Junction avec une table d’association.

Erreurs fréquentes

Symptôme Cause Solution
Build échoue avec « Cannot find implementation for AppDatabase » KSP non configuré ou room3-compiler manquant Vérifier le plugin KSP et la dépendance ksp(libs.room3.compiler)
IllegalStateException A migration from N to M is required Version incrémentée sans Migration Ajouter addMigrations(…) ou fallbackToDestructiveMigration
Crash en main thread « Cannot access database » Requête synchrone sur UI thread Utiliser suspend fun ou Flow
Plus de mise à jour après insertion Pas d’observation Flow, lecture ponctuelle Remplacer par un Flow sur le DAO
Type non supporté Champ Date, UUID, enum sans converter Ajouter un @TypeConverter au @TypeConverters de la base

Adapter le cache à l’usage réel

Tout mettre en cache n’est pas toujours la bonne idée. Pour une application qui consomme une API à fort volume, trois stratégies pratiques. Un TTL (Time To Live) par entité avec une colonne cached_at : si plus vieille que 24 h, on considère obsolète et on refresh. Une limite de taille : conserver seulement les 500 derniers articles et supprimer les plus anciens (LRU). Une purge planifiée via WorkManager qui tourne une fois par semaine pour nettoyer.

Pour les images, ne pas stocker les blobs dans Room — c’est inefficace. Utiliser Coil 3 qui gère son propre cache disque optimisé. Room sert pour les métadonnées textuelles, Coil pour les bitmaps.

Foire aux questions

Room 3 ou Room 2 ?
Room 3 pour tout nouveau projet en 2026. Room 2.8.4 reste maintenu pour les projets existants.

Faut-il chiffrer la base ?
Pour des données sensibles (santé, finance, mots de passe), oui via SQLCipher. Sinon, la sandbox Android suffit.

Combien de bases par application ?
Une seule en règle générale. Plusieurs uniquement si vous avez des données très indépendantes avec des cycles de vie différents.

Comment migrer depuis Realm ou Greendao ?
Exporter les données via une migration de masse écrite à la main : lire avec l’ancien ORM, écrire dans Room. À faire dans un job en arrière-plan, une seule fois.

Room fonctionne-t-il sur Wear OS ?
Oui, sans modification. Attention à la taille du fichier.

Pour aller plus loin

L’application a maintenant une couche données complète : réseau, cache local, observation réactive. L’étape suivante consiste à sécuriser tout ça par des tests UI. Le tutoriel Tests UI Compose détaille la mise en place de tests automatisés. Pour la vue panoramique, voir le guide principal Kotlin et Jetpack Compose.

Ressources et références

Sponsoriser ce contenu

Cet emplacement est à vous

Position premium en fin d'article — c'est l'instant où les lecteurs sont le plus engagés. Réservez cet espace pour votre marque, votre formation ou votre offre.

Recevoir nos tarifs
Publicité