Room とフローの概要

1. 始める前に

前の Codelab では、リレーショナル データベースの基礎と、SQL コマンド(SELECT、INSERT、UPDATE、DELETE)を使用してデータを読み書きする方法について学習しました。リレーショナル データベースの操作方法を学習したのは、これが基礎スキルとして今後のプログラミングに必要となるためです。また、リレーショナル データベースの仕組みを理解することは、このレッスンで行うように、Android アプリにデータの永続性を実装するためにも不可欠です。

Android アプリでデータベースを使用するには、Room というライブラリを使用すると簡単です。Room は、ORM(オブジェクト リレーショナル マッピング)ライブラリというもので、その名のとおり、リレーショナル データベースのテーブルを Kotlin コードで使用できるオブジェクトにマッピングします。このレッスンでは、データの読み取りだけに注目します。事前入力されたデータベースを使用して、バスの到着時刻のテーブルからデータを読み込み、RecyclerView に表示します。

70c597851eba9518.png

その過程で、データベース クラス、DAO、エンティティ、ビューモデルを含め、Room の基本的な使用方法について学びます。また、RecyclerView でデータを表示する別の方法である ListAdapter クラスと、UI をデータベースの変更に対応するようにできる、LiveData に似た Kotlin 言語機能であるフローについても紹介します。

前提条件

  • オブジェクト指向プログラミングと、Kotlin でのクラス、オブジェクト、継承の使い方に精通していること。
  • SQL の基本の Codelab で説明されている、リレーショナル データベースと SQL の基本的な知識。
  • Kotlin コルーチンの使用経験。

学習内容

このレッスンを修了すると次のことができるようになります。

  • データベース テーブルを Kotlin オブジェクト(エンティティ)として表す。
  • アプリで Room を使用するためにデータベース クラスを定義し、ファイルからデータベースにデータを事前入力する。
  • DAO クラスを定義し、SQL クエリを使用して Kotlin コードからデータベースにアクセスする。
  • UI で DAO を操作できるようにビューモデルを定義する。
  • リサイクラー ビューで ListAdapter を使用する方法。
  • Kotlin フローの基本と、フローを使用して基になるデータの変更に UI を対応させる方法。

作成するアプリの概要

  • 簡単なバス時刻表アプリで、事前入力されたデータベースから Room を使用してデータを読み取り、リサイクラー ビューに表示する。

2. 始める

この Codelab では「Bus Schedule」というアプリを扱います。このアプリはバス停と到着時刻のリストを、到着時刻が早い順に表示します。

70c597851eba9518.png

最初の画面で行をタップすると、選択したバス停の到着時刻のみが記載された新しい画面が表示されます。

f477c0942746e584.png

バス停のデータはアプリに組み込まれているデータベースから取得されますが、現在の状態では、アプリの初回実行時に何も表示されません。これから Room を統合して、データベースに事前入力済みの到着時刻がアプリに表示されるようにします。

  1. プロジェクト用に提供されている GitHub リポジトリ ページに移動します。
  2. ブランチ名が Codelab で指定されたブランチ名と一致していることを確認します。たとえば、次のスクリーンショットでは、ブランチ名は main です。

1e4c0d2c081a8fd2.png

  1. プロジェクトの GitHub ページで、[Code] ボタンをクリックすると、ポップアップが表示されます。

1debcf330fd04c7b.png

  1. ポップアップで、[Download ZIP] をクリックして、プロジェクトをパソコンに保存します。ダウンロードが完了するまで待ちます。
  2. パソコンに保存したファイルを見つけます([ダウンロード] フォルダなど)。
  3. ZIP ファイルをダブルクリックして展開します。プロジェクト ファイルが入った新しいフォルダが作成されます。

Android Studio でプロジェクトを開く

  1. Android Studio を起動します。
  2. [Welcome to Android Studio] ウィンドウで、[Open] をクリックします。

d8e9dbdeafe9038a.png

注: Android Studio がすでに開いている場合は、メニューから [File] > [Open] を選択します。

8d1fda7396afe8e5.png

  1. ファイル ブラウザで、展開したプロジェクト フォルダがある場所([ダウンロード] フォルダなど)に移動します。
  2. そのプロジェクト フォルダをダブルクリックします。
  3. Android Studio でプロジェクトが開かれるまで待ちます。
  4. 実行ボタン 8de56cba7583251f.png をクリックして、アプリをビルドし、実行します。期待どおりにビルドされることを確認します。

