Jetpack DataStoreをProtobufではなくKotlin Serializationで使用する

17 min read読了の目安(約15400字

はじめに

この記事は、Android Advent Calendar 2020の15日目の記事です。

本記事では、AndroidX DataStoreを、Protocol Buffersを利用せず、Kotlin Serializationと組み合わせ使う方法を紹介します。

要約

  • AndroidX Proto DataStoreは、必ずしもProtocol Buffersと組み合わせて使う必要はなく、任意のオブジェクトシリアライゼーション方法と組み合わせて利用できます
  • Protocol Buffersの代わりにKotlin SerializationMoshiなどを使うと、Kotlinでデータ型・シリアライズ方法を定義することができます

AndroidX DataStoreとは

DataStoreは、2020年9月から公開が開始されたJetpackライブラリのひとつです。小規模でシンプルなデータをストレージに保存する機能を提供しており、将来的に、SharedPreferencesを置き換えることが目指されています。

この記事を書いている2020年12月15日現在で、DataStoreは1.0.0-alpha05が最新版です。この記事は1.0.0-alpha05のAPIにもとづいて記述しています。

Proto DataStoreとPreferences DataStore

DataStoreには、Proto DataStoreとPreferences DataStoreの2種類の実装があります。Proto DataStoreは、Typed DataStoreと呼ばれたりもします。

2つのDataStore実装とSharedPreferencesの比較については、Android Developers Blog: Prefer Storing Data with Jetpack DataStoreに整理されています。

なお、2つのDataStore実装という形で紹介されていますが、実際のところ、Preferences DataStoreはProto DataStoreの派生クラスです。Preferences DataStoreも、内部的には、ファイルに保存するデータの形式をProtocol Buffersで定義しており (preferences.proto)、Proto DataStoreの実装を使ってファイル入出力を行っています。

Protocol BuffersなしでProto DataStoreを使いたい

さて、ここからが本題です。

Preferences DataStoreは、SharedPreferencesと同じように汎用的なKey-Valueペア形式でデータを管理するため、どのキーでどのデータ型の値を使うかの管理は、アプリケーションコードに任されています。特定のキーの値について、書き込み時と読み取り時で違うデータ型を使ってしまうと、エラーになる可能性があり、型安全ではありません。アプリでのデータ永続化にDataStoreを使うとき、対象データが、Key-Value形式が特別適しているわけでないのなら、保存データの型を強く定義できるProto DataStoreを使おうかと考えるのが人情だと思います。

Proto DataStoreを利用するためには、DataStoreのガイドに説明されているように、Protocol Buffersでデータのスキーマを定義する必要があります。また、DataStoreのガイドでは (なぜか) 説明されていませんが、定義したデータスキーマをDataStoreで利用するためには、protocを使ってデータスキーマをJavaクラスにコンパイルする必要があり、Protocol Buffers関連のプラグイン、ライブラリをプロジェクトに追加する必要があります。詳しくは公式ページを眺めているだけではわからないJetpack DataStoreのあれこれ - Qiitaで説明されていますので、そちらを参照してください。

...なんか、ちょっと、面倒くさそうですね?Protocol Buffersではなく、Kotlinでデータを定義して使いたくなるのが人情だと思います。

Serializer実装にProtocol Buffersは必須ではない

DataStoreは、データをバイト列にシリアライズして、ファイルに保存するためのライブラリです。バイト列 (OutputStream) へのシリアライズ処理と、バイト列 (InputStream) からのシリアライズ処理は Serializer インターフェースとして抽象化されており、DataStore のインスタンス生成時に、アプリコードが Serializer の実装インスタンスを与えるように設計されています。

public object DataStoreFactory {
    public fun <T> create(
        serializer: Serializer<T>, // <-- Serializerの実装インスタンスをDataStore生成時に指定する
        corruptionHandler: ReplaceFileCorruptionHandler<T>? = null,
        migrations: List<DataMigration<T>> = listOf(),
        scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()),
        produceFile: () -> File
    ): DataStore<T> = //...
}

Serializer インターフェースの定義は以下の通りです。

public interface Serializer<T> {

    /**
     * Value to return if there is no data on disk.
     */
    public val defaultValue: T

    /**
     * Unmarshal object from stream.
     *
     * @param input the InputStream with the data to deserialize
     */
    public fun readFrom(input: InputStream): T

    /**
     *  Marshal object to a stream. Closing the provided OutputStream is a no-op.
     *
     *  @param t the data to write to output
     *  @output the OutputStream to serialize data to
     */
    public fun writeTo(t: T, output: OutputStream)
}

そして、Proto DataStoreのガイドや各種ドキュメントでは、Protocol Buffersの利用が前提であるように書かれていますが、実は、DataStoreSerializer でProtocol Buffersに依存している箇所はありません。Serializer でのシリアライズ、デシリアライズ処理にProtocol Buffersを利用する実装が説明・紹介されているだけで、他のシリアライズ方法を用いて Serializer を実装すれば、Protocol BuffersなしでDataStoreが利用できます。

