MFormations
Modern Mobile Engineering

Chapitre 11

11 - Data Persistence

11 - Data Persistence

11 - Data Persistence : Cours complet

Niveau : Intermédiaire → Avancé Durée estimée : 6 h Objectif : stocker, interroger et synchroniser les données de façon robuste et performante sur Android, iOS et cross-platform.


Table des matières

  1. Panorama des solutions
  2. Room
  3. Core Data
  4. SQLite direct + SQLCipher
  5. SharedPreferences / UserDefaults / DataStore
  6. Realm
  7. Caching : offline-first et pagination
  8. DataSync
  9. File storage
  10. Best practices par plateforme

1. Panorama des solutions

1.1 Les familles de persistance

FamilleAndroidiOSCross-platform
Key-ValueSharedPreferences, Jetpack DataStoreUserDefaultsAsyncStorage (RN), shared_preferences (Flutter)
SQLRoom, SQLite, SQLDelightSQLite, SQLite.swift, GRDBSQLDelight, sqflite, WatermelonDB
ORM objetRoomCore DataRealm, Drift
FichiersfilesDir, cacheDirDocuments, Cachespath_provider, expo-file-system

1.2 Choisir selon le besoin

Diagramme en cours de génération...

1.3 Le repository reste la façade

La couche de persistance est toujours derrière le repository (chapitre 10) : l'UI ne connaît jamais Room/Core Data directement.


2. Room

Room est la bibliothèque de persistance officielle d'Android, une couche ORM au-dessus de SQLite avec vérifications compile-time.

2.1 Les 3 composants

Diagramme en cours de génération...
  • Entity : la table (classe annotée @Entity)
  • DAO : l'interface d'accès aux données (annotée @Dao)
  • Database : le point d'accès, fournit les DAOs

2.2 Entités

@Entity(tableName = "tickets")
data class TicketEntity(
    @PrimaryKey val id: String,
    val title: String,
    @ColumnInfo(name = "is_done") val done: Boolean,
    val createdAt: Long
)

Types supportés : primitives, String, enums (via converter), types SQLite natifs.

2.3 DAO avec Flow

@Dao
interface TicketDao {

    @Query("SELECT * FROM tickets ORDER BY createdAt DESC")
    fun observeAll(): Flow<List<TicketEntity>>

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertAll(tickets: List<TicketEntity>)

    @Query("UPDATE tickets SET is_done = :done WHERE id = :id")
    suspend fun setDone(id: String, done: Boolean)

    @Query("DELETE FROM tickets WHERE id = :id")
    suspend fun deleteById(id: String)
}

Le super-pouvoir de Room : Flow. Chaque modification de table re-émet automatiquement le flux. L'UI se met à jour toute seule.

2.4 Relations (one-to-many / many-to-many)

One-to-many : un projet possède plusieurs tickets.

data class ProjectWithTickets(
    @Embedded val project: ProjectEntity,
    @Relation(parentColumn = "id", entityColumn = "projectId")
    val tickets: List<TicketEntity>
)

@Query("SELECT * FROM projects WHERE id = :id")
suspend fun getProjectWithTickets(id: String): ProjectWithTickets?

Many-to-many : une table de liaison.

@Entity(tableName = "project_ticket_cross_ref",
    primaryKeys = ["projectId", "ticketId"])
data class ProjectTicketCrossRef(
    val projectId: String,
    val ticketId: String
)

2.5 Migrations

Quand on modifie le schéma, on augmente la version et on fournit une migration :

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

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

Sans migration, Room crashe (IllegalStateException: A migration from 1 to 2 was required). Toujours tester les migrations avec MigrationTestHelper.

2.6 Écrire des requêtes avec KSP et éviter le N+1

  • Les DAOs sont générés à la compilation par KSP (ksp(roomCompiler)).
  • Pour éviter le problème N+1 (une requête par ligne), utiliser les relations Room ou écrire des jointures SQL explicites (JOIN).

2.7 Index et performance

@Entity(
    tableName = "tickets",
    indices = [Index(value = ["projectId"]), Index(value = ["is_done"])],
    foreignKeys = [
        ForeignKey(
            entity = ProjectEntity::class,
            parentColumns = ["id"],
            childColumns = ["projectId"],
            onDelete = ForeignKey.CASCADE
        )
    ]
)
data class TicketEntity(/* ... */)

Indexer les colonnes de WHERE/JOIN/ORDER BY. Utiliser les foreign keys avec CASCADE pour la cohérence.

2.8 DataStore pour les préférences (voir section 5)


3. Core Data

Core Data est le framework de persistance d'Apple, basé sur SQLite en arrière-plan mais orienté objet et graphe d'objets.

