Aller au contenu
Tous les articles

La recherche plein texte dans Room : ajouter une recherche instantanée à une app Android local-first en 2026

Un guide pratique du support FTS4 de Room sur Android — construire une table virtuelle de recherche, la synchroniser avec des triggers, et pourquoi FTS5 exige une migration manuelle.

MFKAPPS 6 min de lecture

La recherche est une fonctionnalité que personne ne remarque jusqu’à ce qu’elle soit lente. Tapez “yao” dans un garde-manger de trois cents articles et attendez-vous à ce que la liste se filtre au fur et à mesure — si cela prend ne serait-ce que 200 ms à répondre, l’app paraît cassée. La plupart des apps basées sur Room gèrent ça avec une clause LIKE '%requête%', et ça fonctionne très bien sur de petites tables. Ça cesse de fonctionner dès que la table grossit, que la requête contient plus d’un mot, ou que vous voulez une tolérance aux fautes de frappe. SQLite a une vraie réponse à ça depuis toujours : la recherche plein texte, exposée dans Room via l’annotation @Fts4. Voici comment ça fonctionne réellement, où ça casse, et pourquoi FTS5 — la version que la plupart des tutoriels supposent que vous utilisez — n’est pas quelque chose que Room vous donne gratuitement.

Pourquoi LIKE ne passe pas à l’échelle

SELECT * FROM pantry_items WHERE name LIKE '%yaourt%' ne peut pas utiliser d’index. SQLite doit scanner chaque ligne et exécuter une correspondance de sous-chaîne sur chacune. Sur quelques centaines de lignes, c’est invisible. Sur quelques milliers — ce qu’une app de garde-manger avec scan de codes-barres et historique de reçus atteint plus vite qu’on ne le pense — ça devient un accroc visible à chaque frappe, surtout si la requête doit aussi faire un OR sur plusieurs colonnes (nom, marque, catégorie).

La recherche plein texte inverse ça. Au lieu de scanner les lignes, SQLite construit un index inversé au moment de l’écriture : chaque mot est associé aux lignes qui le contiennent. Une recherche devient une consultation d’index, pas un scan, donc la performance reste stable à mesure que la table grossit.

Ce que Room vous donne réellement : FTS4, pas FTS5

C’est le point qui piège la plupart des gens, parce que la majorité du contenu FTS sur le web suppose le module FTS5 plus récent de SQLite. L’annotation @Fts4 de Room câble une table virtuelle FTS4 — elle n’a pas d’équivalent @Fts5. FTS4 et FTS5 diffèrent suffisamment pour que ça compte : FTS5 a une syntaxe de requête plus sensée, un classement bm25() intégré, et une meilleure gestion des requêtes par préfixe — rien de tout cela ne vient automatiquement avec FTS4 dans Room.

Si vous avez besoin de FTS5, vous pouvez toujours l’obtenir — mais à la main, en créant vous-même la table virtuelle dans une Migration avec du SQL brut (CREATE VIRTUAL TABLE ... USING fts5(...)) au lieu d’une entité annotée, puis en la mappant via une @DatabaseView simple ou une requête brute pour les lectures. Pour la plupart des cas de recherche locale — noms d’articles de garde-manger, noms d’abonnements, titres de notes — FTS4 suffit largement, et ça ne coûte rien de plus qu’une annotation. Commencez là ; ne passez à FTS5 manuellement que si vous avez réellement besoin de requêtes de phrase ou d’un classement intégré.

Câbler une table FTS4 dans Room

Une table FTS a besoin d’une entité de contenu compagne — la table normale que vous interrogez déjà pour tout le reste — plus une table virtuelle qui indexe les colonnes recherchables :

@Entity(tableName = "pantry_items")
data class PantryItem(
    @PrimaryKey(autoGenerate = true) val id: Long = 0,
    val name: String,
    val brand: String,
    val category: String,
)

