具備檢視功能的 Android 會議室 - Kotlin

「架構元件」目的在於提供應用程式架構的相關指引,並提供程式庫,說明生命週期管理和資料持續性等常見工作。架構元件可協助您建立應用程式架構,讓應用程式具備穩固、測試及維護,能夠減少樣板程式碼。架構元件程式庫是 Android Jetpack 的一部分。

這是程式碼研究室的 Kotlin 版本。如需 Java 程式語言的版本,請按這裡

使用本程式碼研究室時,如果遇到任何問題 (程式碼錯誤、文法錯誤、措辭不明確等),請透過程式碼研究室左下角的 [回報錯誤] 連結回報問題。

事前準備

您必須熟悉 Kotlin、物件導向設計概念和 Android 開發基礎知識,尤其是:

而且,也有助於瞭解軟體架構模式與使用者介面不同的資料,例如 MVP 或 MVC。本程式碼研究室將實作應用程式架構指南中定義的架構。

本程式碼研究室著重於 Android 架構元件。我們提供離題和程式碼,方便您直接複製並貼上。

如果您不熟悉 Kotlin,請參閱這裡的 Java 程式設計語言版本。

要執行的步驟

在本程式碼研究室中,您將瞭解如何使用架構元件會議室、ViewModel 和 LiveData 設計及建構應用程式,以及如何建構會執行下列作業的應用程式:

  • 使用 Android 架構元件實作建議的架構
  • 使用資料庫取得及儲存資料,並在資料庫中預先填入一些字詞。
  • 顯示 MainActivityRecyclerView 中的所有字詞。
  • 使用者輕觸 + 按鈕時,系統會開啟第二個活動。使用者輸入字詞時,將字詞加入資料庫和清單。

這款應用程式十分簡單,但功能相當複雜,你可以將其當做範本建構。以下是預覽畫面:

軟硬體需求

這個程式碼研究室會提供建構完整應用程式的一切必要程式碼。

使用架構元件及實作建議的架構有許多步驟。最重要的一件事就是建立寫作模型,瞭解這些元件的結合方式以及資料流動方式。編輯這個程式碼研究室時,請不要直接複製及貼上程式碼,而是開始著手培養對內心的瞭解。

為了介紹這項術語,以下將簡單介紹一下「架構元件」及其共同的運作方式。請注意,本程式碼研究室著重在部分元件,也就是 LiveData、ViewModel 和 Room。每個元件在您使用時都會更清楚說明。

這個架構的基本架構如下:

實體註解類別,用來說明使用 Room 時的資料庫表格。

SQLite 資料庫:裝置上的儲存空間。「會議室持續性程式庫」會為您建立及維護這個資料庫。

DAO資料存取物件。SQL 查詢和函式的對應。使用 DAO 時,您須呼叫方法,其餘的任務則由 Room 處理。

會議室資料庫簡化資料庫工作,並做為基礎 SQLite 資料庫的存取點 (隱藏 SQLiteOpenHelper))。Room 資料庫使用 DAO 對 SQLite 資料庫發出查詢。

存放區:您建立的類別,主要用於管理多個資料來源。

ViewModel做為存放區 (資料) 和 UI 之間的通訊中心。並不需要擔心資料的來源。ViewModel 執行個體在「活動」/「片段」娛樂中存活。

LiveData可以觀察的帳戶持有人類別。一律保留/快取最新版本的資料,並在資料變更時通知觀察器。「LiveData」可辨識生命週期。UI 元件只會觀察相關資料,而且不會停止或繼續觀測。LiveData 會觀察相關的生命週期狀態變化,因此在處理過程中自動管理上述所有事項。

RoomWordSample 架構總覽

下圖顯示應用程式的所有部分。每個封閉框 (SQLite 資料庫除外) 代表您要建立的類別。

  1. 開啟 Android Studio,然後按一下 [Start a new Android Studio project] (建立新的 Android Studio 專案)
  2. 在「建立新專案」視窗中,選擇 [空白活動],然後按一下 [下一步]
  3. 在下一個畫面中,輸入名為 RoomWordSample 的應用程式,然後按一下 [完成]

接下來,您必須將元件程式庫加入 Gradle 檔案。

  1. 在 Android Studio 中,按一下 [Projects] (專案) 標籤並展開 Gradle Scripts 資料夾。

開啟 build.gradle (模組:應用程式)。

  1. 套用 kapt 註解處理工具 Kotlin 外掛程式,將這個檔案新增至 build.gradle (模組:應用程式) 檔案頂端的其他外掛程式之後。
apply plugin: 'kotlin-kapt'
  1. android 區塊中加上 packagingOptions 區塊,即可將不可分割函式模組從套件中排除,以免造成警告。
