Android Native - persist state with Proto DataStore

dimitrilc 2 Tallied Votes 101 Views Share

Introduction

Proto DataStore is a great way to store the permanent state of your application, especially if you prefer type safety. In this tutorial, we will learn how to store our App state using the Proto DataStore.

Goals

At the end of the tutorial, you would have learned:

  1. How to store App state using Proto DataStore.

Tools Required

  1. Android Studio. The version used in this tutorial is Arctic Fox 2020.3.1 Patch 4.

Prerequisite Knowledge

  1. Intermediate Android.
  2. protobuf3. If you are not familiar with protobuf, you can check out the syntax here.
  3. Lifecycle-aware coroutine scopes.
  4. Java Serialization.

Project Setup

To follow along with the tutorial, perform the steps below:

  1. Create a new Android project with the default Empty Activity.

  2. Remove the default “Hello World!” TextView.

  3. Add the Gradle dependency below into your project build.gradle.

     implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.4.0"
     implementation "androidx.datastore:datastore:1.0.0"
     implementation “com.google.protobuf:protobuf-javalite:3.19.3”
  4. Add the Google protobuf plugin to your project build.gradle.

     id "com.google.protobuf" version "0.8.18"
  5. Append the configuration below to the end of your project build.gradle.

     protobuf {
         protoc {
             artifact = 'com.google.protobuf:protoc:3.8.0'
         }
         generateProtoTasks {
             all().each { task ->
                 task.builtins {
                     java {
                         option "lite"
                     }
                 }
             }
         }
     }
  6. If you are not sure what these steps do, the instructions for setting up protobuf in your Android project can also be found in the official docs here.

  7. Add the code below inside of <ConstraintLayout>. This will add four checkboxes.

     <CheckBox
        android:id="@+id/checkBox1"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="@string/checkbox1"
        app:layout_constraintBottom_toTopOf="@+id/checkBox2"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />
    
     <CheckBox
        android:id="@+id/checkBox2"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="@string/checkbox2"
        app:layout_constraintBottom_toTopOf="@+id/checkBox3"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/checkBox1" />
    
     <CheckBox
        android:id="@+id/checkBox3"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="@string/checkbox3"
        app:layout_constraintBottom_toTopOf="@+id/checkBox4"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/checkBox2" />
    
     <CheckBox
        android:id="@+id/checkBox4"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="@string/checkbox4"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/checkBox3" />
  8. Add the string resources below into strings.xml.

     <string name="checkbox1">CheckBox 1</string>
     <string name="checkbox2">CheckBox 2</string>
     <string name="checkbox3">CheckBox 3</string>
     <string name="checkbox4">CheckBox 4</string>

Project Overview

Our sample app for this tutorial is quite simple. It has one activity and four checkboxes.

1.png

Currently, the app is unable to remember which checkbox is checked after the app is killed. For the app to be able to save its state after it has been killed, we can employ the help of the Proto DataStore.

The Proto DataStore is not the only way to persist app states, but is merely one of the many methods to achieve this goal.

Protobuf schema

Similar to how you would define JPA Entities, Proto DataStore requires a schema file declared using the protobuf language. We are using latest version of protobuf (protobuf3) for this tutorial.

Follow the steps below to create a schema for our App.

  1. Under the Project panel of Android Studio, switches from Android to Project view.

  2. Navigate to app/src/main.

  3. Right-click on main > New > Directory, name the new directory proto.

  4. Right-click on the newly created directory proto > New > File, name the new file CheckBoxStates.proto. You might need to install the official Protocol Buffers (from Jetbrains) plugin for Android Studio here for the protobuf3 syntax highlighting to show.

  5. Paste the code below into CheckboxStates.proto.

     syntax = "proto3";
    
     option java_package = "com.example.daniwebprotobufdatastore";
     option java_multiple_files = true;
    
     message CheckboxStates {
      bool check_box_1 = 1;
      bool check_box_2 = 2;
      bool check_box_3 = 3;
      bool check_box_4 = 4;
     }
  6. Rebuild your project.

Creating a Serializer

Now that we have our schema, the next step is to create a class that can serialize the protobuf data into POJOs and vice-versa.

  1. Create a new class called CheckboxStatesSerializer.kt in the same package as MainActivity.kt.

  2. Copy and paste the code below into it. This is the class that serialize/de-serialize your CheckboxStates POJO as well as providing a default instance of CheckboxStates.

     package com.example.daniwebprotobufdatastore
    
     import android.content.Context
     import androidx.datastore.core.CorruptionException
     import androidx.datastore.core.DataStore
     import androidx.datastore.core.Serializer
     import androidx.datastore.dataStore
     import com.google.protobuf.InvalidProtocolBufferException
     import java.io.InputStream
     import java.io.OutputStream
    
     object CheckboxStatesSerializer: Serializer<CheckboxStates> {
        override val defaultValue: CheckboxStates
            get() = CheckboxStates.getDefaultInstance()
    
        override suspend fun readFrom(input: InputStream): CheckboxStates {
            try {
                return CheckboxStates.parseFrom(input)
            } catch (exception: InvalidProtocolBufferException) {
                throw CorruptionException("Cannot read proto.", exception)
            }
        }
    
        override suspend fun writeTo(t: CheckboxStates, output: OutputStream) {
            t.writeTo(output)
        }
     }
    
     val Context.checkboxStatesDataStore: DataStore<CheckboxStates> by dataStore(
        fileName = "checkbox_states.pb",
        serializer = CheckboxStatesSerializer
     )

