Proto DataStore を使用する

DataStore とは

DataStore は、SharedPreferences に代わるものとして改善された新しいデータ ストレージ ソリューションです。Kotlin のコルーチンと Flow に基づいて構築された DataStore には、次の 2 種類の実装があります。型付きオブジェクトを保存する Proto DataStoreプロトコル バッファによってサポートされます)と、Key-Value ペアを保存する Preferences DataStore です。一貫して非同期でトランザクションとしてデータが保存され、SharedPreferences の欠点の一部が解消されます。

学習内容

  • DataStore の概要と使用すべき理由。
  • DataStore をプロジェクトに追加する方法。
  • Preferences DataStore と Proto DataStore の違い、およびそれぞれの利点。
  • Proto DataStore の使用方法。
  • SharedPreferences から Proto DataStore に移行する方法。

作業内容

この Codelab ではまず、完了ステータスによるフィルタリングと優先度および期限による並べ替えができるタスクのリストを表示する、サンプルアプリを扱います。

fcb2ffa4e6b77f33.gif

[Show completed tasks] フィルタのブール値フラグはメモリに保存されます。並べ替え順序は、SharedPreferences オブジェクトを使用してディスクに引き継がれます。

DataStore には Preferences DataStore と Proto DataStore という 2 種類の実装があるので、それぞれの実装で Proto DataStore を使用して次のタスクを行う方法を学習します。

  • 完了ステータスのフィルタを DataStore に引き継ぐ。
  • 並べ替え順序を SharedPreferences から DataStore に移行する。

両者の違いをより深く理解するために、Preferences DataStore Codelab も使用してみることをおすすめします。

必要なもの

アーキテクチャ コンポーネントの概要については、Room と View の Codelab をご覧ください。Flow の概要については、Kotlin Flow と LiveData による高度なコルーチンの Codelab をご覧ください。

このステップでは、Codelab 全体のコードをダウンロードし、その後、簡単なサンプルアプリを実行します。

できるだけ迅速に開始していただけるよう、たたき台として利用できるスターター プロジェクトをご用意しました。

git がインストールされている場合は、以下のコマンドをそのまま実行できます。git がインストールされているかどうかを確認するには、ターミナルまたはコマンドラインで「git --version」と入力し、正しく実行されることを確認します。

 git clone https://github.com/googlecodelabs/android-datastore

初期状態は master ブランチにあります。ソリューションのコードは proto_datastore ブランチにあります。

git がない場合は、次のボタンをクリックして、この Codelab のすべてのコードをダウンロードできます。

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

  1. コードを解凍し、Android Studio バージョン 3.6 以降でプロジェクトを開きます。
  2. デバイスまたはエミュレータでアプリ実行構成を実行します。

b3c0dfdb92dfed77.png

アプリが実行され、タスクのリストが表示されます。

16eb4ceb800bf131.png

このアプリで、タスクのリストを表示できます。各タスクには、名前、完了ステータス、優先度、期限というプロパティがあります。

作業に必要なコードを簡素化するため、アプリで実施可能な作業は次の 2 つのみとします。

  • 完了したタスクの公開設定を切り替える - デフォルトでは、タスクは非表示になります
  • タスクを優先度、期限、または期限および優先度で並べ替える

アプリは、アプリ アーキテクチャ ガイドで推奨されているアーキテクチャに沿って実行されます。各パッケージの内容を以下に示します。

data

  • Task モデルクラス
  • TasksRepository クラス - タスクを提供します。わかりやすくするために、ハードコードされたデータを返し、それを Flow を介して公開することで、より現実的なシナリオを再現しています。
  • UserPreferencesRepository クラス - enum として定義された SortOrder を保持します。列挙値名に基づいて、現在の並べ替え順が String として SharedPreferences に保存されます。これにより、並べ替え順を保存および取得する同期メソッドが公開されます。

ui

  • RecyclerView を使用した Activity の表示に関するクラス。
  • TasksViewModel クラスは、UI ロジックに関与しています。