android {
    // other configuration (buildTypes, defaultConfig, etc.)

    packagingOptions {
        exclude 'META-INF/atomicfu.kotlin_module'
    }
}
  1. dependencies 區塊的結尾處加入下列程式碼。
// Room components
implementation "androidx.room:room-runtime:$rootProject.roomVersion"
kapt "androidx.room:room-compiler:$rootProject.roomVersion"
androidTestImplementation "androidx.room:room-testing:$rootProject.roomVersion"

// Lifecycle components
implementation "androidx.lifecycle:lifecycle-extensions:$rootProject.archLifecycleVersion"
kapt "androidx.lifecycle:lifecycle-compiler:$rootProject.archLifecycleVersion"
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$rootProject.archLifecycleVersion"

// Kotlin components
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
api "org.jetbrains.kotlinx:kotlinx-coroutines-core:$rootProject.coroutines"
api "org.jetbrains.kotlinx:kotlinx-coroutines-android:$rootProject.coroutines"

// Material design
implementation "com.google.android.material:material:$rootProject.materialVersion"

// Testing
testImplementation 'junit:junit:4.12'
androidTestImplementation "androidx.arch.core:core-testing:$rootProject.coreTestingVersion"
  1. 在您的 build.gradle (Project: RoomWordsSample) 檔案中,將程式碼的版本號碼加到檔案結尾處,如下所示。
ext {
    roomVersion = '2.2.5'
    archLifecycleVersion = '2.2.0'
    coreTestingVersion = '2.1.0'
    materialVersion = '1.1.0'
    coroutines = '1.3.4'
}

這個應用程式的資料為文字,您必須建立簡單的表格才能存放這些值:

聊天室可讓您透過實體建立資料表。現在,讓我們開始吧。

  1. 建立名為 Word 的新 Kotlin 類別檔案,其中包含 Word 資料類別
    本課程將說明您實體的「實體」(代表 SQLite 表格)。類別中的每個屬性都代表表格中的一個資料欄。會議室最終會使用這些屬性來建立資料表,並將資料庫中的物件執行個體化。

程式碼如下:

data class Word(val word: String)

如要讓 Word 類別對 Room 資料庫具有意義,您必須加上註解。註解可識別這個類別的各個部分與資料庫項目之間的關係。聊天室會使用這項資訊產生驗證碼。

如果您是自行輸入註解 (而不是貼上),Android Studio 會自動匯入註解類別。

  1. 按照以下程式碼所示的註解來更新 Word 類別:
@Entity(tableName = "word_table")
class Word(@PrimaryKey @ColumnInfo(name = "word") val word: String)

讓我們看看這些註解的作用:

  • @Entity(tableName = "word_table")
    每個 @Entity 類別都代表一個 SQLite 資料表。為類別宣告加上註解,表示這是實體。如果您希望表格名稱與類別的名稱不同,可以指定資料表名稱。這會把表格命名為「word_table」。
  • @PrimaryKey
    每個實體都需要一個主鍵。為了讓每個字簡潔,每個字詞都是獨立的主鍵。
  • @ColumnInfo(name = "word")
    如果資料欄的名稱與成員變數名稱不同,請指定表格中的資料欄名稱。這會將這個資料欄命名為「wordt」。
  • 儲存在資料庫中的所有資源都必須具有公開顯示設定,這是 Kotlin 預設值。

如需註解的完整清單,請參閱會議室套件摘要參考資料

什麼是 DAO?

DAO (資料存取物件) 中,您可以指定 SQL 查詢,並將這些查詢與方法呼叫建立關聯。編譯器會檢查 SQL,並從便利註解產生常用查詢的查詢 (如 @Insert)。Room 會使用 DAO 為您的程式碼建立簡潔的 API。

DAO 必須是介面或抽象類別。

根據預設,所有查詢都必須在個別執行緒上執行。

會議室提供協同程式支援,可讓您使用 suspend 修飾符來為您的查詢加上註解,然後再透過協同程式或其他暫停函式呼叫。

實作 DAO

我們編寫 DAO 來提供下列查詢:

  • 按字母順序排列所有字詞
  • 插入字詞
  • 刪除所有字詞
  1. 建立名為 WordDao 的新 Kotlin 類別檔案。
  2. 複製下列程式碼並貼到 WordDao 中,然後視需要修正匯入,以便進行編譯。
@Dao
interface WordDao {

    @Query("SELECT * from word_table ORDER BY word ASC")
    fun getAlphabetizedWords(): List<Word>

    @Insert(onConflict = OnConflictStrategy.IGNORE)
    suspend fun insert(word: Word)

    @Query("DELETE FROM word_table")
    suspend fun deleteAll()
}