DataStoreFactory のAPIリファレンスではProtocol BuffersでDataStoreを実装することが強く推奨されていますが、データ型のImmutability (不変性) が保証できるなら、必ずしも、Protocol Buffersである必要はなさそうです。

We strongly recommend using protocol buffers: https://developers.google.com/protocol-buffers/docs/javatutorial - which provides immutability guarantees, a simple API and efficient serialization.

DataStoreFactory  |  Android Developers

Kotlin SerializationでDataStoreを実装する

というところで、本記事では、Protocol Buffersの代わりにKotlin Serializationを利用してDataStoreの Serializer を実装し、DataStoreを利用してみます。

本記事で紹介するコードの完全版は kafumi/android-datastore-serialization-sample に格納してあります。

Kotlin Serializationをプロジェクトに追加する

まずは、kotlinx.serializationのREADME に従って、Kotlin Serializationのプラグイン・依存ライブラリをプロジェクトに追加します。

build.gradle
 buildscript {
     ext.kotlin_version = "1.4.21"
     repositories {
         google()
         jcenter()
     }
     dependencies {
         classpath "com.android.tools.build:gradle:4.2.0-beta02"
         classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
+        classpath "org.jetbrains.kotlin:kotlin-serialization:$kotlin_version"
     }
 }
app/build.gradle
 plugins {
     id 'com.android.application'
     id 'kotlin-android'
+    id 'kotlinx-serialization'
 }
 
 dependencies {
     // ...
+    implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.0.1'
 }

保存するデータ型を定義する

次に、ストレージに保存するデータクラスを定義します。定義したデータクラスには、Kotlin Serializationのシリアライズ対象であることを表す @Serializable アノテーションをつけておきます。

ここでは、サンプルとして、3つのプロパティを持つデータクラスを定義します。ネストされている MyEnum クラスにも、忘れずに @Serializable アノテーションをつけておきます。

MyData.kt
@Serializable
data class MyData(
    val myBooleanValue: Boolean = false,
    val myStringValue: String? = null,
    val myEnumValue: MyEnum = MyEnum.FOO,
) {
    @Serializable
    enum class MyEnum {
        @SerialName("FOO")
        FOO,

        @SerialName("BAR")
        BAR,

        @SerialName("BAZ")
        BAZ,
    }
}

Kotlin Serializationのシリアライズ定義の詳細については、Kotlin Serialization Guideをご参照ください。

Jetpack DataStoreをプロジェクトに追加する

次に、Jetpack DataStoreの依存ライブラリをプロジェクトに追加します。追加が必要なアーティファクトは DataStoreのDeveloper Guide に説明されています。

今回は、Preferences DataStoreではなくTyped DataStoreを実装に利用するので、 androidx.datastore:datastore:1.0.0-alpha05 を追加します。

app/build.gradle
 dependencies {
     // ...
+    implementation 'androidx.datastore:datastore:1.0.0-alpha05'
 }

DataStore SerializerをKotlin Serializationで実装する

さて、いよいよ、Protocol Buffersの代わりに、Kotlin Serializationを使って、DataStoreの Serializer インターフェースを実装していきます。インターフェースの概要は以下の通りです。

public interface Serializer<T> {
    public val defaultValue: T
    public fun readFrom(input: InputStream): T
    public fun writeTo(t: T, output: OutputStream)
}

保存対象である MyData を型パラメーターにした Serializer<MyData> が実装対象のインターフェースです。ここでは、MyDataSerializer と名付けて、実装クラスを定義します。

MyDataSerializer.kt
class MyDataSerializer : Serializer<MyData>

コンストラクタ

Kotlin SerializationでデータオブジェクトとJSON文字列を相互に変換するには、Json クラスを利用します。Json のインスタンスは、後で他のコードと共有したり、挙動をカスタマイズしたくなる可能性があるので、コンストラクタパラメーターなどで外部から注入できるようにしておくと便利です。

