データレイヤーの構築

1. 始める前に

この Codelab では、データレイヤーの概要と、アプリ アーキテクチャ全体へのデータレイヤーの組み込み方について説明します。

ドメイン レイヤー、UI レイヤーの下に位置する最下層としてのデータレイヤー。

図 1. ドメイン レイヤーと UI レイヤーが依存する層としてのデータレイヤーを示す図

タスク管理アプリのデータレイヤーを構築します。ローカル データベースやネットワーク サービス、およびデータを公開、更新、同期するリポジトリを作成します。

前提条件

学習内容

この Codelab では、以下について学びます。

  • 効果的でスケーラブルなデータ管理のために、リポジトリやデータソース、データモデルを作成します。
  • 他のアーキテクチャ レイヤーにデータを公開します。
  • 非同期データの更新、および複雑なタスクや長期にわたるタスクを処理します。
  • 複数のデータソース間でデータを同期します。
  • リポジトリやデータソースの動作を確認するテストを作成します。

作成するアプリの概要

タスクを追加して、完了マークを付けることができるタスク管理アプリを作成します。

アプリを最初から作成する必要はありません。すでに UI レイヤーがあるアプリで作業します。このアプリの UI レイヤーには、ViewModel を使用して実装した画面と、画面レベルの状態ホルダーが含まれています。

Codelab では、データレイヤーを追加し、既存の UI レイヤーと接続することで、アプリを完全に機能させることが可能になります。

タスクリスト画面

タスクの詳細画面

図 2. タスクリスト画面のスクリーンショット

図 3. タスクの詳細画面のスクリーンショット

2. 設定する

  1. コードをダウンロードします。

https://github.com/android/architecture-samples/archive/refs/heads/data-codelab-start.zip

  1. または、GitHub リポジトリのクローンを作成してコードを入手することもできます。
git clone https://github.com/android/architecture-samples.git
git checkout data-codelab-start
  1. Android Studio を開き、architecture-samples プロジェクトを読み込みます。

フォルダ構造

  • Android ビューでプロジェクト エクスプローラを開きます。

java/com.example.android.architecture.blueprints.todoapp フォルダの下に数個のフォルダがあります。

Android ビューに表示される Android Studio のプロジェクト エクスプローラ ウィンドウ

図 4. Android ビューに表示される Android Studio のプロジェクト エクスプローラ ウィンドウを示すスクリーンショット

  • <root> には、ナビゲーション、メイン アクティビティ、アプリケーション クラスなどアプリレベルのクラスが含まれます。
  • addedittask には、ユーザーがタスクを追加、編集できる UI 機能が含まれます。
  • data には、データレイヤーが含まれます。ほとんどの作業は、このフォルダ内で行います。
  • di には、依存関係インジェクションのための Hilt モジュールが含まれます。
  • tasks には、ユーザーがタスクのリストを表示し、更新できる UI 機能が含まれます。
  • util には、ユーティリティ クラスが含まれます。

テストフォルダも 2 つあり、フォルダ名の末尾にかっこで囲んだテキストで示されています。

  • androidTest<root> と同じ構造ですが、インストルメンテーション テストが含まれます。
  • test<root> と同じ構造ですが、ローカルテストが含まれます。

プロジェクトの実行

  • 上部のツールバーにある緑色の再生アイコンをクリックします。

Android Studio の実行構成、対象デバイス、実行ボタン

図 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 つのデータモデルがあります。

モデル名

データレイヤーの外部か内部か

表現

関連するデータソース

Task

外部

アプリのあらゆる部分で使用可能で、メモリ内かアプリケーション状態の保存時にのみ保存されるタスク

なし

LocalTask

内部

ローカル データベースに保存されるタスク

TaskDao

NetworkTask

内部

ネットワーク サーバーから取得されたタスク

NetworkTaskDataSource

データソース

データソースとは、データベースやネットワーク サービスなどの単一のソースに対して、データの読み書きを担当するクラスのことです。

このアプリには、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 モデルを保存するためにデータベースを更新する必要があります。

  1. ToDoDatabase.kt を開き、BlankEntityLocalTask に変更します。
  2. BlankEntity と不要な import ステートメントを削除します。
  3. 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 つあり、パッケージ名の末尾にかっこで囲んだテキストで示されています。

プロジェクト エクスプローラの test フォルダ、および AndroidTest フォルダ

図 10. プロジェクト エクスプローラーの test フォルダ、および AndroidTest フォルダを示すスクリーンショット

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

