Jetpack Room 浅入浅出 Part I

276 阅读7分钟

Room 是Google官方在Jetpack中提供的基于SQLite的抽象层。有几个优点:

  • 编译期 SQL 语法检查
  • 设计友好,方便使用
  • 支持 RxJavaLiveDataCoroutine

因此是目前基于SQLite的数据库管理的不二之选。

接入

dependencies {
    def room_version = "2.4.0"
    implementation "androidx.room:room-runtime:$room_version"
    annotationProcessor "androidx.room:room-compiler:$room_version"
    ...
}

基本组件

Room的三个核心组件:

  • Database: 操作数据库的入口
  • Entity:数据表
  • DataAccessObject (DAO): 读写数据层

定义 Entities

一个 Entity 代表一张表,使用@Entiry注解一个 class,这个Entity就会参与到表(Table)的创建,其属性对应表的column

import android.graphics.Bitmap
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.Ignore
import androidx.room.PrimaryKey

@Entity
data class Song(
    @PrimaryKey
    val id: Long,
    val name: String,
    val artist: String,
    val duration: Long
)

@Entity
data class Album(
    @PrimaryKey 
    val id: Long,
    val name: String,
    @ColumnInfo(name = "front_cover")
    val img: String,
    @Ignore
    var image: Bitmap?
)
  • 所有字段都需要有 gettersetterpublic

  • 使用 @ColumnInfo(name = "xxx")注解来定义对应属性在 Table 中的 ColumnName

  • 至少有一个使用 @PrimaryKey 修饰的主键。可以定义多个主键,多个主键的声明方式略有不同:

    @Entity(primaryKeys = ["id", "name"])
    
  • 默认使用类名作为 TableName, 也可以通过 tableName 参数指定

    @Entity(tableName = "music") 
    
  • @Entity 注解添加 indices 参数可指定数据库索引

    @Entity(indices = [Index("id", "name")])
    
    @Entity(indices = [Index(value = ["id", "name"], unique = true)])
    
  • 使用 @Ignore 注解的属性不会作为 column 进行持久化

定义 Database

通过 @Database() 注解,继承自 RoomDatabase,声明数据库版本。关联所有可操作的表,提供获取 DAO 的无参数抽象方法。

@Database(entities = [Song::class, Album::class], version = 1)
abstract class MediaDatabase: RoomDatabase() {

    abstract fun songDao(): SongDao
    
    abstract fun albumDao(): AlbumDao

    companion object {
        @Volatile
        var INSTANCE: MediaDatabase? = null

        fun getDatabase(context: Context): MediaDatabase {
            return INSTANCE ?: synchronized(this) {
                val instance = Room.databaseBuilder(context.applicationContext, MediaDatabase::class.java, "media.db")
                    .enableMultiInstanceInvalidation()
                    .build()
                INSTANCE = instance
                instance
            }
        }
    }
}

定义 Dao

DAO 作为访问数据库的 API,提供CRUD方法

import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import com.example.room.db.entities.Album
import com.example.room.db.entities.Song

@Dao
interface SongDao {

    @Insert
    fun insertSong(song: Song)

    @Insert
    fun insertAll(vararg songs: Song)

    @Delete
    fun delete(song: Song)
}

@Dao
interface AlbumDao {
    @Insert
    fun insertAlbum(album: Album)

}

Type Converters

对于自定义类型的成员数据,很多情况下也需要持久化到数据库,可以使用 @TypeConverter

class Converters {
    @TypeConverter
    fun tsToData(ts: Long): Date = Date(ts)

    @TypeConverter
    fun dataToTs(date: Date) = date.time

    @TypeConverter
    fun jsonToTags(json: String?): List<Tag> {
        return if (json == null) emptyList() else Gson().fromJson(json, object : TypeToken<List<Tag>>() {}.type)
    }

    @TypeConverter
    fun tagsToJson(tags: List<Tag>?): String? {
        return if (tags == null) null else Gson().toJson(tags)
    }
}

Converter 需要在声明 Database 时使用@TypeConverters指定:

@Database(entities = [Song::class, Album::class], version = 1)
@TypeConverters(Converters::class)
abstract class MediaDatabase: RoomDatabase() {}

到此,Room 的基本组件就齐活了。

DAO 操作数据库

Room Database 在使用中都是通过DAO对数据库读写。可以与LiveData、RxJava、Coroutine方便的集成。Room在编译时通过DAO中定义的方法来生成对应的具体实现,同时可以检查SQL的正确性,方便、友好。

@Insert

@Insert 注解插入操作,以单独的事务更新到数据库。