TasksViewModel - UI に表示される必要があるデータの構築に必要なすべての要素(タスクのリスト、完了の表示、並べ替え順のフラグ)を保持し、TasksUiModel オブジェクトにラップされます。いずれかの値が変更されるたびに、新しい TasksUiModel を構築し直す必要があります。そのために、次の 3 つの要素を組み合わせます。

  • Flow<List<Task>>TasksRepository から取得されます。
  • 最新の完了の表示フラグを保持する MutableStateFlow<Boolean>。これは、メモリにのみ保持されます。
  • 最新の SortOrder 値を保持する MutableStateFlow<SortOrder>

UI の正しい更新を確実に実施するために、アクティビティの開始時にのみ LiveData<TasksUiModel> を公開します。

コードにはいくつかの問題があります。

  • UserPreferencesRepository.sortOrder を初期化すると、ディスク IO の UI スレッドがブロックされます。その結果、UI ジャンクが発生することがあります。
  • 完了の表示フラグはメモリにのみ保持されるため、ユーザーがアプリを開くたびにリセットされます。SortOrder と同様に、これはアプリを閉じた後も効力を有するよう引き継がれる必要があります。
  • 現在はデータの引き継ぎに SharedPreferences を使用していますが、ここでは MutableStateFlow をメモリに保持することで、手動で変更し、変更の通知を受け取れるようにしています。アプリ内の他の場所で値が変更されると、これは機能しません。
  • UserPreferencesRepository では、並べ替え順序を変更する 2 つのメソッド、enableSortByDeadline()enableSortByPriority() を公開します。どちらのメソッドも現在の並べ替え順の値に依存していますが、一方が終了する前に他方が呼び出されると、最終的に誤った値になります。さらに、これらのメソッドは UI スレッドで呼び出されるので、UI ジャンクと厳格モードの違反を引き起こす可能性があります。

完了の表示フラグと並べ替え順フラグはどちらもユーザー設定ですが、現時点では 2 つの異なるオブジェクトとして表現されています。したがって、今回の目標のひとつは、これら 2 つのフラグを UserPreferences クラスの下に統合することです。

では、DataStore を使ってこうした問題に対処する方法を確認しましょう。

小規模またはシンプルなデータセットの保存が必要になる状況に、皆さんもよく遭遇することでしょう。その場合、以前は SharedPreferences を使用したかもしれませんが、この API にはいくつかの欠点もあります。Jetpack DataStore ライブラリは、このような問題の解決を目指して、よりシンプルで安全な非同期のデータ格納 API を作成します。これは、次の 2 つの実装を提供します。

  • Preferences DataStore
  • Proto DataStore

機能

SharedPreferences

PreferencesDataStore

ProtoDataStore

非同期 API

✅(リスナー経由で変更された値を読み取る場合のみ)

✅(Flow 経由)

✅(Flow 経由)

同期 API

✅(ただし、UI スレッドで安全に呼び出せない)

UI スレッドで安全に呼び出せる

❌*

✅(処理は内部で Dispatchers.IO に移動)

✅(処理は内部で Dispatchers.IO に移動)

エラーのシグナルを送信できる

ランタイム例外から保護される

❌**

強整合性保証を備えたトランザクション API がある

データの移行を処理する

✅(SharedPreferences から)

✅(SharedPreferences から)

型の安全性

✅(プロトコル バッファを使用)

  • SharedPreferences には、UI スレッドで呼び出しても安全そうに見える同期 API がありますが、実は、これはディスク I/O オペレーションを行います。さらに、apply()fsync() で UI スレッドをブロックします。fsync() 呼び出しの保留は、サービスが開始または停止するたび、およびアプリのどこかでアクティビティが開始または停止するたびにトリガーされます。UI スレッドは、apply() によってスケジュール設定された保留中の fsync() 呼び出しでブロックされ、多くの場合、ANR の発生元になります。

** SharedPreferences は、ランタイム例外として解析エラーをスローします。

Preferences DataStore と Proto DataStore

Preferences DataStore と Proto DataStore はどちらもデータを保存できますが、その方法はそれぞれ異なります。

  • Preferences DataStore は、SharedPreferences と同様に、スキーマを事前に定義することなく、キーに基づいてデータにアクセスします。
  • Proto DataStore は、プロトコル バッファを使用してスキーマを定義します。Protobuf を使用すると、厳密に型付けされたデータを保持できます。XML やその他類似のデータ形式よりも、高速で小さくシンプル、かつ具体的です。Proto DataStore では新しいシリアル化メカニズムを学ぶ必要がありますが、Proto DataStore には厳密な型付けという利点があるので、学ぶだけの価値が十分にあると Google は考えています。

