Android 架構組件之 Room

Room 是 Google 推出的一個在 SQLite 上提供抽象層的持久存儲庫。本文將從以下幾個方面對 Room 進行介紹:

  • 爲什麼要使用 Room?
  • 通過一個案例,介紹如何使用 Room
  • 分析 Room 的組成及使用原理
  • 總結一下 Room 的使用

1. 爲什麼要使用 Room?

在 Android 中直接使用 SQLite 數據庫存在多個缺點:

  • 必須編寫大量的樣板代碼;
  • 必須爲編寫的每一個查詢實現對象映射;
  • 很難實施數據庫遷移;
  • 很難測試數據庫;
  • 如果不小心,很容易在主線程上執行長時間運行的操作。

爲了解決這些問題,Google 創建了 Room,一個在 SQLite 上提供抽象層的持久存儲庫。

Room 是一個穩健的、基於對象關係映射(ORM)模型的、數據庫框架。Room 提供了一套基於 SQLite 的抽象層,在完全實現 SQLite 全部功能的同時實現更強大的數據庫訪問。

針對 SQLite數據庫的上述缺點,Room 框架具有如下特點:

  • 由於使用了動態代理,減少了樣板代碼;
  • 在 Room 框架中使用了編譯時註解,在編譯過程中就完成了對 SQL 的語法檢驗;
  • 相對方便的數據庫遷移;
  • 方便的可測試性;
  • 保持數據庫的操作遠離了主線程。
  • 此外 Room 還支持 RxJava2 和 LiveData。

2. 通過一個案例,介紹如何使用 Room

介紹 Room 先從一個真實的案例開始,我們從最開始的 ER 圖的設計,到數據庫的增刪改查操作,到考慮數據庫的遷移上線。

總結起來,這個使用 Room 框架的案例要經歷以下幾個過程:

    1. 設計數據庫的 ER 圖(非必須);
    1. 添加對 Room 的依賴;
    1. 創建數據庫實體 Entity;
    1. 創建數據庫訪問的 DAO;
    1. 創建數據庫 Database;
    1. 封裝數據庫與業務邏輯交互的 Repository;
    1. 創建數據庫中使用到的類型轉換器;
    1. 考慮數據庫遷移;
    1. 數據庫的測試。

接下來,我們將分別從這幾個步驟,介紹 Room 的使用。

2.1 數據庫 ER 圖

我們要完成將 NBA 的球隊和球員信息存儲到數據庫中,具體涉及到兩張表,球隊表(team)和球員表(player),爲了能夠篩選出各項數據指標靠前的球員,我們又創建了一張明星球員表(star),三張表的 ER 圖如下:

其中:

  • 一個球隊擁有多名球員;
  • 一名球星屬於某支球隊,球星表中的球星可以來自多隻不同的球隊;
  • 球星是從球員中選擇出來的。

2.2 添加 Room 的依賴

明確了三張數據表之間的關係之後,我們開始引入 Room,添加依賴關係:

 

1
2
3
implementation "androidx.room:room-runtime:$rootProject.roomVersion"
implementation "androidx.room:room-ktx:$rootProject.roomVersion"
kapt "androidx.room:room-compiler:$rootProject.roomVersion"

由於 Room框架中使用了註解處理器,因此需要使用 kapt 依賴,需要在 build.gradle 文件中引入 kapt 插件。

 

1
apply plugin: 'kotlin-kapt'

如果是 Java工程,需要使用 annotationProcessor 關鍵字。

這裏我們使用官方最新的版本2.1.0。

 

1
roomVersion : '2.1.0',

2.3 創建實體

開始創建實體(數據表)這裏以 Player 爲例:

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/**
 * 球員表
 */