3.1 Le modèle objet

Diagramme en cours de génération...
  • NSManagedObject : une ligne / un objet métier.
  • NSManagedObjectContext : l'« espace de travail » où on lit/écrit. C'est lui qu'on sauvegarde (save()).
  • Persistent Store Coordinator : le pont modèle ↔ store.
  • NSManagedObjectModel : le schéma (.xcdatamodeld).

3.2 Configurer le Persistent Container

final class PersistenceController {
    static let shared = PersistenceController()

    let container: NSPersistentContainer

    init(inMemory: Bool = false) {
        container = NSPersistentContainer(name: "TaskFlow")
        if inMemory {
            container.persistentStoreDescriptions.first!.url =
                URL(fileURLWithPath: "/dev/null")
        }
        container.loadPersistentStores { _, error in
            if let error { fatalError("Core Data failed: \(error)") }
        }
        container.viewContext.automaticallyMergesChangesFromParent = true
    }
}

3.3 NSManagedObject et @FetchRequest

@MainActor
extension TicketEntity {
    @nonobjc class func fetchRequest() -> NSFetchRequest<TicketEntity> {
        NSFetchRequest<TicketEntity>(entityName: "TicketEntity")
    }

    @NSManaged var id: String?
    @NSManaged var title: String?
    @NSManaged var done: Bool
    @NSManaged var createdAt: Date?
    @NSManaged var project: ProjectEntity? // relationship
}

Dans une View SwiftUI :

struct TicketListView: View {
    @FetchRequest(sortDescriptors: [SortDescriptor(\.createdAt, order: .reverse)])
    var tickets: FetchedResults<TicketEntity>

    var body: some View {
        List(tickets) { ticket in
            Text(ticket.title ?? "")
        }
    }
}

3.4 Contexts : viewContext vs background contexts

  • viewContext : sur le main thread, pour l'UI. Accès rapide en lecture.
  • Background contexts (container.newBackgroundContext()) : pour les imports volumineux, l'écriture réseau → ne jamais bloquer l'UI.
let background = container.newBackgroundContext()
background.perform {
    let tickets = try remote.fetchTickets()
    try tickets.forEach { t in
        let entity = TicketEntity(context: background)
        entity.id = t.id
        entity.title = t.title
    }
    try background.save() // merge automatiquement dans viewContext
}

3.5 Relationships

Dans le modèle .xcdatamodeld, définir les relations Project → Ticket (to-many) et Ticket → Project (to-one), avec règle de suppression (Delete Rule) : Cascade (supprimer les enfants) ou Nullify.

3.6 Concurrency — les règles d'or

  1. Un NSManagedObject ne se manipule que dans le contexte qui l'a créé.
  2. Ne pas passer d'objets entre threads ; passer des NSManagedObjectID puis re-fetcher.
  3. viewContext → main thread uniquement.

3.7 Core Data vs SwiftData (iOS 17+)

SwiftData est la version moderne et déclarative de Core Data (@Model, @Query). C'est la recommandation pour les nouvelles apps iOS 17+ :

@Model
final class Ticket {
    var id: String
    var title: String
    var done: Bool = false
    var createdAt: Date = .now
}

Cependant, Core Data reste omniprésent dans les codebases existantes — savoir les deux est un atout.


4. SQLite direct + SQLCipher

4.1 Quand du SQL brut ?

  • Besoin de requêtes très spécifiques (FTS5, trigrammes, agrégats complexes)
  • Migration de legacy
  • Volume de données énorme
  • Contrôle total du SQL

4.2 Android — SQLiteDatabase direct

class TicketStore(context: Context) {
    private val db = context.openOrCreateDatabase("app.db", MODE_PRIVATE, null)

    fun insert(ticket: TicketEntity) {
        val values = ContentValues().apply {
            put("id", ticket.id)
            put("title", ticket.title)
            put("is_done", ticket.done)
        }
        db.insertWithOnConflict("tickets", null, values, SQLiteDatabase.CONFLICT_REPLACE)
    }

    fun query(): List<TicketEntity> {
        val cursor = db.rawQuery("SELECT id, title, is_done FROM tickets ORDER BY created_at DESC", null)
        return cursor.use { c ->
            buildList {
                while (c.moveToNext()) {
                    add(TicketEntity(c.getString(0), c.getString(1), c.getInt(2) == 1))
                }
            }
        }
    }
}

Attention : toujours fermer les Cursor et les bases, utiliser des transactions pour les écritures groupées.

4.3 iOS — SQLite3 + GRDB