タスク ストリームの最初のアイテムが、挿入されたタスクと一致します

  1. 失敗するテストを作成するところから始めます。これにより、テストが実際に実行されていること、正しいオブジェクトとその依存関係がテストされていることを確認します。
@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)
}
  1. ガターにあるテストの横の再生ボタンをクリックして、テストを実行します。

コードエディタのガターにあるテスト再生ボタン

図 11. コードエディタのガターにあるテスト再生ボタンを示すスクリーンショット

テスト結果ウィンドウの中に、expected:<0> but was:<1> というメッセージとともにテストの失敗が表示されるはずです。データベース内のタスク数が 0 ではなく 1 であるため、これは想定内です。

テストの失敗

図 12. テストの失敗を示すスクリーンショット

  1. 既存の assertEquals ステートメントを削除します。
  2. コードを追加し、データソースから提供されるタスクは 1 つだけで、それが挿入されたタスクと同じであることをテストします。

パラメータから assertEquals までの順序は常に期待値、そして次が実際の値**.**になっているはずです

assertEquals(1, tasks.size)
assertEquals(task, tasks[0])
  1. テストを再度実行します。テスト結果ウィンドウの中に、テストに合格したことが表示されるはずです。

テストの合格

図 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
    }
}

LocalTaskNetworkTask との違いを次に示します。

  • タスクの説明は、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

このオブジェクトは、loadTaskssaveTasks の呼び出しは毎回 2 秒遅れることなども含めて、サーバーとのインタラクションをシミュレートします。これは、ネットワークやサーバーの応答レイテンシを表しています。

ここには、タスクがネットワークから正常に読み込まれることを確認するために、後に使用するテストデータも含まれています。

実際のサーバーの API が HTTP を使用する場合は、KtorRetrofit などのライブラリを利用してネットワーク データソースを構築することを検討してください。

7. タスク リポジトリの作成

要素はすべて揃っています。

DefaultTaskRepository の依存関係

図 14. DefaultTaskRepository の依存関係を示す図

ローカルデータ(TaskDao)とネットワーク データ(TaskNetworkDataSource)という 2 つのデータソースがあります。いずれも読み書きが可能で、固有のタスク表現を持っています(それぞれ LocalTaskNetworkTask)。

次に、他のアーキテクチャ レイヤーがこのタスクデータにアクセスできるように、これらのデータソースを使用するリポジトリを作成し、API を提供します。

データの公開

  1. data パッケージの DefaultTaskRepository.kt を開き、TaskDaoTaskNetworkDataSource を依存関係とする DefaultTaskRepository という名前のクラスを作成します。
class DefaultTaskRepository @Inject constructor(
    private val localDataSource: TaskDao,
    private val networkDataSource: TaskNetworkDataSource,
) {

}

データはフローを使用して公開する必要があります。これにより、データへの経時的な変更を呼び出し元に通知できます。

  1. observeAll という名前のメソッドを追加し、Flow を使用した Task モデルのストリームを返します。
fun observeAll() : Flow<List<Task>> {
    // TODO add code to retrieve Tasks
}

リポジトリは、信頼できる唯一の情報源からのデータを公開する必要があります。データを得るデータソースは 1 つだけにしてください。インメモリ キャッシュやリモート サーバー、またこの場合はローカル データベースが該当します。

ローカル データベースのタスクは、フローを返す際に便利な TaskDao.observeAll を使用してアクセスできます。しかし、それは LocalTask モデルのフローであり、その中にある LocalTask は、他のアーキテクチャ レイヤーには公開すべきではない内部モデルです。

LocalTaskTask に変換する必要があります。これは、データレイヤー API の一部を形成する外部モデルです。

内部モデルの外部モデルへのマッピング

このコンバージョンを行うには、LocalTask のフィールドを Task のフィールドへマッピングする必要があります。

  1. そのため、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() }

これから、LocalTaskTask に変換する必要のあるときはいつでも、toExternal を呼び出すだけでよいのです。

  1. 新たに作成した toExternal 関数を observeAll の中で使います。
fun observeAll(): Flow<List<Task>> {
    return localDataSource.observeAll().map { tasks ->
        tasks.toExternal()
    }
}

ローカル データベースのタスクデータが変更されるたびに、新しい LocalTask モデルのリストがフローに出力されます。その後、各 LocalTaskTask にマッピングされます。