@Entity(tableName = "player")
data class PlayerModel(
    @ColumnInfo(name = "player_code") var code: String,
    @ColumnInfo(name = "player_country") var country: String, //國家
    @ColumnInfo(name = "player_country_en") var countryEn: String,//國家英文名稱
    @ColumnInfo(name = "player_display_name") var displayName: String,//球員名稱
    @ColumnInfo(name = "player_display_name_en") var displayNameEn: String,//球員英文名稱
    @ColumnInfo(name = "player_dob") var dob: Calendar = Calendar.getInstance(),//出生日期
    ...
    @ColumnInfo(name = "player_team_name") var teamName: String,//所屬球隊
    @Embedded var statAverage: StatAverageModel,//平均數據
    @Embedded var statTotal: StatTotalModel//總數據
) {
    @PrimaryKey(autoGenerate = true)
    @ColumnInfo(name = "id")
    var id: Long = 0
}

這裏用到了幾個註解,說明一下:

序號 註解名稱 描述
1 @Entity 聲明所標記的類是一個數據表,@Entity 包括的參數有:tableName(表名),indices(表的索引),primaryKeys(主鍵),foreignKeys(外鍵),ignoredColumns(忽略實體中的屬性,不作爲數據表中的字段),inheritSuperIndices(是否集成父類的索引,默認 false)
2 @ColumnInfo 用來聲明數據庫中的字段名
3 @PrimaryKey 被修飾的屬性作爲數據表的主鍵,@PrimaryKey 包含一個參數:autoGenerate(是否允許自動創建,默認false)
4 @Embedded 用來修飾嵌套字段,被修飾的屬性中的所有字段都會存在數據表中

關於@Embedded的進一步說明,我們的 Player實體中有兩個被@Embedded修飾的屬性,我們看其中的一個statTotal,它的類型是StatTotalModel,用來描述球員的數據。

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
/**
 * 總數據
 */
data class StatTotalModel(
    var assists: Int,//助攻
    var blocks: Int,//蓋帽
    var defRebs: Int,//防守籃板
    var fga: Int,
    var fgm: Int,
    var fgpct: Float,
    var fouls: Int,//犯規
    var fta: Int,
    var ftm: Int,
    var ftpct: Float,
    var mins: Int,//上場時間
    var offRebs: Int,//進攻籃板
    var points: Int,//得分
    var rebs: Int,//總籃板
    var secs: Int,
    var steals: Int,//搶斷
    var tpa: Int,
    var tpm: Int,
    var tppct: Float,
    var turnovers: Int//失誤
)

通常情況下將這些數據存儲在數據庫中有兩種方式:

  • 新建一張StatTotalModel表,用 Player 表的 id 作爲外鍵,與 Player表進行一一關聯;
  • 將StatTotalModel實體中的字段存儲在 Player 表中,這種方式減少了數據表的創建,也減少了聯合查詢的複雜程度。

如果直接將這些字段打散在 Player 表中,顯得不夠面向對象,這時就可以使用@Embedded註解,即顯得面向對象,又不用再創建數據表,非常的優雅。

除了上面說的註解,Room 框架還包括以下的註解:

序號 註解名稱 描述
1 @ColumnInfo.SQLiteTypeAffinity 可以在typeAffinity()中使用的SQLite列類型常量,包括:UNDEFINED, TEXT, INTEGER, REAL, BLOB,其中 UNDEFINED 未定義類型關聯,將根據類型解析;TEXT SQLite列類型爲 String;INTEGER SQLite列類型爲 Integer 或 Boolean; REAL SQLite列類型爲 Float 或 Double;BLOB SQLite列類型爲二進制類型
2 @Dao 將類標記爲數據訪問對象(Data Access Object)
3 @Database 將類標記爲RoomDatabase
4 @Delete 將 DAO 中的方法標記爲與刪除相關的方法
5 @Embedded 可以用作實體或Pojo字段上的註釋,以指示嵌套字段
6 @ForeignKey 在另一個實體上聲明外鍵
7 @ForeignKey.Action 可以在onDelete()和onUpdate()中使用的值的常量定義。包括:NO_ACTION, RESTRICT, SET_NULL, SET_DEFAULT, CASCADE
8 @Ignore 忽略Room的處理邏輯中標記的元素
9 @Index 聲明實體的索引
10 @Insert 將Dao註釋類中的方法標記爲插入方法
11 @OnConflictStrategy Dao方法處理衝突的策略集合,包括:REPLACE, ROLLBACK, ABORT,FAIL,IGNORE,其中ROLLBACK和FAIL已經被標記爲@Deprecated,REPLACE用新的數據行替換舊的數據行;ABORT直接回滾衝突的事務;IGNORE保持現有數據行。
12 @PrimaryKey 將實體中的字段標記爲主鍵
13 @Query 將Dao註釋類中的方法標記爲查詢方法
14 @RawQuery 將Dao註釋類中的方法標記爲原始查詢方法,可以將查詢作爲SupportSQLiteQuery傳遞
15 @Relation 一個方便的註釋,可以在Pojo中用於自動獲取關係實體。
16 @SkipQueryVerification 跳過帶註釋元素的數據庫驗證
17 @Transaction 將Dao類中的方法標記爲事務方法
18 @TypeConverter 將方法標記爲類型轉換器
19 @TypeConverters 指定Room可以使用的其他類型轉換器
20 @Update 將Dao註釋類中的方法標記爲更新方法

