5
2

More than 3 years have passed since last update.

DataStoreのProtoBufの定義をdata classに書けると嬉しい

Last updated at Posted at 2020-11-11

はじめに

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ファイルは必要ありません。

app/src/main/proto/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;
}

代わりにdata classを作って@Serializableをつけましょう。
protoBufは順番が重要なので@ProtoNumberも書いておいた方がいいです。

UserPreferences.kt(変更後-新規作成)
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

UserPreferencesSerializer.kt(変更前)
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です。

UserPreferencesSerializer.kt(変更後)
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

変更ありません。

UserPreferencesRepository(変更なし)
private val dataStore: DataStore<UserPreferences> =
    context.createDataStore(
        fileName = "user_prefs.pb",
        serializer = UserPreferencesSerializer)

Reading data from Proto DataStore

UserPreferencesRepository(変更前)
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定義時に設定しているので引数なしでインスタンス化するだけ

UserPreferencesRepository(変更後)
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

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

変更後、data classに toBuilderはないけど元の値をコピーして必要な箇所を変更すれば同じ。はず。
(data classの変数はvalで宣言しているためcopy時の変更しか受け付けないつもりです。)

UserPreferencesRepository(変更後)
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を生やす必要はないでしょう。

UserPreferences.kt
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

UserPreferencesRepository(変更前)
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に変えます。

UserPreferencesRepository(変更後)
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
今回の場合だと以下の記載を追加することになります。

proguard-rules.pro
-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への乗り換えが捗りますね。

変更内容は以下の通り
1. dataStore用のdata classに@Serializableを設定。 @ProtoNumber設定も推奨
2. Serializerはserializationの書き方に変更
3. 読み取り時は変更なし/ 更新時はbuilderの代わりにcopyを使う

ポイントはSerializerがkotlinx.serializationにもandroidx.datastoreにもあるのでインポート元を間違えないことでしょうか。
SerializableはRoomの@Entityなdata classにも適用できるので、DataStoreとRoomの中身をまとめてprotoBufでバイナリにしてバックアップするなどが簡単にできますね。

5
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
2