Room と DataStore

部分更新、参照整合性、大規模なデータセットや複雑なデータセットが必要な場合は、DataStore の代わりに Room を使用することを検討してください。DataStore は小規模または単純なデータセットに適したものであり、部分更新や参照整合性はサポートしていません。

SharedPreferences と Preferences DataStore の欠点のひとつとして、スキーマを定義したりキーへのアクセスが正しい型によるものか確認したりする方法がないということが挙げられます。Proto DataStore では、プロトコル バッファを使用してスキーマを定義することで、この問題に対処します。Proto DataStore を使用すると、格納される型が認識され、その型のみが提供されるので、キーを使用する必要がなくなります。

Proto DataStore と Protobuf をプロジェクトに追加する方法、プロトコル バッファの概要、Proto DataStore での使用方法、SharedPreferences を DataStore に移行する方法について見ていきましょう。

依存関係を追加する

Proto DataStore を使用して Protobuf でスキーマのコードを生成するには、build.gradle ファイルにいくつかの変更を加える必要があります。

  • Protobuf プラグインを追加する
  • Protobuf と Proto DataStore の依存関係を追加する
  • Protobuf を構成する
plugins {
    ...
    id "com.google.protobuf" version "0.8.12"
}

dependencies {
    implementation  "androidx.datastore:datastore-core:1.0.0-alpha04"
    implementation  "com.google.protobuf:protobuf-javalite:3.10.0"
    ...
}

protobuf {
    protoc {
        artifact = "com.google.protobuf:protoc:3.10.0"
    }

    // Generates the java Protobuf-lite code for the Protobufs in this project. See
    // https://github.com/google/protobuf-gradle-plugin#customizing-protobuf-compilation
    // for more information.
    generateProtoTasks {
        all().each { task ->
            task.builtins {
                java {
                    option 'lite'
                }
            }
        }
    }
}

プロトコル バッファは構造化データのシリアル化を行うメカニズムです。データを構造化する方法を定義すると、コンパイラによって、構造化データを簡単に読み書きするためのソースコードが生成されます。

proto ファイルを作成する

スキーマは proto ファイルで定義します。この Codelab では、完了の表示と並べ替え順の 2 つのユーザー設定があります。これらは現時点では 2 つの異なるオブジェクトとして表現されています。したがって、今回の目標のひとつは、DataStore に格納される UserPreferences クラスの下にこの 2 つのフラグを統合することです。このクラスを Kotlin で定義するのではなく、protobuf スキーマで定義します。

構文の詳細については、Proto 言語ガイドをご覧ください。この Codelab では、必要な型のみを取り上げます。

user_prefs.proto という新しいファイルを app/src/main/proto ディレクトリに作成します。このフォルダ構造が表示されない場合は、[Project] ビューに切り替えます。protobuf では、message キーワードを使用して各構造が定義されており、構造の各メンバーは、型と名前に基づいてメッセージ内で定義され、1 から始まる順序が割り当てられます。ここでは、showCompleted というブール値を持つ UserPreferences メッセージを定義します。

syntax = "proto3";

option java_package = "com.codelab.android.datastore";
option java_multiple_files = true;

message UserPreferences {
  // filter for showing / hiding completed tasks
  bool show_completed = 1;
}

シリアライザを作成する

proto ファイルで定義したデータ型の読み書き方法を DataStore に指示するには、シリアライザを実装する必要があります。シリアライザでは、ディスク上にデータがない場合に返されるデフォルト値も定義されます。UserPreferencesSerializer という新しいファイルを data パッケージ内に作成します。

object UserPreferencesSerializer : Serializer<UserPreferences> {
    override val defaultValue: UserPreferences = UserPreferences.getDefaultInstance()
    override fun readFrom(input: InputStream): UserPreferences {
        try {
            return UserPreferences.parseFrom(input)
        } catch (exception: InvalidProtocolBufferException) {
            throw CorruptionException("Cannot read proto.", exception)
        }
    }

    override fun writeTo(t: UserPreferences, output: OutputStream) = t.writeTo(output)
}