3. Room の依存関係を追加する

他のライブラリと同様、Bus Schedule アプリで Room を使用できるようにするには、まず必要な依存関係を追加する必要があります。これは、Gradle ファイルごとに 1 つずつ、計 2 つの小さな変更を加えるだけで済みます。

  1. プロジェクト レベルの build.gradle ファイルの ext ブロックで room_version を定義します。
ext {
   kotlin_version = "1.6.20"
   nav_version = "2.4.1"
   room_version = '2.4.2'
}
  1. アプリレベルの build.gradle ファイルで、依存関係リストの最後に次の依存関係を追加します。
implementation "androidx.room:room-runtime:$room_version"
kapt "androidx.room:room-compiler:$room_version"

// optional - Kotlin Extensions and Coroutines support for Room
implementation "androidx.room:room-ktx:$room_version"
  1. 変更を同期してプロジェクトをビルドし、依存関係が正しく追加されたことを確認します。

以降の数ページでは、Room をアプリに統合するために必要なコンポーネント(モデル、DAO、ビューモデル、データベース クラス)を紹介します。

4. エンティティを作成する

前の Codelab でリレーショナル データベースについて学んだとき、複数の列(それぞれが特定のデータ型の特定のプロパティを表している)で構成されたテーブルに、データがどのように整理されるかについて確認しました。Kotlin のクラスが各オブジェクトのテンプレートを提供するように、データベースのテーブルは、そのテーブルの各項目(つまり行)のテンプレートを提供します。データベースの各テーブルを表すために Kotlin のクラスを使用できるのは、驚くようなことではありません。

Room を扱う場合、各テーブルはクラスで表されます。Room などの ORM(オブジェクト リレーショナル マッピング)ライブラリでは、多くの場合これを「モデルクラス」または「エンティティ」と呼びます。

Bus Schedule アプリのデータベースは、バスの到着時刻に関する基本情報が含まれる、schedule という 1 つのテーブルで構成されています。

  • id: 主キーとして機能する一意の識別子を提供する整数
  • stop_name: 文字列
  • arrival_time: 整数

なお、データベースで使用される SQL 型は、実際には Int の場合は INTEGERString の場合は TEXT です。ただし、Room を扱う場合は、モデルクラスを定義するときに Kotlin 型のみを考慮する必要があります。モデルクラスのデータ型とデータベースで使用されるデータ型のマッピングは、自動的に処理されます。

プロジェクトに多数のファイルがある場合、ファイルをさまざまなパッケージに整理して、クラスごとにアクセス制御を最適化し、関連するクラスを見つけやすくすることを検討してください。「schedule」テーブルのエンティティを作成するには、com.example.busschedule パッケージで database という新しいパッケージを追加します。そのパッケージ内で、schedule という新しいパッケージをエンティティに追加します。次に、database.schedule パッケージで Schedule.kt という新しいファイルを作成し、Schedule というデータクラスを定義します。

data class Schedule(
)

SQL の基本のレッスンで説明したとおり、データテーブルには、各行を一意に識別するための主キーが必要です。Schedule クラスに追加する最初のプロパティは、一意の ID を表す整数です。新しいプロパティを追加し、@PrimaryKey アノテーションを付けます。これにより Room は、新しい行が挿入されたときにこのプロパティを主キーとして扱うようになります。

@PrimaryKey val id: Int

バス停の名前の列を追加します。列の型は String にする必要があります。新しい列の場合は、@ColumnInfo アノテーションを追加して列の名前を指定する必要があります。SQL の列名には通常、Kotlin プロパティで使用される lowerCamelCase とは対照的に、アンダースコアで区切られた単語を使用します。この列では値が null であってはならないため、@NonNull アノテーションを付ける必要もあります。

@NonNull @ColumnInfo(name = "stop_name") val stopName: String,

到着時刻は、データベースでは整数を使用して表されます。Unix タイムスタンプであり、表示に適した日付に変換できます。日付を変換する方法はさまざまなバージョンの SQL で提供されていますが、ここでは Kotlin の日付形式設定関数を使用することにします。モデルクラスに次の @NonNull 列を追加します。