The checkboxStatesDataStore extension property with the Context receiver exposes an instance of DataStore for us to use.

Saving State

Using the DataStore extension property of Context, we can read and write to the DataStore. Follow the steps below to save checkbox states.

  1. In MainActivity.kt, add the saveCheckboxStates() function below.

     private suspend fun saveCheckboxStates() {
        val checkbox1 = findViewById<CheckBox>(R.id.checkBox1)
        val checkbox2 = findViewById<CheckBox>(R.id.checkBox2)
        val checkbox3 = findViewById<CheckBox>(R.id.checkBox3)
        val checkbox4 = findViewById<CheckBox>(R.id.checkBox4)
    
        applicationContext.checkboxStatesDataStore.updateData { states ->
            states.toBuilder()
                .setCheckBox1(checkbox1.isChecked)
                .setCheckBox2(checkbox2.isChecked)
                .setCheckBox3(checkbox3.isChecked)
                .setCheckBox4(checkbox4.isChecked)
                .build()
        }
     }
  2. Since IO writing is expensive, we will save the state only at the onStop() lifecycle callback for this tutorial. Override onStop(). To keep things simple, I am doing everything from the Activity here, but this job is more fit a ViewModel.

     override fun onStop() {
        super.onStop()
        lifecycleScope.launch {
            saveCheckboxStates()
        }
     }

Restoring State

Next, we will add code to restore the state when the app is started again after being killed.

  1. Add the restoreCheckboxStates() function below to MainActivity.

     private suspend fun restoreCheckBoxStates() {
        val checkbox1 = findViewById<CheckBox>(R.id.checkBox1)
        val checkbox2 = findViewById<CheckBox>(R.id.checkBox2)
        val checkbox3 = findViewById<CheckBox>(R.id.checkBox3)
        val checkbox4 = findViewById<CheckBox>(R.id.checkBox4)
    
        applicationContext.checkboxStatesDataStore.data.collect { states ->
            checkbox1.isChecked = states.checkBox1
            checkbox2.isChecked = states.checkBox2
            checkbox3.isChecked = states.checkBox3
            checkbox4.isChecked = states.checkBox4
        }
     }
  2. Append the code below to onCreate() to restore the state. Again, you should properly do this in a ViewModel instead.

     lifecycleScope.launch {
        restoreCheckBoxStates()
     }

Run the App

We are now ready to run the App. Your app should properly save the state when performing a workflow similar to the animation below.

protobuf_final.gif

Solution Code

CheckboxStatesSerializer.kt

package com.example.daniwebprotobufdatastore

import android.content.Context
import androidx.datastore.core.CorruptionException
import androidx.datastore.core.DataStore
import androidx.datastore.core.Serializer
import androidx.datastore.dataStore
import com.google.protobuf.InvalidProtocolBufferException
import java.io.InputStream
import java.io.OutputStream

object CheckboxStatesSerializer: Serializer<CheckboxStates> {
   override val defaultValue: CheckboxStates
       get() = CheckboxStates.getDefaultInstance()

   override suspend fun readFrom(input: InputStream): CheckboxStates {
       try {
           return CheckboxStates.parseFrom(input)
       } catch (exception: InvalidProtocolBufferException) {
           throw CorruptionException("Cannot read proto.", exception)
       }
   }

   override suspend fun writeTo(t: CheckboxStates, output: OutputStream) {
       t.writeTo(output)
   }
}

val Context.checkboxStatesDataStore: DataStore<CheckboxStates> by dataStore(
   fileName = "checkbox_states.pb",
   serializer = CheckboxStatesSerializer
)

MainActivity.kt

package com.example.daniwebprotobufdatastore

import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.widget.CheckBox
import androidx.lifecycle.coroutineScope
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking

class MainActivity : AppCompatActivity() {
   override fun onCreate(savedInstanceState: Bundle?) {
       super.onCreate(savedInstanceState)
       setContentView(R.layout.activity_main)

       lifecycleScope.launch {
           restoreCheckBoxStates()
       }
   }

   private suspend fun restoreCheckBoxStates() {
       val checkbox1 = findViewById<CheckBox>(R.id.checkBox1)
       val checkbox2 = findViewById<CheckBox>(R.id.checkBox2)
       val checkbox3 = findViewById<CheckBox>(R.id.checkBox3)
       val checkbox4 = findViewById<CheckBox>(R.id.checkBox4)

       applicationContext.checkboxStatesDataStore.data.collect { states ->
           checkbox1.isChecked = states.checkBox1
           checkbox2.isChecked = states.checkBox2
           checkbox3.isChecked = states.checkBox3
           checkbox4.isChecked = states.checkBox4
       }
   }