お疲れさまでした。これで、他のレイヤーは、ローカル データベースからすべての Task モデルを取得するために observeAll を使用することができ、Task モデルが変わるたびに通知されます。

データの更新

タスクを作成して更新できなければ、TODO アプリはあまり意味がありません。ここで、そのためのメソッドを追加します。

データを作成、更新、削除するためのメソッドはワンショット オペレーションであり、suspend 関数を使用して実装する必要があります。

  1. create という名前のメソッドを追加しますが、これは titledescription をパラメータとして伴い、新たに作成されたタスクの ID を返します。
suspend fun create(title: String, description: String): String {
}

なお、データレイヤー API は、他のレイヤにより Task が作成されることを禁じており、Task ではなく個別のパラメータを受け入れる create メソッドだけを提供しています。このアプローチにより、以下をカプセル化します。

  • 一意のタスク ID を作成するためのビジネス ロジック。
  • 最初の作成後にタスクを保存する場所。
  1. タスク ID を作成するメソッドを追加します。
// This method might be computationally expensive
private fun createTaskId() : String {
    return UUID.randomUUID().toString()
}
  1. 新たに追加された createTaskId メソッドを使用してタスク ID を作成します。
suspend fun create(title: String, description: String): String {
    val taskId = createTaskId()
}

メインスレッドのブロック禁止

では、タスク ID を作成する際の計算コストが高い場合はどうすべきでしょうか。暗号を使って ID のハッシュキーを作成する方法がありますが、それには数秒かかります。これは、メインスレッドで呼び出されると、UI ジャンクの原因となる可能性があります。

データレイヤーには、長時間実行中のタスクや複雑なタスクが、メインスレッドをブロックしないようにする役割があります。

これを修正するために、これらの命令の実行に使用するコルーチン ディスパッチャを指定します。

  1. まず、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,
)
  1. 次に、UUID.randomUUID().toString() への呼び出しを withContext ブロックの内部に配置します。
val taskId = withContext(dispatcher) {
    createTaskId()
}

データレイヤーのスレッドについての詳細は、こちらをご覧ください

タスクの作成と保存

  1. タスク 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 にマッピングしてから、ローカル データソースに挿入する必要があります。

  1. LocalTask の末尾に次の拡張関数を追加します。これは、前に作成した LocalTask.toExternal への逆マッピング関数です。
fun Task.toLocal() = LocalTask(
    id = id,
    title = title,
    description = description,
    isCompleted = isCompleted,
)
  1. この関数を 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)
}

これで、タスクを作成、完了するいくつかの有効なメソッドを取得したことになります。

データの同期

このアプリでは、ネットワーク データソースは、ローカルにデータの書き込みがあるたびに更新されるオンライン バックアップとして使用されます。ユーザーが更新を要求するたびに、ネットワークからデータが読み込まれます。

次の図は、操作のタイプごとに動作をまとめたものです。

オペレーションのタイプ

リポジトリ メソッド

手順

データの移動

読み込み

observeAll

ローカル データベースからデータを読み込む

ローカル データソースからタスク リポジトリへのデータフロー図 15.ローカル データソースからタスク リポジトリへのデータフローを示す図

保存

createcomplete

1. ローカル データベース 2 にデータを書き込む。ネットワークに全データをコピーし、すべてを上書きする

タスク リポジトリからローカル データソースへ、そしてネットワーク データソースへと続くデータフロー図 16. タスク リポジトリからローカル データソースへ、そしてネットワーク データソースへと続くデータフローを示す図

更新

refresh

1. ネットワーク 2 からデータを読み込む。読み込んだデータをローカル データベースにコピーし、すべてを上書きする

ネットワーク データソースからローカル データソースへ、そしてタスク リポジトリへと続くデータフロー図 17. ネットワーク データソースからローカル データソースへ、そしてタスク リポジトリへと続くデータフローを示す図

ネットワーク データの保存と更新

リポジトリは、ローカル データソースからのタスクの読み込みを終えました。同期アルゴリズムを完了させるには、ネットワーク データソースからのデータを保存し、更新するためのメソッドを作成する必要があります。

  1. まず、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)

ここでは、データソースごとにモデルを分けることの利点が示されています。つまり、あるデータ型から別のデータ型へのマッピングが個別の関数にカプセル化されているのです。

  1. DefaultTaskRepository の末尾に refresh メソッドを追加します。