@NonNull @ColumnInfo(name = "arrival_time") val arrivalTime: Int

最後に、Room がこのクラスをデータベース テーブルの定義に使用できるものとして認識するように、クラス自体にアノテーションを追加する必要があります。クラス名の前に、別の行で @Entity を追加します。

デフォルトでは、Room はクラス名をデータベース テーブル名として使用します。したがって、現時点でクラスで定義されているテーブル名は Schedule になります。必要に応じて、@Entity(tableName="schedule") を指定することもできますが、Room では大文字と小文字は区別されないため、ここで小文字のテーブル名を明示的に定義する必要はありません。

schedule エンティティのクラスは次のようになります。

@Entity
data class Schedule(
   @PrimaryKey val id: Int,
   @NonNull @ColumnInfo(name = "stop_name") val stopName: String,
   @NonNull @ColumnInfo(name = "arrival_time") val arrivalTime: Int
)

5. DAO を定義する

Room を統合するために、次に追加する必要があるクラスは DAO です。DAO とはデータ アクセス オブジェクトのことであり、データへのアクセスを可能にする Kotlin クラスです。具体的には、DAO はデータを読み書きするための関数を含めるためのものです。DAO で関数を呼び出すことは、データベースで SQL コマンドを実行することと同じです。実際、このアプリで定義するような DAO 関数は、SQL コマンドを指定することが多いため、関数で行う内容そのものを指定できます。DAO を定義するときは、前の Codelab で学んだ SQL の知識が役立ちます。

  1. Schedule エンティティの DAO クラスを追加します。database.schedule パッケージで、ScheduleDao.kt という新しいファイルを作成し、ScheduleDao というインターフェースを定義します。Schedule クラスと同様に、Room でインターフェースを使用できるようにするために、アノテーション(今回は @Dao)を追加する必要があります。
@Dao
interface ScheduleDao {
}
  1. アプリには画面が 2 つあり、それぞれに異なるクエリが必要です。最初の画面には、到着時刻の昇順ですべてのバス停を表示します。このユースケースでは、クエリはすべての列を取得し、適切な ORDER BY 句を含めるだけで済みます。このクエリを、@Query アノテーションに渡す文字列に指定します。次に示すように @Query アノテーションを指定して、Schedule オブジェクトのリストを返す関数 getAll() を定義します。
@Query("SELECT * FROM schedule ORDER BY arrival_time ASC")
fun getAll(): List<Schedule>
  1. 2 番目のクエリでも、時刻表のすべての列を選択します。ただし、選択したバス停名に一致する結果のみが必要であるため、WHERE 句を追加する必要があります。クエリの前にコロン(:)を付けると(例: 関数パラメータの :stopName)、Kotlin 値を参照できます。前と同様に、結果を到着時刻の昇順で並べ替えます。次に示すように @Query アノテーションを指定して、stopName という String パラメータを受け取り Schedule オブジェクトの List を返す getByStopName() 関数を定義します。
@Query("SELECT * FROM schedule WHERE stop_name = :stopName ORDER BY arrival_time ASC")
fun getByStopName(stopName: String): List<Schedule>

6. ViewModel を定義する

これで DAO のセットアップが完了しました。技術的には、フラグメントからデータベースにアクセスするために必要なものがすべて揃いました。しかし、これは理論的には機能しますが、通常はベスト プラクティスとは見なされません。これは、より複雑なアプリには、データの特定の部分のみにアクセスする複数の画面が存在する可能性があるためです。ScheduleDao は比較的シンプルですが、2 つ以上の異なる画面を扱うときにこれが手に負えなくなることは、容易に想像がつきます。たとえば、その場合の DAO は次のようになります。

@Dao
interface ScheduleDao {

    @Query(...)
    getForScreenOne() ...

    @Query(...)
    getForScreenTwo() ...

    @Query(...)
    getForScreenThree()

}

Screen 1 のコードは getForScreenOne() にアクセスできますが、他のメソッドにアクセスする正当な理由はありません。代わりに、ビューに公開する DAO の一部を、「ビューモデル」という別のクラスに分割することをおすすめします。これは、モバイルアプリの一般的なアーキテクチャ パターンです。ビューモデルを使用すると、アプリの UI とデータモデルのコードを明確に分離できます。また、コードの各部分を個別にテストする際にも役立ちます。これについては、Android 開発を進めていく中で詳しく調べることになります。

