Sansan Tech Blog

Sansanのものづくりを支えるメンバーの技術やデザイン、プロダクトマネジメントの情報を発信

Jetpack DataStore入門〜Proto DataStore実装編〜

こんにちは!Sansan事業部 プロダクト開発部のふるしんです。

私は大阪のオフィスでSansanプロダクトのAndroidアプリの開発に従事しています。

play.google.com


この記事はJetpack DataStore入門〜Preferences DataStore実装編〜 の連載として、Proto DataStoreの実装を見ていきます。


Preferences DataStoreに関しては以前の記事を御覧ください。
buildersbox.corp-sansan.com

Jetpack DataStoreとは?

SharedPreferencesに代わるものとして提案されている、新しいデータの永続化の方法です。

公式ドキュメントによると、SharedPreferencesから置き換えることを考えてね、と書かれていました。

DataStoreは、基本的にはSharedPreferencesの欠点を補うために提案されているようです。
ですので例えば動作はKotlin CotourinesのFlowに則って動作して非同期で走ってくれたりと、他にも素敵だなと思う点がいつくかありました。
今回はそんな点を紹介できたらなと思います。

DataStoreと一言で言っても実は2種類あります。
Preferences DataStoreと、Proto DataStoreです。

本記事ではProto DataStoreに関して見ていきます。

Proto DataStore

Preferences DataStoreとの違いは「Protocol Buffersを使う」部分です。
Preferences DataStoreではほぼプリミティブ型しか格納できなかったものが、Proto DataStoreではProto Buffersを活用することで自由な型を格納できるようになります。

この `自由な型を格納できるようになる` が嬉しい人は多いのではないでしょうか。
従来のSharedPrefernecesだとプリミティブ型しか格納できなかったためにわざわざDBのテーブルを用意していたものが、サラッと格納できるようになるのは大きな利点だと思います。

では実際にどうやって使うのか、コードを追って説明していきます。

build.gradleに追加

本記事の執筆時点では 1.0.0-beta01が最新でした。
また、後述しますがlifecycleScopeも必要なので追加します。
app/build.gradle

dependencies {
    // DataStore
    implementation "androidx.datastore:datastore:1.0.0-beta01"
    implementation "androidx.datastore:datastore-core:1.0.0-beta01"

    // Protocol Buffers
    implementation 'com.google.protobuf:protobuf-lite:3.0.1'

    // lifecycleScope
    implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.3.1"
}

Protocol Buffersを使えるようにする

次に、Protocol Buffersを使えるように設定をbuild.gradleに追加します。
Protocol Buffersってなんぞ?という方はGoogleのドキュメントを御覧ください。
developers.google.com

app/build.gradle

protobuf {
    protoc {
        artifact = 'com.google.protobuf:protoc:3.0.0'
    }
    plugins {
        javalite {
            artifact = "com.google.protobuf:protoc-gen-javalite:3.0.0"
        }
    }
    generateProtoTasks {
        all().each { task ->
            task.plugins {
                javalite {}
            }
        }
    }
}

Schemaを定義

実際に格納したい型となるSchemaを定義します。
ここで定義したものが実際にクラスとして生成されるのはビルドしたタイミングです。
編集したらリビルドすることを忘れないようにしましょう。

本記事ではサンプルとして、Userを定義します。

syntax = "proto3";

option java_package = "net.furusin.www.jetpackdatastoresample";
option java_multiple_files = true;

message User {
  int32 age = 1;
  string name = 2;
}

Serializerを準備

「Schemaを定義」で定義したものを使ってDataStoreへ書き込み/読み取りできるようにするための `Serializer` を定義します。
Serializerとは、定義した型をどのように格納するのか、取得するのかを定義するものとなります。

private const val DATA_STORE_FILE_NAME = "users.pb"
object UserSerializer : Serializer<User> {
    override val defaultValue: User = User.getDefaultInstance()

    // データの読み取り
    override suspend fun readFrom(input: InputStream): User {
        try {
            return User.parseFrom(input)
        } catch (exception: InvalidProtocolBufferException) {
            throw CorruptionException("Cannot read proto.", exception)
        }
    }

    // データの書き込み
    override suspend fun writeTo(t: User, output: OutputStream) = t.writeTo(output)

    // UserのDataStoreのインスタンスを生成するための拡張関数
    val Context.userDataStore: DataStore<User> by dataStore(
        fileName = DATA_STORE_FILE_NAME,
        serializer = UserSerializer
    )
}

readFromが読み取り, writeToメソッドが書き込みの方法をそれぞれ定義しています。*1

公式ドキュメントのサンプルではContextの拡張関数としてDataStoreのインスタンスを生成するようになっていましたのでここでもそうしてみました。

「Contextに拡張関数を生やすの…?」と少し疑問に思いましたが、 dataStoreメソッドの先で呼んでいるDataStoreSingletonDelegateでContextが必要なので、Contextに生やすのは適切なようです。

実際に使ってみる

それでは実際に呼び出すところを見ていきましょう。
SerializerのところでContextに拡張関数を生やしたので、Activityから触ってみます。

DataStoreインスタンスを用意する

まずはDataStoreのインスタンスを用意します。

class MainActivity : AppCompatActivity() {
    private lateinit var dataStore: DataStore<User>
    override fun onCreate(savedInstanceState: Bundle?) {
        // ...
        dataStore = userDataStore
    }

このdataStoreを使って、データの書き込み/読み取りを行います。
dataStore = userDataStore
だけで済んでいるのは、先ほどContextの拡張関数を作ったおかげです。

データを書き込む

まずは書き込む方法です。

    private suspend fun setUserAge(age: Int) {
        dataStore.updateData { currentUser: User ->
            currentUser.toBuilder()
                .setAge(age)
                .build()
        }
    }

データを書き込む際は、DataStoreの `updateData` メソッドを使います。
最初に用意したSchemaでsetterが用意されるので活用します。

データを読み取る

次にデータを読み取る方法です。

GlobalScope.launch {
    dataStore.data.collect { Log.d("test", "age = ${it.age}, name = ${it.name}") }
}

DataStore インターフェースが持つ変数のdataはFlowになっています。
collectで格納されているデータが取得可能です。

簡単なまとめ

サンプルのActivityの全体はこうなりました

class MainActivity : AppCompatActivity() {
    private lateinit var dataStore: DataStore<User>
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        dataStore = userDataStore

        findViewById<Button>(R.id.saveAgeButton).setOnClickListener {
            lifecycleScope.launch {
                setUserAge(20)
            }
        }
        
        findViewById<Button>(R.id.loadButton).setOnClickListener {
            lifecycleScope.launch {
                dataStore.data.collect { Log.d("test", "age = ${it.age}, name = ${it.name}") }
            }
        }
    }

    private suspend fun setUserAge(age: Int) {
        dataStore.updateData { currentUser: User ->
            currentUser.toBuilder()
                .setAge(age)
                .build()
        }
    }
}

とても簡単にデータの書き込み/読み取りができました。

最後に

Sansanでは一緒に働く仲間を募集しています。

私は大阪におりますので、興味が湧きましたらぜひお声がけください!
media.sansan-engineering.com

*1:以前の私の登壇資料ではSuspend関数ではなかったのですが、alphaから更新のタイミングでSuspend関数になったようです。素晴らしいですね

© Sansan, Inc.