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
- Panorama des solutions
- Room
- Core Data
- SQLite direct + SQLCipher
- SharedPreferences / UserDefaults / DataStore
- Realm
- Caching : offline-first et pagination
- DataSync
- File storage
- Best practices par plateforme
1. Panorama des solutions
1.1 Les familles de persistance
| Famille | Android | iOS | Cross-platform |
|---|---|---|---|
| Key-Value | SharedPreferences, Jetpack DataStore | UserDefaults | AsyncStorage (RN), shared_preferences (Flutter) |
| SQL | Room, SQLite, SQLDelight | SQLite, SQLite.swift, GRDB | SQLDelight, sqflite, WatermelonDB |
| ORM objet | Room | Core Data | Realm, Drift |
| Fichiers | filesDir, cacheDir | Documents, Caches | path_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
- Un
NSManagedObjectne se manipule que dans le contexte qui l'a créé. - Ne pas passer d'objets entre threads ; passer des
NSManagedObjectIDpuis re-fetcher. 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ère | SharedPreferences (Android) | Jetpack DataStore | UserDefaults (iOS) |
|---|---|---|---|
| Type | XML (legacy) | Preferences / Proto | plist |
| Async | Non | Oui (Flow / suspend) | Non |
| Transactions | Non | Oui | Non |
| Recommandation | Déconseillé (sync, data loss, no type safety) | Recommandé | Standard iOS |
| Quand l'utiliser | Legacy | Nouveaux projets | Standard |
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
| Avantages | Inconvénients |
|---|---|
| Très rapide en lecture | Requêtes limitées vs SQL (pas de JOIN natif) |
| APIs simples objet | Licence source-ouverte avec clause (vérifier les termes) |
| Realm Sync (device↔cloud) intégré | Historique des migrations moins riche que Room |
| Cross-platform | Verbosité 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égie | Comportement | Cas d'usage |
|---|---|---|
| Network-only | Pas de cache | Données volatiles (solde bancaire) |
| Cache-first | Locale d'abord, refresh en arrière-plan | Liste tickets, feed |
| Stale-while-revalidate | Locale → réseau → maj locale | Profils, articles |
| Temps d'expiration | Cache valide X minutes | Mé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égie | Règle | Utilisation |
|---|---|---|
| Last-write-wins | Le plus récent gagne | Simple, pertes possibles |
| Merge (CRDT) | Fusion automatique des champs | Collaboratif (notes, docs) |
| Versioning | Rejet si version dépassée | Données critiques |
| Résolution manuelle | L'utilisateur choisit | Idé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épertoire | Contenu | Purge système |
|---|---|---|
filesDir | Documents utilisateur, DB | Non |
cacheDir | Images temporaires, téléchargements | Oui |
getExternalFilesDir() | Fichiers visibles de l'utilisateur | Non (app-specific) |
| MediaStore | Photos/vidéos publics | Non |
// 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épertoire | Contenu | Purge |
|---|---|---|
Documents/ | Données utilisateur, à sauvegarder | Non |
Library/Caches/ | Cache re-téléchargeable | Oui |
Library/Application Support/ | Données d'appli (Core Data souvent) | Non |
tmp/ | Fichiers jetables | Oui |
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
cacheDirpuisrenameToà la fin.
10. Best practices par plateforme
10.1 Android
- Room par défaut pour les données structurées ; DataStore pour les préférences.
- DAOs en
suspend/Flow; jamais de blocage du main thread. - Migrations versionnées et testées (
MigrationTestHelper). - Indexer les colonnes interrogées ; éviter
SELECT *inutile. - WorkManager pour le sync ; outbox pattern pour les écritures hors-ligne.
- Ne jamais stocker de secrets (chapitre 15).
10.2 iOS
- Core Data / SwiftData pour les données structurées ; UserDefaults pour les réglages.
viewContext= main thread ; background contexts pour l'import.- Passer des
NSManagedObjectID, pas des objets, entre contexts. - Marquer
isExcludedFromBackuppour le cache. - BackgroundTasks pour le sync opportuniste.
10.3 Cross-platform
- Flutter :
drift(SQL typé) ousqflite;shared_preferences;path_provider. - React Native :
WatermelonDB(sync) ouSQLiteviaexpo-sqlite;AsyncStoragepour les préférences ;react-native-mmkv(perf). - 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 :
- Room (Android), Core Data/SwiftData (iOS) et SQLite sont les bases solides.
- Le cache est local-first : l'UI lit la base, le réseau la synchronise.
- Les préférences ne sont jamais pour les données sensibles ni volumineuses.
- La synchronisation se fait avec un outbox + stratégie de conflit explicite.
- 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.