MyDataSerializer.kt
@OptIn(ExperimentalSerializationApi::class)
class MyDataSerializer(
    private val stringFormat: StringFormat = Json {
        ignoreUnknownKeys = true
        encodeDefaults = true
    },
) : Serializer<MyData> {

ここでは、保存されたJSONファイルのパース時に未知のプロパティがあっても無視するために ignoreUnknownKeys を、データのプロパティの値がデフォルト値であってもJSONに出力するために encodeDefaults プロパティを指定しています。これらの指定は必須ではありませんが、将来、データ型の定義を変更 (プロパティの削除やデフォルト値の変更) した後に、変更前のデータ型で保存されたJSONファイルを読み込む場合に備えて指定しています。

また、 StringFormat はまだExperimentalなAPIなので、@OptIn アノテーションをつけて ExperimentalSerializationApi をopt-inしています。

defaultValue プロパティ

defaultValue プロパティは、データがまだ保存されていないときに使用するデフォルト値を返すプロパティです。

    /**
     * Value to return if there is no data on disk.
     */
    public val defaultValue: T

MyData の各プロパティにはデフォルト値を定義しておいたので、MyData インスタンスをコンストラクタパラメーター省略で生成して返せばOKです。

MyDataSerializer.kt
    override val defaultValue: MyData
        get() = MyData()

writeTo(t: T, output: OutputStream) 関数

writeTo(t: T, output: OutputStream) 関数は、データをファイルに保存する際に、データオブジェクトをバイト列に変換するために呼ばれる関数です。

    /**
     *  Marshal object to a stream. Closing the provided OutputStream is a no-op.
     *
     *  @param t the data to write to output
     *  @output the OutputStream to serialize data to
     */
    public fun writeTo(t: T, output: OutputStream)

以下の手順で、MyData をバイト列に変化し、OutputStream に書き込みます。

  1. Kotlin Serializationの StringFormat.encodeToString()MyDataString に変換し
  2. kotlin-stdlibの String.encodeToByteArray()StringByteArray に変換 (UTF-8エンコーディング) し
  3. outputwrite() します
MyDataSerializer.kt
    override fun writeTo(t: MyData, output: OutputStream) {
        val string = stringFormat.encodeToString(t)
        val bytes = string.encodeToByteArray()
        output.write(bytes)
    }

readFrom(input: InputStream) 関数

readFrom(input: InputStream) 関数 は、ファイルから読み込んだバイト列を、データオブジェクトに変換するときに呼ばれる関数です。

    /**
     * Unmarshal object from stream.
     *
     * @param input the InputStream with the data to deserialize
     */
    public fun readFrom(input: InputStream): T

以下の手順で InputStream からバイト列を読み出し、MyData に変換します。

  1. kotlin-stdlib の InputStream.readBytes() を使って InputStream からバイト列を読み出し
  2. kotlin-stdlib の ByteArray.decodeToString() を使って ByteArrayString に変換 (UTF-8エンコーディング) し
  3. Kotlin Serializationの StringFormat.decodeFromString()StringMyData に変換します

また、Serializer では、データフォーマットの問題でデシリアライズに失敗したことを表す CorruptionException が用意されています。Kotlin Serializationから SerializationException が投げられたときは、CorruptionException で包んで投げ直しておくと、CorruptionHandler でデータリカバリーの機会を得ることができます。

MyDataSerializer.kt
    override fun readFrom(input: InputStream): MyData {
        try {
            val bytes = input.readBytes()
            val string = bytes.decodeToString()
            return stringFormat.decodeFromString(string)
        } catch (e: SerializationException) {
            throw CorruptionException("Cannot read stored data", e)
        }
    }

以上で、MyDataSerializer の実装は完了です。完全なコードは、GitHubレポジトリの MyDataSerializer.kt を参照してください。

DataStoreのインスタンスを作成する

あとは、実装した MyDataSerializer を指定して、DataStoreのインスタンスを生成すれば、DataStoreでデータの保存を行うことができます。DataStoreは、androidx.datastore:datastore アーティファクトの Context.createDataStore() を使って生成することができます。

例えば、AndroidViewModel 内部でDataStoreを生成する場合は、以下のようになります。

MainViewModel.kt
class MainViewModel(application: Application) : AndroidViewModel(application) {
    private val dataStore = application.createDataStore(
        fileName = "my_data.json",
        serializer = MyDataSerializer(),
    )

この例の場合は、アプリのストレージ領域の中の files/datastore/my_data.json にファイルが生成され、Kotlin SerializationでJSON形式に変換されたUTF-8文字列が保存されます。

my_data.json
{"myBooleanValue":true,"myStringValue":"Sample string","myEnumValue":"BAR"}

作成したDataStoreを利用する

作成したDataStoreは、Protocol Buffersを利用して実装したDataStoreと同様に利用することができます。DataStoreからのデータの読み込みや、データの更新については、公式のガイドを参照してください。

おわりに

以上、Protocol Buffersの代わりに、Kotlin Serializationを利用して、Proto DataStoreを利用する方法を紹介しました。

Protocol Buffersのほうが、保存されるデータのサイズも、シリアライズ・デシリアライズ処理の効率もよいのでしょうが、データが非常に大きかったり、更新頻度が高かったりしないようなら、無視できる差のような気がします (要ベンチマークですが)。

DataStoreはまだアルファ版ですが、シンプルなAPIなので、APIの破滅的な変更がおこる可能性はそれほど高くないように思われます。もし、アプリプロジェクトですでに利用中のJSONライブラリがあるようなら、Protocol Buffersの代わりにそれを利用することで、導入コストやメンテナンスコストが減らせるはずですので、SharedPreferencesの置き換えを検討してみてはいかがでしょうか。