suspend fun refresh() {
    val networkTasks = networkDataSource.loadTasks()
    localDataSource.deleteAll()
    val localTasks = withContext(dispatcher) {
        networkTasks.toLocal()
    }
    localDataSource.upsertAll(networkTasks.toLocal())
}

これにより、すべてのローカルタスクがネットワークからのタスクに置き換えられます。タスク数が不明で、マッピング操作ごとに計算コストが高くなる可能性があるため、toLocal の一括操作には withContext が使用されます。

  1. DefaultTaskRepository の末尾に saveTasksToNetwork メソッドを追加します。
private suspend fun saveTasksToNetwork() {
    val localTasks = localDataSource.observeAll().first()
    val networkTasks = withContext(dispatcher) {
        localTasks.toNetwork()
    }
    networkDataSource.saveTasks(networkTasks)
}

これにより、すべてのネットワーク タスクがローカル データソースからのタスクに置き換えられます。

  1. ここで、既存のメソッドを更新し、ローカルデータが変更されたときにはネットワークに保存されるように、タスク create とタスク complete を更新します。
    suspend fun create(title: String, description: String): String {
        ...
        saveTasksToNetwork()
        return taskId
    }

     suspend fun complete(taskId: String) {
        localDataSource.updateCompleted(taskId, true)
        saveTasksToNetwork()
    }

呼び出し元の待機を回避する

このコードを実行すると、saveTasksToNetwork がブロックしていることに気付くでしょう。つまり、createcomplete の呼び出し元は、操作が完了したことを確認できないまま、データがネットワークに保存されるまで待機することになります。シミュレートされたネットワーク・データソースでは、待つのは 2 秒だけですが、実際のアプリではもっと長くかかるかもしれません。あるいはネットワークに接続していなければ、まったく待機が発生しない可能性もあります。

この制限は無用であり、ユーザー エクスペリエンスの低下につながりかねません。特に忙しいときには、タスクの作成に時間をとられたくないものです。

これを効果的に解決する策として、ネットワークにデータを保存するのに別のコルーチン・スコープを使用するという方法があります。これにより、呼び出し元を待機させることなく、バックグラウンドで操作を完了させることができます。

  1. DefaultTaskRepository に、パラメータとしてコルーチン・スコープを追加します。
class DefaultTaskRepository @Inject constructor(
    // ...other parameters...
    @ApplicationScope private val scope: CoroutineScope,
)

アプリのライフサイクルに沿ったスコープを挿入するために、Hilt 修飾子 @ApplicationScopedi/CoroutinesModule.ktで定義)を使用します。

  1. 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)をインスタンス化する必要があります。まず、それらの依存関係を作成しましょう。

  1. プロジェクト エクスプローラ ウィンドウで、(test) フォルダを展開し、それから source.local フォルダを展開して、FakeTaskDao.kt. を開きます。

プロジェクト フォルダ構造内の FakeTaskDao.kt ファイル

図 18. プロジェクト フォルダ構造内の FakeTaskDao.kt を示すスクリーンショット

  1. 次の内容を追加します。
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 の目的上、直接これを使用します。

  1. DefaultTaskRepositoryTest に以下を追加します。

すべてのテストで使用するメイン ディスパッチャを設定するルール

いくつかのテストデータ

ローカル データソース、ネットワーク データソースのテスト依存関係

テスト対象: DefaultTaskRepository

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

タスク ストリームは、observeAll を使用してリポジトリから取得します

Then

タスク ストリームの最初のアイテムは、ローカル データソースのタスクの外部表現と一致します

  • 次の内容で、observeAll_exposesLocalData という名前のテストを作成します。
@Test
fun observeAll_exposesLocalData() = runTest {
    val tasks = taskRepository.observeAll().first()
    assertEquals(localTasks.toExternal(), tasks)
}

first 関数を使用して、タスク ストリームから最初のアイテムを取得します。

テストデータの更新

次は、タスクが作成され、ネットワーク データソースに保存されることを確認するテストを記述します。

Given

空のデータベース

When

create を呼び出してタスクを作成します

Then

ローカル データソースとネットワーク データソース、どちらにもタスクが作成されます

  1. 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

complete を呼び出してタスクを完了させます

Then

ローカルデータとネットワーク データも更新されます

  1. 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

refresh が呼び出されます

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 から始めます。これは、アプリの最初の画面、つまり現在アクティブな全タスクのリストを表示するためのビューモデルです。

  1. このクラスを開き、DefaultTaskRepository をコンストラクタ パラメータとして追加します。