@Dao
interface SongDao {

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    fun insertSong(song: Song)

    @Insert(onConflict = OnConflictStrategy.ABORT)
    fun insertAll(vararg songs: Song)

    @Insert(onConflict = OnConflictStrategy.IGNORE)
    fun insertAll(songs: List<Song>)
}

onConflict:遭遇冲突时的解决策略,默认值为ABORT

  • REPLACE: 新值替换旧值
  • ABORT: 结束当前事务,回滚
  • IGNORE: 返回成功插入的 row-ids,忽略其中有冲突的 Rows

@Update

@Update 注解更新操作,根据主键更新指定 rows

    @Update(onConflict = OnConflictStrategy.REPLACE)
    fun updateSong(song: Song)

    @Update
    fun updateAll(songs: List<Song>)

    @Update
    fun updateSongs(vararg songs: Song)

@Delete

@Delete 注解删除操作,根据主键删除指定 rows

    @Delete
    fun delete(song: Song)
    
    @Delete
    fun deleteAll(songs: List<Song>)

@Query

@Query 注解查询操作,参数为SQL,并且SQL和返回值在编译时会进行检查。

    @Query("SELECT * FROM song")
    fun queryAllSongs():List<Song>

Query 参数

SQL 语句中可以通过 ":" 引用WHERE子句中所需参数:

@Query("SELECT * FROM song WHERE duration < :target")
fun querySongsWitchDurationLessThanGiven(target: Long): List<Song>

查询非全部Column

播放列表仅需要显示歌曲名和歌手的情形:

data class PlaylistItem(val name: String, val artist: String)

// Dao:
@Query("SELECT name, artist FROM song")
fun queryAllPlaylistItem(): List<PlaylistItem>

Cursor

貌似没什么使用场景

@Query("SELECT * FROM song")
fun loadAllSongsCursor(): Cursor

多表查询

data class SongInfo(
    val id: Long, 
    val name: String, 
    val artist: String, 
    @ColumnInfo(name = "album_name") 
    val albumName: String,
    val duration: Long
)

// Query:
     @Query(
        """
            SELECT song.id, song.name, song.artist, album.album_name, song.duration
            FROM song  
            JOIN album 
            ON song.albumId = album.id
        """
    )
    fun queryAllSongsWithAvailableAlbum(): List<SongInfo>

Entitiy / 表关系

SQLite 是关系行数据库,使用中需要讲数据拆分到具体的EntityEntities之间允许多种关系,Room 也提供了这些关系的实现方式。

@Embedded 内嵌对象

@Embedded 注解可以将一个Entity作为一个属性内嵌到另一个Entity,访问这个内部Entity就像访问Column一样

@Entity(indices = [Index(value = ["id", "name"], unique = true)])
data class Song(
    @PrimaryKey
    val id: Long,
    val name: String,
    val artist: String,
    val duration: Long,
    val albumId: Long
)

@Entity
data class Album(
    @PrimaryKey
    val id: Long,
    val name: String,
    @ColumnInfo(name = "front_cover")
    val img: String,
    @Embedded(prefix = "song_")
    val song: Song
)

由于 SongAlbum 中都包含 name 属性,可以通过prefix 参数指定前缀

上面的例子中等价于 Album包含了Song的全部属性,并且数据表中引用的Song的属性字段全部以"song_"开头:

Embedded_Song.png

@Embedded 注解方式建立的表关系比较粗暴。其它更复杂一点的关系如何处理呢?

一对一

对应用户(User)与曲库(Library)的关系,一个用户只拥有一个曲库,一个曲库只属于一个用户:

@Entity
data class User(
    @PrimaryKey
    val uid: Long,
    @ColumnInfo(name = "first_name")
    val firstName: String,
    @ColumnInfo(name = "last_name")
    val lastName: String
)

@Entity
data class Library(
    @PrimaryKey
    val libid: Long,
    val title: String,
    @ColumnInfo(name = "owner_id")
    val ownerId: Long
)

为了建立两个实体的一对一关系,需要创建一个新的数据类,其中包含一个父Entity和一个子Entity,使用 @Relation 注解到子实体的实例,并设置对应的 parentColumnentityColumn

data class UserWithLibrary(
    @Embedded
    val user: User,
    @Relation(
        parentColumn = "uid",
        entityColumn = "owner_id"
    )
    val library: Library
)

接下来在Dao中加入Query

@Transaction
@Query("SELECT * FROM User")
fun getUsersAndLibraries(): List<UserAndLibrary>

