はじめに
CodeLabs にDataStoreのプラクティスがあります。
それによると、ProtoBufの定義はProto languageで書く必要があり、
またその.protoファイルは app/src/main/proto
に配置する必要がありました。
更にenumを使う場合はその定義自体も.protoに書いてJava / Kotlinのコードから消すことで2重管理を回避していました。
ここで気になったのは以下の点です。
- DataStoreに関する一部のコードだけProto language で書く必要がありなんか美しくない
- Android Studioのプロジェクトツリーで表示タイプをAndroidにすると
app/src/main/proto
が見えない - Enumを.protoで定義し直すのが面倒。Enumに定数やメソッドを追加している場合どうするの?
kotlinx.serialization ではProtoBufを扱うことができ、定義はdata classを使っています。
DataStoreのデータ定義もこれに対応できれば上記の懸念が解決できると思い試してみました。
プラクティスの改造
前述のCodeLabsのプラクティスに沿って変更してみます。
結論だけ見たい方はまとめへ
##5. Proto DataStore - overview
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'
}
}
}
}
}
変更前との共通点はdatastore-coreだけです。
plugins{
...
id 'org.jetbrains.kotlin.plugin.serialization' version '1.4.10' //追加
}
android{
kotlinOptions {
freeCompilerArgs += [
"-Xopt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",
"-Xopt-in=kotlinx.serialization.ExperimentalSerializationApi" // 追加
]
}
}
dependencies {
implementation "androidx.datastore:datastore-core:1.0.0-alpha05" //最新化(2020/12/22時点)
implementation "org.jetbrains.kotlinx:kotlinx-serialization-protobuf:1.0.1" //追加
...
}
// protobuf部分は不要
##6 Defining and using protobuf objects
Create the proto file
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;
}
代わりにdata classを作って@Serializableをつけましょう。
protoBufは順番が重要なので@ProtoNumberも書いておいた方がいいです。
package com.codelab.android.datastore.data
import kotlinx.serialization.Serializable
import kotlinx.serialization.protobuf.ProtoNumber
@Serializable
data class UserPreferences(
//初期値も設定しておく
@ProtoNumber(1) val showCompleted : Boolean = false
)
Create the serializer
object UserPreferencesSerializer : Serializer<UserPreferences> {
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)
}
Serializationの書き方に変えます。読み込み失敗時に発生するのはSerializationExceptionです。
package com.codelab.android.datastore.data
import kotlinx.serialization.SerializationException
import kotlinx.serialization.decodeFromByteArray
import kotlinx.serialization.encodeToByteArray
import kotlinx.serialization.protobuf.ProtoBuf
import java.io.InputStream
import java.io.OutputStream
object UserPreferencesSerializer : androidx.datastore.core.Serializer<UserPreferences> {
override fun readFrom(input: InputStream): UserPreferences {
try {
return ProtoBuf.decodeFromByteArray(input.readBytes()) //変更
} catch (exception: SerializationException){ //変更
throw CorruptionException("Cannot read proto.", exception)
}
}
override fun writeTo(t: UserPreferences, output: OutputStream) =
output.write(ProtoBuf.encodeToByteArray(t)) //変更
override val defaultValue: UserPreferences //alpha05では defaultValueも必要なので追加
get() = UserPreferences()
}
7. Persisting data in Proto DataStore
Creating the DataStore
変更ありません。
private val dataStore: DataStore<UserPreferences> =
context.createDataStore(
fileName = "user_prefs.pb",
serializer = UserPreferencesSerializer)
Reading data from Proto DataStore
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
}
}
変更後、初期値はdata class定義時に設定しているので引数なしでインスタンス化するだけ
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()) // <-変更
} else {
throw exception
}
}
Writing data to Proto DataStore
suspend fun updateShowCompleted(completed: Boolean) {
dataStore.updateData { preferences ->
preferences.toBuilder().setShowCompleted(completed).build()
}
}
変更後、data classに toBuilderはないけど元の値をコピーして必要な箇所を変更すれば同じ。はず。
(data classの変数はvalで宣言しているためcopy時の変更しか受け付けないつもりです。)
suspend fun updateShowCompleted(completed: Boolean) {
dataStore.updateData { preferences ->
preferences.copy(showCompleted = completed) // <-変更
}
}
8. SharedPreferences to Proto DataStore
Defining the data to be saved in proto
SortOrderの対応ですがEnumに変更はなくそのまま使えます。
初期値を設定しておけばUNSPECIFIEDを生やす必要はないでしょう。
package com.codelab.android.datastore.data
import kotlinx.serialization.Serializable
import kotlinx.serialization.protobuf.ProtoNumber
@Serializable
data class UserPreferences(
//初期値も設定しておく
@ProtoNumber(1) val showCompleted: Boolean = false,
@ProtoNumber(2) val sortOrder: SortOrder = SortOrder.NONE //追加
)
Migrating from SharedPreferences
ここも対応不要です。data class 作成時に初期値を設定しているのが効いてます。
Saving the sort order to DataStore
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()
}
}
更新行だけcopyに変えます。
suspend fun enableSortByDeadline(enable: Boolean) {
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.copy(sortOrder = newSortOrder) // <-変更
}
}
9. Update TasksViewModel to use UserPreferencesRepository
ViewMode側には変更ありません。
10. 追加作業(pro-guard対応)
Androidのリリース版で動作させるにはproguardの難読化を回避する必要があります。
https://github.com/Kotlin/kotlinx.serialization#android
今回の場合だと以下の記載を追加することになります。
-keepattributes *Annotation*, InnerClasses
-dontnote kotlinx.serialization.AnnotationsKt # core serialization annotations
# kotlinx-serialization-json specific. Add this if you have java.lang.NoClassDefFoundError kotlinx.serialization.json.JsonObjectSerializer
#noinspection ShrinkerUnresolvedReference
-keepclassmembers class kotlinx.serialization.protobuf.** {
*** Companion;
}
-keepclasseswithmembers class kotlinx.serialization.protobuf.** {
kotlinx.serialization.KSerializer serializer(...);
}
# Change here com.yourcompany.yourpackage
-keep,includedescriptorclasses class com.codelab.android.datastore.data.**$$serializer { *; } # <-- change package name to your app's
-keepclassmembers class com.codelab.android.datastore.data.** { # <-- change package name to your app's
*** Companion;
}
-keepclasseswithmembers class com.codelab.android.datastore.data.** { # <-- change package name to your app's
kotlinx.serialization.KSerializer serializer(...);
}
#noinspection ShrinkerUnresolvedReference
はバグ対応です。(参考: https://issuetracker.google.com/issues/153616200)
まとめ
kotlinx.serializationを使ってもDataStoreのprotoBuf化に成功しました。
.protoを触ることなく、enumも変更しなくていいのでかなり実装が楽になります。
SharedPreferencesからDataStoreへの乗り換えが捗りますね。
変更内容は以下の通り
- dataStore用のdata classに@Serializableを設定。 @ProtoNumber設定も推奨
- Serializerはserializationの書き方に変更
- 読み取り時は変更なし/ 更新時はbuilderの代わりにcopyを使う
ポイントはSerializerがkotlinx.serialization
にもandroidx.datastore
にもあるのでインポート元を間違えないことでしょうか。
SerializableはRoomの@Entityなdata classにも適用できるので、DataStoreとRoomの中身をまとめてprotoBufでバイナリにしてバックアップするなどが簡単にできますね。