DataStore を作成する

完了の表示フラグは、メモリ内に TasksViewModel で保持されます。Context.createDataStore() 拡張メソッドに基づいて、UserPreferencesRepositoryDataStore<UserPreferences> プライベート フィールドを作成してみましょう。このメソッドには 2 つの必須パラメータがあります。

  • DataStore が処理するファイルの名前。
  • DataStore で使用する型のシリアライザ。ここでは、UserPreferencesSerializer とします。
private val dataStore: DataStore<UserPreferences> =
    context.createDataStore(
        fileName = "user_prefs.pb",
        serializer = UserPreferencesSerializer)

Proto DataStore からデータを読み取る

Proto DataStore は Flow<UserPreferences> に格納されているデータを公開します。dataStore.data が割り当てられるパブリック userPreferencesFlow: Flow<UserPreferences> 値を作成しましょう。

val userPreferencesFlow: Flow<UserPreferences> = dataStore.data

データ読み取り中の例外の処理

DataStore がファイルからデータを読み取る際、データの読み取り中にエラーが発生すると IOException がスローされます。catch Flow 変換を使用することでこれに対処し、エラーのみをログに記録することができます。

private val TAG: String = "UserPreferencesRepo"

val userPreferencesFlow: Flow<UserPreferences> = dataStore.data
    .catch { exception ->
        // dataStore.data throws an IOException when an error is encountered when reading data
        if (exception is IOException) {
            Log.e(TAG, "Error reading sort order preferences.", exception)
            emit(UserPreferences.getDefaultInstance())
        } else {
            throw exception
        }
    }

Proto DataStore にデータを書き込む

DataStore では、データ書き込み用に suspend 関数 DataStore.updateData() が用意されています。ここで、UserPreferences の現在の状態をパラメータとして取得します。更新するには、設定オブジェクトをビルダーに変換し、新しい値を設定してから、新しい設定をビルドする必要があります。

updateData() は、アトミックな読み取り-書き込み-修正オペレーションでデータをトランザクションとして更新します。データがディスク内で引き継がれると、コルーチンが完了します。

UserPreferences の完了表示プロパティを更新できる suspend 関数(updateShowCompleted())を作成しましょう。この関数は、dataStore.updateData() を呼び出して新しい値を設定します。

suspend fun updateShowCompleted(completed: Boolean) {
    dataStore.updateData { preferences ->
        preferences.toBuilder().setShowCompleted(completed).build()
    }
}

この時点で、アプリのコンパイルは行われるはずですが、UserPreferencesRepository で作成したばかりの機能は使用されません。

proto に保存するデータを定義する

並べ替え順は SharedPreferences に保存されます。それを DataStore に移しましょう。そのためには、まず proto ファイル内の UserPreferences を更新して、並べ替え順も保存されるようにします。並べ替え順は enum であるため、UserPreference で定義する必要があります。enums は、Kotlin と同様に protobuf で定義されます。

列挙型の場合、デフォルト値は、列挙型の型定義でリストの最初に表示される値になります。しかし、SharedPreferences から移行するときは、取得した値がデフォルト値なのか SharedPreferences で設定済みのものなのかを把握する必要があります。そこで、SortOrder 列挙型に新しい値 UNSPECIFIED を定義し、リストの最初に表示して、これをデフォルト値にできるようにしています。

user_prefs.proto ファイルは次のようになります。

syntax = "proto3";

option java_package = "com.codelab.android.datastore";
option java_multiple_files = true;

message UserPreferences {
  // filter for showing / hiding completed tasks
  bool show_completed = 1;

  // defines tasks sorting order: no order, by deadline, by priority, by deadline and priority
  enum SortOrder {
    UNSPECIFIED = 0;
    NONE = 1;
    BY_DEADLINE = 2;
    BY_PRIORITY = 3;
    BY_DEADLINE_AND_PRIORITY = 4;
  }

  // user selected tasks sorting order
  SortOrder sort_order = 2;
}

プロジェクトをクリーンアップおよび再ビルドし、新しいフィールドを含む新しい UserPreferences オブジェクトが生成されるようにします。

proto ファイルで SortOrder が定義されたので、UserPreferencesRepository から宣言を削除できます。次を削除します。