@HiltViewModel
class TasksViewModel @Inject constructor(
    private val savedStateHandle: SavedStateHandle,
    private val taskRepository: DefaultTaskRepository,
) : ViewModel () { /* ... */ }
  1. リポジトリを使用して tasksStream 変数を初期化します。
private val tasksStream = taskRepository.observeAll()

これでビューモデルは、リポジトリが提供する全タスクにアクセスし、データが変更されるたびに、新たなタスクのリストを受け取ることになりました(使用するコードは、わずか 1 行です)。

  1. あとは、ユーザーのアクションをリポジトリ内の対応するメソッドに接続するだけです。complete メソッドを探し、次のように更新します。
fun complete(task: Task, completed: Boolean) {
    viewModelScope.launch {
        if (completed) {
            taskRepository.complete(task.id)
            showSnackbarMessage(R.string.task_marked_complete)
        } else {
            ...
       }
    }
}
  1. refresh でも同様にします。
    fun refresh() {
        _isLoading.value = true
        viewModelScope.launch {
            taskRepository.refresh()
            _isLoading.value = false
        }
    }

タスク追加画面のビューモデルの更新

  1. 上記の手順と同様に、AddEditTaskViewModel を開き、DefaultTaskRepository をコンストラクタ パラメータとして追加します。
class AddEditTaskViewModel @Inject constructor(
    savedStateHandle: SavedStateHandle,
    private val taskRepository: DefaultTaskRepository,
)
  1. create メソッドを次のように更新します。
    private fun createNewTask() = viewModelScope.launch {
        taskRepository.create(uiState.value.title, uiState.value.description)
        _uiState.update {
            it.copy(isTaskSaved = true)
        }
    }

アプリの実行

  1. いよいよアプリを実行するときが来ました。画面には、You have no tasks!と表示されているはずです。

タスクがないときのアプリのタスク画面

図 19. タスクがないときのアプリのタスク画面を示すスクリーンショット

  1. 右上の 3 つのドットをタップして、[Refresh] を押します。

操作メニューが表示されたアプリのタスク画面

図 20. 操作メニューが表示されたアプリのタスク画面のスクリーンショット

読み込みスピナーが 2 秒間表示されてから、前に追加したテストタスクが表示されるはずです。

2 つのタスクが表示されたアプリのタスク画面

図 21. 2 つのタスクが表示されたアプリのタスク画面のスクリーンショット

  1. 右下隅のプラス記号をタップして、新たなタスクを追加します。タイトルと説明のフィールドを完成させます。

アプリのタスク追加画面

図 22. アプリのタスク追加画面のスクリーンショット

  1. 右下のチェックボタンをタップして、タスクを保存します。

タスク追加後のアプリのタスク画面

図 23. タスク追加後のアプリのタスク画面のスクリーンショット

  1. タスクの完了を示すために、タスク横のチェックボックスを選択します。

タスクの完了を示すアプリのタスク画面

図 24. タスクの完了を示すアプリのタスク画面のスクリーンショット

10. 完了

アプリのデータレイヤーが正常に作成されました。

データレイヤーは、アプリケーション アーキテクチャの必須部分を形成しています。他のレイヤー構築の基盤となるものであり、これを適切に行うことで、ユーザーやビジネスのニーズに対応できます。

学んだ内容

  • Android アプリ アーキテクチャにおけるデータレイヤーの役割
  • データソースとモデルの作成方法
  • リポジトリの役割と、データを公開し、1 回限りのメソッドを提供して更新する方法
  • コルーチン ディスパッチャを変更するタイミングとそれが重要である理由
  • 複数のデータソースを使用したデータの同期
  • 一般的なデータレイヤー クラスの単体テストとインストルメンテーション テストの作成方法

さらなるチャレンジ

さらなるチャレンジを試みるのであれば、次の機能を実装してください。

  • 完了としてマークしたタスクを再度有効にする
  • タスクのタイトルと説明をタップして編集する

指示はありません。ご自身で作業してみてください。行き詰まったときには、main ブランチでアプリの完全版を確認できます。

git checkout main

次のステップ

データレイヤーについての詳細は、公式ドキュメントとオフラインファースト アプリのガイドをご確認ください。UI レイヤードメイン レイヤーなど、他のアーキテクチャ レイヤーについても学ぶことができます。

より複雑で実用的なサンプルに関しては、Now in Android アプリでご覧ください。