我們來逐步說明一下:

  • WordDao 是介面;DAO 必須是介面或抽象類別。
  • @Dao 註解識別為 Room 的 DAO 類別。
  • suspend fun insert(word: Word):宣告暫停函式來插入一個字詞。
  • @Insert 註解是一種特殊的 DAO 方法註解,您不需要提供任何 SQL!(另外還有 @Delete@Update 個註解可用來刪除及更新列,但您並未在此應用程式中使用這些註解)。
  • onConflict = OnConflictStrategy.IGNORE:如果所選「衝突」策略中的字詞與清單中現有的字詞完全相同,系統會忽略該字詞。如要進一步瞭解可用的衝突策略,請參閱說明文件
  • suspend fun deleteAll():宣告暫停函式,以刪除所有字詞。
  • 您沒有刪除多個實體的便利註解,因此使用一般 @Query 註解。
  • @Query("DELETE FROM word_table")@Query 需要在註解中提供 SQL 查詢字串字串,以便執行複雜的讀取查詢和其他作業。
  • fun getAlphabetizedWords(): List<Word>:取得所有字詞,並傳回 WordsList 的方法。
  • @Query("SELECT * from word_table ORDER BY word ASC"):此查詢會傳回按遞增順序排序的字詞清單。

資料變更時,您通常會想要採取某些行動,例如在使用者介面中顯示更新後的資料。也就是說,您必須觀察資料,才能進行修改。

視資料儲存的方式而定,這可能並不容易。觀察應用程式中多個元件的資料變化時,可在元件之間建立明確且嚴格的相依性路徑。這會導致測試和偵錯等問題變得難以解決。

LiveData 是資料觀察的生命週期程式庫類別,能解決這個問題。使用方法說明中的 LiveData 類型傳回值時,Room 會在資料庫更新時產生所有必要的程式碼,以更新 LiveData

WordDao 中,變更 getAlphabetizedWords() 方法簽名,以便傳回的 List<Word> 納入 LiveData

   @Query("SELECT * from word_table ORDER BY word ASC")
   fun getAlphabetizedWords(): LiveData<List<Word>>

稍後在這個程式碼研究室中,您將透過 MainActivity 中的 Observer 追蹤資料變更。

什麼是聊天室資料庫

  • 聊天室是 SQLite 資料庫頂端的資料庫層。
  • 聊天室會處理您透過 SQLiteOpenHelper 處理的日常工作。
  • Room 會使用 DAO 對資料庫提出查詢。
  • 根據預設,為避免使用者介面效能不佳,「聊天室」不允許在主執行緒上發出查詢。如果聊天室查詢傳回 LiveData,查詢就會自動以非同步方式在背景執行緒上執行。
  • Room 提供 SQLite 陳述式的編譯時間檢查。

實作 Room 資料庫

您的 Room 資料庫類別必須進行抽象並擴充 RoomDatabase。一般來說,整個應用程式只需要一個「Room」資料庫執行個體。

現在我們就開始製作吧!

  1. 建立名為 WordRoomDatabase 的 Kotlin 類別檔案,然後將下列程式碼加入檔案:
// Annotates class to be a Room Database with a table (entity) of the Word class
@Database(entities = arrayOf(Word::class), version = 1, exportSchema = false)
public abstract class WordRoomDatabase : RoomDatabase() {

   abstract fun wordDao(): WordDao

   companion object {
        // Singleton prevents multiple instances of database opening at the
        // same time. 
        @Volatile
        private var INSTANCE: WordRoomDatabase? = null

        fun getDatabase(context: Context): WordRoomDatabase {
            val tempInstance = INSTANCE
            if (tempInstance != null) {
                return tempInstance
            }
            synchronized(this) {
                val instance = Room.databaseBuilder(
                        context.applicationContext,
                        WordRoomDatabase::class.java, 
                        "word_database"
                    ).build()
                INSTANCE = instance
                return instance
            }
        }
   }
}

我們來逐步瀏覽程式碼:

  • 會議室的資料庫類別須為 abstract 並擴充 RoomDatabase
  • 您可以使用 @Database 將類別註解為 Room 資料庫,並使用註解參數宣告資料庫內的實體,並設定版本號碼。每個實體都會對應至會在資料庫中建立的資料表。資料庫遷移作業不在這個程式碼研究室的範圍內,因此將 exportSchema 設為 false 以避免發生建構警告。在實際應用程式中,建議您為 Room 設定一個目錄,以便用來匯出架構,以便檢查目前的架構到版本控制系統中。
  • 資料庫透過抽象「getter」方法公開 DAO,每個 @Dao 方法都會有相同的方法。
  • 我們定義了 singleton (WordRoomDatabase,),以避免同時開啟多個資料庫執行個體。
  • getDatabase 會傳回單調。

什麼是存放區?

存放區類別可抽象存取多個資料來源。該存放區不屬於架構元件程式庫,但建議您選用程式碼區隔和架構的最佳做法。存放區類別提供簡潔的 API,可用來存取應用程式其餘部分的資料。

為什麼要使用存放區?

存放區可用來管理查詢,並讓您使用多個後端。在最常見的例子中,存放區會實作邏輯來決定要從網路擷取資料,還是使用本機資料庫中快取的結果。

實作存放區

建立名為 WordRepository 的 Kotlin 類別檔案,並將下列程式碼貼到該檔案中:

// Declares the DAO as a private property in the constructor. Pass in the DAO
// instead of the whole database, because you only need access to the DAO
class WordRepository(private val wordDao: WordDao) {

    // Room executes all queries on a separate thread.
    // Observed LiveData will notify the observer when the data has changed.
    val allWords: LiveData<List<Word>> = wordDao.getAlphabetizedWords()
 
    suspend fun insert(word: Word) {
        wordDao.insert(word)
    }
}

重點如下:

  • DAO 會傳送至存放區建構函式,而不是整個資料庫。這是因為該 DAO 包含資料庫的所有讀取/寫入方法,因此只需要存取 DAO。完全不需將整個資料庫公開至存放區。
  • 字詞清單是公開的屬性。其是透過初始化的 LiveData 字詞清單進行初始化;這是因為我們定義了在「LiveData 類別」步驟中傳回 LiveData 方法以傳回 LiveData 的方法。聊天室會分別在另一個執行緒上執行所有查詢。然後,觀察到 LiveData 會在資料變更時通知主執行緒。
  • suspend 修飾符會通知編譯器,其需由協同程式或其他懸置函式呼叫。

什麼是 ViewModel?

ViewModel的職責是將資料提供給使用者介面,並繼續沿用設定變更。ViewModel 是存放區和 UI 之間的通訊中心。您也可以使用 ViewModel 在片段之間共用資料。ViewModel 屬於生命週期程式庫的一部分。

如需這個主題的入門指南,請參閱 ViewModel OverviewViewModels:簡易範例網誌文章。

為什麼要使用 ViewModel?

ViewModel 會沿用應用程式的 UI 資料,並採用可維持生命週期的永續做法。將應用程式的 UI 資料與 ActivityFragment 類別區隔,您就能更妥善地遵守單一責任準則:您的活動和片段應負責將畫面繪製到畫面中,ViewModel 可負責處理及處理該 UI 所需的所有資料。

ViewModel 中,使用 LiveData 針對使用者介面將使用或顯示可變更的資料。使用 LiveData 有幾個好處:

  • 您可以針對資料建立觀察項目 (而非輪詢變更),而且只有在資料實際變更時,
    才更新使用者介面。
  • 存放區與使用者介面完全由 ViewModel 區隔。
  • 沒有任何來自 ViewModel 的資料庫呼叫 (全部在存放區中處理),因此可以測試更多程式碼。

viewModelScope

在 Kotlin 中,所有協同程式均會在 CoroutineScope 內執行。範圍可控制協同程式的生命週期。當您取消特定範圍的工作時,系統會取消在該範圍內啟動的所有協同程式。

AndroidX lifecycle-viewmodel-ktx 程式庫將 viewModelScope 新增為 ViewModel 類別的擴充功能函式,讓您可以處理範圍。

如要進一步瞭解如何在 ViewModel 中使用協同程式,請參閱在 Android 應用程式中使用 Kotlin Coroutine 程式碼研究室的步驟 5,或是參閱在 Android 中輕鬆使用 Coroutouts:viewModelScope 網誌文章一文。

導入 ViewModel

WordViewModel 建立 Kotlin 類別檔案並新增下列程式碼:

class WordViewModel(application: Application) : AndroidViewModel(application) {

    private val repository: WordRepository
    // Using LiveData and caching what getAlphabetizedWords returns has several benefits:
    // - We can put an observer on the data (instead of polling for changes) and only update the
    //   the UI when the data actually changes.
    // - Repository is completely separated from the UI through the ViewModel.
    val allWords: LiveData<List<Word>>

    init {
        val wordsDao = WordRoomDatabase.getDatabase(application).wordDao()
        repository = WordRepository(wordsDao)
        allWords = repository.allWords
    }

    /**
     * Launching a new coroutine to insert the data in a non-blocking way
     */
    fun insert(word: Word) = viewModelScope.launch(Dispatchers.IO) {
        repository.insert(word)
    }
}