enum class SortOrder {
    NONE,
    BY_DEADLINE,
    BY_PRIORITY,
    BY_DEADLINE_AND_PRIORITY
}

すべての場所で適切な SortOrder インポートが使用されていることを確認します。

import com.codelab.android.datastore.UserPreferences.SortOrder

TasksViewModel.filterSortTasks() では、SortOrder 型に基づいて、異なるアクションを行います。UNSPECIFIED オプションも追加したので、when(sortOrder) ステートメントのための別のケースを追加する必要があります。現在処理中のオプション以外は処理する必要がないので、他のケースでも UnsupportedOperationException をスローできます。

filterSortTasks() 関数は次のようになります。

private fun filterSortTasks(
    tasks: List<Task>,
    showCompleted: Boolean,
    sortOrder: SortOrder
): List<Task> {
    // filter the tasks
    val filteredTasks = if (showCompleted) {
        tasks
    } else {
        tasks.filter { !it.completed }
    }
    // sort the tasks
    return when (sortOrder) {
        SortOrder.UNSPECIFIED -> filteredTasks
        SortOrder.NONE -> filteredTasks
        SortOrder.BY_DEADLINE -> filteredTasks.sortedByDescending { it.deadline }
        SortOrder.BY_PRIORITY -> filteredTasks.sortedBy { it.priority }
        SortOrder.BY_DEADLINE_AND_PRIORITY -> filteredTasks.sortedWith(
            compareByDescending<Task> { it.deadline }.thenBy { it.priority }
        )
        // We shouldn't get any other values
        else -> throw UnsupportedOperationException("$sortOrder not supported")
    }
}

SharedPreferences から移行する

移行をサポートするために、DataStore は SharedPreferencesMigration クラスを定義します。それを UserPreferencesRepository で作成しましょう。migrate ブロックは、次の 2 つのパラメータを提供します。

  • SharedPreferencesView。これにより、SharedPreferences からデータを取得できます
  • UserPreferences。現在のデータ

UserPreferences オブジェクトを返す必要があります。

migrate ブロックを実装する場合、次の手順を行う必要があります。

  1. UserPreferencessortOrder 値を確認します。
  2. これが SortOrder.UNSPECIFIED の場合、SharedPreferences から値を取得する必要があります。SortOrder がない場合は、SortOrder.NONE をデフォルトとして使用できます。
  3. 並べ替え順を取得した後で、UserPreferences オブジェクトをビルダーに変換し、並べ替え順を設定してから、build() を呼び出してオブジェクトを再びビルドする必要があります。この変更は他のフィールドには影響しません。
  4. UserPreferencessortOrder 値が SortOrder.UNSPECIFIED でない場合は、移行がすでに正常に実行されているはずなので、migrate で取得した現在のデータをそのまま返すことができます。
private val sharedPrefsMigration = SharedPreferencesMigration(
    context,
    USER_PREFERENCES_NAME
) { sharedPrefs: SharedPreferencesView, currentData: UserPreferences ->
        // Define the mapping from SharedPreferences to UserPreferences
        if (currentData.sortOrder == SortOrder.UNSPECIFIED) {
            currentData.toBuilder().setSortOrder(
                SortOrder.valueOf(
                    sharedPrefs.getString(
                        SORT_ORDER_KEY,SortOrder.NONE.name)!!
                )
            ).build()
        } else {
            currentData
        }
    }

移行ロジックを定義したので、DataStore にそれを使用するよう指示する必要があります。そのためには、DataStore ビルダーを更新し、SharedPreferencesMigration のインスタンスを含む新しいリストを migrations パラメータに割り当てます。

private val dataStore: DataStore<UserPreferences> = context.createDataStore(
    fileName = "user_prefs.pb",
    serializer = UserPreferencesSerializer,
    migrations = listOf(sharedPrefsMigration)
)

DataStore に並べ替え順を保存する

enableSortByDeadline()enableSortByPriority() の呼び出し時に並べ替え順を更新するには、次の操作を行う必要があります。

  • dataStore.updateData() のラムダで、それぞれの機能を呼び出します。
  • updateData() は suspend 関数であるため、enableSortByDeadline()enableSortByPriority() も suspend 関数にする必要があります。
  • updateData() から受け取った現在の UserPreferences を使用して、新しい並べ替え順を作成します。
  • UserPreferences をビルダーに変換して更新し、新しい並べ替え順を設定してから、設定を再ビルドします。

