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:
Each row in the list now shows four pieces of data:
- The lifecycle method that was called (e.g.,
onCreate()
) - The number of seconds since the app was started when the event occurred (e.g.,
0:00
for initial events) - A random number identifying the activity (in the upper right corner)
- A random number identifying the viewmodel (in the lower right corner)
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.
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:
- In Java, identity equality is via
==
, and we have to examine each one of theEvent
fields ourselves inareContentsTheSame()
- In Kotlin, identity is via
===
, and we can use object equality (==
) for ourEvent
, since it is adata
class and generates anequals()
function that compares each property for us
The EventViewModel
Our ViewModel
is now called EventViewModel
, and it has two properties:
-
events
, which is a list of the events that we have had to date -
startTime
, which is the time when theEventViewModel
is created, once again obtained viaSystemClock.elapsedRealtime()
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:
-
addEvent()
, to record an event when it occurs -
onCleared()
, to clear theevents
list when theEventViewModel
is no longer being used
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:
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.