🏪

SharedPreferencesをProto DataStoreに移行する

2023/06/18に公開

導入のきっかけ

5 年半ほど運用している個人アプリで SharedPreferences で管理しているデータを Proto DataStore へ移行しました。
それまで、SharedPreference の扱いをしやすくしている、KVS schemaというライブラリを使っていました。そちらのライブラリ更新が止まっているようだったのと、SharedPreferencesからDataStoreへの移行の検討を促されていたこと、また、Proto DataStoreであればタイプセーフである恩恵を受けられることから、Proto DataStoreへ移行することにしました。

DataStore

DataStore ライブラリは Jetpack で提供されています。
https://developer.android.com/topic/libraries/architecture/datastore?hl=ja

Jetpack DataStore は、プロトコル バッファを使用して Key-Value ペアや型付きオブジェクトを格納できるデータ ストレージ ソリューションです。DataStore は、Kotlin コルーチンと Flow を使用して、データを非同期的に、一貫した形で、トランザクションとして保存します。
現在 SharedPreferences を使用してデータを保存している場合は、DataStore に移行することを検討してください。

DataStore には Preference DataStore と Proto DataStore の 2 種類があります。

  • Preference DataStore : SharedPreferences のように Key-Value 形式で値を保存。タイプセーフでない
  • Proto DataStore : カスタムデータ型のインスタンスとしてデータを保存。プロトコルバッファを使用する場合、スキーマ定義が必要。タイプセーフ。
    ※ プロトコルバッファの使用は必須ではなく、Kotlin Serialization を使って利用することもできるとのことです。
    (参考)https://zenn.dev/kafumi/articles/377bca464613f3

Proto DataStore へ移行する

https://developer.android.com/topic/libraries/architecture/datastore?hl=ja#proto-datastore
Proto DataStore 利用に関する公式ドキュメントです。

https://developer.android.com/codelabs/android-proto-datastore?hl=ja#0
移行に関してはこちらの Codelab が参考になります。

使用したライブラリバージョン

androidx.datastore:datastore:1.0.0
com.google.protobuf:protoc:3.22.4

移行前の SharedPreference 定義

NotificationPrefsSchema.kt
@Table(name = "notification")
abstract class NotificationPrefsSchema {
    @Key(name = "enable_notification")
    val enableNotification: Boolean = false
    @Key(name = "selected_volume")
    val selectedVolume: String = VolumeType.MEDIA.name
}

移行前の KVS schema を利用した SharedPreference の定義です。

  • "notification" : 生成される SharedPreferences ファイル名
    → /data/data/{アプリパッケージ名}/shared_prefs/notification.xml
  • "enable_notification" : 通知機能の ON/OFF を表す Boolean 値
  • "selected_volume" : 選択した音量タイプを表す String 値

Proto DataStore : セットアップ

build.gradle
dependencies {
    implementation "androidx.datastore:datastore:1.0.0"
}

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

    generateProtoTasks {
        all().each { task ->
            task.builtins {
                java {
                    option 'lite'
                }
            }
        }
    }
}

androidx.datastore:datastore の依存追加と、プロトコルバッファを使うための記述をします。

Proto DataStore : スキーマ定義


app/src/main/proto ディレクトリに、.proto ファイルを作成します。

notification_store.proto
syntax = "proto3";

option java_package = "アプリパッケージ名";
option java_multiple_files = true;

message NotificationStore {
  bool enable_notification = 1;
  string volumeType = 2;
}

こちらが、移行前のデータ構造と同じ形のスキーマ定義をした proto ファイルです。ビルドすると、この定義を元にした Java クラス(NotificationStore.java)が生成されます。

Proto DataStore : Serializer クラスを作成

データ型の読み取り/書き込み方法を DataStore に指示する、Serializer クラスを作成します。

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

    override suspend fun writeTo(t: NotificationStore, output: OutputStream) {
        t.writeTo(output)
    }
}

Proto DataStore : DataStore インスタンスの生成