   private suspend fun saveCheckboxStates() {
       val checkbox1 = findViewById<CheckBox>(R.id.checkBox1)
       val checkbox2 = findViewById<CheckBox>(R.id.checkBox2)
       val checkbox3 = findViewById<CheckBox>(R.id.checkBox3)
       val checkbox4 = findViewById<CheckBox>(R.id.checkBox4)

       applicationContext.checkboxStatesDataStore.updateData { states ->
           states.toBuilder()
               .setCheckBox1(checkbox1.isChecked)
               .setCheckBox2(checkbox2.isChecked)
               .setCheckBox3(checkbox3.isChecked)
               .setCheckBox4(checkbox4.isChecked)
               .build()
       }
   }

   override fun onStop() {
       super.onStop()
       lifecycleScope.launch {
           saveCheckboxStates()
       }
   }

}

strings.xml

<resources>
   <string name="app_name">Daniweb protobuf DataStore</string>
   <string name="checkbox1">CheckBox 1</string>
   <string name="checkbox2">CheckBox 2</string>
   <string name="checkbox3">CheckBox 3</string>
   <string name="checkbox4">CheckBox 4</string>
</resources>

build.gradle

plugins {
   id 'com.android.application'
   id 'kotlin-android'
   id "com.google.protobuf" version "0.8.18"
}

android {
   compileSdk 31

   defaultConfig {
       applicationId "com.example.daniwebprotobufdatastore"
       minSdk 21
       targetSdk 31
       versionCode 1
       versionName "1.0"

       testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
   }

   buildTypes {
       release {
           minifyEnabled false
           proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
       }
   }
   compileOptions {
       sourceCompatibility JavaVersion.VERSION_1_8
       targetCompatibility JavaVersion.VERSION_1_8
   }
   kotlinOptions {
       jvmTarget = '1.8'
   }
}

dependencies {
   implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.4.0"
   implementation 'com.google.protobuf:protobuf-javalite:3.19.3'
   implementation "androidx.datastore:datastore:1.0.0"
   implementation 'androidx.core:core-ktx:1.7.0'
   implementation 'androidx.appcompat:appcompat:1.4.1'
   implementation 'com.google.android.material:material:1.5.0'
   implementation 'androidx.constraintlayout:constraintlayout:2.1.3'
   testImplementation 'junit:junit:4.+'
   androidTestImplementation 'androidx.test.ext:junit:1.1.3'
   androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
}


protobuf {
   protoc {
       artifact = 'com.google.protobuf:protoc:3.8.0'
   }
   generateProtoTasks {
       all().each { task ->
           task.builtins {
               java {
                   option "lite"
               }
           }
       }
   }
}

activity_main.xml

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
   xmlns:app="http://schemas.android.com/apk/res-auto"
   xmlns:tools="http://schemas.android.com/tools"
   android:layout_width="match_parent"
   android:layout_height="match_parent"
   tools:context=".MainActivity">

   <CheckBox
       android:id="@+id/checkBox1"
       android:layout_width="wrap_content"
       android:layout_height="wrap_content"
       android:text="@string/checkbox1"
       app:layout_constraintBottom_toTopOf="@+id/checkBox2"
       app:layout_constraintEnd_toEndOf="parent"
       app:layout_constraintStart_toStartOf="parent"
       app:layout_constraintTop_toTopOf="parent" />

   <CheckBox
       android:id="@+id/checkBox2"
       android:layout_width="wrap_content"
       android:layout_height="wrap_content"
       android:text="@string/checkbox2"
       app:layout_constraintBottom_toTopOf="@+id/checkBox3"
       app:layout_constraintEnd_toEndOf="parent"
       app:layout_constraintStart_toStartOf="parent"
       app:layout_constraintTop_toBottomOf="@+id/checkBox1" />

   <CheckBox
       android:id="@+id/checkBox3"
       android:layout_width="wrap_content"
       android:layout_height="wrap_content"
       android:text="@string/checkbox3"
       app:layout_constraintBottom_toTopOf="@+id/checkBox4"
       app:layout_constraintEnd_toEndOf="parent"
       app:layout_constraintStart_toStartOf="parent"
       app:layout_constraintTop_toBottomOf="@+id/checkBox2" />

   <CheckBox
       android:id="@+id/checkBox4"
       android:layout_width="wrap_content"
       android:layout_height="wrap_content"
       android:text="@string/checkbox4"
       app:layout_constraintBottom_toBottomOf="parent"
       app:layout_constraintEnd_toEndOf="parent"
       app:layout_constraintStart_toStartOf="parent"
       app:layout_constraintTop_toBottomOf="@+id/checkBox3" />
</androidx.constraintlayout.widget.ConstraintLayout>

Summary

We have learned how to persist state using Proto DataStore. The full project code can be found at https://github.com/dmitrilc/DaniwebProtobufDataStore

Be a part of the DaniWeb community

We're a friendly, industry-focused community of developers, IT pros, digital marketers, and technology enthusiasts meeting, networking, learning, and sharing knowledge.