ee2524be13171538.png

ビューモデルを使用することで、ViewModel クラスを利用できます。ViewModel クラスは、アプリの UI に関連するデータを格納するために使用されます。また、このクラスはライフサイクル対応であり、アクティビティまたはフラグメントと同様にライフサイクル イベントに応答します。画面の回転などのライフサイクル イベントによってアクティビティまたはフラグメントが破棄されて再作成される場合、関連する ViewModel を再作成する必要はありません。これは DAO クラスに直接アクセスする方法では不可能であるため、ViewModel サブクラスを使用して、データを読み込む責任をアクティビティまたはフラグメントから分離することをおすすめします。

  1. ビューモデル クラスを作成するには、viewmodels という新しいパッケージに BusScheduleViewModel.kt という新しいファイルを作成し、ビューモデルのクラスを定義します。ScheduleDao 型のパラメータを 1 つ取る必要があります。
class BusScheduleViewModel(private val scheduleDao: ScheduleDao): ViewModel() {
  1. このビューモデルは両方の画面で使用されるため、完全な時刻表を取得するメソッドと、バス停名でフィルタされた時刻表を取得するメソッドを追加する必要があります。そのためには、ScheduleDao から対応するメソッドを呼び出します。
fun fullSchedule(): List<Schedule> = scheduleDao.getAll()

fun scheduleForStopName(name: String): List<Schedule> = scheduleDao.getByStopName(name)

ビューモデルの定義は終わりましたが、BusScheduleViewModel を直接インスタンス化して、すべてが機能すると期待することはできません。ViewModel クラス BusScheduleViewModel はライフサイクル対応であるため、ライフサイクル イベントに応答できるオブジェクトによってインスタンス化する必要があります。フラグメントのいずれかで直接インスタンス化する場合、フラグメント オブジェクトがすべてのメモリ管理を含めて何もかも処理する必要がありますが、これはアプリのコードで行うべきことの範囲を超えています。代わりに、ビューモデル オブジェクトをインスタンス化する、ファクトリーというクラスを作成できます。

  1. ファクトリーを作成するには、ビューモデル クラスの下に、ViewModelProvider.Factory を継承する新しいクラス BusScheduleViewModelFactory を作成します。
class BusScheduleViewModelFactory(
   private val scheduleDao: ScheduleDao
) : ViewModelProvider.Factory {
}
  1. ちょっとしたボイラープレート コードがあれば、ビューモデルを正しくインスタンス化できます。クラスを直接初期化するのではなく、エラーチェックを行って BusScheduleViewModelFactory を返す create() というメソッドをオーバーライドします。次のように、BusScheduleViewModelFactory クラス内で create() を実装します。
override fun <T : ViewModel> create(modelClass: Class<T>): T {
       if (modelClass.isAssignableFrom(BusScheduleViewModel::class.java)) {
           @Suppress("UNCHECKED_CAST")
           return BusScheduleViewModel(scheduleDao) as T
       }
       throw IllegalArgumentException("Unknown ViewModel class")
   }

これで、BusScheduleViewModelFactory.create() を使用して BusScheduleViewModelFactory オブジェクトをインスタンス化し、フラグメントで直接処理しなくてもビューモデルをライフサイクル対応にできるようになりました。

7. データベース クラスを作成してデータベースにデータを事前入力する

モデル、DAO、DAO にアクセスするフラグメントのビューモデルを定義しましたが、次はこれらのクラスをどうするか、Room に伝える必要があります。このような場合、AppDatabase クラスが役立ちます。Room を使用する Android アプリは、RoomDatabase クラスをサブクラス化し、いくつかの重要な役割を担います。アプリで AppDatabase が次のことを行う必要があります。

  1. データベースで定義されているエンティティを指定する
  2. 各 DAO クラスのインスタンスの 1 つにアクセスできるようにする
  3. データベースの事前入力など、追加のセットアップを行う

なぜ Room がすべてのエンティティや DAO オブジェクトを見つけられないのか、不思議に思うかもしれませんが、アプリに複数のデータベースが存在する可能性があり、Room ライブラリが開発者の意図を想定できないシナリオはいくつも考えられます。AppDatabase クラスを使用すると、モデル、DAO クラス、実施するデータベース セットアップを完全に管理できます。

