Jetpack DataStoreをProtobufではなくKotlin Serializationで使用する
はじめに
この記事は、Android Advent Calendar 2020の15日目の記事です。
本記事では、AndroidX DataStoreを、Protocol Buffersを利用せず、Kotlin Serializationと組み合わせ使う方法を紹介します。
Google公式の紹介記事 (2020-04-20 追記)
DataStoreをKotlin Serializationと組み合わせて使う方法について、Android Developersの以下の記事でも紹介されています。
要約
- AndroidX Proto DataStoreは、必ずしもProtocol Buffersと組み合わせて使う必要はなく、任意のオブジェクトシリアライゼーション方法と組み合わせて利用できます
- Protocol Buffersの代わりにKotlin SerializationやMoshiなどを使うと、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と呼ばれたりもします。
- Proto DataStore: Protocol Buffersで定義したデータ型を保存する
- Preferences DataStore: Key-Valueペア形式でデータを保存する
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の利用が前提であるように書かれていますが、実は、DataStore
や Serializer
で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.
Kotlin SerializationでDataStoreを実装する
というところで、本記事では、Protocol Buffersの代わりにKotlin Serializationを利用してDataStoreの Serializer
を実装し、DataStoreを利用してみます。
本記事で紹介するコードの完全版は kafumi/android-datastore-serialization-sample に格納してあります。
Kotlin Serializationをプロジェクトに追加する
まずは、kotlinx.serializationのREADME に従って、Kotlin Serializationのプラグイン・依存ライブラリをプロジェクトに追加します。
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"
}
}
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
アノテーションをつけておきます。
@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
を追加します。
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
と名付けて、実装クラスを定義します。
class MyDataSerializer : Serializer<MyData>
コンストラクタ
Kotlin SerializationでデータオブジェクトとJSON文字列を相互に変換するには、Json
クラスを利用します。Json
のインスタンスは、後で他のコードと共有したり、挙動をカスタマイズしたくなる可能性があるので、コンストラクタパラメーターなどで外部から注入できるようにしておくと便利です。
@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です。
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
に書き込みます。
- Kotlin Serializationの
StringFormat.encodeToString()
でMyData
をString
に変換し - kotlin-stdlibの
String.encodeToByteArray()
でString
をByteArray
に変換 (UTF-8エンコーディング) し -
output
にwrite()
します
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
に変換します。
- kotlin-stdlib の
InputStream.readBytes()
を使ってInputStream
からバイト列を読み出し - kotlin-stdlib の
ByteArray.decodeToString()
を使ってByteArray
をString
に変換 (UTF-8エンコーディング) し - Kotlin Serializationの
StringFormat.decodeFromString()
でString
をMyData
に変換します
また、Serializer
では、データフォーマットの問題でデシリアライズに失敗したことを表す CorruptionException
が用意されています。Kotlin Serializationから SerializationException
が投げられたときは、CorruptionException
で包んで投げ直しておくと、CorruptionHandler
でデータリカバリーの機会を得ることができます。
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を生成する場合は、以下のようになります。
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文字列が保存されます。
{"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の置き換えを検討してみてはいかがでしょうか。
Discussion