2.4 創建 Dao

我們開始創建數據訪問對象層 Dao,在這裏我們需要定義一些對數據庫增刪改查的方法。

具體來說,就是創建一個使用@Dao 註釋的接口。並在其上聲明使用該數據庫所需的所有函數,並編寫相應的 SQL 查詢語句,Room 將爲你實現這些函數,並在單次事務中運行它們,Room 支持的查詢語句包括:插入、更新、刪除和查詢。查詢語句會在編譯時被校驗,這意味着,如果你編寫了一個無效的應用,你會立刻發現錯誤。

來看 Player 的 Dao:

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Dao
interface PlayerDao {
    @Insert(onConflict = OnConflictStrategy.REPLACE)
    fun insertPlayer(player: PlayerModel)

    @Delete
    fun deletePlayers(players: List<PlayerModel>)

    @Update
    fun updatePlayers(players: List<PlayerModel>)

    @Query("SELECT * FROM player WHERE id=:id")
    fun findPlayerById(id: Long): PlayerModel?
    ……
}

對數據表的增刪改查方法對應的註解分別是@Insert、@Delete、@Update和 @Query,其中:

  • @Insert、@Update 可設置參數onConflict,處理數據衝突時採取的策略,可以設置包括:REPLACE, ROLLBACK, ABORT,FAIL,IGNORE五種策略,其中ROLLBACK和FAIL已經被標記爲@Deprecated,這裏只介紹三種策略,REPLACE用新的數據行替換舊的數據行;ABORT直接回滾衝突的事務;IGNORE保持現有數據行。
  • @Query 中聲明我們要查詢的 SQL 語句,使用@Query不僅成完成查,還能進行增刪改的操作。

這些查詢都是同步的,也就是說,這些查詢將在你觸發查詢的同一個線程上運行。如果這是主線程,你的應用將崩潰,並顯示 IllegalStateException,因此,請使用在 Android 中推薦的線程處理方法,並確保其遠離主線程。

當使用 LiveData 或 RxJava 時,Room 也支持異步查詢,更重要的是,返回 LiveData 或 Flowable 的查詢是可觀測的查詢。也就是說,每當表格中的數據被更新時,就會收到通知。

 

1
2
3
4
5
@Query("SELECT * FROM player WHERE id=:id")
fun findPlayerByIdLD(id: Long): LiveData<PlayerModel>

@Query("SELECT * FROM player WHERE player_team_name=:teamName")
fun findPlayersByTeamLD(teamName: String): LiveData<List<PlayerModel>>

2.5 創建數據庫

將實體和 DAO整合在一起的類是 RoomDatabase,先創建一個擴展 RoomDatabase 的抽象類,對它進行註釋,聲明實體和相應的 DAO。

數據庫的創建是一件非常消耗資源的工作,所以我們將數據庫設計爲單例,避免創建多個數據庫對象。另外對數據庫的操作都不能放在 UI 線程中完成,否則會出現異常:

 

1
Cannot access database on the main thread since it may potentially lock the UI for a long period of time.

