我們今天來介紹如何導入使用 Android 的資料庫 Room,Android Jetpack 套件元件之一,如何融入 MVVM 架構,並且善用一些特性減少開發上的困難。
先來看看 Room 有哪些特點:
- Room 把一些 SQLite 底層實作封裝起來讓我們能更方便存取資料庫,不需要再寫冗長的程式碼才能將 SQL 和 Kotlin程式資料類別轉換
- Room支援編譯時期的 SQL 語法檢查,不需要等到執行後才能發現錯誤。
- 容易整合且語法簡單需多,少掉很多囉唆的程式碼。
- 支援LiveData / RxJava,可以使用觀察者模式來訂閱資料變更。
這篇文章將會使用社群 App 發文草稿當作範例來說明,需求是在 App 上可以發布新的影片貼文,發布過程中要把新貼文當作草稿暫存下來,且在草稿列表中顯示上傳狀態,直到上傳成功後才把暫存貼文刪除。
這邊我們完全不會解釋資料庫相關的概念,這篇我們會假設你都已經有資料庫基礎,知道資料庫、表格、欄位、primary key、foreign key…等是什麼
設計架構
我們使用 MVVM 架構,Model 提供一個資料流給 ViewModel,ViewModel 再讓這資料流給 View 去訂閱然後顯示。 依照需求來看我們需要把新貼文儲存起來在資料庫,當要發新貼文時,我們會將新產生的貼文物件寫入資料庫,然後因為 View 觀察(訂閱)資料的變更,當有資料庫有新貼文寫入或更新時,列表自動會收到更新顯示新貼文或變更。
元件架構圖和資料流如下:

Room 在版本 v2.1.0 已經對於 RxJava 有非常完整的整合,所以我們採用 RxJava + Room 架構來實做。
實作
Room 有三個主要元件,架構如下圖:

1. Entity
首先,第一個元件是 Entity (以下稱「實體」),這是一個類別用來表示資料庫的表格,會用 @Entity
來標註(可以用 tableName = ...
來設定表格名稱),接著用 @ColumnInfo
標註類別內的屬性成為表格欄位,預設情況在實體類別內的所有屬性都會儲存到資料庫,對於不想儲存的屬性,可以加上 @Ignore
來略過。
對於新訊息我們會儲存文字、上傳檔案、貼文種類、打卡位置,所以我們的實體類別會是:
data class Location( | |
val name: String, | |
val lat: Double, | |
val lng: Double | |
) | |
enum class PostType { | |
TEXT, | |
PHOTO, | |
VIDEO, | |
LINK | |
} | |
@Entity(tableName = "new_post") | |
data class NewPost( | |
@PrimaryKey val id: String = UUID.randomUUID().toString(), | |
@ColumnInfo val caption: String? = null, | |
@ColumnInfo(name = "media_file") val mediaFile: File? = null, | |
@ColumnInfo val type: PostType? = null, | |
val location: Location? = null | |
) |
依據我們實體類別的宣告,資料庫表格就會長這樣:
id | caption | mediaFile | type | location |
57ad8bce0a | Hi, this is … | /… | … | … |
2. DAO
有了實體類別後,再來需要 DAO (Data Access Object) 資料存取物件來定義所有資料庫的存取方法,裡面包含了最常見的 CURD (Create, Update, Read, Delete) 方法。
Room 的 DAO 宣告就像是 Retrofit 的 API 都要宣告為 interface,每個查詢方法都加上 @Query(SQL)
/ @Insert
/ … 標注,底層 Room 自動幫我們產生對應的資料庫操作實作,這樣的架構設計出發點就是讓我們可以容易測試,我們更容易 Mock 資料庫存取,可以不用接真正的資料庫就可以測試:
@Dao | |
interface NewPostDao { | |
@Query("SELECT * FROM `new_post`") | |
fun getAll(): Observable<List<NewPost>> | |
@Query("SELECT * FROM `new_post` WHERE `id` = :id") | |
fun getPostById(id: String): Observable<NewPost> | |
@Insert(onConflict = OnConflictStrategy.IGNORE) | |
fun insertNewPost(newPost: NewPost): Long | |
@Update(onConflict = OnConflictStrategy.REPLACE) | |
fun updateNewPost(newPost: NewPost) | |
@Delete | |
fun deleteNewPost(newPost: NewPost) | |
@Transaction | |
fun upsert(newPost: NewPost) { | |
if (-1L == insertNewPost(newPost)) { | |
updateNewPost(newPost) | |
} | |
} | |
} |
這邊還有一點值得一提,就是在DAO
@Query
裡面,Room編譯器會幫我們在開發階段的時候,就會檢查SQL語法,不需要等到執行階段才知道SQL語法錯誤。
3. Database
最後就是資料庫本身的宣告,資料庫類別包含了表格的實體類別、版本,類別內包含了取得每個 DAO 的抽象方法,且類別要宣告為抽象類別,和 DAO 同樣原因,因為 Room 幫我們產生所有底層我們不在意的實作細節,我們使用上只需要知道這些介面和方法即可。
@Database(entities = [NewPost::class], version = 1) | |
abstract class NewPostDatabase: RoomDatabase() { | |
abstract fun newPostDao(): NewPostDao | |
companion object { | |
private var INSTANCE: NewPostDatabase? = null | |
fun getInstance(context: Context): NewPostDatabase? { | |
if (INSTANCE == null) { | |
synchronized(NewPostDatabase::class) { | |
INSTANCE = Room.databaseBuilder(context, | |
NewPostDatabase::class.java, | |
NewPostDatabase::class.java.simpleName).build() | |
} | |
} | |
return INSTANCE | |
} | |
fun destroyInstance() { | |
INSTANCE = null | |
} | |
} | |
} |
這邊要注意的是,資料庫實體的產生和取得,官方建議用 singleton 的方式取得,因為實體的產生很耗資源,而且也不需要多個資料庫實體,所以宣告為 singleton 即可。
整合
資料庫元件都定義好之後,我們就可以來整合串接這些元件,依照上述的架構圖我們把列表串接起來。記得!資料庫的存取操作不能在 Main Thread 執行,要記得切換 Schedulers。
Repository
interface PostRepository { | |
fun getNewPostList(): Observable<List<NewPost>> | |
// ... | |
} | |
class PostRepositoryImpl : PostRepository { | |
private val localDataSource: NewPostDao by inject() | |
override fun getNewPostList(): Observable<List<NewPost>> { | |
return localDataSource.getAll() | |
} | |
// ... | |
} |
View & ViewModel
class PostListViewModel : ViewModel() { | |
private val repo: PostRepository by inject() | |
fun getNewPostList(): Observable<List<NewPost>> { | |
return repo.getNewPostList() | |
} | |
} | |
class PostListFragment : Fragment { | |
private val viewModel by viewModel<PostListViewModel>() | |
override fun onViewCreated() { | |
viewModel.getNewPostList() | |
.subscribeOn(Schedulers.io()) | |
.observeOn(AndroidSchedulers.mainThread()) | |
.subscribe { | |
// update list | |
} | |
} | |
} |
執行
整合完後就可以執行看看結果了,Oops!! 編譯錯誤,出現 error: Cannot figure out how to save this field into database. You can consider adding a type converter for it. 這錯誤表示 Room 無法將我們實體類別裡面的某些欄位存到資料庫,因為那些欄位不是基本型態,可能是集合類別或者我們自定義的類別, Room 不知道該如何儲存這些類別的欄位到資料庫去,所以才會出現這錯誤。 這時候我們需要增加一個 TypeConverter 來告訴 Room 這些欄位該如何轉換後儲存。
我們實體類別有包含三個需要轉換的類別: File
, PostType
, Location
,所以我們寫一個轉接器:
object NewPostFieldConvert { | |
@TypeConverter | |
fun postTypeToStr(type: PostType?): String? = type?.name | |
@TypeConverter | |
fun strToPostType(str: String?): PostType? = str?.let { PostType.valueOf(it) } | |
@TypeConverter | |
fun fileToPath(file: File?): String? = file?.absolutePath | |
@TypeConverter | |
fun pathToFile(path: String?): File? = path?.let { File(it) } | |
} |
這個轉換類別轉換了 PostType
和 File
,還有一個 Location
物件還沒轉換,這邊我們會另外使用 Room 另一個annotation: @Embedded
來處理,我們回到 entity class,在 Location
加上 @Embedded
,Room 在儲存這個欄位的時候,就會把 Location
裡面的每個欄位攤平一起成為 NewPost
這個表格裡面的欄位,攤平物件後的表格欄位就會長這樣:
id | caption | mediaFile | type | name | lat | lng |
57ad8bce0a | Hi, …. | /… | … | … | … | … |
為了預防攤平後的欄位名稱衝突 (可能 NewPost
和 Location
都有定義相同的屬性名稱),我們可以在 @Embedded
加上(prefix= …),Room 儲存這些攤平欄位時,就會幫我們加上這些前綴以避免命名衝突。
Reactive Design Pattern
在 MVVM 的架構中,有一個重要的核心概念就是資料流,Model 提供原始的資料流,ViewModel 把 Model 的資料流轉換為 UI 呈現的資料流讓 View 來訂閱/監聽。每當 Model 的資料流有變更時,View 因為訂閱了該資料流,所以 UI 也會自動跟著變更。