  1. AppDatabase クラスを追加するには、database パッケージに AppDatabase.kt という新しいファイルを作成し、RoomDatabase から継承する新しい抽象クラス AppDatabase を定義します。
abstract class AppDatabase: RoomDatabase() {
}
  1. データベース クラスを使用すると、他のクラスが DAO クラスに簡単にアクセスできます。ScheduleDao を返す抽象関数を追加します。
abstract fun scheduleDao(): ScheduleDao
  1. AppDatabase クラスを使用する際、競合状態やその他の起こり得る問題を回避するために、データベースのインスタンスが 1 つしか存在しないようする必要があります。インスタンスはコンパニオン オブジェクトに格納されます。また、既存のインスタンスを返すメソッド、または初めてデータベースを作成するメソッドも必要です。これはコンパニオン オブジェクトで定義します。scheduleDao() 関数のすぐ下に、次の companion object を追加します。
companion object {
}

companion object に、AppDatabase 型の INSTANCE というプロパティを追加します。この値は最初は null に設定されているため、型に ? が付いています。@Volatile アノテーションも付いています。volatile プロパティを使用するタイミングの詳細は、このレッスンで扱うには少し高度です。起こり得るバグを回避するために、AppDatabase インスタンスに使用することをおすすめします。

@Volatile
private var INSTANCE: AppDatabase? = null

INSTANCE プロパティの下で、AppDatabase インスタンスを返す関数を定義します。

fun getDatabase(context: Context): AppDatabase {
    return INSTANCE ?: synchronized(this) {
        val instance = Room.databaseBuilder(
            context,
            AppDatabase::class.java,
            "app_database")
            .createFromAsset("database/bus_schedule.db")
            .build()
        INSTANCE = instance

        instance
    }
}

getDatabase() の実装では、Elvis 演算子を使用して、データベースの既存のインスタンスを返すか(すでに存在する場合)、必要に応じてデータベースを新規作成します。このアプリでは、データが事前入力されているため、createFromAsset() を呼び出して既存のデータを読み込むこともできます。bus_schedule.db ファイルは、プロジェクトの assets.database パッケージにあります。

  1. モデルクラスや DAO と同様に、データベース クラスには特定の情報を示すアノテーションが必要です。すべてのエンティティ タイプ(ClassName::class を使用してそのタイプ自体にアクセスします)が、配列にリストされます。データベースにはバージョン番号も付与されます。これを 1 に設定します。次のように @Database アノテーションを追加します。
@Database(entities = arrayOf(Schedule::class), version = 1)

AppDatabase クラスを作成したので、使用可能にするための手順はあと 1 つです。Application クラスのカスタム サブクラスを用意して、getDatabase() の結果を保持する lazy プロパティを作成する必要があります。

  1. com.example.busschedule パッケージで BusScheduleApplication.kt という新しいファイルを追加し、Application から継承する BusScheduleApplication クラスを作成します。
class BusScheduleApplication : Application() {
}
  1. AppDatabase 型のデータベース プロパティを追加します。このプロパティは lazy とし、AppDatabase クラスで getDatabase() の呼び出し結果を返す必要があります。
class BusScheduleApplication : Application() {
   val database: AppDatabase by lazy { AppDatabase.getDatabase(this) }
  1. 最後に、(デフォルトの基本クラス Application ではなく)BusScheduleApplication クラスが使用されるようにするために、マニフェストに少し変更を加える必要があります。AndroidMainifest.xml で、android:name プロパティを com.example.busschedule.BusScheduleApplication に設定します。
<application
    android:name="com.example.busschedule.BusScheduleApplication"
    ...

アプリのモデルのセットアップは以上です。UI で Room のデータを使用する準備がすべて整いました。以降の数ページでは、アプリの RecyclerViewListAdapter を作成して、バス時刻表データを表示し、データの変更に動的に対応します。

8. ListAdapter を作成する

それでは、この大変な作業をすべて行い、モデルをビューにつなげましょう。これまで、RecyclerView を使用するときは RecyclerView.Adapter を使用して、データの静的リストを表示していました。これは Bus Schedule などのアプリでは問題なく動作しますが、データベースを扱う場合、通常はリアルタイムでデータの変更を処理します。1 つの項目の内容が変更されただけの場合でも、リサイクラー ビュー全体が更新されます。これは、永続性を使用する大半のアプリでは不十分です。

動的に変更されるリストの代替手段としては、ListAdapter があります。ListAdapterAsyncListDiffer を使用して、古いデータリストと新しいデータリストの違いを特定します。次に、リサイクラー ビューは 2 つのリストの違いのみに基づいて更新されます。その結果、データベース アプリでよくあるように、頻繁に更新されるデータを処理する場合にリサイクラー ビューのパフォーマンスが向上します。

f59cc2fd4d72c551.png

UI はどちらの画面でも同じであるため、両方の画面で使用できる ListAdapter を 1 つ作成するだけで済みます。

