Room 是 Google 推出的一個在 SQLite 上提供抽象層的持久存儲庫。本文將從以下幾個方面對 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 框架的案例要經歷以下幾個過程:
-
- 設計數據庫的 ER 圖(非必須);
-
- 添加對 Room 的依賴;
-
- 創建數據庫實體 Entity;
-
- 創建數據庫訪問的 DAO;
-
- 創建數據庫 Database;
-
- 封裝數據庫與業務邏輯交互的 Repository;
-
- 創建數據庫中使用到的類型轉換器;
-
- 考慮數據庫遷移;
-
- 數據庫的測試。
接下來,我們將分別從這幾個步驟,介紹 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
- 本文標題:Android 架構組件之 Room
- 本文作者:GT
- 發佈時間:2019-11-30
- 版權聲明:本博客所有文章除特別聲明外,均採用 CC BY-NC-SA 4.0 許可協議。轉載請註明出處!