給出我們設計的數據庫:

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
@Database(entities = [PlayerModel::class, TeamModel::class], version = 1, exportSchema = false)
@TypeConverters(Converters::class)
abstract class NBADatabase : RoomDatabase() {

    abstract fun playerDao(): PlayerDao
    abstract fun teamDao(): TeamDao

    companion object {
        @Volatile
        private var INSTANCE: NBADatabase? = null

        fun getInstance(context: Context): NBADatabase {
            return INSTANCE ?: synchronized(this) {
                Room.databaseBuilder(
                    context.applicationContext,
                    NBADatabase::class.java,
                    "nba_db"
                ).addCallback(object : RoomDatabase.Callback() {
                    override fun onCreate(db: SupportSQLiteDatabase) {
                        super.onCreate(db)
                    }

                    override fun onOpen(db: SupportSQLiteDatabase) {
                        super.onOpen(db)
                    }
                }).build().also {
                    INSTANCE = it
                }
            }
        }
    }
}

在創建數據庫時需要完成以下幾件工作:

  • 設計成單例模式,避免創建多個數據庫對象消耗資源;
  • 創建的數據庫類需要繼承 RoomDatabase,數據庫類聲明爲抽象類;
  • 需要提供方法來獲取數據訪問對象層(Dao)對象,方法聲明爲抽象方法;
  • 數據庫類需要使用@Database註解,@Database包括幾個參數:entities(數據庫包含的數據表,@entities註解修飾的實體),default(數據庫包含的視圖),version(數據庫的版本號),exportSchema(可以理解爲開關,如果開關爲 true,Room 框架會通過註解處理器將一些數據庫相關的schema輸出到指定的目錄中,默認 true)

2.6 封裝 Repository

先給出封裝的與 Player 相關的 Repository:

 

1
2
3
4
5
6
7
8
9
10
11
class PlayerRepository(private val playerDao: PlayerDao) {

    @WorkerThread
    suspend fun insert(players: List<PlayerModel>) {
        playerDao.insertPlayers(players)
    }

    fun findAllPlayers():List<PlayerModel>{
        return playerDao.findAllPlayers()
    }
}

這裏只定義了兩個方法,insert()用來插入數據,findAllPlayers()查詢所有的 Player,可以根據業務邏輯的需要,在Repository中添加方法。

最後調用PlayerRepository中的方法,完成將 NBA 球隊和球員信息存儲到數據庫中。

2.7 類型轉換器@TypeConverter

在我們的球員實體PlayerModel中,球員的出生日期 dob類型是 Calendar 類型,但是 SQLite 只支持 5 中類型,分別是NULL、INTEGER、REAL、TEXT和BLOB,這時可以用到@TypeConverters註解。

 

1
2
3
4
5
6
7
8
class Converters {
    @TypeConverter
    fun calendarToDatestamp(calendar: Calendar): Long = calendar.timeInMillis

    @TypeConverter
    fun datestampToCalendar(value: Long): Calendar =
        Calendar.getInstance().apply { timeInMillis = value }
}

在數據庫中添加該註解

 

1
@TypeConverters(Converters::class)

2.8 數據庫遷移 Migration

如果要進行數據庫遷移操作,需要在 Database 類中執行以下操作:

首先更新你的數據庫版本。

 

1
2
3
4
@Database(entities = {User.class}, version = 2)
abstract class MyDatabase extends RoomDatabase {
    public abstract UserDao getUserDao();
}

其次,實現一個 Migration 類,定義如何處理從舊版本到新版本的遷移:

 

1
2
3
4
5
6
7
static final Migration MIGRATION_1_2 = new Migration(1, 2)

    @Override
    public void migrate(SupportSQLiteDatabase database) {
        database.execSQL("ALTER TABLE users" +
                "ADD COLUMN address STRING");
    }

第三,將此 Migration 類添加爲 Database 構建器的一個參數,

 

1
2
Room.databaseBuilder(context.getApplicationContext(),
    MyDatabase.class,"sample.db").addMigrations(MIGRATION_1_2).build();