  1. 次に示すように、新しいファイル BusStopAdapter.ktBusStopAdapter クラスを作成します。このクラスは、Schedule オブジェクトのリストと UI の BusStopViewHolder クラスを受け取る汎用の ListAdapter を拡張します。BusStopViewHolder については、この後すぐ定義する DiffCallback 型も渡します。BusStopAdapter クラス自体もパラメータ onItemClicked() を受け取ります。この関数は、最初の画面で項目が選択されたときにナビゲーションを処理するために使用されますが、2 番目の画面では空の関数を渡すだけです。
class BusStopAdapter(private val onItemClicked: (Schedule) -> Unit) : ListAdapter<Schedule, BusStopAdapter.BusStopViewHolder>(DiffCallback) {
}
  1. リサイクラー ビュー アダプターと同様に、レイアウト ファイルから作成されたビューにコードからアクセスするにはビューホルダーが必要です。セルのレイアウトはすでに作成されています。単純に、次に示すように BusStopViewHolder クラスを作成し、bind() 関数を実装して、stopNameTextView のテキストをバス停名に、arrivalTimeTextView のテキストを形式設定された日付に設定します。
class BusStopViewHolder(private var binding: BusStopItemBinding): RecyclerView.ViewHolder(binding.root) {
    @SuppressLint("SimpleDateFormat")
    fun bind(schedule: Schedule) {
        binding.stopNameTextView.text = schedule.stopName
        binding.arrivalTimeTextView.text = SimpleDateFormat(
            "h:mm a").format(Date(schedule.arrivalTime.toLong() * 1000)
        )
    }
}
  1. onCreateViewHolder() をオーバーライドして実装し、レイアウトをインフレートして、現在の位置にある項目の onItemClicked() を呼び出すように onClickListener() を設定します。
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BusStopViewHolder {
   val viewHolder = BusStopViewHolder(
       BusStopItemBinding.inflate(
           LayoutInflater.from( parent.context),
           parent,
           false
       )
   )
   viewHolder.itemView.setOnClickListener {
       val position = viewHolder.adapterPosition
       onItemClicked(getItem(position))
   }
   return viewHolder
}
  1. onBindViewHolder() をオーバーライドして実装し、指定した位置にビューをバインドします。
override fun onBindViewHolder(holder: BusStopViewHolder, position: Int) {
   holder.bind(getItem(position))
}
  1. ListAdapterDiffCallback クラスを指定しましたが、これは、ListAdapter がリストを更新する際、新しいリストと古いリストの項目を区別するために役立つオブジェクトです。方法は 2 つあります。areItemsTheSame() は、ID のみを確認することで、オブジェクト(この場合はデータベースの行)が同じかどうかを確認します。areContentsTheSame() は、ID だけでなくすべてのプロパティが同じかどうかを確認します。こうした方法により ListAdapter は、挿入、更新、削除された項目を特定し、それに応じて UI を更新できます。

コンパニオン オブジェクトを追加し、次に示すように DiffCallback を実装します。

companion object {
   private val DiffCallback = object : DiffUtil.ItemCallback<Schedule>() {
       override fun areItemsTheSame(oldItem: Schedule, newItem: Schedule): Boolean {
           return oldItem.id == newItem.id
       }

       override fun areContentsTheSame(oldItem: Schedule, newItem: Schedule): Boolean {
           return oldItem == newItem
       }
   }
}

アダプターのセットアップは以上です。これを、両方のアプリ画面で使用します。

