A State-Aware ViewModel

Version 2.2.0 of the Jetpack lifecycle artifacts added first-class support for tying ViewModel and your saved instance state together. The InstanceState sample module in the Sampler and SamplerJ projects use an EventViewModel that still holds our events and start time. However, in this case, we also use the new ViewModel capabilities to store that data in the saved instance state Bundle and use that data when creating a new EventViewModel instance in a new process.

The SavedStateHandle

The first step for making this work is to add a SavedStateHandle constructor parameter to our ViewModel. SavedStateHandle is a wrapper around the saved instance state Bundle that the Jetpack developers introduced.

So, EventViewModel has that constructor parameter:

package com.commonsware.jetpack.samplerj.state;

import android.os.SystemClock;
import java.util.ArrayList;
import java.util.Random;
import androidx.lifecycle.SavedStateHandle;
import androidx.lifecycle.ViewModel;

public class EventViewModel extends ViewModel {
  private static final String STATE_EVENTS = "events";
  private static final String STATE_START_TIME = "startTime";

  final ArrayList<Event> events;
  final Long startTime;
  private final int id = new Random().nextInt();
  private final SavedStateHandle state;

  public EventViewModel(SavedStateHandle state) {
    this.state = state;

    ArrayList<Event> events = state.get(STATE_EVENTS);

    if (events == null) {
      this.events = new ArrayList<>();
    }
    else {
      this.events = events;
    }

    Long startTime = state.get(STATE_START_TIME);

    if (startTime == null) {
      this.startTime = SystemClock.elapsedRealtime();
      state.set(STATE_START_TIME, this.startTime);
    }
    else {
      this.startTime = startTime;
    }
  }

  void addEvent(String message, int activityHash) {
    events.add(new Event(message, activityHash, id));
    state.set(STATE_EVENTS, events);
  }

  @Override
  protected void onCleared() {
    events.clear();
  }
}
package com.commonsware.jetpack.sampler.state

import android.os.SystemClock
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import java.util.*

private const val STATE_EVENTS = "events"
private const val STATE_START_TIME = "startTime"

class EventViewModel(private val state: SavedStateHandle) : ViewModel() {
  val events: ArrayList<Event> = state.get(STATE_EVENTS) ?: arrayListOf()
  val startTime: Long = state.get(STATE_START_TIME)
    ?: SystemClock.elapsedRealtime().also { state.set(STATE_START_TIME, it) }
  private val id = Random().nextInt()

  fun addEvent(message: String, activityId: Int) {
    events.add(Event(message, activityId, id))
    state.set(STATE_EVENTS, events)
  }

  override fun onCleared() {
    events.clear()
  }
}

SavedStateHandle uses basic get() and set() methods for manipulated the saved instance state. Both operate using strings as keys, as with a Bundle. So, in EventViewModel, we initialize our events and startTime properties to be based on the supplied SavedStateHandle. But, if our handle does not contain that data, we initialize it to be an empty list of events and the current time, respectively.

Whenever we change these properties, we also update the SavedStateHandle. Since the value of startTime is only defined once, if we need to use the current time for its value, we also use set() to save that in the handle. And, in the addEvent() function, we not only update the contents of the events ArrayList, but we make sure that the SavedStateHandle has the latest copy of those events.

Note that id is not part of the saved instance state. That value will get regenerated for each new EventViewModel.

The Results

We do not need to do anything special when retrieving our viewmodels — the Jetpack viewmodel system already knows how to handle SavedStateHandle scenarios by default. As a result, our activities look and work much the same as the earlier LifecycleList counterparts:

package com.commonsware.jetpack.samplerj.state;

import android.os.Bundle;
import com.commonsware.jetpack.samplerj.state.databinding.ActivityMainBinding;
import java.util.ArrayList;
import java.util.Random;
import androidx.appcompat.app.AppCompatActivity;
import androidx.lifecycle.ViewModelProvider;
import androidx.recyclerview.widget.DividerItemDecoration;
import androidx.recyclerview.widget.LinearLayoutManager;

