Android local-first en 2026 : SQLite, Room et garder les données de l'utilisateur sur l'appareil
Un guide 2026 pour construire des applications Android local-first avec Room et SQLite — conception du schéma, migrations, WAL, exports, et quand (et quand ne pas) ajouter la synchronisation.
L’architecture par défaut d’une application Android en 2026 suppose toujours l’existence d’un backend. Authentification, synchronisation, notifications push, un serveur à appeler dès que le réseau est disponible. Pour beaucoup d’applications, c’est la bonne forme. Pour beaucoup d’autres, ce n’est que de l’architecture à maintenir pour toujours — et un flux de données que votre politique de confidentialité doit défendre.
Je construis l’inverse : des applications Android local-first où l’appareil est la source de vérité. Granyn (budget), Hydrame (hydratation), Subly (abonnements) fonctionnent toutes ainsi. Pas de comptes, pas de base de données dans le cloud détenant vos données, pas de serveurs que je dois maintenir à 3 heures du matin. Cet article est la version pratique 2026 de ma façon de faire.
Ce que « local-first » signifie vraiment
Local-first est une affirmation plus forte que « fonctionne hors ligne » :
- L’appareil possède les données. Les lectures et les écritures passent d’abord par la base de données locale ; l’interface ne se bloque jamais sur un appel réseau.
- L’application est pleinement fonctionnelle en mode avion. Y compris les fonctionnalités qui, dans une application cloud-first, se désactiveraient silencieusement.
- La synchronisation, si elle existe, est chiffrée de bout en bout entre les propres appareils de l’utilisateur — le serveur est un relais, pas un registre.
- L’export est une fonctionnalité, pas un bricolage. Vos données, dans un format que vous pouvez emporter ailleurs.
Pour une application de productivité mono-appareil — un tracker, un journal, un budget — c’est tout ce dont vous avez besoin. Le serveur est un fardeau de maintenance que vous choisissez de ne pas assumer.
Local-first, c’est l’architecture du « nous ne pouvons pas perdre vos données, parce que nous ne les avons jamais eues ».
Room en 2026 : le choix ennuyeux qui a bien vieilli
Pour le stockage local sur Android en 2026, Room reste la bonne réponse. C’est une fine couche au-dessus de SQLite avec vérification SQL à la compilation, intégration des coroutines + Flow, et KSP (plus d’attente liée aux annotation processors). Il est resté suffisamment stable pour que des articles écrits il y a des années s’appliquent encore, et les petites additions depuis (migrations automatiques, @MapColumn, prise en charge multi-processus facilitée) vont toutes dans la bonne direction.
Une configuration minimale :
@Entity(tableName = "subscriptions")
data class SubscriptionEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val service: String,
val amountMinor: Long, // store cents, never floats
val currency: String, // "TRY", "EUR", "USD"
val cycle: BillingCycle,
val nextChargeDate: LocalDate,
val createdAt: Instant = Instant.now(),
)
@Dao
interface SubscriptionDao {
@Query("SELECT * FROM subscriptions ORDER BY nextChargeDate ASC")
fun observeAll(): Flow<List<SubscriptionEntity>>
@Query("SELECT SUM(amountMinor) FROM subscriptions WHERE currency = :currency")
fun totalForCurrency(currency: String): Flow<Long?>
@Insert
suspend fun insert(s: SubscriptionEntity): Long
@Delete
suspend fun delete(s: SubscriptionEntity)
}
Quelques principes que j’applique toujours, et que je défendrais :
- Stockez l’argent comme un entier en unité mineure.
amountMinor: Longau lieu deDouble. Les flottants et les devises ne font pas bon ménage ; un seul bug d’arrondi vous coûtera une semaine. - Utilisez
InstantetLocalDatedekotlinx-datetimeavec unTypeConverter. Les typesjava.time.*du JDK fonctionnent aussi, mais kotlinx-datetime est plus accueillant pour le multiplateforme si jamais vous prenez cette voie. Flowpartout depuis le DAO. L’interface s’abonne ; l’application réagit automatiquement aux écritures sans que vous ayez à câbler des listeners.
Migrations : pensez à la base de données comme à une API publique
La raison la plus fréquente pour laquelle les applications indie perdent des données est une migration ratée. Traitez votre schéma comme une API publique : chaque changement est versionné, testé et irréversible.
Room rend cela presque facile :
@Database(
entities = [SubscriptionEntity::class],
version = 3,
autoMigrations = [
AutoMigration(from = 1, to = 2),
AutoMigration(from = 2, to = 3, spec = AddNotesField::class),
],
exportSchema = true,
)
@TypeConverters(Converters::class)
abstract class AppDatabase : RoomDatabase()
@RenameColumn(tableName = "subscriptions", fromColumnName = "note", toColumnName = "notes")
class AddNotesField : AutoMigrationSpec
Deux réglages à verrouiller dès le premier jour :
exportSchema = true+ commitez les fichiers JSON générés dansschemas/sous git. Ajoutez ensuite unRoomDatabaseSchemaTestqui charge chaque schéma historique et migre vers l’avant. C’est l’assurance la moins chère que vous achèterez jamais.- Ne modifiez jamais une migration publiée. Si vous trouvez un bug, écrivez une nouvelle migration qui le corrige. Le schéma est un registre à sens unique.
Le pattern qui m’a sauvé deux fois : effectuer toutes les transformations destructrices sous forme de MIGRATION en SQL brut, pas en auto-migration, afin de pouvoir copier les anciennes lignes dans une table temporaire, reconstruire, et confirmer que les décomptes correspondent avant de valider la migration.
WAL et autres petites victoires SQLite
Room active par défaut le write-ahead logging (WAL) sur Android, mais il vaut la peine de savoir ce que cela signifie : les écrivains ne bloquent pas les lecteurs, le fsync est moins coûteux, et l’application paraît nettement plus réactive sur les listes qui se re-interrogent au fur et à mesure que l’utilisateur tape. Quelques petits réglages au niveau SQLite qui valent encore une ligne :
val db = Room.databaseBuilder(context, AppDatabase::class.java, "app.db")
.setQueryExecutor(Dispatchers.IO.asExecutor())
.setTransactionExecutor(Dispatchers.IO.asExecutor())
.addCallback(object : RoomDatabase.Callback() {
override fun onOpen(db: SupportSQLiteDatabase) {
super.onOpen(db)
db.query("PRAGMA journal_mode=WAL;").use { it.moveToFirst() }
db.query("PRAGMA synchronous=NORMAL;").use { it.moveToFirst() }
}
})
.build()
synchronous=NORMAL avec WAL est une valeur par défaut suffisamment sûre pour une application sur l’appareil où vous ne gérez pas un grand livre bancaire. Si vos données sont littéralement un grand livre bancaire, laissez-le sur FULL.
Pour les listes, indexez les colonnes selon lesquelles vous triez. Le nombre d’applications indie que j’ai vues avec une requête WHERE created_at > et aucun index sur created_at est trop élevé. C’est de la performance gratuite que vous ne prenez pas.
Les exports sont une fonctionnalité, pas un bouton de panique
Toute application local-first devrait proposer un export en un seul geste. Trois raisons :
- Cela crée de la confiance. « Vos données sont à vous » cesse d’être du marketing au moment où l’utilisateur a un CSV sur son téléphone.
- C’est de la portabilité gratuite. Fini les tickets de support « mais comment je passe à une autre application ? ».
- C’est une bonne histoire de sauvegarde sans que vous ayez à exploiter un service de sauvegarde.
Un export CSV minimal à partir d’un Flow d’entités :
suspend fun exportCsv(uri: Uri, context: Context, dao: SubscriptionDao) {
val rows = dao.observeAll().first()
context.contentResolver.openOutputStream(uri)?.use { out ->
out.bufferedWriter().use { w ->
w.appendLine("service,amount,currency,cycle,nextDate")
rows.forEach {
w.appendLine("${it.service},${it.amountMinor / 100.0},${it.currency},${it.cycle},${it.nextChargeDate}")
}
}
}
}
Associez-le à un import et l’utilisateur peut passer d’un appareil à l’autre en s’envoyant un CSV par e-mail. Pas glamour ; fonctionne parfaitement.
Quand la synchronisation commence vraiment à compter
Local-first ne veut pas dire pas de synchronisation. Cela veut dire que l’appareil est la source de vérité. La synchronisation, quand vous l’ajoutez, doit maintenir cet invariant vrai.
Les options sérieuses en 2026 :
- Synchronisation basée sur les CRDT (Yjs, Automerge) pour les cas collaboratifs ou multi-appareils. Lourd pour une application mono-utilisateur.
- Synchronisation de blobs chiffrés de bout en bout pour le cas « deux appareils, même utilisateur ». Chiffrez localement, le serveur stocke des octets opaques, déchiffrez localement sur l’autre appareil. Une identité à la Tailscale, les API iCloud / Drive comme transports, ou votre propre petit relais.
- Simplement basée sur des fichiers pour de nombreux cas. Exportez vers Google Drive selon un calendrier, restaurez sur le nouvel appareil. Pas aussi fluide qu’une synchronisation en direct, mais aucun serveur à gérer, aucune primitive E2EE à ne pas rater.
Pour Granyn et Hydrame, j’ai livré sans synchronisation. La plupart des utilisateurs n’ont qu’un seul téléphone et le gardent des années ; il s’avère que la synchronisation est bien moins importante que les gens le supposent lors des entretiens de découverte produit. Je préfère ne pas l’avoir plutôt que de livrer une version à moitié cassée.
À quoi ressemble une journée local-first
Chaque atelier indie a sa propre variante, mais la mienne ressemble à ceci :
- L’interface est en Compose, observe les Flows du repository.
- Le repository enveloppe le DAO avec le mapping vers/depuis les types du domaine.
- Le DAO est la seule chose qui touche à Room.
- WorkManager effectue des tâches locales périodiques (planification des notifications, recalcul du total quotidien) — jamais de réseau.
- DataStore conserve les réglages, jamais les données de l’utilisateur.
Il y a un Repository, une seule instance de database, et le reste de l’application parle aux coroutines et à StateFlow. Pas de LiveData dans le nouveau code, pas de RxJava nulle part. Le graphe de dépendances tient sur une serviette de table.
Ce que vous abandonnez, ce que vous gagnez
Ce que vous abandonnez en passant au local-first :
- La synchronisation multi-appareils sans travail d’ingénierie
- L’analytique côté serveur sur le contenu réel (et les tentations qui vont avec)
- Les fonctionnalités faciles de « partage avec un ami »
- Le mot à la mode marketing de « l’IA sur vos données » si votre IA est dans le cloud
Ce que vous gagnez :
- Une politique de confidentialité qui s’écrit pratiquement toute seule
- Zéro coût de serveur pour toujours
- Des applications qui fonctionnent en mode avion et paraissent instantanées
- Des utilisateurs qui vous font confiance parce que l’architecture le mérite
Pour une application indie livrée en 2026, c’est le bon compromis. La stack cloud-first est impressionnante, mais c’est aussi deux emplois supplémentaires pour lesquels vous n’avez pas signé : SRE et support client en cas de perte de données. Local-first supprime les deux. Vous devenez une personne qui livre des applications, pas une personne qui gère des serveurs et livre aussi des applications quand elle a le temps.
Si vous hésitez à propos d’une idée d’application Android indie parce que la partie cloud vous semble écrasante, je passerais simplement outre. Commencez en local. Ajoutez la synchronisation plus tard, si jamais vous en avez vraiment besoin. La plupart du temps, ce ne sera pas le cas.
// À lire aussi
D’autres notes du journal
Construire Granyn : un suivi de budget sans connexion bancaire, en trois tables
Comment Granyn suit les dépenses dans plusieurs devises et détecte les factures récurrentes sans lier le moindre compte bancaire — le schéma Room derrière tout ça, et les compromis qu'il impose.
Construire Stocky : prédire ce qui va manquer dans votre cuisine — sur l'appareil lui-même
Comment Stocky transforme un scan de code-barres et un peu d'historique d'utilisation en une liste de courses qui apparaît avant que vous ne tombiez à court — avec tous les calculs, et toutes les données, qui restent sur votre téléphone.
OCR respectueux de la vie privée sur Android : comment Subly lit les factures sans le cloud
Un guide pratique 2026 de l'OCR sur l'appareil sous Android avec ML Kit Text Recognition — la configuration, le code et les astuces d'extraction de champs qui gardent les données utilisateur hors de vos serveurs.