  1. まず、FullScheduleFragment.kt で、ビューモデルへの参照を取得する必要があります。
private val viewModel: BusScheduleViewModel by activityViewModels {
   BusScheduleViewModelFactory(
       (activity?.application as BusScheduleApplication).database.scheduleDao()
   )
}
  1. その後 onViewCreated() で、次のコードを追加し、リサイクラー ビューを設定して、そのレイアウト マネージャーを割り当てます。
recyclerView = binding.recyclerView
recyclerView.layoutManager = LinearLayoutManager(requireContext())
  1. 次に、アダプターのプロパティを割り当てます。渡されたアクションは stopName を使用して、選択された次の画面に移動し、バス停のリストをフィルタできるようにします。
val busStopAdapter = BusStopAdapter({
   val action = FullScheduleFragmentDirections.actionFullScheduleFragmentToStopScheduleFragment(
       stopName = it.stopName
   )
   view.findNavController().navigate(action)
})
recyclerView.adapter = busStopAdapter
  1. 最後に、リストビューを更新するために submitList() を呼び出し、ビューモデルからバス停のリストを渡します。
// submitList() is a call that accesses the database. To prevent the
// call from potentially locking the UI, you should use a
// coroutine scope to launch the function. Using GlobalScope is not
// best practice, and in the next step we'll see how to improve this.
GlobalScope.launch(Dispatchers.IO) {
   busStopAdapter.submitList(viewModel.fullSchedule())
}
  1. StopScheduleFragment についても同様に行います。まず、ビューモデルへの参照を取得します。
private val viewModel: BusScheduleViewModel by activityViewModels {
   BusScheduleViewModelFactory(
       (activity?.application as BusScheduleApplication).database.scheduleDao()
   )
}
  1. 次に、onViewCreated() でリサイクラー ビューを設定します。今回は、{} で空のブロック(関数)を渡すだけです。画面上の行がタップされても実際には何も起こらないようにします。
recyclerView = binding.recyclerView
recyclerView.layoutManager = LinearLayoutManager(requireContext())
val busStopAdapter = BusStopAdapter({})
recyclerView.adapter = busStopAdapter
// submitList() is a call that accesses the database. To prevent the
// call from potentially locking the UI, you should use a
// coroutine scope to launch the function. Using GlobalScope is not
// best practice, and in the next step we'll see how to improve this.
GlobalScope.launch(Dispatchers.IO) {
   busStopAdapter.submitList(viewModel.scheduleForStopName(stopName))
}
  1. これでアダプターのセットアップが完了し、Room を Bus Schedule アプリに統合できました。アプリを実行すると、到着時刻のリストが表示されます。行をタップすると、詳細画面に移動します。

9. フローを使用してデータの変更に対応する

リストビューは submitList() が呼び出されるたびにデータの変更を効率的に処理するように設定されていますが、アプリはまだ動的な更新を処理できません。試しに Database Inspector を開いて次のクエリを実行し、時刻表に新しい項目を挿入してみましょう。

INSERT INTO schedule
VALUES (null, 'Winding Way', 1617202500)

しかし、エミュレータには何も起こりません。ユーザーは、データが変更されていないと考えます。変更を確認するには、アプリを再実行する必要があります。

問題は、List<Schedule> が各 DAO 関数から 1 回しか返されないことです。基となるデータが更新されても、submitList() が呼び出されて UI が更新されることはありません。ユーザーからは、何も変わっていないように見えます。

これを修正するには、「非同期フロー」(多くの場合は単に「フロー」と呼びます)という Kotlin の機能を利用して、DAO が継続的にデータベースからデータを出力できるようにします。項目が挿入、更新、削除されると、その結果がフラグメントに返されます。collect(), という関数を使用すると、フローから出力された新しい値を使用して submitList() を呼び出すことができます。これにより、ListAdapter は新しいデータに基づいて UI を更新できます。

  1. Bus Schedule アプリでフローを使用するには、ScheduleDao.kt を開きます。Flow を返すように DAO 関数を変換するには、単に getAll() 関数の戻り値の型を Flow<List<Schedule>> に変更します。
fun getAll(): Flow<List<Schedule>>
  1. 同様に、getByStopName() 関数の戻り値を更新します。
fun getByStopName(stopName: String): Flow<List<Schedule>>
  1. DAO にアクセスするビューモデル内の関数も更新する必要があります。fullSchedule()scheduleForStopName() の両方について、戻り値を Flow<List<Schedule>> に更新します。
class BusScheduleViewModel(private val scheduleDao: ScheduleDao): ViewModel() {