iOS natif : SQLite3 (C API) ou GRDB (Swift idiomatique).

import GRDB

var dbQueue: DatabaseQueue!
try dbQueue = DatabaseQueue(path: databasePath)

try dbQueue.write { db in
    try db.execute(
        sql: "INSERT INTO tickets (id, title, done) VALUES (?, ?, ?)",
        arguments: ["1", "Acheter du lait", true]
    )
}

let tickets = try dbQueue.read { db in
    try Row.fetchAll(db, sql: "SELECT * FROM tickets")
}

4.4 SQLCipher — chiffrement

SQLCipher est SQLite chiffré (AES-256). Parfait pour les données sensibles.

Android :

val passphrase = charArrayOf('s', 'e', 'c', 'r', 'e', 't')
val database = SQLiteDatabase.openOrCreateDatabase(
    SQLiteDatabase.getBytes(passphrase), context, null, null
)

iOS :

import SQLCipher

var db: OpaquePointer?
sqlite3_open(dbPath, &db)
sqlite3_key(db, passphrase, Int32(passphraseLength))

Best practice : ne jamais stocker la passphrase en clair — la dériver du Keychain/Keystore (chapitre 15).


5. SharedPreferences / UserDefaults / DataStore

5.1 Comparatif

CritèreSharedPreferences (Android)Jetpack DataStoreUserDefaults (iOS)
TypeXML (legacy)Preferences / Protoplist
AsyncNonOui (Flow / suspend)Non
TransactionsNonOuiNon
RecommandationDéconseillé (sync, data loss, no type safety)RecommandéStandard iOS
Quand l'utiliserLegacyNouveaux projetsStandard

5.2 Jetpack DataStore Preferences

private val Context.dataStore by preferencesDataStore(name = "settings")

val themeKey = stringPreferencesKey("theme")

class SettingsRepository(private val dataStore: DataStore<Preferences>) {

    val theme: Flow<String> = dataStore.data.map { it[themeKey] ?: "system" }

    suspend fun setTheme(value: String) {
        dataStore.edit { prefs -> prefs[themeKey] = value }
    }
}

5.3 DataStore Proto (typé)

Pour des données structurées, utiliser Proto DataStore avec une définition .proto compilée en classes typées — équivalent de @Serializable mais avec schéma.

5.4 UserDefaults (iOS)

// Écriture
UserDefaults.standard.set("dark", forKey: "theme")
UserDefaults.standard.set(true, forKey: "hasSeenOnboarding")

// Lecture typée
let theme = UserDefaults.standard.string(forKey: "theme") ?? "system"

Limites : pas de transactions, pas de requêtes, données non chiffrées (utiliser le Keychain pour les secrets). Pour observer les changements : NotificationCenter ou @AppStorage (SwiftUI).

5.5 Ce qu'il ne faut JAMAIS mettre dans ces stores

  • Mots de passe, tokens (→ Keystore/Keychain)
  • Données volumineuses (> 100 Ko)
  • Collections potentiellement importantes (→ SQL/Room)

6. Realm

Realm est une base de données orientée objet et mobile-first (pas de SQL), très rapide, disponible sur Android/iOS/React Native/Flutter via .realm.

import RealmSwift

final class Ticket: Object {
    @Persisted(primaryKey: true) var id: String
    @Persisted var title: String
    @Persisted var done: Bool = false
    @Persisted var createdAt = Date()
}

let realm = try! Realm()
try! realm.write {
    realm.add(Ticket(id: "1", title: "Étudier Realm"))
}

let tickets = realm.objects(Ticket.self).filter("done == false")

6.1 Avantages / inconvénients

AvantagesInconvénients
Très rapide en lectureRequêtes limitées vs SQL (pas de JOIN natif)
APIs simples objetLicence source-ouverte avec clause (vérifier les termes)
Realm Sync (device↔cloud) intégréHistorique des migrations moins riche que Room
Cross-platformVerbosité des écritures (transactions manuelles)

6.2 Alternatives cross-platform

  • WatermelonDB (React Native) : SQLite très optimisé + sync.
  • Drift (Flutter/Dart) : ORM SQL typé, alternative moderne à sqflite.
  • SQLDelight (Kotlin multiplatform) : SQL typé partagé entre plateformes.

7. Caching : offline-first et pagination

7.1 Offline-first

Le principe : la base locale est la source de vérité de l'UI ; le réseau ne fait que la synchroniser.

Diagramme en cours de génération...

Implémentation dans le repository (vue au chapitre 10) :

