Changing Data in the ViewModel

In our 25-random-numbers example, the data never changed. That is what we wanted for that example, so our random numbers would be consistent through configuration changes.

However, it is not very realistic. Most of the time, our data changes when the user uses the app, and we need our viewmodel to handle that.

The LifecycleList sample module in the Sampler and SamplerJ projects blend our lifecycle-logging logic with our show-a-list logic. Now, instead of showing 25 random colors, we will show a list of lifecycle events, collected as the user uses the app:

LifecycleList Demo, As Initially Launched
LifecycleList Demo, As Initially Launched

Each row in the list now shows four pieces of data:

These latter two values will help show us when we wind up with different instances of those objects.

The Event Model

We need some object to hold that data to be shown in each of our RecyclerView rows. So, this project has an Event model class, in Java:

package com.commonsware.jetpack.samplerj.lifecycle;

import android.os.SystemClock;

class Event {
  final long timestamp = SystemClock.elapsedRealtime();
  final String message;
  final int activityHash;
  final int viewmodelHash;

  Event(String message, int activityHash, int viewmodelHash) {
    this.message = message;
    this.activityHash = activityHash;
    this.viewmodelHash = viewmodelHash;
  }
}

…or Kotlin:

package com.commonsware.jetpack.sampler.lifecycle

import android.os.SystemClock

data class Event(
  val message: String,
  val activityHash: Int,
  val viewmodelHash: Int,
  val timestamp: Long = SystemClock.elapsedRealtime()
)

The two “hash code” values are Int properties. The lifecycle method is a String referred to as the message. And we have a timestamp property that will track when this Event was created. For that, we use SystemClock.elapsedRealtime(), which returns the number of milliseconds since the device was powered on.

The New RecyclerView Bits

Our row layout now needs widgets for those four pieces of data that we wish to display:

<?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="wrap_content"
  android:padding="@dimen/content_padding">

  <TextView
    android:id="@+id/activityHash"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    app:layout_constraintEnd_toEndOf="parent"
    app:layout_constraintTop_toTopOf="parent"
    tools:text="0x12345678" />

  <TextView
    android:id="@+id/viewmodelHash"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    app:layout_constraintBottom_toBottomOf="parent"
    app:layout_constraintEnd_toEndOf="parent"
    app:layout_constraintTop_toBottomOf="@id/activityHash"
    tools:text="0x90ABCDEF" />

  <androidx.constraintlayout.widget.Barrier
    android:id="@+id/barrier"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:layout_marginEnd="4dp"
    android:layout_marginStart="4dp"
    app:barrierDirection="start"
    app:constraint_referenced_ids="activityHash,viewmodelHash" />

  <TextView
    android:id="@+id/timestamp"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    app:layout_constraintBottom_toBottomOf="parent"
    app:layout_constraintStart_toStartOf="parent"
    app:layout_constraintTop_toTopOf="parent"
    tools:text="01:23" />

  <TextView
    android:id="@+id/message"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:textAppearance="?android:attr/textAppearanceLarge"
    app:layout_constraintBottom_toBottomOf="parent"
    app:layout_constraintEnd_toStartOf="@id/barrier"
    app:layout_constraintStart_toEndOf="@id/timestamp"
    app:layout_constraintTop_toTopOf="parent"
    tools:text="onDestroy()" />

</androidx.constraintlayout.widget.ConstraintLayout>

Each of the four pieces of data is represented by a TextView. The two hash values are anchored to the “end” side of the ConstraintLayout, with the activityHash on the top and the viewmodelHash on the bottom. The timestamp widget is anchored on the “start” side of the ConstraintLayout, centered between the top and the bottom. And the message widget is centered in the remaining space, using a Barrier to determine where the hashes start.

Android Studio Layout Editor, Showing row Layout
Android Studio Layout Editor, Showing row Layout

Note that we dropped the android:background, android:clickable, and android:focusable attributes from the ConstraintLayout, as this particular sample is not going to respond to click events on the rows. That allows our new view-holder class, EventViewHolder, to just focus on populating the widgets for its row:

package com.commonsware.jetpack.samplerj.lifecycle;

import android.text.format.DateUtils;
import com.commonsware.jetpack.samplerj.lifecycle.databinding.RowBinding;
import androidx.recyclerview.widget.RecyclerView;

class EventViewHolder extends RecyclerView.ViewHolder {
  private final RowBinding binding;
  private final long startTime;

  EventViewHolder(RowBinding binding, long startTime) {
    super(binding.getRoot());

    this.binding = binding;
    this.startTime = startTime;
  }

  void bindTo(Event event) {
    long elapsedSeconds = (event.timestamp - startTime)/1000;

    binding.timestamp.setText(DateUtils.formatElapsedTime(elapsedSeconds));
    binding.message.setText(event.message);
    binding.activityHash.setText(Integer.toHexString(event.activityHash));
    binding.viewmodelHash.setText(Integer.toHexString(event.viewmodelHash));
  }
}
package com.commonsware.jetpack.sampler.lifecycle

import android.text.format.DateUtils
import androidx.recyclerview.widget.RecyclerView
import com.commonsware.jetpack.sampler.lifecycle.databinding.RowBinding

class EventViewHolder(
  private val binding: RowBinding,
  private val startTime: Long
) : RecyclerView.ViewHolder(binding.root) {
  fun bindTo(event: Event) {
    val elapsedSeconds = (event.timestamp - startTime) / 1000

    binding.timestamp.text = DateUtils.formatElapsedTime(elapsedSeconds)
    binding.message.text = event.message
    binding.activityHash.text = Integer.toHexString(event.activityHash)
    binding.viewmodelHash.text = Integer.toHexString(event.viewmodelHash)
  }
}