   fun fullSchedule(): Flow<List<Schedule>> = scheduleDao.getAll()

   fun scheduleForStopName(name: String): Flow<List<Schedule>> = scheduleDao.getByStopName(name)
}
  1. 最後に、FullScheduleFragment.kt で、クエリ結果に対して collect() を呼び出したときに busStopAdapter が更新される必要があります。fullSchedule() は suspend 関数であるため、コルーチンから呼び出す必要があります。次の行を、
busStopAdapter.submitList(viewModel.fullSchedule())

次のコードで置き換えます。このコードは fullSchedule() から返されたフローを使用します。

lifecycle.coroutineScope.launch {
   viewModel.fullSchedule().collect() {
       busStopAdapter.submitList(it)
   }
}
  1. StopScheduleFragment についても同様に行いますが、scheduleForStopName() の呼び出しは次のように置き換えます。
lifecycle.coroutineScope.launch {
   viewModel.scheduleForStopName(stopName).collect() {
       busStopAdapter.submitList(it)
   }
}
  1. 前述の変更を行ったらアプリを再実行し、データ変更がリアルタイムで処理されることを確認できます。アプリを実行したら Database Inspector に戻り、次のクエリを実行して、午前 8 時より前の新しい到着時刻を挿入します。
INSERT INTO schedule
VALUES (null, 'Winding Way', 1617202500)

新しい項目がリストの一番上に表示されます。

79d6206fc9911fa9.png

Bus Schedule アプリは以上です。おつかれさまでした。これで、Room を使用する際の基礎がしっかりとできたはずです。次のパスウェイでは、新しいサンプルアプリで Room に対する理解を深め、ユーザーが作成したデータをデバイスに保存する方法について学びます。

10. 解答コード

この Codelab の解答コードは、以下に示すプロジェクトとモジュールにあります。

  1. プロジェクト用に提供されている GitHub リポジトリ ページに移動します。
  2. ブランチ名が Codelab で指定されたブランチ名と一致していることを確認します。たとえば、次のスクリーンショットでは、ブランチ名は main です。

1e4c0d2c081a8fd2.png

  1. プロジェクトの GitHub ページで、[Code] ボタンをクリックすると、ポップアップが表示されます。

1debcf330fd04c7b.png

  1. ポップアップで、[Download ZIP] をクリックして、プロジェクトをパソコンに保存します。ダウンロードが完了するまで待ちます。
  2. パソコンに保存したファイルを見つけます([ダウンロード] フォルダなど)。
  3. ZIP ファイルをダブルクリックして展開します。プロジェクト ファイルが入った新しいフォルダが作成されます。

Android Studio でプロジェクトを開く

  1. Android Studio を起動します。
  2. [Welcome to Android Studio] ウィンドウで、[Open] をクリックします。

d8e9dbdeafe9038a.png

注: Android Studio がすでに開いている場合は、メニューから [File] > [Open] を選択します。

8d1fda7396afe8e5.png

  1. ファイル ブラウザで、展開したプロジェクト フォルダがある場所([ダウンロード] フォルダなど)に移動します。
  2. そのプロジェクト フォルダをダブルクリックします。
  3. Android Studio でプロジェクトが開かれるまで待ちます。
  4. 実行ボタン 8de56cba7583251f.png をクリックして、アプリをビルドし、実行します。期待どおりにビルドされることを確認します。

11. 完了

まとめ:

  • SQL データベースのテーブルは、Room ではエンティティという Kotlin クラスによって表される。
  • DAO は、データベースを操作する SQL コマンドに対応するメソッドを提供する。
  • ViewModel はライフサイクル対応コンポーネントであり、アプリのデータとビューを分離するために使用される。
  • AppDatabase クラスは、使用するエンティティを Room に伝え、DAO にアクセスできるようにし、データベースの作成時のセットアップを行う。
  • ListAdapterRecyclerView で使用されるアダプターであり、動的に更新されるリストの処理に適している。
  • フローはデータ ストリームを返す Kotlin の機能であり、Room とともに使用して UI とデータベースを同期させることができる。

詳細