override suspend fun getTickets(): Flow<List<Ticket>> = flow {
    emit(localDao.observeAll().first())          // 1. cache immédiat
    val fresh = remote.fetchTickets()            // 2. réseau
    localDao.insertAll(fresh)                    // 3. maj locale
    emit(localDao.observeAll().first())          // 4. re-émission
}.flattenConcat()

7.2 Stratégies de cache

StratégieComportementCas d'usage
Network-onlyPas de cacheDonnées volatiles (solde bancaire)
Cache-firstLocale d'abord, refresh en arrière-planListe tickets, feed
Stale-while-revalidateLocale → réseau → maj localeProfils, articles
Temps d'expirationCache valide X minutesMétéo, tarifs

7.3 Pagination avec cache

Page-based :

@Query("SELECT * FROM tickets ORDER BY createdAt DESC LIMIT :limit OFFSET :offset")
suspend fun getPage(offset: Int, limit: Int): List<TicketEntity>

// Repository
suspend fun loadPage(page: Int): Result<List<Ticket>> {
    val remote = remoteDataSource.fetchPage(page)   // ?page=0&size=20
    localDao.insertAll(remote)                      // cache toujours incrémental
    return Result.success(remote)
}

Cursor-based (recommandé pour les feeds) :

@Query("SELECT * FROM tickets WHERE createdAt < :cursor ORDER BY createdAt DESC LIMIT :limit")
suspend fun getPageAfter(cursor: Long, limit: Int): List<TicketEntity>

Avec Paging 3 (Android), le PagingSource combine Room + réseau :

class TicketPagingSource(
    private val dao: TicketDao,
    private val remote: TicketRemoteDataSource
) : PagingSource<Int, TicketEntity>() {

    override suspend fun load(params: LoadParams<Int>): LoadResult<Int, TicketEntity> {
        val page = params.key ?: 0
        return try {
            val fresh = remote.fetchPage(page)
            dao.insertAll(fresh)
            LoadResult.Page(
                data = dao.getPage(page, params.loadSize),
                prevKey = if (page > 0) page - 1 else null,
                nextKey = if (fresh.isNotEmpty()) page + 1 else null
            )
        } catch (e: IOException) {
            // fallback sur le cache local en cas d'erreur réseau
            LoadResult.Page(dao.getPage(page, params.loadSize), null, page + 1)
        }
    }
}

7.4 Invalidation

  • TTL : timestamp sur chaque ligne (updatedAt) ; purge des entrées périmées.
  • Événements : push/WebSocket qui invalident un scope (ex : ticket édité).
  • Toujours fournir une purge manuelle et respecter les quotas.

8. DataSync

8.1 Le problème de la synchronisation

Deux utilisateurs éditent le même ticket hors-ligne. Qui gagne ? Il faut définir une stratégie de conflit :

StratégieRègleUtilisation
Last-write-winsLe plus récent gagneSimple, pertes possibles
Merge (CRDT)Fusion automatique des champsCollaboratif (notes, docs)
VersioningRejet si version dépasséeDonnées critiques
Résolution manuelleL'utilisateur choisitIdéal quand rare

8.2 Le pattern SyncEngine

Diagramme en cours de génération...

Outbox pattern : chaque écriture locale passe d'abord par une file (outbox), puis est poussée au réseau en arrière-plan. En cas d'échec réseau, la file attend. C'est LA base d'un sync fiable.

@Entity(tableName = "outbox")
data class OutboxEntry(
    @PrimaryKey val id: String,
    val operation: String,          // "CREATE_TICKET"
    val payload: String,            // JSON
    val status: OutboxStatus,       // PENDING / SENT / FAILED
    val createdAt: Long
)

class SyncWorker(private val outboxDao: OutboxDao) {

    suspend fun pushPending() {
        outboxDao.getPending().forEach { entry ->
            runCatching { api.push(entry.payload) }
                .onSuccess { outboxDao.markSent(entry.id) }
                .onFailure { outboxDao.markFailed(entry.id) }
        }
    }
}

8.3 Android — WorkManager

WorkManager (dépendant du cycle de vie) est l'outil officiel pour le sync périodique :

class SyncWorker(
    context: Context,
    params: WorkerParameters
) : CoroutineWorker(context, params) {

    override suspend fun doWork(): Result {
        return try {
            syncEngine.syncAll()
            Result.success()
        } catch (e: IOException) {
            Result.retry() // backoff configuré
        }
    }
}

val request = PeriodicWorkRequestBuilder<SyncWorker>(15, TimeUnit.MINUTES)
    .setConstraints(Constraints.Builder()
        .setRequiredNetworkType(NetworkType.CONNECTED)
        .build())
    .build()
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
    "sync", ExistingPeriodicWorkPolicy.KEEP, request
)

