6

使用 Android 資料庫: Room

 3 years ago
source link: https://enginebai.com/2019/04/03/android-database-room/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

使用 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 觀察(訂閱)資料的變更,當有資料庫有新貼文寫入或更新時,列表自動會收到更新顯示新貼文或變更。

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

room1.png
MVVM Data Stream

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

實作

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

blog_-android-room_architecture-1.png
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 )

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

idcaptionmediaFiletypelocation57ad8bce0aHi, 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) } }

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

idcaptionmediaFiletypenamelatlng57ad8bce0aHi, …./……………

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

Reactive Design Pattern

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

blog_-android-room_reactive-1.png
Reactive

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

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


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK