Android Room とビュー - Kotlin

Android のアーキテクチャ コンポーネント コレクションは、ライフサイクル管理やデータ永続化などの一般的タスク用のライブラリとともに、アプリ アーキテクチャに関する指針を提供します。アーキテクチャ コンポーネントを使用すると、ボイラープレート コードを減らし、堅牢でテストとメンテナンスがしやすいアプリを構築できます。

アーキテクチャ コンポーネント ライブラリは Android Jetpack に含まれています。

なお、この Codelab は Kotlin 版です。Java プログラミング言語版については、こちらをご覧ください。

この Codelab で問題(コードのバグ、文法的な誤り、不明確な表現など)が見つかった場合は、Codelab の左下隅にある [誤りを報告] から問題を報告してください。

前提条件

Kotlin、オブジェクト指向設計のコンセプト、Android アプリ開発の基礎、特に以下に精通している必要があります。

また、Model-View-Presenter(MVP)や Model-View-Controller(MVC)など、ユーザー インターフェースからデータを分離するソフトウェア アーキテクチャ パターンに精通していれば、より理解しやすいでしょう。この Codelab は、アプリ アーキテクチャ ガイドの Android デベロッパー向けドキュメントに定義されているアーキテクチャを実装します。

取り扱う範囲は、Android アーキテクチャ コンポーネントです。それ以外のコンセプトやコードについては、コピーして貼り付ければ済む形で提供します。

演習内容

アーキテクチャ コンポーネントである Room、ViewModel、LiveData を使用したアプリの設計・構築方法を学習します。作成するアプリの機能は次のとおりです。

  • Android アーキテクチャ コンポーネントを使用して推奨アーキテクチャを実装する。
  • データベースと連携してデータの取得、保存を行う。また、そのデータベースに単語サンプルを事前入力する。
  • MainActivity クラスの RecyclerView にすべての単語を表示する。
  • ユーザーが [+] ボタンをタップすると 2 つ目のアクティビティが開く。ユーザーが単語を入力すると、その単語がデータベースに追加され、RecyclerView リストに表示される。

このアプリは、見た目はシンプルですが、開発用のテンプレートとして使用できるほど複雑な内容を持っています。プレビューを以下に示します。

必要なもの

  • Android Studio 4.0 以降とその使用方法に関する知識。Android Studio に加え、SDK と Gradle も必ず最新版にしてください。
  • Android デバイスまたはエミュレータ。

この Codelab には、アプリを完成させるために必要なコードがすべて用意されています。

ここで、アーキテクチャ コンポーネントと、それらがどのように連携して機能するかを簡単に紹介します。なお、この Codelab で取り上げるのは、アーキテクチャ コンポーネントの一部(具体的には LiveData、ViewModel、Room)です。各コンポーネントについて詳しくは、アプリで使用する際に説明します。

8e4b761713e3a76b.png

LiveData: 監視可能なデータホルダー クラス。常に最新バージョンのデータを保持またはキャッシュに保存し、データが変更されたときにオブザーバーに通知します。LiveData はライフサイクルを認識します。UI コンポーネントは関連データを監視するだけで、監視の停止や再開は行いませんが、LiveData はそのすべてを自動的に管理します。これは、LiveData が監視を行いながら、関連するライフサイクルの状態の変化を認識するためです。

ViewModel: リポジトリ(データ)と UI 間のコミュニケーション センターとして機能します。これにより、UI はデータの生成元を意識する必要がなくなります。ViewModel インスタンスは、アクティビティやフラグメントを再作成しても存続します。

リポジトリ: デベロッパーが作成するクラスで、主に複数のデータソースを管理するために使用します。

エンティティ: Room を使用する際にデータベース テーブルを記述するアノテーション付きクラス。

Room データベース: 基盤にある SQLite データベースへのアクセス ポイントとして機能(SQLiteOpenHelper) を隠蔽)し、データベースの処理をシンプルにします。Room データベースは、DAO を使用して SQLite データベースにクエリを発行します。

SQLite データベース: デバイス上のストレージ。Room 永続ライブラリが作成して管理します。

DAO: データ アクセス オブジェクト。関数に対する SQL クエリのマッピング。DAO を使用する際は、メソッドを呼び出せばあとは Room が処理します。

RoomWordSample のアーキテクチャ概要

下図に、アプリの各要素がどのように連携するかを示します。長方形の四角(SQLite データベースではない)はすべて、これから作成するクラスを表しています。

a70aca8d4b737712.png

  1. Android Studio を開き、[Start a new Android Studio project] をクリックします。
  2. [Create New Project] ウィンドウで [Empty Activity] を選択し、[Next] をクリックします。
  3. 次の画面で、アプリに「RoomWordSample」という名前を付けて、[Finish] をクリックします。

9b6cbaec81794071.png

次に、コンポーネント ライブラリを Gradle ファイルに追加する必要があります。

  1. Android Studio で [Project] タブをクリックし、Gradle Scripts フォルダを展開します。

build.gradleModule: app)を開きます。

  1. build.gradleModule: app)ファイルの先頭に定義されているプラグイン セクションの下に次の行を追加して、kapt アノテーション プロセッサ Kotlin プラグインを適用します。
apply plugin: 'kotlin-kapt'
  1. android ブロック内に packagingOptions ブロックを追加して、パッケージからアトミック関数モジュールを除外し、警告を回避します。
  2. 使用する API の一部は 1.8 の jvmTarget を必要とするため、android ブロックにそれも追加します。
android {
    // other configuration (buildTypes, defaultConfig, etc.)

    packagingOptions {
        exclude 'META-INF/atomicfu.kotlin_module'
    }

    kotlinOptions {
        jvmTarget = "1.8"
    }

}
  1. dependencies ブロックを次の内容に置き換えます。
dependencies {
    implementation "androidx.appcompat:appcompat:$rootProject.appCompatVersion"
    implementation "androidx.activity:activity-ktx:$rootProject.activityVersion"

    // Dependencies for working with Architecture components
    // You'll probably have to update the version numbers in build.gradle (Project)

    // Room components
    implementation "androidx.room:room-ktx:$rootProject.roomVersion"
    kapt "androidx.room:room-compiler:$rootProject.roomVersion"
    androidTestImplementation "androidx.room:room-testing:$rootProject.roomVersion"

    // Lifecycle components
    implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$rootProject.lifecycleVersion"
    implementation "androidx.lifecycle:lifecycle-livedata-ktx:$rootProject.lifecycleVersion"
    implementation "androidx.lifecycle:lifecycle-common-java8:$rootProject.lifecycleVersion"

    // 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"

    // UI
    implementation "androidx.constraintlayout:constraintlayout:$rootProject.constraintLayoutVersion"
    implementation "com.google.android.material:material:$rootProject.materialVersion"

    // Testing
    testImplementation "junit:junit:$rootProject.junitVersion"
    androidTestImplementation "androidx.arch.core:core-testing:$rootProject.coreTestingVersion"
    androidTestImplementation ("androidx.test.espresso:espresso-core:$rootProject.espressoVersion", {
        exclude group: 'com.android.support', module: 'support-annotations'
    })
    androidTestImplementation "androidx.test.ext:junit:$rootProject.androidxJunitVersion"
}

この時点で Gradle がバージョンの不足または未定義に関するメッセージを表示する可能性がありますが、この問題は次のステップで解消されます。

  1. build.gradleProject: RoomWordsSample)ファイルを開き、最後尾にバージョン番号を次のコードのように追加します。
ext {
    activityVersion = '1.1.0'
    appCompatVersion = '1.2.0'
    constraintLayoutVersion = '2.0.2'
    coreTestingVersion = '2.1.0'
    coroutines = '1.3.9'
    lifecycleVersion = '2.2.0'
    materialVersion = '1.2.1'
    roomVersion = '2.2.5'
    // testing
    junitVersion = '4.13.1'
    espressoVersion = '3.1.0'
    androidxJunitVersion = '1.1.2'
}

このアプリのデータは単語であり、その値を保持するために下図のようなシンプルなテーブルが必要になります。

3821ac1a6cb01278.png

Room のテーブルは、エンティティを使用して作成できます。それでは作成しましょう。

  1. Word データクラスを含む Word という名前の新しい Kotlin クラスファイルを作成します。このクラスにより、単語用のエンティティ(SQLite テーブルを表す)が記述されます。クラス内の各プロパティは、テーブル内の列を表します。Room は最終的にこのプロパティを使用して、テーブルの作成と、データベース行からのオブジェクトのインスタンス化を行います。

コードは次のとおりです。

data class Word(val word: String)

Room データベースで Word クラスを使用できるようにするには、Kotlin アノテーションを使ってこのクラスをデータベースに関連付ける必要があります。個別のアノテーションを使用して、クラスの各部分がデータベース内のエントリにどう関連しているかを示します。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"): テーブル内の列名をメンバー変数と異なる名前にする場合に指定します。ここでは、列に「word」という名前を付けています。
  • データベースに格納されるすべてのプロパティは公開設定を一般公開(Kotlin のデフォルト)にする必要があります。

すべてのアノテーションの一覧については、Room パッケージ概要リファレンスをご覧ください。

DAO とは

DAO(データ アクセス オブジェクト)とは、SQL クエリを指定してメソッド呼び出しに関連付けるためのものです。一般的なクエリを利便性の良いアノテーション(@Insert など)に関連付けておくと、コンパイラにより SQL のチェックとクエリの生成が行われます。Room はこの DAO を使用して、コード用のクリーンな API を作成します。

DAO はインターフェースまたは抽象クラスである必要があります。

デフォルトでは、すべてのクエリは個別のスレッドで実行する必要があります。

Room は Kotlin コルーチンをサポートしています。これにより、クエリに suspend 修飾子付きのアノテーションを指定して、コルーチンや他の 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): 1 つの単語を挿入する suspend 関数を宣言しています。
  • @Insert アノテーションは特殊な DAO メソッド アノテーションであり、ここには SQL を指定する必要はありません。なお、行の削除・更新用に @Delete@Update というアノテーションもありますが、このアプリでは使用しません。
  • onConflict = OnConflictStrategy.IGNORE: ここで選択した onConflict 戦略により、すでにリストにある単語と完全に一致するものを挿入しようとした場合は無視されます。利用可能な競合戦略について詳しくは、ドキュメントをご覧ください。
  • suspend fun deleteAll(): すべての単語を削除する suspend 関数を宣言しています。
  • 複数のエンティティを削除するための利便性の良いアノテーションがないため、汎用の @Query アノテーションを使用しています。
  • @Query("DELETE FROM word_table"): @Query では、アノテーションの文字列パラメータに SQL クエリを指定する必要があります。これにより、複雑な読み取りクエリなどの操作が可能になります。
  • fun getAlphabetizedWords(): List<Word>: すべての単語を取得して、WordsList を返すメソッド。
  • @Query("SELECT * FROM word_table ORDER BY word ASC"): 単語のリストを昇順に並べ替えて返すクエリ。

通常、データが変更された際には、更新されたデータを UI に表示するなど、なんらかのアクションが必要になります。そのためには、データの変更に対応できるように監視する必要があります。

ここでは、データの変更を監視するために、kotlinx-coroutinesFlow を使用します。メソッドの記述で Flow 型の戻り値を使用すると、データベースが更新された際の Flow の更新に必要なすべてのコードが Room により生成されます。

WordDao で、返される List<Word>Flow でラップされるように getAlphabetizedWords() メソッドのシグネチャを変更します。

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

この Codelab では、後ほどこの Flow を ViewModel で LiveData に変換します。これらのコンポーネントについては、実装するときにさらに詳しく説明します。

Room データベースとは****

  • Room は、SQLite データベースを基盤とするデータベース レイヤです。
  • SQLiteOpenHelper で処理していたような一般的なタスクを担います。
  • DAO を使用してデータベースにクエリを発行します。
  • UI のパフォーマンス低下を避けるため、デフォルトではメインスレッドからクエリを発行できません。Room のクエリが Flow を返す場合、クエリは自動的にバックグラウンド スレッドで非同期に実行されます。
  • Room は、SQLite ステートメントのコンパイル時チェックを行います。

Room データベースを実装する

Room データベースは、RoomDatabase を拡張した抽象クラスにする必要があります。通常、アプリ全体で必要な Room データベースのインスタンスは 1 つのみです。

それでは作成してみましょう。

  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 {
            // 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"
                    ).build()
                INSTANCE = instance
                // return instance
                instance
            }
        }
   }
}

コードを詳しく見ていきましょう。

  • Room データベースのクラスは abstract で、RoomDatabase. を拡張する必要があります。
  • クラスに @Database アノテーションを付けて Room データベースであることを示し、アノテーション パラメータを使用してデータベースに属するエンティティの宣言とバージョンの指定を行います。各エンティティは、データベース内に作成されるテーブルに対応します。この Codelab では、データベースの移行は範囲外のため、exportSchema を false に設定してビルド警告を回避します。実際のアプリでは、現在のスキーマをバージョン管理システムにチェックインできるように、Room によるスキーマのエクスポートに使用されるディレクトリを設定することを検討してください。
  • データベースは、@Dao ごとに抽象「ゲッター」メソッドを介して DAO を公開します。
  • データベースのインスタンスが複数同時に開かれるのを防ぐために、シングルトンWordRoomDatabase, を定義しています。
  • getDatabase はこのシングルトンを返します。そして、最初にアクセスされた時点でデータベースを作成します。その際には、Room のデータベース ビルダーを使用して WordRoomDatabase クラスからアプリのコンテキスト内に RoomDatabase オブジェクトを作成し、"word_database" という名前を付けます。

リポジトリとは

リポジトリ クラスは、複数のデータソースへのアクセスを抽象化します。リポジトリは、アーキテクチャ コンポーネント ライブラリには含まれていませんが、コードの分離とアーキテクチャの観点から推奨されるベスト プラクティスです。リポジトリ クラスは、アプリの他の部分に対するデータアクセスのためのクリーンな API を提供します。

cdfae5b9b10da57f.png

リポジトリを使用する理由

リポジトリはクエリを管理し、複数のバックエンドの使用を可能にします。よくあるのは、データをネットワークから取得するか、ローカル データベース内のキャッシュに保存された結果を使用するかの決定ロジックを、リポジトリで実装する例です。

リポジトリの実装

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 Flow will notify the observer when the data has changed.
    val allWords: Flow<List<Word>> = wordDao.getAlphabetizedWords()

    // By default Room runs suspend queries off the main thread, therefore, we don't need to
    // implement anything else to ensure we're not doing long running database work
    // off the main thread.
    @Suppress("RedundantSuspendModifier")
    @WorkerThread
    suspend fun insert(word: Word) {
        wordDao.insert(word)
    }
}

要点は以下のとおりです。

  • リポジトリ コンストラクタには、データベース全体ではなく DAO が渡されます。DAO には、データベースに対するすべての読み取り・書き込みメソッドが含まれているため、これにアクセスするだけでよいのです。データベース全体をリポジトリに公開する必要はありません。
  • 単語のリストは公開プロパティです。Room から単語の Flow リストを取得することで初期化されます。これができるのは、「データベースの変更の監視」ステップで Flow を返すように getAlphabetizedWords メソッドを定義したためです。Room は、すべてのクエリを個別のスレッドで実行します。
  • suspend 修飾子により、この関数はコルーチンまたは他の suspend 関数から呼び出される必要があることをコンパイラに伝えます。
  • Room は、メインスレッド以外で suspend クエリを実行します。

ViewModel とは

ViewModel の役割は、UI にデータを提供し、構成変更に耐えられるようにすることです。ViewModel は、リポジトリと UI 間のコミュニケーション センターとして機能します。また、ViewModel を使用すれば、フラグメント間でデータを共有することもできます。ViewModel はライフサイクル ライブラリに含まれています。

72848dfccfe5777b.png

このトピックの入門ガイドとしては、ViewModel の概要(ViewModel Overviewまたは ViewModel: 簡単な例のブログ投稿をご覧ください。

ViewModel を使用する理由

ViewModel は、構成変更に耐えられるようライフサイクルを意識した方法で、アプリの UI データを保持します。アプリの UI データを Activity クラスや Fragment クラスから分離することで、単一責任の原則への遵守性を高めることができます。つまり、アクティビティやフラグメントは画面へのデータの描画を担い、ViewModel は UI に必要なすべてのデータの保持と処理を担います。

LiveData と ViewModel

LiveData は監視可能なデータホルダーです。これを使用することで、データが変更されるたびに通知を受け取れます。Flow とは異なり、LiveData はライフサイクルを認識します。つまり、アクティビティやフラグメントなど、他のコンポーネントのライフサイクルを考慮します。LiveData は、変更をリッスンするコンポーネントのライフサイクルに応じて監視を自動的に停止・再開することから、UI が使用・表示するデータで変更される可能性があるものを監視するのに最適なコンポーネントです。

ViewModel は、リポジトリからのデータを Flow から LiveData に変換し、単語のリストを LiveData として UI に公開します。このようにして、データベースのデータが変更されるたびに、UI も自動的に更新されるようにします。

viewModelScope

Kotlin では、すべてのコルーチンが CoroutineScope 内で実行されます。スコープは、そのジョブを通じてコルーチンの存続期間を制御します。スコープのジョブをキャンセルすると、そのスコープで開始されたコルーチンがすべてキャンセルされます。

AndroidX lifecycle-viewmodel-ktx ライブラリにより、ViewModel クラスの拡張関数として viewModelScope が追加され、スコープを操作できるようになります。

ViewModel でのコルーチンの操作について詳しくは、Android アプリで Kotlin コルーチンを使用する Codelab のステップ 5、または Android の簡単なコルーチン: viewModelScope のブログ投稿をご覧ください。

ViewModel を実装する

WordViewModel 用の Kotlin クラスファイルを作成し、次のコードを追加します。

class WordViewModel(private val repository: WordRepository) : ViewModel() {

    // Using LiveData and caching what allWords 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>> = repository.allWords.asLiveData()

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

class WordViewModelFactory(private val repository: WordRepository) : ViewModelProvider.Factory {
    override fun <T : ViewModel> create(modelClass: Class<T>): T {
        if (modelClass.isAssignableFrom(WordViewModel::class.java)) {
            @Suppress("UNCHECKED_CAST")
            return WordViewModel(repository) as T
        }
        throw IllegalArgumentException("Unknown ViewModel class")
    }
}

このコードを細かく見てみましょう。次の操作を行いました。

  • ViewModel を拡張して、WordRepository をパラメータとして取得する WordViewModel というクラスを作成しています。リポジトリは、ViewModel に必要な唯一の依存関係です。仮に他のクラスが必要であれば、それもコンストラクタに渡しているでしょう。
  • 単語のリストをキャッシュに保存するためのパブリック LiveData メンバー変数を追加しています。
  • リポジトリからの allWords フローで LiveData を初期化しています。その後、asLiveData(). を呼び出して Flow を LiveData に変換しています。
  • リポジトリの insert() メソッドを呼び出すラッパー insert() メソッドを作成しています。これにより、insert() の実装が UI からカプセル化されます。新しいコルーチンを開始し、suspend 関数であるリポジトリの insert を呼び出します。前述のとおり、ViewModel にはライフサイクルに基づくコルーチン スコープ viewModelScope があり、ここではそれを使用しています。
  • ViewModel を作成し、WordViewModel の作成に必要な依存関係 WordRepository をパラメータとして取得する ViewModelProvider.Factory を実装しています。

viewModelsViewModelProvider.Factory を使用することにより、フレームワークは ViewModel のライフサイクルを管理します。これにより、ViewModel は構成変更があっても存在し続け、アクティビティが再作成された場合でも、常に WordViewModel クラスの適切なインスタンスを取得できるようになります。

次に、リストとアイテム用の XML レイアウトを追加する必要があります。

この Codelab は、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_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>

次のようにして、サイズリソース ファイルを新たに作成します。

  1. [Project] ウィンドウでアプリ モジュールをクリックします。
  2. [File] > [New] > [Android Resource File] を選択します。
  3. [Available qualifiers] から [Dimension] を選択します。
  4. ファイルに dimens という名前を付けます。

aa5895240838057.png

次のサイズリソースを values/dimens.xml に追加します。

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

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 で、TextViewRecyclerView に置き換え、フローティング操作ボタン(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 の外観は実際の動作に対応しているほうがよいため、アイコンを「+」記号に置き換えます。

まず、次のようにして新しいベクター アセットを追加する必要があります。

  1. [File] > [New] > [Vector Asset] を選択します。
  2. [Clip Art] で Android ロボットのアイコンをクリックします。8d935457de8e7a46.png
  3. 「add」を検索して「+」アセットを選択します。[OK] をクリックします。758befc99c8cc794.png
  4. [Asset Studio] ウィンドウで [Next] をクリックします。672248bada3cfb25.png
  5. アイコンのパスが main > drawable であることを確認し、[Finish] をクリックしてこのアセットを追加します。ef118084f96c6176.png
  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 にスローするよりも少し都合がよいためです。この Codelab は、RecyclerViewRecyclerView.ViewHolderListAdapter の仕組みを理解していることを前提としています。

次のものを作成する必要があります。

  • ListAdapter を拡張した WordListAdapter クラス
  • WordListAdapter. 内にネストされた DiffUtil.ItemCallback クラス
  • リストの各単語を表示する ViewHolder

コードは次のとおりです。

class WordListAdapter : ListAdapter<Word, WordViewHolder>(WordsComparator()) {

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): WordViewHolder {
        return WordViewHolder.create(parent)
    }

    override fun onBindViewHolder(holder: WordViewHolder, position: Int) {
        val current = getItem(position)
        holder.bind(current.word)
    }

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

        fun bind(text: String?) {
            wordItemView.text = text
        }

        companion object {
            fun create(parent: ViewGroup): WordViewHolder {
                val view: View = LayoutInflater.from(parent.context)
                    .inflate(R.layout.recyclerview_item, parent, false)
                return WordViewHolder(view)
            }
        }
    }

    class WordsComparator : DiffUtil.ItemCallback<Word>() {
        override fun areItemsTheSame(oldItem: Word, newItem: Word): Boolean {
            return oldItem === newItem
        }

        override fun areContentsTheSame(oldItem: Word, newItem: Word): Boolean {
            return oldItem.word == newItem.word
        }
    }
}

次のクラスを実装しました。

  • WordViewHolder クラス。これにより、テキストを TextView にバインドできます。このクラスは、レイアウトのインフレートを処理する静的 create() 関数を公開します。
  • WordsComparator は、2 つの単語が同じかどうか、またはコンテンツが同じかどうかを計算する方法を定義します。
  • WordListAdapter は、onCreateViewHolderWordViewHolder を作成し、それを onBindViewHolder にバインドします。

RecyclerViewMainActivityonCreate() メソッドに追加します。

onCreate() メソッドの setContentView より下は次のようになります。

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

アプリを実行して、すべての動作に問題がないことを確認します。まだデータに接続していないため、アイテムはありません。

79cb875d4296afce.png

アプリに含めるデータベースとリポジトリのインスタンスは、それぞれ 1 つのみにする必要があります。そのための簡単な方法は、これらを Application クラスのメンバーとして作成することです。こうすれば、必要な際にはそのたびに作成する代わりに、アプリから取得するだけで済みます。

次のようにして、Application を拡張した WordsApplication というクラスを新たに作成します。以下にコードを示します。

class WordsApplication : Application() {
    // Using by lazy so the database and the repository are only created when they're needed
    // rather than when the application starts
    val database by lazy { WordRoomDatabase.getDatabase(this) }
    val repository by lazy { WordRepository(database.wordDao()) }
}

ここで行っている内容は次のとおりです。

  • データベース インスタンスを作成しています。
  • データベース DAO に基づいてリポジトリ インスタンスを作成しています。
  • これらのオブジェクトは、アプリ起動時ではなく、最初に必要になった時点でのみ作成するため、Kotlin のプロパティ委任 by lazy. を使用しています。

Application クラスを作成したので、AndroidManifest ファイルを更新して、WordsApplicationapplication android:name に設定します。

application タグは次のようになります。

<application
        android:name=".WordsApplication"
        android:allowBackup="true"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@style/AppTheme">
...

現時点では、データベースにはデータが存在しません。データを登録するには、2 通りの方法があります。データベース作成時にデータをいくつか登録する方法と、単語を登録する Activity を追加する方法です。

アプリが作成されるたびにデータベースのコンテンツをすべて削除して再入力するには、RoomDatabase.Callback を作成して onCreate() をオーバーライドします。UI スレッドからは Room データベース操作を行えないため、onCreate() は IO ディスパッチャ上でコルーチンを起動します。

コルーチンを起動するには、CoroutineScope が必要です。WordRoomDatabase クラスの getDatabase メソッドを更新し、CoroutineScope もパラメータとして取得します。

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

データベースへのデータの入力は UI ライフサイクルとは関連していないため、viewModelScope のような CoroutineScope を使うべきではありません。関連しているのはアプリのライフサイクルです。applicationScope を含むように WordsApplication を更新し、これを WordRoomDatabase.getDatabase に渡します。

class WordsApplication : Application() {
    // No need to cancel this scope as it'll be torn down with the process
    val applicationScope = CoroutineScope(SupervisorJob())

    // Using by lazy so the database and the repository are only created when they're needed
    // rather than when the application starts
    val database by lazy { WordRoomDatabase.getDatabase(this, applicationScope) }
    val repository by lazy { WordRepository(database.wordDao()) }
}

WordRoomDatabase で、RoomDatabase.Callback() のカスタム実装を作成します。これも、CoroutineScope をコンストラクタ パラメータとして取得します。次に、データベースにデータを入力するため onOpen メソッドをオーバーライドします。

WordRoomDatabase クラスのでコールバックを作成するコードを以下に示します。

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

    override fun onCreate(db: SupportSQLiteDatabase) {
        super.onCreate(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 onCreate(db: SupportSQLiteDatabase) {
           super.onCreate(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>
<string name="add_word">Add word</string>

次のカラーリソースを value/colors.xml に追加します。

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

次のように min_height サイズリソースを values/dimens.xml に追加します。

<dimen name="min_height">48dp</dimen>

次の手順で、空のアクティビティ テンプレートを使って空の Android Activity を新たに作成します。

  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 に表示することにより、UI をデータベースに接続します。

データベースの現在の内容を表示するには、ViewModelLiveData を監視するオブザーバーを追加します。

データが変更されるたびに、onChanged() コールバックが起動されます。このコールバックは、アダプターの setWords() メソッドを呼び出して、アダプターのキャッシュに保存されたデータを更新するとともに表示中のリストを更新します。

MainActivity で、次のように ViewModel を作成します。

private val wordViewModel: WordViewModel by viewModels {
    WordViewModelFactory((application as WordsApplication).repository)
}

ViewModel を作成するために、viewModels 委任を使用して WordViewModelFactory のインスタンスを渡しています。作成は、WordsApplication から取得したリポジトリに基づいて行われます。

また、onCreate() に、WordViewModel の allWords LiveData プロパティのオブザーバーを追加します。

onChanged() メソッド(ラムダのデフォルト メソッド)は、監視対象データが変更され、かつアクティビティがフォアグラウンドにある場合に呼び出されます。

wordViewModel.allWords.observe(this, Observer { words ->
            // Update the cached copy of the words in the adapter.
            words?.let { adapter.submitList(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()
    }
}

MainActivity, で、ユーザーが FAB をタップしたら 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 val newWordActivityRequestCode = 1
    private val wordViewModel: WordViewModel by viewModels {
        WordViewModelFactory((application as WordsApplication).repository)
    }

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

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

        // Add an observer on the LiveData returned by getAlphabetizedWords.
        // The onChanged() method fires when the observed data changes and the activity is
        // in the foreground.
        wordViewModel.allWords.observe(owner = this) { words ->
            // Update the cached copy of the words in the adapter.
            words.let { adapter.submitList(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, intentData: Intent?) {
        super.onActivityResult(requestCode, resultCode, intentData)

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

それではアプリを実行しましょう。NewWordActivity でデータベースに単語を追加すると、UI が自動的に更新されます。

正常に動作するアプリが出来上がったので、これを振り返ってみましょう。アプリの構造を再度以下に示します。

a70aca8d4b737712.png

アプリのコンポーネントは次のとおりです。

  • MainActivity: RecyclerViewWordListAdapter を使用して単語をリストに表示します。MainActivity には、データベースの単語を監視して、変更があった場合には通知を受け取る Observer が存在します。
  • NewWordActivity: リストに新しい単語を追加します。
  • WordViewModel: データレイヤにアクセスするためのメソッドを提供します。また、MainActivity がオブザーバーの関連付けをセットアップできるように LiveData を返します。*
  • LiveData<List<Word>>: UI コンポーネントの自動更新を可能にします。flow.toLiveData() を呼び出すことで、Flow から LiveData に変換できます。
  • Repository: 1 つ以上のデータソースを管理します。Repository は、基盤にあるデータ プロバイダと対話するためのメソッドを ViewModel に公開します。このアプリでは、バックエンドのデータ プロバイダは Room データベースです。
  • Room: SQLite データベースを実装し、そのラッパーとなります。これまでデベロッパーが行っていたさまざまな処理を代行します。
  • DAO: メソッド呼び出しをデータベース クエリにマッピングします。たとえば、リポジトリが getAlphabetizedWords() を呼び出したら Room が SELECT * FROM word_table ORDER BY word ASC を実行するようにできます**。**
  • DAO はワンショット リクエスト用の suspend クエリと、Flow クエリを公開できます(データベース変更の通知を受け取る必要がある場合)。
  • Word: 1 つの単語を含むエンティティ クラス。
  • ViewsActivities(および Fragments)は、ViewModel を介してのみデータを操作します。そのため、データの取得元は関係ありません。

UI 自動更新のデータフロー(リアクティブ UI)

LiveData を使用しているため、自動更新が可能です。MainActivity には、データベースからの単語の LiveData を監視し、変更があった場合には通知を受け取る Observer が存在します。変更があると、オブザーバーの onChange() メソッドが実行され、WordListAdaptermWords が更新されます。

データは LiveData のため監視可能です。監視対象となるのは、WordViewModel allWords プロパティから返される LiveData<List<Word>> です。

WordViewModel は、UI レイヤに対してバックエンドに関するすべての情報を隠蔽します。そして、データレイヤにアクセスするためのメソッドを提供し、MainActivity がオブザーバーの関連付けをセットアップできるように LiveData を返します。ViewsActivities(および Fragments)は、ViewModel を介してのみデータを操作します。そのため、データの取得元は関係ありません。

今回の場合、データは Repository から取得されます。ViewModel は、そのリポジトリが何とやり取りするかを知る必要はありません。知る必要があるのは Repository とどうやり取りするかです。ここでは Repository によって公開されたメソッドを使用します。

リポジトリは 1 つ以上のデータソースを管理します。WordListSample アプリの場合、データソースのバックエンドは Room データベースです。Room は SQLite データベースを実装し、そのラッパーとなります。これまでデベロッパーが行っていたさまざまな処理を代行します。たとえば、Room は、SQLiteOpenHelper クラスで行っていたことをすべて行えます。

DAO は、メソッド呼び出しをデータベース クエリにマッピングします。たとえば、リポジトリが getAllWords() を呼び出したら Room が SELECT * FROM word_table ORDER BY word ASC を実行するようにできます。

クエリから返される結果は監視対象の LiveData であるため、Room でデータが変更されるたびに Observer インターフェースの onChanged() メソッドが実行され、UI が更新されます。

(省略可)解答コードをダウンロードする

この Codelab の解答コードをまだ確認していない場合は確認します。GitHub リポジトリを参照しても、以下からコードをダウンロードしてもかまいません。

ソースコードをダウンロード

ダウンロードした zip ファイルを解凍すると、アプリ全体を含んだルートフォルダ android-room-with-a-view-kotlin が展開されます。