@Fts4(contentEntity = PantryItem::class)
@Entity(tableName = "pantry_items_fts")
data class PantryItemFts(
    val name: String,
    val brand: String,
    val category: String,
)

contentEntity indique à Room de traiter pantry_items comme la source de vérité et de ne stocker que l’index dans la table FTS, pas une copie de chaque ligne. La requête DAO ressemble presque à une requête normale, sauf qu’elle joint la rowid de la table FTS :

@Query("""
    SELECT pantry_items.* FROM pantry_items
    JOIN pantry_items_fts ON pantry_items.id = pantry_items_fts.rowid
    WHERE pantry_items_fts MATCH :query
""")
fun search(query: String): Flow<List<PantryItem>>

MATCH est l’opérateur FTS — c’est ce qui transforme la requête en consultation d’index plutôt qu’en scan. Passer "yao*" au lieu de "yao" vous donne une correspondance par préfixe, ce qu’il vous faut pour une recherche au fil de la frappe.

Garder l’index synchronisé

Avec contentEntity, Room génère les triggers qui gardent pantry_items_fts synchronisé avec pantry_items lors des insertions, mises à jour et suppressions — vous ne les écrivez pas à la main. La seule chose à surveiller : cette synchronisation ne couvre que les écritures passant par les méthodes insert/update/delete générées par Room. Un UPDATE SQL brut exécuté hors de la couche DAO de Room, ou une importation en masse faite avec execSQL, contourne le mappage d’entité de contenu adossé aux triggers de façons subtiles et faciles à mal gérer. Faites passer les modifications du garde-manger — y compris le chemin d’insertion par scan de code-barres — par les mêmes méthodes DAO que la validation de schéma de Room prend déjà en compte, plutôt que par un raccourci SQL brut séparé pour les imports “rapides”.

Le classement : la vraie limite de FTS4

FTS4 vous donne des correspondances correctes mais aucun classement de pertinence au-delà de l’ordre de correspondance. Si quelqu’un cherche “lait” et que “Lait Entier” et “Barre Chocolat au Lait” correspondent tous les deux, FTS4 ne vous dira pas lequel l’utilisateur voulait probablement — vous obtenez les lignes dans l’ordre de la table, pas dans l’ordre de pertinence. Pour quelques centaines d’articles de garde-manger, ça compte rarement en pratique ; la liste est assez courte pour être parcourue du regard. Si ça commence à compter — un catalogue plus grand, ou une recherche sur un ensemble de champs plus large — la solution sans passer à FTS5 est un simple réordonnancement côté client : récupérez les correspondances, puis triez selon que la requête correspond au début du nom avant tout le reste. C’est quelques lignes de Kotlin, pas une migration de base de données, et ça corrige le cas qui agace réellement les utilisateurs : des correspondances exactes et de préfixe enterrées sous des correspondances partielles non liées.

À retenir

Le support FTS4 de Room transforme un scan linéaire en consultation d’index pour le prix d’une annotation et d’une requête DAO légèrement différente — pas de serveur, pas de SDK de recherche tiers, pas d’aller-retour réseau pour quelque chose qui devrait paraître instantané. C’est le bon défaut pour la recherche dans toute app Android local-first. Les deux choses à retenir : Room parle FTS4, pas FTS5, donc ne concevez pas autour de fonctionnalités de classement qui n’existent que dans le module plus récent, et chaque chemin d’écriture censé être recherchable doit passer par la couche DAO de Room, sinon l’index se désynchronise silencieusement de la table qu’il est censé décrire. Faites bien ces deux choses, et la recherche cesse d’être une fonctionnalité dont vous devez vous inquiéter.

Si vous voulez voir ce schéma dans une app publiée, Stocky l’utilise pour chercher dans un garde-manger qui peut grossir jusqu’à des centaines d’articles — scans de codes-barres, imports de reçus et saisies manuelles atterrissent tous dans la même table recherchable.