以下是我們:

  • 建立名為 WordViewModel 的類別,該類別將 Application 做為參數並延伸 AndroidViewModel
  • 已新增私人成員變數,用來存放存放區的參照。
  • 新增公開的 LiveData 成員變數來快取字詞清單。
  • 建立了 init 區塊,從 WordRoomDatabase 取得 WordDao 的參照。
  • init 區塊中,根據 WordRoomDatabase 建構 WordRepository
  • init 區塊中,使用存放區初始化 allWords LiveData。
  • 建立會呼叫 Repository 的 insert() 方法的包裝函式 insert() 方法。如此一來,使用者介面會納入 insert() 的實作。我們不想插入插入阻斷主執行緒,所以我們正在發起一個新的協同程式,然後呼叫一個存儲備插入片段,它是一個懸浮函數。如前文所述,ViewModels 有使用之前稱為 viewModelScope 的生命週期範圍,我們在這裡使用的是這個類別。

接下來,您必須為清單和項目新增 XML 版面配置。

本程式碼研究室假設您已熟悉在 XML 中建立版面配置,因此我們僅提供程式碼給您。

AppTheme 的家長設為 Theme.MaterialComponents.Light.DarkActionBar,藉此製作應用程式主題內容。為「values/styles.xml」中的清單項目新增樣式:

<resources>

    <!-- Base application theme. -->
    <style name="AppTheme" parent="Theme.MaterialComponents.Light.DarkActionBar">
        <!-- Customize your theme here. -->
        <item name="colorPrimary">@color/colorPrimary</item>
        <item name="colorPrimaryDark">@color/colorPrimaryDark</item>
        <item name="colorAccent">@color/colorAccent</item>
    </style>

    <!-- The default font for RecyclerView items is too small.
    The margin is a simple delimiter between the words. -->
    <style name="word_title">
        <item name="android:layout_width">match_parent</item>
        <item name="android:layout_marginBottom">8dp</item>
        <item name="android:paddingLeft">8dp</item>
        <item name="android:background">@android:color/holo_orange_light</item>
        <item name="android:textAppearance">@android:style/TextAppearance.Large</item>
    </style>
</resources>

新增 layout/recyclerview_item.xml 版面配置:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="vertical" 
    android:layout_width="match_parent"
    android:layout_height="wrap_content">

    <TextView
        android:id="@+id/textView"
        style="@style/word_title"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:background="@android:color/holo_orange_light" />
</LinearLayout>

layout/activity_main.xml 中,將 TextView 替換為 RecyclerView,然後新增浮動動作按鈕 (FAB)。現在您的版面配置應該如下所示:

<androidx.constraintlayout.widget.ConstraintLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/recyclerview"
        android:layout_width="0dp"
        android:layout_height="0dp"
        tools:listitem="@layout/recyclerview_item"
        android:padding="@dimen/big_padding"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <com.google.android.material.floatingactionbutton.FloatingActionButton
        android:id="@+id/fab"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_margin="16dp"
        android:contentDescription="@string/add_word"/>

</androidx.constraintlayout.widget.ConstraintLayout>

您的 FAB 外貌似乎應該要配合可用的操作,所以我們將把它換成 &&33;+' 符號。

首先,我們必須新增 Vector 資產:

  1. 選取 [File > New > Vector Asset]
  2. 按一下 [剪輯藝術品:] 欄位中的 Android 機器人圖示。
  3. 搜尋「add」,然後選取 '+' 資產。按一下 [確定]。
  4. 完成後,點選 [下一步]
  5. 將圖示路徑確認為 main > drawable,然後按一下 [完成] 即可新增資產。
  6. layout/activity_main.xml 中更新 FAB,加入新的可繪項目:
<com.google.android.material.floatingactionbutton.FloatingActionButton
        android:id="@+id/fab"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_margin="16dp"
        android:contentDescription="@string/add_word"
        android:src="@drawable/ic_add_black_24dp"/>

您將在 RecyclerView 中顯示資料,比將資料丟入 TextView 來得簡單。本程式碼研究室假設您瞭解 RecyclerViewRecyclerView.LayoutManagerRecyclerView.ViewHolderRecyclerView.Adapter 如何運作。

請注意,轉接程式中的 words 變數可以快取資料。在下一項工作中,您會加入自動更新資料的程式碼。

為延伸至 RecyclerView.AdapterWordListAdapter 建立 Kotlin 類別檔案。程式碼如下:

class WordListAdapter internal constructor(
        context: Context
) : RecyclerView.Adapter<WordListAdapter.WordViewHolder>() {

    private val inflater: LayoutInflater = LayoutInflater.from(context)
    private var words = emptyList<Word>() // Cached copy of words

    inner class WordViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
        val wordItemView: TextView = itemView.findViewById(R.id.textView)
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): WordViewHolder {
        val itemView = inflater.inflate(R.layout.recyclerview_item, parent, false)
        return WordViewHolder(itemView)
    }

    override fun onBindViewHolder(holder: WordViewHolder, position: Int) {
        val current = words[position]
        holder.wordItemView.text = current.word
    }

    internal fun setWords(words: List<Word>) {
        this.words = words
        notifyDataSetChanged()
    }

    override fun getItemCount() = words.size
}

MainActivityonCreate() 方法中新增 RecyclerView

setContentView 之後的 onCreate() 方法中:

   val recyclerView = findViewById<RecyclerView>(R.id.recyclerview)
   val adapter = WordListAdapter(this)
   recyclerView.adapter = adapter
   recyclerView.layoutManager = LinearLayoutManager(this)

執行應用程式,確保一切運作正常。沒有項目,因為您尚未連結資料。

資料庫中沒有任何資料。新增資料的方法有兩種:在資料庫開啟時新增一些資料,然後新增 Activity 來加入字詞。

如要刪除所有內容並在每次啟動應用程式時填入資料庫,請建立 RoomDatabase.Callback 並覆寫 onOpen()。由於您無法對 UI 執行緒執行 Room 資料庫作業,onOpen() 會在 IO 調度員中啟動協同程式。

如要啟動協同程式,請提供 CoroutineScope。更新 WordRoomDatabase 類別的 getDatabase 方法,也可取得參數範圍做為參數:

fun getDatabase(
       context: Context,
       scope: CoroutineScope
  ): WordRoomDatabase {
...
}

更新 WordViewModelinit 區塊中的資料庫擷取初始化工具,以傳遞範圍:

val wordsDao = WordRoomDatabase.getDatabase(application, viewModelScope).wordDao()

WordRoomDatabase 中,我們建立了 RoomDatabase.Callback() 的自訂實作,這也會取得 CoroutineScope 做為建構函式參數。接著,我們會覆寫 onOpen 方法以填入資料庫。

以下是在 WordRoomDatabase 類別「中」建立回呼的程式碼:

private class WordDatabaseCallback(
    private val scope: CoroutineScope
) : RoomDatabase.Callback() {

    override fun onOpen(db: SupportSQLiteDatabase) {
        super.onOpen(db)
        INSTANCE?.let { database ->
            scope.launch {
                populateDatabase(database.wordDao())
            }
        }
    }

    suspend fun populateDatabase(wordDao: WordDao) {
        // Delete all content here.
        wordDao.deleteAll()

        // Add sample words.
        var word = Word("Hello")
        wordDao.insert(word)
        word = Word("World!")
        wordDao.insert(word)

        // TODO: Add your own words!
    }
}

最後,在 Room.databaseBuilder() 上呼叫 .build() 之前,立即將回呼新增至資料庫建構序列:

.addCallback(WordDatabaseCallback(scope))

最終程式碼看起來應該像這樣:

@Database(entities = arrayOf(Word::class), version = 1, exportSchema = false)
abstract class WordRoomDatabase : RoomDatabase() {

   abstract fun wordDao(): WordDao

   private class WordDatabaseCallback(
       private val scope: CoroutineScope
   ) : RoomDatabase.Callback() {

       override fun onOpen(db: SupportSQLiteDatabase) {
           super.onOpen(db)
           INSTANCE?.let { database ->
               scope.launch {
                   var wordDao = database.wordDao()

                   // Delete all content here.
                   wordDao.deleteAll()

                   // Add sample words.
                   var word = Word("Hello")
                   wordDao.insert(word)
                   word = Word("World!")
                   wordDao.insert(word)

                   // TODO: Add your own words!
                   word = Word("TODO!")
                   wordDao.insert(word)
               }
           }
       }
   }

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

       fun getDatabase(
           context: Context,
           scope: CoroutineScope
       ): WordRoomDatabase {
            // if the INSTANCE is not null, then return it,
            // if it is, then create the database
            return INSTANCE ?: synchronized(this) {
                val instance = Room.databaseBuilder(
                        context.applicationContext,
                        WordRoomDatabase::class.java,
                        "word_database"
                )
                 .addCallback(WordDatabaseCallback(scope))
                 .build()
                INSTANCE = instance
                // return instance
                instance
        }
     }
   }
}

values/strings.xml 中加入這些字串資源:

<string name="hint_word">Word...</string>
<string name="button_save">Save</string>
<string name="empty_not_saved">Word not saved because it is empty.</string>

value/colors.xml 中加入這個顏色資源:

<color name="buttonLabel">#FFFFFF</color>

建立新的維度資源檔案:

  1. 在「專案」視窗中按一下應用程式模組。
  2. 選取 [File > New > Android 資源檔案]
  3. 在「限定詞」中選取 [維度]。
  4. 設定檔案名稱:dimens

values/dimens.xml 中新增這些維度資源:

<dimen name="small_padding">8dp</dimen>
<dimen name="big_padding">16dp</dimen>

使用空白活動範本Activity建立新的空白 Android 裝置:

  1. 選取 [File > New > Activity > Empty Activity] (檔案 > > 活動 & > 空白活動)
  2. 針對活動名稱輸入 NewWordActivity
  3. 確認新的活動已新增至 Android 資訊清單中。
<activity android:name=".NewWordActivity"></activity>

在版面配置資料夾中更新 activity_new_word.xml 檔案,然後加入下列程式碼:

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <EditText
        android:id="@+id/edit_word"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:minHeight="@dimen/min_height"
        android:fontFamily="sans-serif-light"
        android:hint="@string/hint_word"
        android:inputType="textAutoComplete"
        android:layout_margin="@dimen/big_padding"
        android:textSize="18sp" />

    <Button
        android:id="@+id/button_save"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:background="@color/colorPrimary"
        android:text="@string/button_save"
        android:layout_margin="@dimen/big_padding"
        android:textColor="@color/buttonLabel" />

</LinearLayout>

更新活動程式碼:

class NewWordActivity : AppCompatActivity() {

    private lateinit var editWordView: EditText

    public override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_new_word)
        editWordView = findViewById(R.id.edit_word)

        val button = findViewById<Button>(R.id.button_save)
        button.setOnClickListener {
            val replyIntent = Intent()
            if (TextUtils.isEmpty(editWordView.text)) {
                setResult(Activity.RESULT_CANCELED, replyIntent)
            } else {
                val word = editWordView.text.toString()
                replyIntent.putExtra(EXTRA_REPLY, word)
                setResult(Activity.RESULT_OK, replyIntent)
            }
            finish()
        }
    }

    companion object {
        const val EXTRA_REPLY = "com.example.android.wordlistsql.REPLY"
    }
}

最後一個步驟是儲存使用者輸入的新字詞,並在 RecyclerView 中顯示字詞資料庫的目前內容,藉此將使用者介面連結到資料庫。

如要顯示資料庫目前的內容,請新增觀察 ViewModelLiveData 的觀測器。

只要資料有所變更,就會叫用 onChanged() 回呼,以呼叫轉接程式的 setWords() 方法更新轉接程式的快取資料並重新整理顯示的清單。

MainActivity 中,為 ViewModel 建立成員變數:

private lateinit var wordViewModel: WordViewModel

使用「ViewModelProvider將「ViewModel」與您的「Activity」建立關聯。

首次使用 Activity 時,ViewModelProviders 將會建立 ViewModel。如果活動遭到刪除 (例如透過設定變更),ViewModel 也會繼續保留該活動。重新建立活動時,ViewModelProviders 會傳回現有的 ViewModel。詳情請參閱 ViewModel

RecyclerView 代碼區塊下方的 onCreate() 中,從 ViewModelProvider 取得 ViewModel

wordViewModel = ViewModelProvider(this).get(WordViewModel::class.java)

此外,在 onCreate() 中,針對 WordViewModel 中的所有 AdWords 資源 LiveData 新增觀察器。

onChanged() 方法 (Lumda 的預設方法) 會在觀察到的資料變更且活動於前景運作時啟動:

wordViewModel.allWords.observe(this, Observer { words ->
            // Update the cached copy of the words in the adapter.
            words?.let { adapter.setWords(it) }
})

我們想在輕觸 FAB 時開啟 NewWordActivity,並在系統回到 MainActivity 時,在資料庫中插入新字詞或顯示 Toast。為了達成這個目標,讓我們先定義要求代碼:

private val newWordActivityRequestCode = 1

MainActivity 中,新增 NewWordActivityonActivityResult() 代碼。

如果活動傳回 RESULT_OK,請呼叫 WordViewModelinsert() 方法,將傳回的字詞插入資料庫:

override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
    super.onActivityResult(requestCode, resultCode, data)

    if (requestCode == newWordActivityRequestCode && resultCode == Activity.RESULT_OK) {
        data?.getStringExtra(NewWordActivity.EXTRA_REPLY)?.let {
            val word = Word(it)
            wordViewModel.insert(word)
        }
    } else {
        Toast.makeText(
            applicationContext,
            R.string.empty_not_saved,
            Toast.LENGTH_LONG).show()
    }
}

在使用者輕觸 FAB 時,在MainActivity,啟動NewWordActivity後啟動。在 MainActivity onCreate 中找出 FAB,並加入以下代碼的 onClickListener

val fab = findViewById<FloatingActionButton>(R.id.fab)
fab.setOnClickListener {
  val intent = Intent(this@MainActivity, NewWordActivity::class.java)
  startActivityForResult(intent, newWordActivityRequestCode)
}

完成的程式碼應如下所示:

class MainActivity : AppCompatActivity() {

   private const val newWordActivityRequestCode = 1
   private lateinit var wordViewModel: WordViewModel

