Simplifiez vos bases de données SQL avec room
Aujourd’hui nous allons parler d’une des librairies qui composent l’Android Architecture Components : Room
Pour rappel, l’Android Architecture Components est une collection de librairies qui vous aident à concevoir des applications robustes, testables et maintenables. Avec, en autre, la gestion du cycle de vie (LifecycleObserver, LifecycleOwner, …), de la persistance des données (Room) mais aussi de l’interface de votre application (LiveData, ViewModel, …).
Introduction
Comme vous l’avez compris, on va parler base de données, SQLite et de son DatabaseHandler, faire des execSQL, rawQuery, ContentValues, … Eh bien pas du tout !
En effet, Room est une couche d’abstraction à SQLite qui facilite la gestion de votre base, de sa création jusqu’à la lecture de vos données en passant par leur mise à jour. De plus, Room permet de mettre en cache des données pertinentes, issues de votre modèle, lorsque vous n’avez pas de réseau et de synchroniser ces dernières avec votre serveur une fois que vous le retrouvez. La librairie fournit aussi un ensemble d’annotations qui vont nous permettre d’accélérer grandement le développement de notre couche de persistance sans perdre en compréhension.
Prérequis
Avant de commencer je précise que le développement est réalisé avec le langage Kotlin et qu’aucune explication ne sera faite sur les syntaxes utilisées. J’ajoute aussi que ce tutoriel peut constituer une première expérience Kotlin (comme ce fut le cas pour moi 🙂 ) et reste très compréhensible pour des non-initiés. De plus, il n’y aura pas d’implémentation avec les librairies LiveData ou RxJava, ces dernières feront l’objet d’un autre tutoriel.
Qui dit librairie, dit dépendance, nous allons commencer par ajouter celles qui vont nous permettre de réaliser l’ensemble de ce tutoriel :
// Kotlin compile "android.arch.persistence.room:runtime:1.0.0" annotationProcessor "android.arch.persistence.room:compiler:1.0.0" // Gson compile "com.google.code.gson:gson:2.8.0"
Gson sera utilisé pour la sérialisation/désérialisation de certains objets persistés en base de données.
Modèle de données
Pour la présentation de ce tutoriel j’ai décidé de ne pas reprendre la classique classe User seule, avec le développement de son CRUD (Create, Read, Update, Delete) associé, que j’ai pu voir sur la plupart des autres présentations de Room. Mais de l’agrémenter avec de l’héritage, et d’ajouter d’autres classes ainsi que des relations entre-elles.
Nous aurons donc, dans ce modèle de données, des Player et des Coach qui seront à la base, des User. Les Player et Coach pourront intégrer des Team (un Coach et plusieurs Player) et ces dernières pourront se rencontrer lors de Match. Les résultats des rencontres seront représentés par des Score.
Implémentation
User, Player, Coach
abstract class User(var name: String) class Player (name: String, var position: String) : User(name) { lateinit var avatar: String } class Coach(name: String, var experience: Int) : User(name)
Team
class Team(var name: String, var coachId: Long, var address: Address, var players: List<Long>) class Address(var address: String, var postal: String, var city: String)
Match, Score
class Match (var date: Date, var firstTeamId: Long, var secondTeamId: Long) class Score(var label: String, var scoreTeam1: Int, var scoreTeam2: Int, var matchId: Long)
Persistance
Nous allons maintenant persister (sauvegarder), en base de données, des instances qui composent notre modèle. Nous allons devoir effectuer des modifications sur ce dernier: ajouter des annotations, faire des choix pour les relations entre nos objets et, bien sûr, créer notre base de données ainsi que des méthodes pour réaliser notre CRUD.
Les Entités
Modifions nos classes pour pouvoir les persister. Nous allons pour cela les transformer en entités, c’est à dire pouvoir avoir une représentation de notre objet en base.
Player – Coach
@Entity(tableName="players") class Player (name: String, var position: String) : User(name) { @Ignore lateinit var avatar: String }
@Entity class Coach(name: String, @ColumnInfo(name = "xp") var experience: Int ) : User(name)
Comme vous pouvez le voir, la transformation en entité se fait via l’annotation @Entity, rien de plus. Il est possible d’ajouter des paramètres à cette annotation, comme la déclaration de clé primaire (et/ou étrangère), la définition des indices et, dans notre cas, de définir le nom de la table (tableName). Si vous ne définissez pas de nom, Room prendra le nom de la classe en lowercase. Une seconde annotation présente ici, @Ignore qui va indiquer que le champs (la variable) ‘avatar‘ ne sera pas présente lors de la persistance. A la lecture de l’objet depuis notre base, la variable ‘avatar’ faudra null. Dernière annotation: @ColumnInfo(name = « xp ») qui permet de spécifier un nom à la colonne. Comme pour tableName, si vous ne précisez pas de nom de colonne, Room prendra le nom de la variable par défaut.
User
abstract class User(var name: String) { @PrimaryKey(autoGenerate = true) var id: Long = 0 }
La contrainte d’unicité est représentée par l’annotation @PrimaryKey. Ici le choix a été fait d’ajouter un identifiant auto-généré (autoGenerate=true) à la classe User qui sera donc commun à la classe Player et Coach (un Coach pourrait devenir Player ou inversement). J’ajoute qu’il est possible de créer des clés primaires composées de plusieurs champs. Il faut pour cela, ajouter l’information directement dans l’annotation @Entity :
@Entity(primaryKeys = { "nomVariable1", "nomVariable2" })
Team – Address
@Entity(foreignKeys = arrayOf( ForeignKey(entity = Coach::class, parentColumns = arrayOf("id"), childColumns = arrayOf("coachId"), onDelete = ForeignKey.NO_ACTION)) ) class Team(var name: String, var coachId: Long, @Embedded var address: Address) { @PrimaryKey(autoGenerate = true) var id: Long = 0 }
Deux nouveautés dans cette classe : @Embedded et ForeignKey. La première annotation permet d’indiquer que la classe Team va embarquer un objet Address (qui ne doit pas être annoté comme une entité). Cela ce traduit par l’ajout de colonnes dans la table Team représentants les variables de l’objet Address: address, postal, city (voir résultat ci-dessous). L’objet Team contient aussi l’identifiant d’un Coach (coachId) qui sera la référence vers l’enregistrement de ce dernier grâce à la création d’une clé étrangère. On précise dans sa définition que l’on fait référence à la classe Coach et sa clé primaire ‘id’ a pour nom ‘coachId’ dans la classe Team. On ajoute aussi l’action à effectuer quand on supprime un enregistrement (un Coach), ici pas d’action (onDelete = ForeignKey.NO_ACTION). Vous pouvez retrouver des informations sur les autres actions ici.
Résultat avec l’annotation @Embedded
Match – Score
@Entity(foreignKeys = arrayOf( ForeignKey(entity = Team::class, parentColumns = arrayOf("id"), childColumns = arrayOf("firstTeamId"), onDelete = ForeignKey.NO_ACTION), ForeignKey(entity = Team::class, parentColumns = arrayOf("id"), childColumns = arrayOf("secondTeamId"), onDelete = ForeignKey.NO_ACTION)) ) class Match (var date: Date, var firstTeamId: Long, var secondTeamId: Long) { @PrimaryKey(autoGenerate = true) var id: Long = 0 }
@Entity(foreignKeys = arrayOf( ForeignKey(entity = Match::class, parentColumns = arrayOf("id"), childColumns = arrayOf("matchId"), onDelete = ForeignKey.CASCADE)) ) class Score(var label: String, var scoreTeam1: Int, var scoreTeam2: Int, var matchId: Long) { @PrimaryKey(autoGenerate = true) var id: Long = 0 }
Rien de particulièrement nouveau pour ces deux entités, hormis la double clé étrangère qui fait référence à deux Team pour la classe Match. La condition onDelete = ForeignKey.CASCADE qui supprimera les Score associés à un Match si ce dernier est supprimé de la base de données.
Les Relations
One-to-Many
Lors de la présentation du modèle nous avons défini qu’une Team avait un Coach mais aussi des Player. Plusieurs possibilités s’offrent à nous pour représenter cette relation, comme l’ajout d’une liste de Player dans la classe Team (que nous verrons avec les Converter), ou en ajoutant une clé étrangère faisant référence à l’identifiant d’une Team dans Player. Je vais vous présenter une troisième solution, avec la création d’une classe supplémentaire, qui ne demandera que l’ajout d’une variable ‘teamId’ dans Player (var teamId: Long)
class TeamAllPlayers { @Embedded var team: Team? = null @Relation(parentColumn = "id", entityColumn = "teamId") var players: List<Player>? = null }
Nous créons donc une classe nommée ici TeamAllPlayers qui va contenir, via l’annotation @Embedded, une Team et une liste de Player. La liaison sera faite avec l’annotation @Relation entre le champs ‘id’ d’une Team et des Player qui contiennent un champs ‘teamId’ similaire. Nous aborderons l’utilisation de cette classe lors de la création des méthodes du DAO. L’avantage de cette solution est de ne pas avoir une contrainte de clé étrangère dans la classe Player ce qui nous permet de créer des Player sans Team.
Many-to-Many
Pour mettre en place une relation Many-to-Many il vous suffira de combiner deux concepts déjà abordés: PrimaryKey et ForeignKey. Imaginons que nous rendions possible qu’une Team possède plusieurs Player mais que les Player peuvent avoir plusieurs Team aussi. Nous aurions donc une nouvelle entité qui représenterait cette liaison:
@Entity(tableName = "team_player_join", primaryKeys = { "teamId", "playerId" }, foreignKeys = { ForeignKey(entity = Team.class, parentColumns = "id", childColumns = "teamId"), ForeignKey(entity = Player.class, parentColumns = "id", childColumns = "playerId") }) class TeamPlayerJoin(val teamId: Long, val playerId: Long)
Les Converters
Pour l’instant nous avons mis en place la persistance de données dites primitives: Int, Long, String, etc … Mais parfois nous serons amenés à vouloir stocker des données avec un type personnalisé: Date, List, Class, etc… Pour cela nous devrons indiquer à Room comment convertir notre type personnalisé en type primitif. Nous allons créer une nouvelle classe PlayerConverter avec deux méthodes: une qui convertira une liste d’identifiant Player en une chaine de caractères (au format Json) et une seconde pour effectuer l’opération inverse.
class PlayersConverter { @TypeConverter fun stringToPlayers(value: String): List<Long> { val listPlayers = object : TypeToken<Long>() {}.type return Gson().fromJson(value, listPlayers) } @TypeConverter fun playersToString(list: List<Long>): String { val gson = Gson() return gson.toJson(list) } }
Nos méthodes portent l’annotation @TypeConverter et réalisent, pour la seconde, une transformation du type List<Long> vers une chaine de caractères au format Json qui aura, par exemple, le résultat suivant: « [3, 6, 9] ». La première méthode effectue le traitement inverse et transforme la chaine de caractère en une liste de Long. Pour faciliter l’implémentation nous avons utilisé la librairie Gson.
Je vous donne le code du Converter pour le type Date, utilisé lors de la persistance d’un objet Match. Ce dernier va convertir le type Date en primitif Long (le timestamp):
class Converter { @TypeConverter fun fromTimestamp(value: Long?): Date? { return if (value == null) null else Date(value) } @TypeConverter fun dateToTimestamp(date: Date?): Long? { return date?.time } }
Les Dao
Les DAO (Data Access Object) sont des interfaces qui vont nous permettent de communiquer et d’interagir avec notre base de données. C’est ici que nous allons implémenter nos méthodes dites CRUD.
@Dao interface UserDao { /* PLAYER */ @Insert fun insertPlayer(player: Player) : Long @Insert fun insertPlayers(players: List<Player>) : List<Long> @Insert fun insertPlayers(vararg players: Player) @Update fun updatePlayer(player: Player) ... @Delete fun deletePlayer(player: Player) ... @Query("SELECT * FROM Player") fun getAllPlayer(): List<Player> @Query("SELECT * FROM Player WHERE id=:playerId") fun getPlayer(playerId: Long) : Player /* Coach */ ... }
L’interface est annotée avec @Dao et sera implémentée lors de la création de la base de donnée (ci-dessous). Nous implémentons, via la définition de signature de méthode, le CRUD complet avec l’annotation pour l’action à effectuer:
- @Insert: Persister un objet en base de données. L’annotation permet à la méthode de pouvoir retourner l’identifiant (Long, si autoGenerate=true) de l’objet en base, ou une liste d’identifiant List<Long> (ou long[ ] ) dans le cas d’une persistance multiple. Je vous ai volontairement présenté les trois façons de définir une persistance pour l’ajout, vous pourrez définir les méthodes suivantes (update et delete) de la même façon.
- @Update: Mise à jour d’un objet persisté. L’annotation permet de retourner le nombre (int) de ligne mise à jour.
- @Delete: Suppression d’un objet persisté. l’annotation permet de retourner le nombre (int) de ligne supprimée.
- @Query(« requête »): Opération de lecture d’enregistrement via la définition de requête SQL. Le type retourné peut être, par exemple, un Player, une List<Player> ou même un Cursor ! Je vous présente ci dessous une requête qui va récupérer la somme des Score de chaque équipe pour un Match donné :
@Query("SELECT sum(scoreTeam1) AS score1, sum(scoreTeam2) AS score2 FROM Score WHERE Score.matchId=:matchId GROUP BY Score.matchId") fun getEndOfMatchScore(matchId: Long) : Cursor
Exemple de lecture d’un Cursor (Nous reviendrons sur la partie de code MyApp.database?.matchDao() plus bas dans ce tutoriel) :
val scoreEndMatch : Cursor? = MyApp.database?.matchDao()?.getEndOfMatchScore(match.id) scoreEndMatch.moveToFirst() mTextView.text = "[ Team1 - " + scoreEndMatch1!!.getInt(0) + " | " + scoreEndMatch1.getInt(1) + " - Team2 ]")
Création de la base de données
@Database(entities = arrayOf( Player::class, Coach::class, Team::class, Match::class, Score::class), version = 1) @TypeConverters(Converter::class) abstract class MyDatabase : RoomDatabase() { abstract fun userDao(): UserDao abstract fun matchDao(): MatchDao }
Pour créer notre base de donnée nous devons, à la fois, hériter notre classe de RoomDataBase et ajouter l’annotation @Database, les deux vont de pair. Ensuite nous implémentons autant de fonction que d’interface Dao et nous ajoutons les entités dans les paramètres de l’annotation @Database, ainsi que le numéro la version de notre base de donnée. Pour terminer, nous devons préciser les Converter qui doivent être utilisés pour les types personnalisés.
Application
Je vous propose, ci-dessous, un exemple d’implémentation et d’utilisation de notre base de données. Comme précisé au début, je ne présenterai pas d’implémentation avec LiveData (ou RxJava), qui est recommandée, mais qui ajouterait une part, non nécessaire, de complexité.
class MyApp : Application() { companion object { var database: MyDatabase? = null } override fun onCreate() { super.onCreate() MyApp.database = Room.databaseBuilder(this, MyDatabase::class.java, "championship-db").build() } }
Nous précisons, lors de la création de notre base de données, la classe qui la définit (MyDatabase) ainsi que le nom de cette dernière, ici »championship-db », nous pourrons ensuite faire appel à notre objet ‘database’ pour interagir avec.
Ci-dessous, un exemple d’utilisation avec une fonction qui nous permet de créer et persister un Player :
fun createPlayer(name: String, position: String, avatar: String) : Player { var player = Player(name, position) player.avatar = avatar player.id = MyApp.database?.userDao()?.insertPlayer(player)!! return player }
Conclusion
Comme nous avons pu le voir, Room propose une nouvelle approche pour stocker, lire et mettre à jour des données en base. Un autre avantage est la vérification, lors de la compilation, de la pertinance de notre implémentation (nom de table, colonnes, clé, …), de la cohérence entre nos relations et même de la syntaxe des requêtes. Une autre fonctionnalité intéressante est la prise en charge par Room de LiveData, qui permet d’observer les modifications sur nos données et d’être sensible au cycle de vie de l’application.
N’hésitez pas à tester Fiches Plateau Moto ! : https://www.fiches-plateau-moto.fr/