使用 Android 資料庫: Room

我們今天來介紹如何導入使用 Android 的資料庫 Room,Android Jetpack 套件元件之一,如何融入 MVVM 架構,並且善用一些特性減少開發上的困難。

先來看看 Room 有哪些特點:

  1. Room 把一些 SQLite 底層實作封裝起來讓我們能更方便存取資料庫,不需要再寫冗長的程式碼才能將 SQL 和 Kotlin程式資料類別轉換
  2. Room支援編譯時期的 SQL 語法檢查,不需要等到執行後才能發現錯誤。
  3. 容易整合且語法簡單需多,少掉很多囉唆的程式碼。
  4. 支援LiveData / RxJava,可以使用觀察者模式來訂閱資料變更。

這篇文章將會使用社群 App 發文草稿當作範例來說明,需求是在 App 上可以發布新的影片貼文,發布過程中要把新貼文當作草稿暫存下來,且在草稿列表中顯示上傳狀態,直到上傳成功後才把暫存貼文刪除。

這邊我們完全不會解釋資料庫相關的概念,這篇我們會假設你都已經有資料庫基礎,知道資料庫、表格、欄位、primary key、foreign key…等是什麼

設計架構

我們使用 MVVM 架構,Model 提供一個資料流給 ViewModel,ViewModel 再讓這資料流給 View 去訂閱然後顯示。 依照需求來看我們需要把新貼文儲存起來在資料庫,當要發新貼文時,我們會將新產生的貼文物件寫入資料庫,然後因為 View 觀察(訂閱)資料的變更,當有資料庫有新貼文寫入或更新時,列表自動會收到更新顯示新貼文或變更。

元件架構圖和資料流如下:

MVVM Data Stream

Room 在版本 v2.1.0 已經對於 RxJava 有非常完整的整合,所以我們採用 RxJava + Room 架構來實做。

實作

Room 有三個主要元件,架構如下圖:

Photo credit: Android Developer Doc

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
)
view raw NewPost.kt hosted with ❤ by GitHub

依據我們實體類別的宣告,資料庫表格就會長這樣:

idcaptionmediaFiletypelocation
57ad8bce0aHi, 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)
}
}
}
view raw NewPostDao.kt hosted with ❤ by GitHub

這邊還有一點值得一提,就是在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
}
}
}
view raw NewPostDatabase.kt hosted with ❤ by GitHub

這邊要注意的是,資料庫實體的產生和取得,官方建議用 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 raw PostRepository.kt hosted with ❤ by GitHub

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
}
}
}
view raw PostListPage.kt hosted with ❤ by GitHub

執行

整合完後就可以執行看看結果了,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) }
}

這個轉換類別轉換了 PostTypeFile,還有一個 Location 物件還沒轉換,這邊我們會另外使用 Room 另一個annotation: @Embedded 來處理,我們回到 entity class,在 Location 加上 @Embedded ,Room 在儲存這個欄位的時候,就會把 Location 裡面的每個欄位攤平一起成為 NewPost 這個表格裡面的欄位,攤平物件後的表格欄位就會長這樣:

idcaptionmediaFiletypenamelatlng
57ad8bce0aHi, …./…

為了預防攤平後的欄位名稱衝突 (可能 NewPostLocation 都有定義相同的屬性名稱),我們可以在 @Embedded 加上(prefix= …),Room 儲存這些攤平欄位時,就會幫我們加上這些前綴以避免命名衝突。

Reactive Design Pattern

在 MVVM 的架構中,有一個重要的核心概念就是資料流,Model 提供原始的資料流,ViewModel 把 Model 的資料流轉換為 UI 呈現的資料流讓 View 來訂閱/監聽。每當 Model 的資料流有變更時,View 因為訂閱了該資料流,所以 UI 也會自動跟著變更。

Reactive

Room 本身的設計也是支援這樣的模式,每當資料庫有變更時,能夠自動觸發 UI 的更新,我們的草稿列表是訂閱 Room 的 SELECT * FROM table 的查詢,每當表格有變動時,Room 就會發出資料變更的通知,那我們 UI 有訂閱所以會收到這通知而自動更新 UI。

結語

Room 使用上比原本的 SQLiteOpenHelper 簡單許多,只需要幾個簡單的標注即可完成資料庫、DAO、Entity 的產生和設定,也支援 Reactive 方式來讓 UI 綁定資料庫的資料變更,讓資料有變動時,UI 可以自動變更,而不需要另外設值,對開發者來說是相當方便的。


8 thoughts on “使用 Android 資料庫: Room

Add yours

    1. 我文中的DAO裡面其實有實作一個方法叫做upsert,因為Room再insert的時候會回傳成功寫入的筆數,然後搭配 onConflict = OnConflictStrategy.IGNORE,如果寫入重複資料的時候,會回傳 -1,這時候你就可以換執行 update() 方法。

      Like

    1. 這個 by inject() 是 dependency injection 相關的寫法,我是使用 koin,跟 Room 使用完全無關。 如果你對於 dependency injection 不了解,可能要請你先了解一下這個概念後,才會大概知道這用法。 (我後續也會寫一篇關於 dependency injection 的文章來介紹)

      Like

  1. 我有找到koin,但只能在Activity內使用,無法在class PostRepositoryImpl : PostRepository 內使用,不知道這篇有沒有github可以參考

    Like

  2. 目前沒有 github 可以參考,我還正在準備相關的範例專案,如果你不急,可以再稍等一下。如果很急,那我這邊暫時幫不上你,koin 的東西也不會太困難,網路上蠻多很好的教學。

    Like

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s

Blog at WordPress.com.

Up ↑