For the timestamp, we use DateUtils.formatElapsedTime(). This is a utility method provided by Android that formats a number of seconds into an HH:MM:SS format to show the elapsed time in hours, minutes, and seconds. Note that the Event value for the timestamp is in milliseconds, as is the startTime value that is being passed into EventViewHolder, so we need to divide our net time by 1000 to convert the milliseconds to seconds.

Our new adapter — EventAdapter — wraps a List of Event objects and pours them into EventViewHolder objects as needed:

package com.commonsware.jetpack.samplerj.lifecycle;

import android.os.SystemClock;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import com.commonsware.jetpack.samplerj.lifecycle.databinding.RowBinding;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.DiffUtil;
import androidx.recyclerview.widget.ListAdapter;

class EventAdapter extends ListAdapter<Event, EventViewHolder> {
  private final LayoutInflater inflater;
  private final long startTime;

  EventAdapter(LayoutInflater inflater, long startTime) {
    super(DIFF_CALLBACK);
    this.inflater = inflater;
    this.startTime = startTime;
  }

  @NonNull
  @Override
  public EventViewHolder onCreateViewHolder(@NonNull ViewGroup parent,
                                            int viewType) {
    final RowBinding binding = RowBinding.inflate(inflater, parent, false);

    return new EventViewHolder(binding, startTime);
  }

  @Override
  public void onBindViewHolder(@NonNull EventViewHolder holder, int position) {
    holder.bindTo(getItem(position));
  }

  private static final DiffUtil.ItemCallback<Event> DIFF_CALLBACK =
    new DiffUtil.ItemCallback<Event>() {
      @Override
      public boolean areItemsTheSame(@NonNull Event oldEvent, @NonNull Event newEvent) {
        return oldEvent == newEvent;
      }

      @Override
      public boolean areContentsTheSame(@NonNull Event oldEvent, @NonNull Event newEvent) {
        return oldEvent.timestamp == newEvent.timestamp &&
          oldEvent.message.equals(newEvent.message) &&
          oldEvent.activityHash == newEvent.activityHash &&
          oldEvent.viewmodelHash == newEvent.viewmodelHash;
      }
    };
}
package com.commonsware.jetpack.sampler.lifecycle

import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import com.commonsware.jetpack.sampler.lifecycle.databinding.RowBinding

internal class EventAdapter(
  private val inflater: LayoutInflater,
  private val startTime: Long
) : ListAdapter<Event, EventViewHolder>(EventDiffer) {

  override fun onCreateViewHolder(
    parent: ViewGroup,
    viewType: Int
  ) = EventViewHolder(RowBinding.inflate(inflater, parent, false), startTime)

  override fun onBindViewHolder(holder: EventViewHolder, position: Int) {
    holder.bindTo(getItem(position))
  }

  private object EventDiffer : DiffUtil.ItemCallback<Event>() {
    override fun areItemsTheSame(oldEvent: Event, newEvent: Event) =
      oldEvent === newEvent

    override fun areContentsTheSame(oldEvent: Event, newEvent: Event) =
      oldEvent == newEvent
  }
}

For our DiffUtil.ItemCallback implementation, we use identity equality to determine whether the items are the same and content equality to determine if the contents are the same. Here, though, we have more substantial language differences:

The EventViewModel

Our ViewModel is now called EventViewModel, and it has two properties:

package com.commonsware.jetpack.samplerj.lifecycle;

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

public class EventViewModel extends ViewModel {
  final List<Event> events = new ArrayList<>();
  final long startTime = SystemClock.elapsedRealtime();
  private final int id = new Random().nextInt();

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

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

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

class EventViewModel : ViewModel() {
  val events: MutableList<Event> = mutableListOf()
  val startTime = SystemClock.elapsedRealtime()
  private val id = Random().nextInt()

  fun addEvent(message: String, activityHash: Int) {
    events.add(Event(message, activityHash, id))
  }

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

We also have:

The onCleared() implementation is unnecessary, as the events list would be garbage-collected when the EventViewModel is. We have it here just to show you what overriding that method looks like.

Updating the EventViewModel

MainActivity now has an addEvent() function to update the EventViewModel when lifecycle events occur:

package com.commonsware.jetpack.samplerj.lifecycle;

import android.os.Bundle;
import com.commonsware.jetpack.samplerj.lifecycle.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.lifecycle

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.lifecycle.databinding.ActivityMainBinding
import java.util.*
import kotlin.collections.ArrayList

class MainActivity : AppCompatActivity() {
  private lateinit var adapter: EventAdapter
  private val vm: EventViewModel by viewModels()
  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))
  }
}

addEvent() also calls submitList() on our EventAdapter, to update the RecyclerView when we add events to the list. This requires us to hold onto the EventAdapter and the EventViewModel in properties of the activity, rather than just using them as local variables in onCreate().

When addEvent() calls submitList(), it creates a new ArrayList from the events. That is because ListAdapter and submitList() will “short-circuit” the update logic if you pass the same List to submitList() that you did the previous call. ListAdapter assumes that nothing needs to be done in that case, as ListAdapter assumes that the list contents did not change. To get past this, we have to pass in a brand-new ArrayList object each time.

The Results

When you run the app, lifecycle events show up in the list. If you rotate the screen a couple of times, the events start to pile up:

LifecycleList After Two Configuration Changes
LifecycleList After Two Configuration Changes

The activity “hash code” value in the upper right changes with each configuration change, as we get a fresh instance of MainActivity each time. However, the viewmodel “hash code” value in the lower right remains the same, as we are using the same EventViewModel instance for each of our MainActivity instances.


Prev Table of Contents Next

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