8.4 iOS — BGAppRefreshTask / BGProcessingTask

BackgroundTasks permet un rafraîchissement périodique opportuniste :

BGTaskScheduler.shared.register(
    forTaskWithIdentifier: "com.app.sync", using: nil
) { task in
    guard let task = task as? BGAppRefreshTask else { return }
    Task {
        await syncEngine.syncAll()
        task.setTaskCompleted(success: true)
        scheduleNextSync()  // re-planifier dans 15 min
    }
    task.expirationHandler = { /* sauvegarder l'état et abandonner proprement */ }
}

8.5 Déterminisme et idempotence

  • Chaque opération doit être idempotente (rejouée sans effet de bord) : utiliser un clientOperationId (UUID) que l'API dé-duplique.
  • Journaliser chaque étape (état outbox) pour le debugging.

9. File storage

9.1 Arborescence Android

RépertoireContenuPurge système
filesDirDocuments utilisateur, DBNon
cacheDirImages temporaires, téléchargementsOui
getExternalFilesDir()Fichiers visibles de l'utilisateurNon (app-specific)
MediaStorePhotos/vidéos publicsNon
// Fichier privé
val file = File(context.filesDir, "export.json")

// Fichier de cache
val cache = File(context.cacheDir, "tmp_image.png")

9.2 Arborescence iOS (sandbox)

RépertoireContenuPurge
Documents/Données utilisateur, à sauvegarderNon
Library/Caches/Cache re-téléchargeableOui
Library/Application Support/Données d'appli (Core Data souvent)Non
tmp/Fichiers jetablesOui
let documents = FileManager.default.urls(
    for: .documentDirectory, in: .userDomainMask
).first!

// Marquer comme "ne pas sauvegarder sur iCloud" quand re-téléchargeable :
var url = cacheFile
var values = URLResourceValues()
values.isExcludedFromBackup = true
try url.setResourceValues(values)

9.3 Cas d'usage : images et médias

  • Toujours passer par une lib de cache d'images (Glide/Coil/SDWebImage — chapitre 12).
  • Purger le cache selon une taille max (ex : 100 Mo).
  • Pour les gros fichiers (vidéos), streamer vers cacheDir puis renameTo à la fin.

10. Best practices par plateforme

10.1 Android

  1. Room par défaut pour les données structurées ; DataStore pour les préférences.
  2. DAOs en suspend/Flow ; jamais de blocage du main thread.
  3. Migrations versionnées et testées (MigrationTestHelper).
  4. Indexer les colonnes interrogées ; éviter SELECT * inutile.
  5. WorkManager pour le sync ; outbox pattern pour les écritures hors-ligne.
  6. Ne jamais stocker de secrets (chapitre 15).

10.2 iOS

  1. Core Data / SwiftData pour les données structurées ; UserDefaults pour les réglages.
  2. viewContext = main thread ; background contexts pour l'import.
  3. Passer des NSManagedObjectID, pas des objets, entre contexts.
  4. Marquer isExcludedFromBackup pour le cache.
  5. BackgroundTasks pour le sync opportuniste.

10.3 Cross-platform

  1. Flutter : drift (SQL typé) ou sqflite ; shared_preferences ; path_provider.
  2. React Native : WatermelonDB (sync) ou SQLite via expo-sqlite ; AsyncStorage pour les préférences ; react-native-mmkv (perf).
  3. Toujours encapsuler derrière un repository (chapitre 10) pour pouvoir remplacer la solution.

10.4 La checklist de la couche Data

  • Schéma versionné (migrations prêtes avant de merger)
  • DAOs/requêtes typées et testées
  • Écritures hors main thread
  • Cache-first implémenté au repository
  • Outbox pour les écritures à sync
  • Stratégie de conflit définie
  • Purge et quota du cache
  • Aucun secret en clair

11. Synthèse

Diagramme en cours de génération...

Points à retenir :

  1. Room (Android), Core Data/SwiftData (iOS) et SQLite sont les bases solides.
  2. Le cache est local-first : l'UI lit la base, le réseau la synchronise.
  3. Les préférences ne sont jamais pour les données sensibles ni volumineuses.
  4. La synchronisation se fait avec un outbox + stratégie de conflit explicite.
  5. Toute la persistance est derrière le repository (Clean Architecture).

Aller plus loin

  • Chapitre 12 (Networking-Mobile) : la couche remote qui alimente le sync.
  • Chapitre 15 (Sécurité-Mobile) : chiffrement (SQLCipher, Keystore, Keychain).
  • Chapitre 18 (Projet-Fil-Rouge) : TaskFlow avec Room + sync.