private const val NOTIFICATION_DATA_STORE_FILE_NAME = "notification_store.pb"

val Context.notificationDataStore: DataStore<NotificationStore> by dataStore(
    fileName = NOTIFICATION_DATA_STORE_FILE_NAME,
    serializer = NotificationStoreSerializer,
)

Context の拡張プロパティとしてdataStoreデリゲートを使用して DataStore インスタンスを生成します。生成した DataStore を使用クラス(Repository など)に DI し、使用するケースが多いかなと思います。
SharedPreferences からのマイグレーションもdataStoreデリゲートの引数で行うようにしますが、後ほど説明します。

Proto DataStore : 読み取り/書き込み

NotificationRepository.kt
class NotificationRepository(private val notificationDataStore: DataStore<NotificationStore>) {
    
    suspend fun isEnableNotification(): Boolean {
        return notificationDataStore.data.map { it.enableNotification }.first()
    }
    
    suspend fun setEnableNotification(enabled: Boolean) {
        notificationDataStore.updateData { currentNotificationStore ->
            currentNotificationStore.toBuilder()
                .setEnableNotification(enabled)
                .build()
        }
    }
}

上記手順によって作成されたDataStore<NotificationStore>を使って読み取り/書き込みを行います。

読み取りは、DataStore<T>.dataでFlow<T>を取得できます。戻り値がFlowだけなので、移行前のように戻り値で値を取得する場合、suspendを付けてfirst()で取得する形になるかと思います。

書き込みは、updateDataメソッドの引数に現在の値を持っているDataStore<T>が渡ってくるので、toBuilder()でBuilderに変換し、自動生成された書き込みメソッドで書き込みを行います。
こちらもupdateDataがsuspend関数なので、呼び出すメソッドもsuspendになります。

Proto DataStore : SharedPreferencesからの移行

private const val NOTIFICATION_PREFERENCES_NAME = "notification"
private const val NOTIFICATION_DATA_STORE_FILE_NAME = "notification_store.pb"

val Context.notificationDataStore: DataStore<NotificationStore> by dataStore(
    fileName = NOTIFICATION_DATA_STORE_FILE_NAME,
    serializer = NotificationStoreSerializer,
    produceMigrations = { context ->
        listOf(
            SharedPreferencesMigration(
                context,
                NOTIFICATION_PREFERENCES_NAME
            ) { sharedPrefs: SharedPreferencesView, currentData: NotificationStore ->
                currentData.toBuilder()
                    .setEnableNotification(sharedPrefs.getBoolean("enable_notification", false))
                    .setVolumeType(sharedPrefs.getString("selected_volume", VolumeType.MEDIA.name))
                    .build()
            }
        )
    }
)

DataStore インスタンスの生成箇所で使用した、dataStoreデリゲートのproduceMigrationsに移行処理を書きます。

SharedPreferenceMigrationに移行前のファイル名を渡すことで、そのSharedPreferencesの値を取得できるSharedPreferencesViewと、書き込みができるDataStore<T>が渡ってくるので、移行前のKey名でSharedPreferencesViewから値を取得し、Proto DataStoreに書き込みを行います。

SharedPreferenceMigrationの引数、keysToMigrateでどのKeyを移行するかの指定ができます。何も指定しない場合、keysToMigrate: Set<String> = MIGRATE_ALL_KEYSとなりすべてのKeyが移行対象となります。
その場合、一度移行処理が走ったあとはSharedPreferencesのファイル自体削除されるためKeyが多い場合や移行を慎重に行う必要がある場合は注意してください。

おわりに

以上で、SharedPreferences(+それを利用したライブラリ)から、Proto DataStoreへ移行する手順を紹介しました。Proto DataStoreではPreference DataStoreに比べやや作成するファイルが多いと感じますが、ファイルの中身自体はそこまで実装が大変なものでもないのかなと思います。

参考

DataStore
Proto DataStore を使用する
Jetpack DataStoreをProtobufではなくKotlin Serializationで使用する
Jetpack DataStore入門〜Proto DataStore実装編〜

Discussion