这个Query实际运行两次查询,所以需要添加@Transcation, 确保整个查询的原子性

一对多

用户(User)与播放列表(Playlist)的关系,一个用户可以创建多个列表,一个列表的创建者只能是一个具体的用户:


@Entity
data class User(
    @PrimaryKey
    val uid: Long,
    @ColumnInfo(name = "first_name")
    val firstName: String,
    @ColumnInfo(name = "last_name")
    val lastName: String
)

@Entity
data class Playlist(
    @ColumnInfo(name = "playlist_id")
    @PrimaryKey val playlistId: Long,
    @ColumnInfo(name = "creator_id")
    val creatorId: Long,
    @ColumnInfo(name = "playlist_name")
    val playlistName: String
)

在 User 和 Playlist 之间建立一对多关系:创建一个新的数据类,其中每个实例都包含父实体的一个实例和子实体的实例列表,使用@Relation 添加到子实体,并设置parentColumn为父实体的主键,entityColumn 设置为引用父实体主键的子实体列名称:

    data class UserWithPlaylists(
        @Embedded val user: User,
        @Relation(
              parentColumn = "uid",
              entityColumn = "creator_id"
        )
        val playlists: List<Playlist>
    )
    

Query:

    @Transaction
    @Query("SELECT * FROM User")
    fun getUsersWithPlaylists(): List<UserWithPlaylists>

多对多

播放列表(Playlist)与歌曲(Song)存在多对多关系,每个播放列表可包含多个歌曲,每个歌曲也可处于多个播放列表中,由于多对多关系不能严格界定父实体与子实体,所以也就不存在子实体对父实体的引用,因此需要创建另外一个数据类来作为两个实体的桥梁。实现交叉引用,必须包含每个实体的主键列:


@Entity
data class Playlist(
    @ColumnInfo(name = "playlist_id")
    @PrimaryKey val playlistId: Long,
    @ColumnInfo(name = "creator_id")
    val creatorId: Long,
    @ColumnInfo(name = "playlist_name")
    val playlistName: String
)


@Entity
data class Song(
    @PrimaryKey 
	  @ColumnInfo(name = "song_id")
  	val songId: Long,
	  @ColumnInfo(name = "song_name")
    val songName: String,
    val artist: String
)

@Entity(primaryKeys = ["playlist_id", "song_id"])
data class PlaylistSongCrossRef(
    val playlistId: Long,
    val songId: Long
)

在这种情况下,可以在不同情况下查询不同的实体:

  • 查询播放列表并包含列表中所包含的歌曲:

    	data class PlaylistWithSongs(
            @Embedded val playlist: Playlist,
            @Relation(
                 parentColumn = "playlist_id",
                 entityColumn = "song_id",
                 associateBy = @Junction(PlaylistSongCrossRef::class)
            )
            val songs: List<Song>
        )
    
  • 查询歌曲所在的所有播放列表:

    
        data class SongWithPlaylists(
            @Embedded val song: Song,
            @Relation(
                 parentColumn = "song_id",
                 entityColumn = "playlist_id",
                 associateBy = @Junction(PlaylistSongCrossRef::class)
            )
            val playlists: List<Playlist>
        )
        
    

上述每个类中的@Relation注解中使用 associateBy 来明确两个实体之间关系的桥接实体。

Query:

    @Transaction
    @Query("SELECT * FROM Playlist")
    fun getPlaylistsWithSongs(): List<PlaylistWithSongs>

    @Transaction
    @Query("SELECT * FROM Song")
    fun getSongsWithPlaylists(): List<SongWithPlaylists>
    

嵌套关系

基于以上关系的定义,@Relation 同样可以嵌套使用:

在用户(User)、播放列表(Playlist)、歌曲(Song)中就存在这样的嵌套关系:

  • 用户可创建多个播放列表(一对多)
  • 播放列表可以包含多个歌曲(多对多)
  • 每个歌曲可以出现在多个播放列表中

如果要列出用户所有的播放列表详情,就需要将以上建立的关系嵌套到新的关系中:

    data class UserWithPlaylistsAndSongs {
        @Embedded val user: User
        @Relation(
            entity = Playlist::class,
            parentColumn = "uid",
            entityColumn = "creator_id"
        )
        val playlists: List<PlaylistWithSongs>
    }
    

Query:

    @Transaction
    @Query("SELECT * FROM User")
    fun getUsersWithPlaylistsAndSongs(): List<UserWithPlaylistsAndSongs>
    

[!WARNING]

嵌套关系查询数据库需要多次操作,处理大量数据,可能会影响性能,尽量避免或减少嵌套关系