1. 始める前に
この Codelab では、データレイヤーの概要と、アプリ アーキテクチャ全体へのデータレイヤーの組み込み方について説明します。
図 1. ドメイン レイヤーと UI レイヤーが依存する層としてのデータレイヤーを示す図
タスク管理アプリのデータレイヤーを構築します。ローカル データベースやネットワーク サービス、およびデータを公開、更新、同期するリポジトリを作成します。
前提条件
- この Codelab は中級者向けであるため、Android アプリの作成方法について基本を理解していることが必要です(初心者向けの学習教材については、以下を参照してください)。
- ラムダ、コルーチン、フローなど、Kotlin の使用経験があること。Android アプリでの Kotlin の記述について学ぶには、Kotlin を使用した Android 基本コースのユニット 1 をご覧ください。
- Hilt(依存関係インジェクション)ライブラリ、および Room(データベース ストレージ)ライブラリについての基本的な理解があること。
- Jetpack Compose のある程度の使用経験があること。Compose を使用した Android の基本コースのユニット 1~3 は、Compose について学ぶのに最適です。
- 任意: アーキテクチャの概要とデータレイヤー ガイドを確認済みであること。
- 任意: Room Codelab を修了していること。
学習内容
この Codelab では、以下について学びます。
- 効果的でスケーラブルなデータ管理のために、リポジトリやデータソース、データモデルを作成します。
- 他のアーキテクチャ レイヤーにデータを公開します。
- 非同期データの更新、および複雑なタスクや長期にわたるタスクを処理します。
- 複数のデータソース間でデータを同期します。
- リポジトリやデータソースの動作を確認するテストを作成します。
作成するアプリの概要
タスクを追加して、完了マークを付けることができるタスク管理アプリを作成します。
アプリを最初から作成する必要はありません。すでに UI レイヤーがあるアプリで作業します。このアプリの UI レイヤーには、ViewModel を使用して実装した画面と、画面レベルの状態ホルダーが含まれています。
Codelab では、データレイヤーを追加し、既存の UI レイヤーと接続することで、アプリを完全に機能させることが可能になります。
図 2. タスクリスト画面のスクリーンショット | 図 3. タスクの詳細画面のスクリーンショット |
2. 設定する
- コードをダウンロードします。
https://github.com/android/architecture-samples/archive/refs/heads/data-codelab-start.zip
- または、GitHub リポジトリのクローンを作成してコードを入手することもできます。
git clone https://github.com/android/architecture-samples.git git checkout data-codelab-start
- Android Studio を開き、
architecture-samples
プロジェクトを読み込みます。
フォルダ構造
- Android ビューでプロジェクト エクスプローラを開きます。
java/com.example.android.architecture.blueprints.todoapp
フォルダの下に数個のフォルダがあります。
図 4. Android ビューに表示される Android Studio のプロジェクト エクスプローラ ウィンドウを示すスクリーンショット
<root>
には、ナビゲーション、メイン アクティビティ、アプリケーション クラスなどアプリレベルのクラスが含まれます。addedittask
には、ユーザーがタスクを追加、編集できる UI 機能が含まれます。data
には、データレイヤーが含まれます。ほとんどの作業は、このフォルダ内で行います。di
には、依存関係インジェクションのための Hilt モジュールが含まれます。tasks
には、ユーザーがタスクのリストを表示し、更新できる UI 機能が含まれます。util
には、ユーティリティ クラスが含まれます。
テストフォルダも 2 つあり、フォルダ名の末尾にかっこで囲んだテキストで示されています。
androidTest
は<root>
と同じ構造ですが、インストルメンテーション テストが含まれます。test
は<root>
と同じ構造ですが、ローカルテストが含まれます。
プロジェクトの実行
- 上部のツールバーにある緑色の再生アイコンをクリックします。
図 5. Android Studio の実行構成、対象デバイス、実行ボタンを示すスクリーンショット
読み込みスピナーが、常時タスクリスト画面に表示されます。
図 6. 起動した状態で無限の読み込みスピナーが表示されているアプリのスクリーンショット
Codelab 終了時には、この画面にタスクのリストが表示されます。
data-codelab-final
ブランチをチェックアウトすると、Codelab での最終的なコードが表示されます。
git checkout data-codelab-final
先に変更内容を隠しておくようにしてください。
3. データレイヤーについて確認する
この Codelab では、アプリのデータレイヤーを構築します。
データレイヤーは、その名が示すように、アプリケーション データを管理するアーキテクチャ レイヤーです。データレイヤーにはビジネス ロジック、つまりアプリケーション データの作成や保存、変更方法を決める実際のビジネスルールも含まれています。このように関心の分離を行うことで、データレイヤーは再利用可能になり、複数画面での表示、アプリの各要素間での情報共有ができるほか、単体テスト用に UI の外部でビジネス ロジックを再現することも可能になります。
データレイヤを構成する主要なコンポーネントのタイプは、データモデルやデータソース、リポジトリです。
図 7. データモデル、データソース、リポジトリ間の依存関係など、データレイヤーにおけるコンポーネントのタイプを示す図
データモデル
アプリケーション データは、通常、データモデルとして表されるデータのインメモリ表現です。
これはタスク管理アプリなので、タスクのデータモデルが必要です。Task
クラスは次のとおりです。
data class Task(
val id: String
val title: String = "",
val description: String = "",
val isCompleted: Boolean = false,
) { ... }
このモデルの主要なポイントは、変更不可であるということです。他のレイヤーはタスクのプロパティを変更することができないため、変更する場合は、データレイヤーを使用する必要があります。
内部および外部のデータモデル
Task
は、外部のデータモデルの一例です。データレイヤーにより外部に公開され、他のレイヤーからアクセスできます。後ほど、データレイヤーの中でだけ使用される内部のデータモデルを定義します。
データモデルの定義は、ビジネスモデルの各表現に対して行うことをおすすめします。このアプリには、3 つのデータモデルがあります。
モデル名 | データレイヤーの外部か内部か | 表現 | 関連するデータソース |
| 外部 | アプリのあらゆる部分で使用可能で、メモリ内かアプリケーション状態の保存時にのみ保存されるタスク | なし |
| 内部 | ローカル データベースに保存されるタスク |
|
| 内部 | ネットワーク サーバーから取得されたタスク |
|
データソース
データソースとは、データベースやネットワーク サービスなどの単一のソースに対して、データの読み書きを担当するクラスのことです。
このアプリには、2 つのデータソースがあります。
TaskDao
は、データベースへの読み書きを行うローカル データソースです。NetworkTaskDataSource
は、ネットワーク サーバーへの読み書きを行うネットワーク データソースです。
リポジトリ
1 つのリポジトリでは、単一のデータモデルを管理する必要があります。このアプリでは、Task
モデルを管理するリポジトリを作成します。リポジトリでは次のことが行われます。
Task
モデルのリストを公開します。Task
モデルを作成、更新するメソッドを提供します。- タスクごとに一意の ID を作成するなど、ビジネス ロジックを実行します。
- データソースからの内部データモデルを
Task
モデルに結合、またはマッピングします。 - データソースを同期します。
コーディングの準備
- Android ビューに切り替えて、
com.example.android.architecture.blueprints.todoapp.data
パッケージを展開します。
図 8. フォルダとファイルを表示するプロジェクト エクスプローラ ウィンドウ
残りのアプリをコンパイルできるように、Task
クラスはすでに作成されています。これから、提供された空の .kt
ファイルに実装を追加して、ほとんどのデータレイヤー クラスを最初から作成します。
4. データのローカル保存
ここでは、タスクをデバイスにローカル保存する Room データベース用に、データソースとデータモデルを作成します。
図 9. タスク リポジトリ、モデル、データソース、データベース間の関係を示す図
データモデルの作成
Room データベースにデータを保存するには、データベース エンティティを作成する必要があります。
data/source/local
の中でLocalTask.kt
ファイルを開いてから、次のコードを追加します。
@Entity(
tableName = "task"
)
data class LocalTask(
@PrimaryKey val id: String,
var title: String,
var description: String,
var isCompleted: Boolean,
)
LocalTask
クラスは、Room データベースで task
という名前のテーブルに保存されたデータを表しています。これは、Room と強く結びついているため、DataStore など他のデータソースには使用しないでください。
クラス名の接頭辞 Local
は、このデータがローカル保存されていることを示すものです。これは、このクラスを Task
データモデルと区別するためにも使用され、アプリ内の他のレイヤーに公開されます。言い換えれば、LocalTask
はデータレイヤーの内部であり、Task
はデータレイヤーの外部ということになります。
データソースの作成
データモデルが作成できたら、LocalTask
モデルを作成、読み取り、更新、削除(CRUD)するためのデータソースを作成します。Room を使用しているので、ローカル データソースとしてデータ アクセス オブジェクト(@Dao
アノテーション)を使用できます。
TaskDao.kt
という名前のファイルに、新たな Kotlin インターフェースを作成します。
@Dao
interface TaskDao {
@Query("SELECT * FROM task")
fun observeAll(): Flow<List<LocalTask>>
@Upsert
suspend fun upsert(task: LocalTask)
@Upsert
suspend fun upsertAll(tasks: List<LocalTask>)
@Query("UPDATE task SET isCompleted = :completed WHERE id = :taskId")
suspend fun updateCompleted(taskId: String, completed: Boolean)
@Query("DELETE FROM task")
suspend fun deleteAll()
}
データの読み取りメソッドには、接頭辞 observe
が付いています。これらは、Flow
を返す非サスペンド関数です。基礎となるデータが変更されるたびに、新たなアイテムがストリームに出力されます。Room ライブラリ(および他の多くのデータ ストレージ ライブラリ)の便利な機能を使用すれば、新しいデータのためにデータベースをポーリングするのではなく、データの変更をリッスンできるということです。
データ書き込みのメソッドは、I/O オペレーションを行っているため、サスペンド関数となっています。
データベース スキーマの更新
次に、LocalTask
モデルを保存するためにデータベースを更新する必要があります。
ToDoDatabase.kt
を開き、BlankEntity
をLocalTask
に変更します。BlankEntity
と不要なimport
ステートメントを削除します。taskDao
という名前の DAO を返すためのメソッドを追加します。
更新したクラスは次のようになるはずです。
@Database(entities = [LocalTask::class], version = 1, exportSchema = false)
abstract class ToDoDatabase : RoomDatabase() {
abstract fun taskDao(): TaskDao
}
Hilt 構成の更新
このプロジェクトでは、依存関係インジェクションのために Hilt を使用します。Hilt を使用するクラスに挿入できるように、Hilt に TaskDao
の作成方法を伝える必要があります。
di/DataModules.kt
を開き、次のメソッドをDatabaseModule
に追加します。
@Provides
fun provideTaskDao(database: ToDoDatabase) : TaskDao = database.taskDao()
これで、ローカル データベースにタスクを読み書きするために必要なすべての要素が整いました。
5. ローカル データソースのテスト
最後の手順です。多くのコードを書き込みましたが、それは正しく機能するでしょうか。TaskDao
の SQL クエリには、間違いが起こりやすいです。TaskDao
が正常に動作することを確認するために、テストを作成します。
テストはアプリの一部ではないため、別のフォルダに配置する必要があります。テストフォルダは 2 つあり、パッケージ名の末尾にかっこで囲んだテキストで示されています。
図 10. プロジェクト エクスプローラーの test フォルダ、および AndroidTest フォルダを示すスクリーンショット
androidTest
には、Android Emulator やデバイスで実行するテストが含まれています。これらは、インストルメンテーション テストと呼ばれています。test
には、ホストマシンで実行するテストが含まれており、ローカルテストとも呼ばれています。
TaskDao
は(Android デバイスでのみ作成可能な)Room データベースを必要とするため、テストするには、インストルメンテーション テストを作成する必要があります。
テストクラスの作成
androidTest
フォルダを展開して、TaskDaoTest.kt
を開きます。その中に、TaskDaoTest
という名前の空のクラスを作成します。
class TaskDaoTest {
}
テスト データベースの追加
- 各テストの前に
ToDoDatabase
を追加して初期化します。
private lateinit var database: ToDoDatabase
@Before
fun initDb() {
database = Room.inMemoryDatabaseBuilder(
getApplicationContext(),
ToDoDatabase::class.java
).allowMainThreadQueries().build()
}
これにより、各テストの前にインメモリ データベースが作成されることになります。インメモリ データベースは、ディスクベースのデータベースよりはるかに高速です。そのため、テストよりも長くデータを持続させる必要のない自動化テストに適しています。
テストの追加
LocalTask
を挿入できること、TaskDao
を使用して同じ LocalTask
の読み取りが可能であることを確認するテストを追加します。
この Codelab のテストはすべて、Given-When-Then 記法に沿ったものです。
Given | 空のデータベース |
When | タスクが挿入されると、タスク ストリームの監視を始めます |
Then | タスク ストリームの最初のアイテムが、挿入されたタスクと一致します |
- 失敗するテストを作成するところから始めます。これにより、テストが実際に実行されていること、正しいオブジェクトとその依存関係がテストされていることを確認します。
@Test
fun insertTaskAndGetTasks() = runTest {
val task = LocalTask(
title = "title",
description = "description",
id = "id",
isCompleted = false,
)
database.taskDao().upsert(task)
val tasks = database.taskDao().observeAll().first()
assertEquals(0, tasks.size)
}
- ガターにあるテストの横の再生ボタンをクリックして、テストを実行します。
図 11. コードエディタのガターにあるテスト再生ボタンを示すスクリーンショット
テスト結果ウィンドウの中に、expected:<0> but was:<1>
というメッセージとともにテストの失敗が表示されるはずです。データベース内のタスク数が 0 ではなく 1 であるため、これは想定内です。
図 12. テストの失敗を示すスクリーンショット
- 既存の
assertEquals
ステートメントを削除します。 - コードを追加し、データソースから提供されるタスクは 1 つだけで、それが挿入されたタスクと同じであることをテストします。
パラメータから assertEquals
までの順序は常に期待値、そして次が実際の値**.**になっているはずです
assertEquals(1, tasks.size)
assertEquals(task, tasks[0])
- テストを再度実行します。テスト結果ウィンドウの中に、テストに合格したことが表示されるはずです。
図 13. テストの合格を示すスクリーンショット
6. ネットワーク データソースの作成
タスクをデバイスにローカル保存できるのは良いとして、そのタスクをネットワーク サービスにも保存し、読み込みたい場合はどうすればいいのでしょうか。おそらく、Android アプリは、ユーザーが TODO リストにタスクを追加する方法のひとつにすぎません。タスクは、ウェブサイトやパソコンのアプリケーションで管理することもできます。あるいは、ユーザーがデバイスを変更してもアプリのデータを復元できるように、オンライン データのバックアップを提供したいだけかもしれません。
通常、このようなシナリオでは、Android アプリを含むあらゆるクライアントがデータの読み込みと保存に使用できるネットワークベースのサービスが備えられています。
次のステップでは、このネットワーク サービスと通信するためのデータソースを作成します。この Codelab の目的上、これはライブ ネットワークのサービスに接続しない模擬サービスですが、実際のアプリでの実装方法を確認できます。
ネットワーク サービスについて
ネットワーク API の例は非常にシンプルです。2 つの操作を行うだけです。
- すべてのタスクを保存し、以前に書き込まれたデータを上書きします。
- すべてのタスクを読み込み、現在ネットワーク サービスに保存されているすべてのタスクのリストを提供します。
ネットワーク データのモデル化
ネットワーク API からデータを取得すると、多くの場合、そのデータはローカルでの表現方法とは異なるものになります。タスクのネットワーク表現では、追加のフィールドがあったり、同じ値を表すのに異なる型やフィールド名が使用されていたりすることがあります。
これらの違いを考慮し、ネットワークに固有のデータモデルを作成します。
data/source/network
にあるNetworkTask.kt
ファイルを開き、次のコードを追加してフィールドを表現します。
data class NetworkTask(
val id: String,
val title: String,
val shortDescription: String,
val priority: Int? = null,
val status: TaskStatus = TaskStatus.ACTIVE
) {
enum class TaskStatus {
ACTIVE,
COMPLETE
}
}
LocalTask
と NetworkTask
との違いを次に示します。
- タスクの説明は、
description
ではなくshortDescription
と名付けられます。 isCompleted
フィールドはstatus
列挙型として表現され、ACTIVE
およびCOMPLETE
という 2 つの可能な値を持ちます。- 追加の
priority
フィールドが含まれており、これは整数です。
ネットワーク データソースの作成
TaskNetworkDataSource.kt
を開き、次の内容でTaskNetworkDataSource
という名前のクラスを作成します。
class TaskNetworkDataSource @Inject constructor() {
// A mutex is used to ensure that reads and writes are thread-safe.
private val accessMutex = Mutex()
private var tasks = listOf(
NetworkTask(
id = "PISA",
title = "Build tower in Pisa",
shortDescription = "Ground looks good, no foundation work required."
),
NetworkTask(
id = "TACOMA",
title = "Finish bridge in Tacoma",
shortDescription = "Found awesome girders at half the cost!"
)
)
suspend fun loadTasks(): List<NetworkTask> = accessMutex.withLock {
delay(SERVICE_LATENCY_IN_MILLIS)
return tasks
}
suspend fun saveTasks(newTasks: List<NetworkTask>) = accessMutex.withLock {
delay(SERVICE_LATENCY_IN_MILLIS)
tasks = newTasks
}
}
private const val SERVICE_LATENCY_IN_MILLIS = 2000L
このオブジェクトは、loadTasks
や saveTasks
の呼び出しは毎回 2 秒遅れることなども含めて、サーバーとのインタラクションをシミュレートします。これは、ネットワークやサーバーの応答レイテンシを表しています。
ここには、タスクがネットワークから正常に読み込まれることを確認するために、後に使用するテストデータも含まれています。
実際のサーバーの API が HTTP を使用する場合は、Ktor や Retrofit などのライブラリを利用してネットワーク データソースを構築することを検討してください。
7. タスク リポジトリの作成
要素はすべて揃っています。
図 14. DefaultTaskRepository
の依存関係を示す図
ローカルデータ(TaskDao
)とネットワーク データ(TaskNetworkDataSource
)という 2 つのデータソースがあります。いずれも読み書きが可能で、固有のタスク表現を持っています(それぞれ LocalTask
と NetworkTask
)。
次に、他のアーキテクチャ レイヤーがこのタスクデータにアクセスできるように、これらのデータソースを使用するリポジトリを作成し、API を提供します。
データの公開
data
パッケージのDefaultTaskRepository.kt
を開き、TaskDao
とTaskNetworkDataSource
を依存関係とするDefaultTaskRepository
という名前のクラスを作成します。
class DefaultTaskRepository @Inject constructor(
private val localDataSource: TaskDao,
private val networkDataSource: TaskNetworkDataSource,
) {
}
データはフローを使用して公開する必要があります。これにより、データへの経時的な変更を呼び出し元に通知できます。
observeAll
という名前のメソッドを追加し、Flow
を使用したTask
モデルのストリームを返します。
fun observeAll() : Flow<List<Task>> {
// TODO add code to retrieve Tasks
}
リポジトリは、信頼できる唯一の情報源からのデータを公開する必要があります。データを得るデータソースは 1 つだけにしてください。インメモリ キャッシュやリモート サーバー、またこの場合はローカル データベースが該当します。
ローカル データベースのタスクは、フローを返す際に便利な TaskDao.observeAll
を使用してアクセスできます。しかし、それは LocalTask
モデルのフローであり、その中にある LocalTask
は、他のアーキテクチャ レイヤーには公開すべきではない内部モデルです。
LocalTask
を Task
に変換する必要があります。これは、データレイヤー API の一部を形成する外部モデルです。
内部モデルの外部モデルへのマッピング
このコンバージョンを行うには、LocalTask
のフィールドを Task
のフィールドへマッピングする必要があります。
- そのため、
LocalTask
で拡張関数を作成します。
// Convert a LocalTask to a Task
fun LocalTask.toExternal() = Task(
id = id,
title = title,
description = description,
isCompleted = isCompleted,
)
// Convenience function which converts a list of LocalTasks to a list of Tasks
fun List<LocalTask>.toExternal() = map(LocalTask::toExternal) // Equivalent to map { it.toExternal() }
これから、LocalTask
を Task
に変換する必要のあるときはいつでも、toExternal
を呼び出すだけでよいのです。
- 新たに作成した
toExternal
関数をobserveAll
の中で使います。
fun observeAll(): Flow<List<Task>> {
return localDataSource.observeAll().map { tasks ->
tasks.toExternal()
}
}
ローカル データベースのタスクデータが変更されるたびに、新しい LocalTask
モデルのリストがフローに出力されます。その後、各 LocalTask
が Task
にマッピングされます。
お疲れさまでした。これで、他のレイヤーは、ローカル データベースからすべての Task
モデルを取得するために observeAll
を使用することができ、Task
モデルが変わるたびに通知されます。
データの更新
タスクを作成して更新できなければ、TODO アプリはあまり意味がありません。ここで、そのためのメソッドを追加します。
データを作成、更新、削除するためのメソッドはワンショット オペレーションであり、suspend
関数を使用して実装する必要があります。
create
という名前のメソッドを追加しますが、これはtitle
とdescription
をパラメータとして伴い、新たに作成されたタスクの ID を返します。
suspend fun create(title: String, description: String): String {
}
なお、データレイヤー API は、他のレイヤにより Task
が作成されることを禁じており、Task
ではなく個別のパラメータを受け入れる create
メソッドだけを提供しています。このアプローチにより、以下をカプセル化します。
- 一意のタスク ID を作成するためのビジネス ロジック。
- 最初の作成後にタスクを保存する場所。
- タスク ID を作成するメソッドを追加します。
// This method might be computationally expensive
private fun createTaskId() : String {
return UUID.randomUUID().toString()
}
- 新たに追加された
createTaskId
メソッドを使用してタスク ID を作成します。
suspend fun create(title: String, description: String): String {
val taskId = createTaskId()
}
メインスレッドのブロック禁止
では、タスク ID を作成する際の計算コストが高い場合はどうすべきでしょうか。暗号を使って ID のハッシュキーを作成する方法がありますが、それには数秒かかります。これは、メインスレッドで呼び出されると、UI ジャンクの原因となる可能性があります。
データレイヤーには、長時間実行中のタスクや複雑なタスクが、メインスレッドをブロックしないようにする役割があります。
これを修正するために、これらの命令の実行に使用するコルーチン ディスパッチャを指定します。
- まず、
CoroutineDispatcher
を依存関係としてDefaultTaskRepository
に追加します。すでに作成している@DefaultDispatcher
修飾子(di/CoroutinesModule.kt
で定義)を使用して、この依存関係をDispatchers.Default
で挿入するよう Hilt に指示します。Default
ディスパッチャが指定されているのは、CPU 負荷の高い作業に対して最適化されているためです。コルーチン ディスパッチャについての詳細は、こちらをご覧ください。
class DefaultTaskRepository @Inject constructor(
private val localDataSource: TaskDao,
private val networkDataSource: TaskNetworkDataSource,
@DefaultDispatcher private val dispatcher: CoroutineDispatcher,
)
- 次に、
UUID.randomUUID().toString()
への呼び出しをwithContext
ブロックの内部に配置します。
val taskId = withContext(dispatcher) {
createTaskId()
}
データレイヤーのスレッドについての詳細は、こちらをご覧ください。
タスクの作成と保存
- タスク ID を取得したので、指定されるパラメータと合わせて使用し、新たな
Task
を作成します。
suspend fun create(title: String, description: String): String {
val taskId = withContext(dispatcher) {
createTaskId()
}
val task = Task(
title = title,
description = description,
id = taskId,
)
}
タスクは、LocalTask
にマッピングしてから、ローカル データソースに挿入する必要があります。
LocalTask
の末尾に次の拡張関数を追加します。これは、前に作成したLocalTask.toExternal
への逆マッピング関数です。
fun Task.toLocal() = LocalTask(
id = id,
title = title,
description = description,
isCompleted = isCompleted,
)
- この関数を
create
の中で使用してタスクをローカル データソースに挿入し、taskId
を返します。
suspend fun create(title: String, description: String): Task {
...
localDataSource.upsert(task.toLocal())
return taskId
}
タスクの完了
Task
を完了としてマークする追加のメソッド、complete
を作成します。
suspend fun complete(taskId: String) {
localDataSource.updateCompleted(taskId, true)
}
これで、タスクを作成、完了するいくつかの有効なメソッドを取得したことになります。
データの同期
このアプリでは、ネットワーク データソースは、ローカルにデータの書き込みがあるたびに更新されるオンライン バックアップとして使用されます。ユーザーが更新を要求するたびに、ネットワークからデータが読み込まれます。
次の図は、操作のタイプごとに動作をまとめたものです。
オペレーションのタイプ | リポジトリ メソッド | 手順 | データの移動 |
読み込み |
| ローカル データベースからデータを読み込む | 図 15.ローカル データソースからタスク リポジトリへのデータフローを示す図 |
保存 |
| 1. ローカル データベース 2 にデータを書き込む。ネットワークに全データをコピーし、すべてを上書きする | 図 16. タスク リポジトリからローカル データソースへ、そしてネットワーク データソースへと続くデータフローを示す図 |
更新 |
| 1. ネットワーク 2 からデータを読み込む。読み込んだデータをローカル データベースにコピーし、すべてを上書きする | 図 17. ネットワーク データソースからローカル データソースへ、そしてタスク リポジトリへと続くデータフローを示す図 |
ネットワーク データの保存と更新
リポジトリは、ローカル データソースからのタスクの読み込みを終えました。同期アルゴリズムを完了させるには、ネットワーク データソースからのデータを保存し、更新するためのメソッドを作成する必要があります。
- まず、
NetworkTask.kt
の中にLocalTask
からNetworkTask
へのマッピング関数と、その逆方向のマッピング関数を作成します。LocalTask.kt
の内部に関数を配置することも、同様に有効です。
fun NetworkTask.toLocal() = LocalTask(
id = id,
title = title,
description = shortDescription,
isCompleted = (status == NetworkTask.TaskStatus.COMPLETE),
)
fun List<NetworkTask>.toLocal() = map(NetworkTask::toLocal)
fun LocalTask.toNetwork() = NetworkTask(
id = id,
title = title,
shortDescription = description,
status = if (isCompleted) { NetworkTask.TaskStatus.COMPLETE } else { NetworkTask.TaskStatus.ACTIVE }
)
fun List<LocalTask>.toNetwork() = map(LocalTask::toNetwork)
ここでは、データソースごとにモデルを分けることの利点が示されています。つまり、あるデータ型から別のデータ型へのマッピングが個別の関数にカプセル化されているのです。
DefaultTaskRepository
の末尾にrefresh
メソッドを追加します。
suspend fun refresh() {
val networkTasks = networkDataSource.loadTasks()
localDataSource.deleteAll()
val localTasks = withContext(dispatcher) {
networkTasks.toLocal()
}
localDataSource.upsertAll(networkTasks.toLocal())
}
これにより、すべてのローカルタスクがネットワークからのタスクに置き換えられます。タスク数が不明で、マッピング操作ごとに計算コストが高くなる可能性があるため、toLocal
の一括操作には withContext
が使用されます。
DefaultTaskRepository
の末尾にsaveTasksToNetwork
メソッドを追加します。
private suspend fun saveTasksToNetwork() {
val localTasks = localDataSource.observeAll().first()
val networkTasks = withContext(dispatcher) {
localTasks.toNetwork()
}
networkDataSource.saveTasks(networkTasks)
}
これにより、すべてのネットワーク タスクがローカル データソースからのタスクに置き換えられます。
- ここで、既存のメソッドを更新し、ローカルデータが変更されたときにはネットワークに保存されるように、タスク
create
とタスクcomplete
を更新します。
suspend fun create(title: String, description: String): String {
...
saveTasksToNetwork()
return taskId
}
suspend fun complete(taskId: String) {
localDataSource.updateCompleted(taskId, true)
saveTasksToNetwork()
}
呼び出し元の待機を回避する
このコードを実行すると、saveTasksToNetwork
がブロックしていることに気付くでしょう。つまり、create
や complete
の呼び出し元は、操作が完了したことを確認できないまま、データがネットワークに保存されるまで待機することになります。シミュレートされたネットワーク・データソースでは、待つのは 2 秒だけですが、実際のアプリではもっと長くかかるかもしれません。あるいはネットワークに接続していなければ、まったく待機が発生しない可能性もあります。
この制限は無用であり、ユーザー エクスペリエンスの低下につながりかねません。特に忙しいときには、タスクの作成に時間をとられたくないものです。
これを効果的に解決する策として、ネットワークにデータを保存するのに別のコルーチン・スコープを使用するという方法があります。これにより、呼び出し元を待機させることなく、バックグラウンドで操作を完了させることができます。
DefaultTaskRepository
に、パラメータとしてコルーチン・スコープを追加します。
class DefaultTaskRepository @Inject constructor(
// ...other parameters...
@ApplicationScope private val scope: CoroutineScope,
)
アプリのライフサイクルに沿ったスコープを挿入するために、Hilt 修飾子 @ApplicationScope
(di/CoroutinesModule.kt
で定義)を使用します。
saveTasksToNetwork
内のコードをscope.launch
でラップします。
private fun saveTasksToNetwork() {
scope.launch {
val localTasks = localDataSource.observeAll().first()
val networkTasks = withContext(dispatcher) {
localTasks.toNetwork()
}
networkDataSource.saveTasks(networkTasks)
}
}
こうすることで、saveTasksToNetwork
がすぐに返され、タスクがバックグラウンドでネットワークに保存されます。
8. タスク リポジトリのテスト
データレイヤーに多くの機能が追加されました。DefaultTaskRepository
の単体テストを作成し、すべてが機能することを確認していきましょう。
ローカル データソースとネットワーク データソースにテストの依存関係を持たせ、テスト対象(DefaultTaskRepository
)をインスタンス化する必要があります。まず、それらの依存関係を作成しましょう。
- プロジェクト エクスプローラ ウィンドウで、
(test)
フォルダを展開し、それからsource.local
フォルダを展開して、FakeTaskDao.kt.
を開きます。
図 18. プロジェクト フォルダ構造内の FakeTaskDao.kt
を示すスクリーンショット
- 次の内容を追加します。
class FakeTaskDao(initialTasks: List<LocalTask>) : TaskDao {
private val _tasks = initialTasks.toMutableList()
private val tasksStream = MutableStateFlow(_tasks.toList())
override fun observeAll(): Flow<List<LocalTask>> = tasksStream
override suspend fun upsert(task: LocalTask) {
_tasks.removeIf { it.id == task.id }
_tasks.add(task)
tasksStream.emit(_tasks)
}
override suspend fun upsertAll(tasks: List<LocalTask>) {
val newTaskIds = tasks.map { it.id }
_tasks.removeIf { newTaskIds.contains(it.id) }
_tasks.addAll(tasks)
}
override suspend fun updateCompleted(taskId: String, completed: Boolean) {
_tasks.firstOrNull { it.id == taskId }?.let { it.isCompleted = completed }
tasksStream.emit(_tasks)
}
override suspend fun deleteAll() {
_tasks.clear()
tasksStream.emit(_tasks)
}
}
実際のアプリでは、TaskNetworkDataSource
を置き換えるために(偽のオブジェクトと実際のオブジェクトに共通のインターフェースを実装させることにより)偽の依存関係を作成することもありますが、この Codelab の目的上、直接これを使用します。
DefaultTaskRepositoryTest
に以下を追加します。
すべてのテストで使用するメイン ディスパッチャを設定するルール |
いくつかのテストデータ |
ローカル データソース、ネットワーク データソースのテスト依存関係 |
テスト対象: |
class DefaultTaskRepositoryTest {
private var testDispatcher = UnconfinedTestDispatcher()
private var testScope = TestScope(testDispatcher)
private val localTasks = listOf(
LocalTask(id = "1", title = "title1", description = "description1", isCompleted = false),
LocalTask(id = "2", title = "title2", description = "description2", isCompleted = true),
)
private val localDataSource = FakeTaskDao(localTasks)
private val networkDataSource = TaskNetworkDataSource()
private val taskRepository = DefaultTaskRepository(
localDataSource = localDataSource,
networkDataSource = networkDataSource,
dispatcher = testDispatcher,
scope = testScope
)
}
お疲れさまでした。これで、単体テストの書き込みを開始できます。主に、読み取り、書き込み、データ同期という 3 つの項目をテストします。
公開データのテスト
ここでは、リポジトリが正しくデータを公開しているかどうかをテストする方法を説明します。テストは、Given-When-Then 記法で行います。例:
Given | ローカル データソースには、いくつかの既存のタスクがあります |
When | タスク ストリームは、 |
Then | タスク ストリームの最初のアイテムは、ローカル データソースのタスクの外部表現と一致します |
- 次の内容で、
observeAll_exposesLocalData
という名前のテストを作成します。
@Test
fun observeAll_exposesLocalData() = runTest {
val tasks = taskRepository.observeAll().first()
assertEquals(localTasks.toExternal(), tasks)
}
first
関数を使用して、タスク ストリームから最初のアイテムを取得します。
テストデータの更新
次は、タスクが作成され、ネットワーク データソースに保存されることを確認するテストを記述します。
Given | 空のデータベース |
When |
|
Then | ローカル データソースとネットワーク データソース、どちらにもタスクが作成されます |
onTaskCreation_localAndNetworkAreUpdated
という名前のテストを作成します。
@Test
fun onTaskCreation_localAndNetworkAreUpdated() = testScope.runTest {
val newTaskId = taskRepository.create(
localTasks[0].title,
localTasks[0].description
)
val localTasks = localDataSource.observeAll().first()
assertEquals(true, localTasks.map { it.id }.contains(newTaskId))
val networkTasks = networkDataSource.loadTasks()
assertEquals(true, networkTasks.map { it.id }.contains(newTaskId))
}
次に、タスクの完了時にローカル データソースに正しく書き込まれ、ネットワーク データソースに保存されることを確認します。
Given | ローカル データソースにはタスクが含まれています |
When |
|
Then | ローカルデータとネットワーク データも更新されます |
onTaskCompletion_localAndNetworkAreUpdated
という名前のテストを作成します。
@Test
fun onTaskCompletion_localAndNetworkAreUpdated() = testScope.runTest {
taskRepository.complete("1")
val localTasks = localDataSource.observeAll().first()
val isLocalTaskComplete = localTasks.firstOrNull { it.id == "1" } ?.isCompleted
assertEquals(true, isLocalTaskComplete)
val networkTasks = networkDataSource.loadTasks()
val isNetworkTaskComplete =
networkTasks.firstOrNull { it.id == "1"} ?.status == NetworkTask.TaskStatus.COMPLETE
assertEquals(true, isNetworkTaskComplete)
}
更新データのテスト
最後に、更新の操作が正常に行われることをテストします。
Given | ネットワーク データソースにはデータが含まれています |
When |
|
Then | ローカルデータはネットワーク データと同じです |
onRefresh_localIsEqualToNetwork
という名前のテストを作成します
@Test
fun onRefresh_localIsEqualToNetwork() = runTest {
val networkTasks = listOf(
NetworkTask(id = "3", title = "title3", shortDescription = "desc3"),
NetworkTask(id = "4", title = "title4", shortDescription = "desc4"),
)
networkDataSource.saveTasks(networkTasks)
taskRepository.refresh()
assertEquals(networkTasks.toLocal(), localDataSource.observeAll().first())
}
これで完了です。テストを実行すれば、すべて合格するはずです。
9. UI レイヤーの更新
データレイヤーが機能するとわかったところで、いよいよ UI レイヤーに接続します。
タスクリスト画面のビューモデルの更新
TasksViewModel
から始めます。これは、アプリの最初の画面、つまり現在アクティブな全タスクのリストを表示するためのビューモデルです。
- このクラスを開き、
DefaultTaskRepository
をコンストラクタ パラメータとして追加します。
@HiltViewModel
class TasksViewModel @Inject constructor(
private val savedStateHandle: SavedStateHandle,
private val taskRepository: DefaultTaskRepository,
) : ViewModel () { /* ... */ }
- リポジトリを使用して
tasksStream
変数を初期化します。
private val tasksStream = taskRepository.observeAll()
これでビューモデルは、リポジトリが提供する全タスクにアクセスし、データが変更されるたびに、新たなタスクのリストを受け取ることになりました(使用するコードは、わずか 1 行です)。
- あとは、ユーザーのアクションをリポジトリ内の対応するメソッドに接続するだけです。
complete
メソッドを探し、次のように更新します。
fun complete(task: Task, completed: Boolean) {
viewModelScope.launch {
if (completed) {
taskRepository.complete(task.id)
showSnackbarMessage(R.string.task_marked_complete)
} else {
...
}
}
}
refresh
でも同様にします。
fun refresh() {
_isLoading.value = true
viewModelScope.launch {
taskRepository.refresh()
_isLoading.value = false
}
}
タスク追加画面のビューモデルの更新
- 上記の手順と同様に、
AddEditTaskViewModel
を開き、DefaultTaskRepository
をコンストラクタ パラメータとして追加します。
class AddEditTaskViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val taskRepository: DefaultTaskRepository,
)
create
メソッドを次のように更新します。
private fun createNewTask() = viewModelScope.launch {
taskRepository.create(uiState.value.title, uiState.value.description)
_uiState.update {
it.copy(isTaskSaved = true)
}
}
アプリの実行
- いよいよアプリを実行するときが来ました。画面には、You have no tasks!と表示されているはずです。
図 19. タスクがないときのアプリのタスク画面を示すスクリーンショット
- 右上の 3 つのドットをタップして、[Refresh] を押します。
図 20. 操作メニューが表示されたアプリのタスク画面のスクリーンショット
読み込みスピナーが 2 秒間表示されてから、前に追加したテストタスクが表示されるはずです。
図 21. 2 つのタスクが表示されたアプリのタスク画面のスクリーンショット
- 右下隅のプラス記号をタップして、新たなタスクを追加します。タイトルと説明のフィールドを完成させます。
図 22. アプリのタスク追加画面のスクリーンショット
- 右下のチェックボタンをタップして、タスクを保存します。
図 23. タスク追加後のアプリのタスク画面のスクリーンショット
- タスクの完了を示すために、タスク横のチェックボックスを選択します。
図 24. タスクの完了を示すアプリのタスク画面のスクリーンショット
10. 完了
アプリのデータレイヤーが正常に作成されました。
データレイヤーは、アプリケーション アーキテクチャの必須部分を形成しています。他のレイヤー構築の基盤となるものであり、これを適切に行うことで、ユーザーやビジネスのニーズに対応できます。
学んだ内容
- Android アプリ アーキテクチャにおけるデータレイヤーの役割
- データソースとモデルの作成方法
- リポジトリの役割と、データを公開し、1 回限りのメソッドを提供して更新する方法
- コルーチン ディスパッチャを変更するタイミングとそれが重要である理由
- 複数のデータソースを使用したデータの同期
- 一般的なデータレイヤー クラスの単体テストとインストルメンテーション テストの作成方法
さらなるチャレンジ
さらなるチャレンジを試みるのであれば、次の機能を実装してください。
- 完了としてマークしたタスクを再度有効にする
- タスクのタイトルと説明をタップして編集する
指示はありません。ご自身で作業してみてください。行き詰まったときには、main
ブランチでアプリの完全版を確認できます。
git checkout main
次のステップ
データレイヤーについての詳細は、公式ドキュメントとオフラインファースト アプリのガイドをご確認ください。UI レイヤーやドメイン レイヤーなど、他のアーキテクチャ レイヤーについても学ぶことができます。
より複雑で実用的なサンプルに関しては、Now in Android アプリでご覧ください。