   override fun onCreate(savedInstanceState: Bundle?) {
       super.onCreate(savedInstanceState)
       setContentView(R.layout.activity_main)
       setSupportActionBar(toolbar)

       val recyclerView = findViewById<RecyclerView>(R.id.recyclerview)
       val adapter = WordListAdapter(this)
       recyclerView.adapter = adapter
       recyclerView.layoutManager = LinearLayoutManager(this)

       wordViewModel = ViewModelProvider(this).get(WordViewModel::class.java)
       wordViewModel.allWords.observe(this, Observer { words ->
           // Update the cached copy of the words in the adapter.
           words?.let { adapter.setWords(it) }
       })

       val fab = findViewById<FloatingActionButton>(R.id.fab)
       fab.setOnClickListener {
           val intent = Intent(this@MainActivity, NewWordActivity::class.java)
           startActivityForResult(intent, newWordActivityRequestCode)
       }
   }

   override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
       super.onActivityResult(requestCode, resultCode, data)

       if (requestCode == newWordActivityRequestCode && resultCode == Activity.RESULT_OK) {
           data?.getStringExtra(NewWordActivity.EXTRA_REPLY)?.let {
               val word = Word(it)
               wordViewModel.insert(word)
           }
       } else {
           Toast.makeText(
               applicationContext,
               R.string.empty_not_saved,
               Toast.LENGTH_LONG).show()
       }
   }
}

現在執行您的應用程式吧!當您在 NewWordActivity 的資料庫中新增字詞後,使用者介面會自動更新。

現在您已有運作中的應用程式,接著來回顧一下您建構的應用程式。以下是應用程式結構:

應用程式的元件如下:

  • MainActivity:使用 RecyclerViewWordListAdapter 顯示清單中的字詞。MainActivity 中有一個 Observer 會觀察資料庫中的 LiveData 字詞,並在變更時通知您。
  • NewWordActivity: 會在清單中新增一個字詞。
  • WordViewModel:提供存取資料層的方法,並傳回 LiveData,以便 MainActivity 設定觀察者關係。*
  • LiveData<List<Word>>:可在使用者介面元件中自動更新。在 MainActivity 中,有 Observer 會從資料庫觀察 LiveData 文字,並在變更時收到通知。
  • Repository: 會管理一或多個資料來源。Repository 會公開讓 ModelModel 與基礎資料供應商互動的方法。在這個應用程式中,該後端是 Room 資料庫。
  • Room:是包圍且實作 SQLite 資料庫的包裝函式。會議室需要您花費許多心力,
  • DAO:將方法呼叫對應至資料庫查詢,這樣當存放區呼叫 getAlphabetizedWords() 等方法時,Room 就可以執行 SELECT * from word_table ORDER BY word ASC
  • Word:是包含單一字詞的實體類別。

* ViewsActivities (和 Fragments) 只會透過 ViewModel 與資料互動。因此,無論資料來源為何,都沒有影響。

資料自動更新自動更新介面 (回應式 UI)

由於我們使用 LiveData,所以自動更新可以實現。在 MainActivity 中,有 Observer 會從資料庫觀察 LiveData 文字,並在變更時收到通知。如有任何變更,觀察器的 onChange() 方法會執行,並在 WordListAdapter 中更新 mWords

由於資料是LiveData,因此可以加以觀察。而觀察到的是 WordViewModel a llWords 屬性傳回的 LiveData<List<Word>>

WordViewModel 會隱藏來自使用者介面圖層的所有後端。它提供存取資料層的方法,並傳回 LiveData,以便 MainActivity 設定觀察器關係。ViewsActivities (和 Fragments) 只會透過 ViewModel 與資料互動。因此,無論資料來源為何,都沒有影響。

在此案例中,資料來自 Repository。「ViewModel」不需要知道存放區的互動方式。其中只需知道如何與 Repository 互動,也就是透過 Repository 公開的方法。

存放區會管理一或多個資料來源。在 WordListSample 應用程式中,該後端是 Room 資料庫。Room 是一個環繞式且實作 SQLite 資料庫的包裝函式。會議室需要您花費許多心力,例如,Room 可執行您在 SQLiteOpenHelper 課程中執行的一切作業。

DAO 將方法呼叫傳送至資料庫查詢,這樣當存放區呼叫 getAllWords() 等方法時,Room 就可以執行 SELECT * from word_table ORDER BY word ASC

由於從查詢結果傳回的結果是LiveData,因此每次變更會議室中的資料時,系統都會執行 Observer 介面的 onChanged() 方法,並更新使用者介面。

[選用] 下載解決方案程式碼

如果您尚未查看程式碼研究室的解決方案代碼,請先查看。您可以在 GitHub 存放區中查看程式碼,或是在這裡下載程式碼:

下載原始碼

將下載的 ZIP 檔案解壓縮。這樣做會解壓縮根資料夾 android-room-with-a-view-kotlin,其中包含完整應用程式。