Android のアーキテクチャ コンポーネント コレクションは、ライフサイクル管理やデータ永続化などの一般的タスク用のライブラリとともに、アプリ アーキテクチャに関する指針を提供します。アーキテクチャ コンポーネントを使用すると、ボイラープレート コードを減らし、堅牢でテストとメンテナンスがしやすいアプリを構築できます。
アーキテクチャ コンポーネント ライブラリは Android Jetpack に含まれています。
なお、この Codelab は Kotlin 版です。Java プログラミング言語版については、こちらをご覧ください。
この Codelab で問題(コードのバグ、文法的な誤り、不明確な表現など)が見つかった場合は、Codelab の左下隅にある [誤りを報告] から問題を報告してください。
前提条件
Kotlin、オブジェクト指向設計のコンセプト、Android アプリ開発の基礎、特に以下に精通している必要があります。
RecyclerView
とアダプター- SQLite データベースと SQLite クエリ言語
- コルーチンの基本的な使い方(コルーチンに慣れていない場合は、Android アプリで Kotlin コルーチンを使用するをまずご覧ください)
また、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)です。各コンポーネントについて詳しくは、アプリで使用する際に説明します。
LiveData: 監視可能なデータホルダー クラス。常に最新バージョンのデータを保持またはキャッシュに保存し、データが変更されたときにオブザーバーに通知します。LiveData
はライフサイクルを認識します。UI コンポーネントは関連データを監視するだけで、監視の停止や再開は行いませんが、LiveData はそのすべてを自動的に管理します。これは、LiveData が監視を行いながら、関連するライフサイクルの状態の変化を認識するためです。
ViewModel: リポジトリ(データ)と UI 間のコミュニケーション センターとして機能します。これにより、UI はデータの生成元を意識する必要がなくなります。ViewModel インスタンスは、アクティビティやフラグメントを再作成しても存続します。
リポジトリ: デベロッパーが作成するクラスで、主に複数のデータソースを管理するために使用します。
エンティティ: Room を使用する際にデータベース テーブルを記述するアノテーション付きクラス。
Room データベース: 基盤にある SQLite データベースへのアクセス ポイントとして機能(SQLiteOpenHelper)
を隠蔽)し、データベースの処理をシンプルにします。Room データベースは、DAO を使用して SQLite データベースにクエリを発行します。
SQLite データベース: デバイス上のストレージ。Room 永続ライブラリが作成して管理します。
DAO: データ アクセス オブジェクト。関数に対する SQL クエリのマッピング。DAO を使用する際は、メソッドを呼び出せばあとは Room が処理します。
RoomWordSample のアーキテクチャ概要
下図に、アプリの各要素がどのように連携するかを示します。長方形の四角(SQLite データベースではない)はすべて、これから作成するクラスを表しています。
- Android Studio を開き、[Start a new Android Studio project] をクリックします。
- [Create New Project] ウィンドウで [Empty Activity] を選択し、[Next] をクリックします。
- 次の画面で、アプリに「RoomWordSample」という名前を付けて、[Finish] をクリックします。
次に、コンポーネント ライブラリを Gradle ファイルに追加する必要があります。
- Android Studio で [Project] タブをクリックし、Gradle Scripts フォルダを展開します。
build.gradle
(Module: app)を開きます。
build.gradle
(Module: app)ファイルの先頭に定義されているプラグイン セクションの下に次の行を追加して、kapt
アノテーション プロセッサ Kotlin プラグインを適用します。
apply plugin: 'kotlin-kapt'
android
ブロック内にpackagingOptions
ブロックを追加して、パッケージからアトミック関数モジュールを除外し、警告を回避します。- 使用する API の一部は 1.8 の
jvmTarget
を必要とするため、android
ブロックにそれも追加します。
android { // other configuration (buildTypes, defaultConfig, etc.) packagingOptions { exclude 'META-INF/atomicfu.kotlin_module' } kotlinOptions { jvmTarget = "1.8" } }
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 がバージョンの不足または未定義に関するメッセージを表示する可能性がありますが、この問題は次のステップで解消されます。
build.gradle
(Project: 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' }
このアプリのデータは単語であり、その値を保持するために下図のようなシンプルなテーブルが必要になります。
Room のテーブルは、エンティティを使用して作成できます。それでは作成しましょう。
Word
データクラスを含むWord
という名前の新しい Kotlin クラスファイルを作成します。このクラスにより、単語用のエンティティ(SQLite テーブルを表す)が記述されます。クラス内の各プロパティは、テーブル内の列を表します。Room は最終的にこのプロパティを使用して、テーブルの作成と、データベース行からのオブジェクトのインスタンス化を行います。
コードは次のとおりです。
data class Word(val word: String)
Room データベースで Word
クラスを使用できるようにするには、Kotlin アノテーションを使ってこのクラスをデータベースに関連付ける必要があります。個別のアノテーションを使用して、クラスの各部分がデータベース内のエントリにどう関連しているかを示します。Room はこの追加情報を使用してコードを生成します。
アノテーションを貼り付けずに手動で入力すると、Android Studio によってアノテーション クラスが自動的にインポートされます。
- 次のコードのように、
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 を作成しましょう。
- すべての単語をアルファベット順に並べる
- 単語を挿入する
- すべての単語を削除する
WordDao
という名前の Kotlin クラスファイルを新たに作成します。- 次のコードをコピーして
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>
: すべての単語を取得して、Words
のList
を返すメソッド。@Query(
"SELECT * FROM word_table ORDER BY word ASC"
)
: 単語のリストを昇順に並べ替えて返すクエリ。
通常、データが変更された際には、更新されたデータを UI に表示するなど、なんらかのアクションが必要になります。そのためには、データの変更に対応できるように監視する必要があります。
ここでは、データの変更を監視するために、kotlinx-coroutines
の Flow を使用します。メソッドの記述で 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 つのみです。
それでは作成してみましょう。
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 を提供します。
リポジトリを使用する理由
リポジトリはクエリを管理し、複数のバックエンドの使用を可能にします。よくあるのは、データをネットワークから取得するか、ローカル データベース内のキャッシュに保存された結果を使用するかの決定ロジックを、リポジトリで実装する例です。
リポジトリの実装
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 はライフサイクル ライブラリに含まれています。
このトピックの入門ガイドとしては、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
を実装しています。
viewModels
と ViewModelProvider.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>
次のようにして、サイズリソース ファイルを新たに作成します。
- [Project] ウィンドウでアプリ モジュールをクリックします。
- [File] > [New] > [Android Resource File] を選択します。
- [Available qualifiers] から [Dimension] を選択します。
- ファイルに dimens という名前を付けます。
次のサイズリソースを 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
で、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 の外観は実際の動作に対応しているほうがよいため、アイコンを「+」記号に置き換えます。
まず、次のようにして新しいベクター アセットを追加する必要があります。
- [File] > [New] > [Vector Asset] を選択します。
- [Clip Art] で Android ロボットのアイコンをクリックします。
- 「add」を検索して「+」アセットを選択します。[OK] をクリックします。
- [Asset Studio] ウィンドウで [Next] をクリックします。
- アイコンのパスが
main > drawable
であることを確認し、[Finish] をクリックしてこのアセットを追加します。 - 引き続き
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 は、RecyclerView
、RecyclerView.ViewHolder
、ListAdapter
の仕組みを理解していることを前提としています。
次のものを作成する必要があります。
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
は、onCreateViewHolder
にWordViewHolder
を作成し、それをonBindViewHolder
にバインドします。
RecyclerView
を MainActivity
の onCreate()
メソッドに追加します。
onCreate()
メソッドの setContentView
より下は次のようになります。
val recyclerView = findViewById<RecyclerView>(R.id.recyclerview) val adapter = WordListAdapter() recyclerView.adapter = adapter recyclerView.layoutManager = LinearLayoutManager(this)
アプリを実行して、すべての動作に問題がないことを確認します。まだデータに接続していないため、アイテムはありません。
アプリに含めるデータベースとリポジトリのインスタンスは、それぞれ 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
ファイルを更新して、WordsApplication
を application
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
を新たに作成します。
- [File] > [New] > [Activity] > [Empty Activity] を選択します。
- アクティビティ名として「
NewWordActivity
」と入力します。 - 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 をデータベースに接続します。
データベースの現在の内容を表示するには、ViewModel
に LiveData
を監視するオブザーバーを追加します。
データが変更されるたびに、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
に、NewWordActivity
の onActivityResult()
コードを追加します。
アクティビティが RESULT_OK
で返された場合は、WordViewModel
の insert()
メソッドを呼び出して、返された単語をデータベースに挿入します。
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 が自動的に更新されます。
正常に動作するアプリが出来上がったので、これを振り返ってみましょう。アプリの構造を再度以下に示します。
アプリのコンポーネントは次のとおりです。
MainActivity
:RecyclerView
とWordListAdapter
を使用して単語をリストに表示します。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 つの単語を含むエンティティ クラス。Views
とActivities
(およびFragments
)は、ViewModel
を介してのみデータを操作します。そのため、データの取得元は関係ありません。
UI 自動更新のデータフロー(リアクティブ UI)
LiveData を使用しているため、自動更新が可能です。MainActivity
には、データベースからの単語の LiveData を監視し、変更があった場合には通知を受け取る Observer
が存在します。変更があると、オブザーバーの onChange()
メソッドが実行され、WordListAdapter
の mWords
が更新されます。
データは LiveData
のため監視可能です。監視対象となるのは、WordViewModel
allWords
プロパティから返される LiveData<List<Word>>
です。
WordViewModel
は、UI レイヤに対してバックエンドに関するすべての情報を隠蔽します。そして、データレイヤにアクセスするためのメソッドを提供し、MainActivity
がオブザーバーの関連付けをセットアップできるように LiveData
を返します。Views
と Activities
(および 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
が展開されます。