public class MainActivity extends AppCompatActivity {
  private EventAdapter adapter;
  private EventViewModel vm;
  private final int id = new Random().nextInt();

  @Override
  protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);

    final ActivityMainBinding binding =
      ActivityMainBinding.inflate(getLayoutInflater());

    setContentView(binding.getRoot());

    vm = new ViewModelProvider(this).get(EventViewModel.class);
    adapter = new EventAdapter(getLayoutInflater(), vm.startTime);
    addEvent("onCreate()");

    binding.items.setLayoutManager(new LinearLayoutManager(this));
    binding.items.addItemDecoration(
      new DividerItemDecoration(this, DividerItemDecoration.VERTICAL));
    binding.items.setAdapter(adapter);
  }

  @Override
  protected void onStart() {
    super.onStart();

    addEvent("onStart()");
  }

  @Override
  protected void onResume() {
    super.onResume();

    addEvent("onResume()");
  }

  @Override
  protected void onPause() {
    addEvent("onPause()");

    super.onPause();
  }

  @Override
  protected void onStop() {
    addEvent("onStop()");

    super.onStop();
  }

  @Override
  protected void onDestroy() {
    addEvent("onDestroy()");

    super.onDestroy();
  }

  private void addEvent(String message) {
    vm.addEvent(message, id);
    adapter.submitList(new ArrayList<>(vm.events));
  }
}
package com.commonsware.jetpack.sampler.state

import android.os.Bundle
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import com.commonsware.jetpack.sampler.state.databinding.ActivityMainBinding
import java.util.*
import kotlin.collections.ArrayList

class MainActivity : AppCompatActivity() {
  private val vm: EventViewModel by viewModels()

  private lateinit var adapter: EventAdapter
  private val id = Random().nextInt()

  override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)

    val binding = ActivityMainBinding.inflate(layoutInflater)

    setContentView(binding.root)

    adapter = EventAdapter(layoutInflater, vm.startTime)
    addEvent("onCreate()")

    binding.items.layoutManager = LinearLayoutManager(this)
    binding.items.addItemDecoration(
      DividerItemDecoration(this, DividerItemDecoration.VERTICAL)
    )
    binding.items.adapter = adapter
  }

  override fun onStart() {
    super.onStart()

    addEvent("onStart()")
  }

  override fun onResume() {
    super.onResume()

    addEvent("onResume()")
  }

  override fun onPause() {
    addEvent("onPause()")

    super.onPause()
  }

  override fun onStop() {
    addEvent("onStop()")

    super.onStop()
  }

  override fun onDestroy() {
    addEvent("onDestroy()")

    super.onDestroy()
  }

  private fun addEvent(message: String) {
    vm.addEvent(message, id)
    adapter.submitList(ArrayList(vm.events))
  }
}

Seeing the effect of our saved instance state support is tricky. We need to have our process be terminated while leaving the task alone. That means we cannot swipe our task off of the overview screen, as that terminates the task. Similarly, the stop-process buttons in Android Studio toolbars (with a red square icon) seem to terminate the task as well.

One way to see this work is to use the command line.

The Android SDK ships with an adb command-line utility. We can use this to communicate with a running emulator or debuggable device. You will find adb in the platform-tools/ directory of your Android SDK installation.

In particular:

To experiment with instance states:

In the original LifecycleList sample, configuration changes would show new instance ID codes for the activity, but the EventViewModel instance ID code would be the same, as we reuse the existing viewmodel on a configuration change. Now, though, we are terminating the process, so we should see a new viewmodel ID code for the newer events:

InstanceState Sample App, Following Above Script
InstanceState Sample App, Following Above Script

However, we still have the original events and the original start time — we did not start over with an empty EventViewModel, because we populated it from the saved instance state Bundle.


Prev Table of Contents Next

This book is licensed under the Creative Commons Attribution-ShareAlike 4.0 International license.