enableSortByDeadline() の実装は次のようになります。enableSortByPriority() に関する変更はご自分で行ってください。

suspend fun enableSortByDeadline(enable: Boolean) {
    // updateData handles data transactionally, ensuring that if the sort is updated at the same
    // time from another thread, we won't have conflicts
    dataStore.updateData { preferences ->
        val currentOrder = preferences.sortOrder
        val newSortOrder =
            if (enable) {
                if (currentOrder == SortOrder.BY_PRIORITY) {
                    SortOrder.BY_DEADLINE_AND_PRIORITY
                } else {
                    SortOrder.BY_DEADLINE
                }
            } else {
                if (currentOrder == SortOrder.BY_DEADLINE_AND_PRIORITY) {
                    SortOrder.BY_PRIORITY
                } else {
                    SortOrder.NONE
                }
            }
        preferences.toBuilder().setSortOrder(newSortOrder).build()
    }
}

UserPreferencesRepository は、完了の表示フラグと並べ替え順フラグの両方を DataStore に保存して、Flow<UserPreferences> を公開します。これらが使用されるように TasksViewModel を更新しましょう。

showCompletedFlowsortOrderFlow を削除し、その代わりに userPreferencesRepository.userPreferencesFlow で初期化される userPreferencesFlow という値を作成します。

private val userPreferencesFlow = userPreferencesRepository.userPreferencesFlow

tasksUiModelFlow を作成するときに、showCompletedFlowsortOrderFlowuserPreferencesFlow に置き換えます。パラメータは必要に応じて変更してください。

filterSortTasks を呼び出すときは、userPreferencesshowCompletedsortOrder を渡します。コードの内容は次のようになります。

private val tasksUiModelFlow = combine(
        repository.tasks,
        userPreferencesFlow
    ) { tasks: List<Task>, userPreferences: UserPreferences ->
        return@combine TasksUiModel(
            tasks = filterSortTasks(
                tasks,
                userPreferences.showCompleted,
                userPreferences.sortOrder
            ),
            showCompleted = userPreferences.showCompleted,
            sortOrder = userPreferences.sortOrder
        )
    }

これで showCompletedTasks() 関数が更新され、userPreferencesRepository.updateShowCompleted() を呼び出せるようになりました。これは suspend 関数であるため、viewModelScope に新しいコルーチンを作成します。

fun showCompletedTasks(show: Boolean) {
    viewModelScope.launch {
        userPreferencesRepository.updateShowCompleted(show)
    }
}

userPreferencesRepository 関数の enableSortByDeadline()enableSortByPriority() は、suspend 関数になったため、viewModelScope で開始された新しいコルーチンでも呼び出す必要があります。

fun enableSortByDeadline(enable: Boolean) {
    viewModelScope.launch {
       userPreferencesRepository.enableSortByDeadline(enable)
    }
}

fun enableSortByPriority(enable: Boolean) {
    viewModelScope.launch {
        userPreferencesRepository.enableSortByPriority(enable)
    }
}

UserPreferencesRepository をクリーンアップする

不要になったフィールドとメソッドを削除しましょう。以下を削除できます。

  • _sortOrderFlow
  • sortOrderFlow
  • updateSortOrder()
  • private val sortOrder: SortOrder
  • private val sharedPreferences

これでアプリのコンパイルが正常に行われるようになりました。実行して、完了の表示フラグと並べ替え順フラグが正しく保存されているかを確認してみましょう。

Codelab repo の proto ブランチを確認して、変更を比較します。

これで、Proto DataStore への移行が完了しました。学習内容をおさらいしましょう。

  • SharedPreferences にはいくつかのデメリットがあります。たとえば、UI スレッドで呼び出しても安全そうに見える同期 API、エラーを伝えるメカニズムの欠如、トランザクション API の不足などです。
  • DataStore は SharedPreferences に代わるものであり、API の大部分の欠点に対処しています。
  • DataStore は、Kotlin のコルーチンと Flow を使用する完全非同期の API を備え、データの移行を処理し、データの整合性を保証して、データの破損を処理します。