Room 本身的設計也是支援這樣的模式,每當資料庫有變更時,能夠自動觸發 UI 的更新,我們的草稿列表是訂閱 Room 的 SELECT * FROM table 的查詢,每當表格有變動時,Room 就會發出資料變更的通知,那我們 UI 有訂閱所以會收到這通知而自動更新 UI。
結語
Room 使用上比原本的 SQLiteOpenHelper 簡單許多,只需要幾個簡單的標注即可完成資料庫、DAO、Entity 的產生和設定,也支援 Reactive 方式來讓 UI 綁定資料庫的資料變更,讓資料有變動時,UI 可以自動變更,而不需要另外設值,對開發者來說是相當方便的。
請問要如何實作一筆資料不存在資料庫要執行寫入,存在時執行更新呢?
我文中的DAO裡面其實有實作一個方法叫做upsert,因為Room再insert的時候會回傳成功寫入的筆數,然後搭配 onConflict = OnConflictStrategy.IGNORE,如果寫入重複資料的時候,會回傳 -1,這時候你就可以換執行 update() 方法。
你好,第一次接觸Room,請問by inject() 這部分要怎麼使用呢?
這個 by inject() 是 dependency injection 相關的寫法,我是使用 koin,跟 Room 使用完全無關。 如果你對於 dependency injection 不了解,可能要請你先了解一下這個概念後,才會大概知道這用法。 (我後續也會寫一篇關於 dependency injection 的文章來介紹)
我有找到koin,但只能在Activity內使用,無法在class PostRepositoryImpl : PostRepository 內使用,不知道這篇有沒有github可以參考
目前沒有 github 可以參考,我還正在準備相關的範例專案,如果你不急,可以再稍等一下。如果很急,那我這邊暫時幫不上你,koin 的東西也不會太困難,網路上蠻多很好的教學。
thanks