當觸發遷移後,Room 將爲你驗證 Schema,以確保遷移已正確完成。

2.9 數據庫的測試

我們創建了實體、DAO、數據庫和遷移,那麼應該怎樣對他們進行測試呢?

要測試 DAO,需要實現 AndroidJunitTest 來創建一個內存數據庫,內存數據庫僅會在進程處於活動狀態時保留數據,也就是說,每次測試後,數據庫都將被清除。

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@RunWith(AndroidJunit4.class)
    public class UserDaoTest {
        private UserDatabase database;

        @Before
        public void initDb() throws Exception {
            database = Room.inMemoryDatabaseBuilder(
                    InstrumentationRegistry.getContext(),
                    UsersDatabase.class).build();
        }

        @After
        public void closeDb() throws Exception {
            database.close();
        }
    }

要測試異步查詢,請添加測試規則 InstantTaskExecutorRule,以同步執行每個任務。

 

1
2
3
4
5
@RunWith(AndroidJUnit4.class)
    public class UserDaoTest{
        @Rule
        public InstantTaskExecutorRule rule = new InstantTaskExecutorRule();
    }

在應用的實現中,你最終會在其他類中引用 DAO,要對這些類進行單元測試,只需藉助想 Mockito 之類的框架來模擬 DAO。

 

1
2
3
4
5
6
7
public class UsersRepository {
        private final UserDao userDao;

        public UsersRepository(UserDao userDao) {
            this.userDao = userDao;
        }
    }

還有一點,擴展 CountingTaskExecutorRule,並在 Espresso 測試中使用它,以便在任務開始和結束時進行計數。

 

1
2
@Rule
public CountingTaskExecutorRule rule = new CountingTaskExecutorRule();

最後,不要忘了遷移測試。我們有另一個非常方便的測試規則 MigrationTestHelper。它允許你使用舊版本創建數據庫,然後運行和驗證遷移。你只需檢查在舊版本中插入的數據在遷移後是否仍然存在。

 

1
2
3
4
5
public MigrationTestHelper testHelper = new MigrationTestHelper(
            InstrumentationRegistry.getInstrumentation(),
            MyDatabase.class.getCanonicalName(),
            new FrameworkSQLiteOpenHelperFactory()
    )

3. 分析 Room 的組成及使用原理

下面我們來分析一下 Room 的組成和實現原理,按照慣例我們梳理了一張類圖:

在類圖中,畫出了我們常用的一些註解,按照數據庫、DAO 和實體進行了不同顏色的區分。如果想要了解,Room 是怎樣通過註解,實現的數據庫創建、SQL語句的生成,可能需要了解動態代理技術和註解處理器的相關知識。

再來Room 的原理,Room 實際上是在 SQLite 上,進行了封裝,通過註解的方式,方便開發者。

從圖中我們可以清晰的看到 Room 各個組成部分(Database、Dao 和 Entity)之間是如何協同工作的,總結起來就是:

  • 首先創建數據庫,創建的數據庫類需要繼承 RoomDatabase,並且提供獲取 Dao 的抽象方法,Room 框架的註解處理器會實現生成 Dao 的具體方法。
  • 在 Dao 中,我們會聲明一些操作具體數據庫表的增刪改查的方法,利用這些方法,就可以操作一些具體的實體 Entity。
  • 得到了實體 Entity,就可以處理一些與 app 數據相關的業務邏輯了。

4. 總結一下 Room 的使用

上面通過一個完成的案例,介紹了 Room 的使用,從最初的設計 ER 圖開始,通過創建實體、創建 Dao、創建數據庫、封裝 Repository,如果需要數據類型轉換,要使用 @TypeConverter 註解,如果涉及到數據的遷移,還要用到 Migration 類。

Room 框架具有減少樣板代碼、編譯時校驗查詢、輕鬆實現遷移、高度的可測試性、讓數據庫操作遠離主線程。Room 的所有這些特性,讓使用數據庫變得更輕鬆愉快,從而幫你我們開發更優質的應用。


參考鏈接

Save data in a local database using Room
Understanding migrations with Room

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章