A UDF Implementation
That UDF explanation may make more sense once we work through an example.
The DiceLight
sample module in the Sampler
and SamplerJ
projects implement a “diceware” app. It allows the user to randomly generate a passphrase from a word list. Later, we will look at a more complex Diceware
sample that allows the user to supply an alternative word list. For now, we will settle for having a word list packaged with the app.
The UI
When you launch the app, you are immediately greeted by a random passphrase:
The refresh toolbar button will generate a fresh passphrase, in case you do not like the original one. The “WORDS” item will display a list of word counts:
Switching to a different word count will give you a fresh passphrase with that number of words, in case you want one that is shorter or longer.
The words come from the EFF’s short wordlist, where we randomly choose a few out of the 1,296 in the list. That word list is packaged in the assets/
directory, so it is part of the Android APK that is our compiled app.
The View-State
In order to render this UI, we need a randomly-generated passphrase. However, there are two other scenarios to consider:
- What happens while we are loading the words? Initially, they are not in memory, and until they are, we cannot randomly generate a passphrase from them. That disk I/O will be quick, but not instantaneous. The speed of network I/O is a lot worse, though in this case we do not have any of that. So, in theory, we should show something, such as a
ProgressBar
, while we are loading the words. - What happens if we have some sort of problem loading the words? That should not happen here, as we know that our words are available in our APK. However, in general, disk I/O and network I/O can trigger exceptions. If we have such an exception, we really ought to tell the user about it.
So, now we have three pieces of data to track:
- Whether or not we are loading
- The current passphrase, if we have it
- The current error, if we have one
Our MainViewState
encapsulates those three pieces of data… but how it does so varies by language.
Java
In Java, MainViewState
is a simple class:
package com.commonsware.jetpack.diceware;
class MainViewState {
final boolean isLoading;
final String content;
final int wordCount;
final Throwable error;
MainViewState(boolean isLoading, String content, int wordCount, Throwable error) {
this.isLoading = isLoading;
this.content = content;
this.wordCount = wordCount;
this.error = error;
}
}
We will use null
to indicate that we do not have our passphrase (content
) yet or do not have a current error
.
Kotlin
That Java representation works, and it is simple, but it is not great.
Partly, the problem is that those three pieces of data are mutually exclusive to an extent:
- If we have our passphrase, we did not crash, so we will not have an error
- If we have an error, we will not have a passphrase
- If we are loading, we will not have either of those things
Plus, using null
as a “we don’t have the data yet” value is annoying in Kotlin. You keep having to use safe calls (?.
) and the like to deal with null
.
In Kotlin, we take a substantially different approach, using a sealed
class:
package com.commonsware.jetpack.diceware
sealed class MainViewState {
object Loading : MainViewState()
data class Content(val passphrase: String, val wordCount: Int) : MainViewState()
data class Error(val throwable: Throwable) : MainViewState()
}
Now, any instance of MainViewState
has just the data that is appropriate for it (e.g., the passphrase and word count for Content
). And, for a state that has no data — such as Loading
— it is an object
, rather than a class
.
This loading/content/error pattern is increasingly common in Android, as a way of representing the three major states.
The Repository
So, we need to create those states and get them to the activity, so the activity can do something with that information to display in the UI. A lot of that work is going to be handled by our repository.
The PassphraseRepository
is responsible for loading the words and generating a random subset for us on demand. The core logic is the same between the Java and Kotlin implementations, but the API is different, owing to different ways in handling background work and singletons.
Java
Our PassphraseRepository
is set up as a classic Java singleton, using a static volatile
field and a synchronized
, lazy-initializing get()
method:
package com.commonsware.jetpack.diceware;
import android.content.Context;
import android.content.res.AssetManager;
import com.google.common.util.concurrent.ListenableFuture;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.security.SecureRandom;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.Executor;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicReference;
import androidx.concurrent.futures.CallbackToFutureAdapter;
class PassphraseRepository {
private static final String ASSET_FILENAME = "eff_short_wordlist_2_0.txt";
private static volatile PassphraseRepository INSTANCE;
synchronized static PassphraseRepository get(Context context) {
if (INSTANCE == null) {
INSTANCE = new PassphraseRepository(context.getApplicationContext());
}
return INSTANCE;
}
private final AssetManager assets;
private final AtomicReference<List<String>> wordsCache =
new AtomicReference<>();
private final SecureRandom random = new SecureRandom();
private final Executor threadPool = Executors.newSingleThreadExecutor();
private PassphraseRepository(Context context) {
assets = context.getAssets();
}
ListenableFuture<List<String>> generate(int wordCount) {
return CallbackToFutureAdapter.getFuture(completer -> {
threadPool.execute(() -> {
try {
List<String> words = wordsCache.get();
if (words == null) {
InputStream in = assets.open(PassphraseRepository.ASSET_FILENAME);
words = readWords(in);
in.close();
synchronized (wordsCache) {
wordsCache.set(words);
}
}
completer.set(rollDemBones(words, wordCount));
}
catch (Throwable t) {
completer.setException(t);
}
});
return "generate words";
});
}
private List<String> rollDemBones(List<String> words, int wordCount) {
List<String> result = new ArrayList<>();
int size = words.size();
for (int i = 0; i < wordCount; i++) {
result.add(words.get(random.nextInt(size)));
}
return result;
}
private List<String> readWords(InputStream in) throws IOException {
InputStreamReader isr = new InputStreamReader(in);
BufferedReader reader = new BufferedReader(isr);
String line;
List<String> result = new ArrayList<>();
while ((line = reader.readLine()) != null) {
String[] pieces = line.split("\s");
if (pieces.length == 2) {
result.add(pieces[1]);
}
}
return result;
}
}
get()
takes a Context
as a parameter. We need a Context
to be able to read in our word list stored in assets. However, we do not want to hold onto an arbitary Context
in this singleton — if the Context
is an Activity
, we will wind up with a memory leak. So, we hold onto the Application
singleton edition of Context
instead.
The singleton has a single visible method: generate()
. Given a word count, generate()
generates a list of randomly-chosen words. However, this may involve disk I/O to read in the assets, if this is the first time we are trying to generate a passphrase in this process. As a result, we use a background thread for that work, in the form of an Executor
created from Executors.newSingleThreadExecutor()
. The execute()
method that we call takes a Runnable
(here implemented as a Java 8 lambda expression) and does that work on that background thread.
However, we need to be able to get the list of words to the caller when that background task completes. To that end, generate()
returns a ListenableFuture
. A Future
is an object that represents some outstanding work; ListenableFuture
is one that lets one register a listener to find out when that work is done. CallbackToFutureAdapter
is a Jetpack utility class that helps you create a ListenableFuture
. Specifically, in the Resolver
that you pass to getFuture()
(here implemented as a Java 8 lambda expression), you are passed a “completer” where you can:
- Provide the data to be returned by the background work (
set()
), or - Provide a
Throwable
if something went wrong (setException()
)
The lambda expression needs to return a String
, but this is only used for logging purposes.
ListenableFuture
and CallbacktoFutureAdapter
are obtained via the androidx.concurrent:concurrent-futures
library:
}
The repository holds onto the read-in words in a cache, wrapped by an AtomicReference
to ensure that we handle possible parallel access to the cache across multiple threads. generate()
will:
- See if we have cached the words
- If not, read in the words and save them in the cache
- Randomly choose the requested number of words, passing that
List
to the caller via theListenableFuture
The word list contains a “die roll” value and a word for each line, separated by tabs, such as:
1154 again
1155 agency
1156 aggressor
1161 aghast
1162 agitate
1163 agnostic
The readWords()
method needs to split each line along the whitespace and take the second part.
Kotlin
The Kotlin implementation does the same work, with three key differences:
- Kotlin offers an
object
keyword for creating singletons, so we use that rather than manage our own singleton - Since such singletons cannot have a constructor, we pass the
Context
intogenerate()
, whereas Java supplied it to the repository constructor - Rather than use
ListenableFuture
,generate()
is asuspend
function, doing the work on a background thread, and we can still return our result list or throw an exception
package com.commonsware.jetpack.diceware
import android.content.Context
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.BufferedReader
import java.io.InputStream
import java.io.InputStreamReader
import java.security.SecureRandom
import java.util.concurrent.atomic.AtomicReference
private const val ASSET_FILENAME = "eff_short_wordlist_2_0.txt"
object PassphraseRepository {
private val wordsCache = AtomicReference<List<String>>()
private val random = SecureRandom()
suspend fun generate(context: Context, count: Int): List<String> {
val words: List<String>? = wordsCache.get()
return words?.let { rollDemBones(it, count) }
?: loadAndGenerate(context, count)
}
private suspend fun loadAndGenerate(
context: Context,
count: Int
): List<String> =
withContext(Dispatchers.IO) {
val inputStream = context.assets.open(ASSET_FILENAME)
inputStream.use {
val words = it.readLines()
.map { line -> line.split("\t") }
.filter { pieces -> pieces.size == 2 }
.map { pieces -> pieces[1] }
wordsCache.set(words)
rollDemBones(words, count)
}
}
private fun rollDemBones(words: List<String>, wordCount: Int) =
List(wordCount) { words[random.nextInt(words.size)] }
private fun InputStream.readLines(): List<String> {
val result = mutableListOf<String>()
BufferedReader(InputStreamReader(this)).forEachLine { result.add(it); }
return result
}
}
As a result, our repository is a bit simpler (no singleton code, plus lots of Kotlin functions to make it easier to read in and process the words). And, since we are handling the background thread in the repository, consumers of this repository can be a bit simpler as well.
The Motor
In Android, we have a problem with our UIs: they keep getting destroyed, courtesy of configuration changes. We saw how we can use a ViewModel
for retaining objects across configuration changes, and we saw how we can have a ViewModel
expose LiveData
objects to the UI layer to deliver data updates.
A motor is simply a ViewModel
exposing LiveData
.
The reason for the “motor” name (e.g., MainMotor
) instead of a “viewmodel” name (e.g., ViewModel
) comes from other GUI architectures. In MVVM (Model-View-Viewmodel) and MVP (Model-View-Presenter), what gets referred to as the “viewmodel” fills the sort of role that we have here as the view-state: it is the data to be displayed in the UI. However, in Android’s Architecture Components, just because we use a ViewModel
(for configuration changes) does not mean we want that object to serve as a viewmodel (representing the data to be displayed). So, this book uses “motor” to identify a ViewModel
that exposes a LiveData
of view-state objects.
And, as with our repository, the motor is a bit different between Java and Kotlin, principally due to the way threading is handled.
Java
In Java, our repository gives us a ListenableFuture
. Since that API involves I/O, we need to get its work onto a background thread. So, MainMotor
adapts the ListenableFuture
to LiveData
:
package com.commonsware.jetpack.diceware;
import android.app.Application;
import android.text.TextUtils;
import com.google.common.util.concurrent.ListenableFuture;
import java.util.List;
import androidx.annotation.NonNull;
import androidx.lifecycle.AndroidViewModel;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
public class MainMotor extends AndroidViewModel {
private static final int DEFAULT_WORD_COUNT = 6;
private final MutableLiveData<MainViewState> viewStates =
new MutableLiveData<>();
private final PassphraseRepository repo;
public MainMotor(@NonNull Application application) {
super(application);
repo = PassphraseRepository.get(application);
generatePassphrase(DEFAULT_WORD_COUNT);
}
LiveData<MainViewState> getViewStates() {
return viewStates;
}
void generatePassphrase() {
final MainViewState current = viewStates.getValue();
if (current == null) {
generatePassphrase(DEFAULT_WORD_COUNT);
}
else {
generatePassphrase(current.wordCount);
}
}
void generatePassphrase(int wordCount) {
viewStates.setValue(new MainViewState(true, null, wordCount, null));
ListenableFuture<List<String>> future = repo.generate(wordCount);
future.addListener(() -> {
try {
viewStates.postValue(new MainViewState(false,
TextUtils.join(" ", future.get()), wordCount, null));
}
catch (Exception e) {
viewStates.postValue(new MainViewState(false, null, wordCount, e));
}
}, Runnable::run);
}
}
MainMotor
has a MutableLiveData
for our MainViewState
, exposing it to the UI layer via a getViewStates()
method returning a LiveData
. MainMotor
also has reference to the PassphraseRepository
singleton. And, since we need to supply a Context
to the repository, MainMotor
extends AndroidViewModel
, so we have a getApplication()
method to retrieve the Application
singleton Context
.
The generatePassphrase(int)
method first emits a view-state indicating that we are loading. Then, it gets the ListenableFuture
from the repository via a call to generate()
. Next, it adds a listener to the ListenableFuture
, to be notified when the work is complete. This takes a Runnable
to be invoked at that time, plus an Executor
implementation to control what thread is used to execute the Runnable
. In our case, courtesy of Java 8 method references, we can replace that Executor
with a Runnable::run
method reference, to say “execute the Runnable
on whatever thread you are on, please”.
Then, in the Runnable
(lambda expression), we:
- Use Android’s
TextUtils.join()
method to convert the list of words into a single space-delimited passphrase - Emit a view-state with that result, or emit a view-state with the exception if we ran into a problem
We use postValue()
on MutableLiveData
, to be sure that no matter what thread the Runnable
executes, it is safe for us to update the MutableLiveData
.
We also have a generatePassphrase()
method that takes no parameters. This will use the wordCount
from the previous view-state. If there was no previous view-state, it uses an overall default value.
Note that MainMotor
immediately generates a passphrase, using the default word count, once it is created. This way, the activity does not need to tell the motor to do anything — the activity just observes the LiveData
and reacts when the initial passphrase is emitted.
Kotlin
In Kotlin, since our repository exposes a suspend
function, we use viewModelScope
to launch our coroutine, receiving the results on the main application thread:
package com.commonsware.jetpack.diceware
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
private const val DEFAULT_WORD_COUNT = 6
class MainMotor(application: Application) : AndroidViewModel(application) {
private val _results = MutableLiveData<MainViewState>()
val results: LiveData<MainViewState> = _results
init {
generatePassphrase(DEFAULT_WORD_COUNT)
}
fun generatePassphrase() {
generatePassphrase(
(results.value as? MainViewState.Content)?.wordCount ?: DEFAULT_WORD_COUNT
)
}
fun generatePassphrase(wordCount: Int) {
_results.value = MainViewState.Loading
viewModelScope.launch(Dispatchers.Main) {
_results.value = try {
val randomWords = PassphraseRepository.generate(
getApplication(),
wordCount
)
MainViewState.Content(randomWords.joinToString(" "), wordCount)
} catch (t: Throwable) {
MainViewState.Error(t)
}
}
}
}
We also:
- Use the
PassphraseRepository
singleton supplied by theobject
declaration - Use Kotlin’s own
joinToString()
instead of Android’sTextUtils.join()
to convert the list of words into the passphrase - Use our
sealed
class implementations, so we are emitting some sub-type ofMainViewState
Otherwise, this works the same as its Java equivalent, including the two varieties of generatePassphrase()
(one with an explicit word count, one without) and generating a passphrase when the MainMotor
is created.
The Activity
Our layout is based on a CardView
: a widget that is a simple rounded rectangle with a bit of a drop shadow. Inside of there, we have a TextView
and a ProgressBar
:
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="8dp">
<androidx.cardview.widget.CardView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:padding="8dp">
<TextView
android:id="@+id/passphrase"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:freezesText="true"
android:textSize="20sp"
android:typeface="monospace" />
<ProgressBar
android:id="@+id/progress"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
</androidx.cardview.widget.CardView>
</FrameLayout>
Managing that layout is a simple activity. We could have used a fragment here, but there is only one screen. In general, the remaining sample apps will use fragments and the Navigation component when there is more than one screen, but just a single Activity
otherwise.
In onCreate()
, we get our MainMotor
, start observing the LiveData
, apply the MainViewState
to our widgets:
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
ActivityMainBinding binding =
ActivityMainBinding.inflate(getLayoutInflater());
setContentView(binding.getRoot());
motor = new ViewModelProvider(this).get(MainMotor.class);
motor.getViewStates().observe(this, viewState -> {
binding.progress.setVisibility(
viewState.isLoading ? View.VISIBLE : View.GONE);
if (viewState.content != null) {
binding.passphrase.setText(viewState.content);
}
else if (viewState.error != null) {
binding.passphrase.setText(viewState.error.getLocalizedMessage());
Log.e("Diceware", "Exception generating passphrase",
viewState.error);
}
else {
binding.passphrase.setText("");
}
});
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
motor.results.observe(this) { viewState ->
when (viewState) {
MainViewState.Loading -> {
binding.progress.visibility = View.VISIBLE
binding.passphrase.text = ""
}
is MainViewState.Content -> {
binding.progress.visibility = View.GONE
binding.passphrase.text = viewState.passphrase
}
is MainViewState.Error -> {
binding.progress.visibility = View.GONE
binding.passphrase.text = viewState.throwable.localizedMessage
Log.e(
"Diceware",
"Exception generating passphrase",
viewState.throwable
)
}
}
}
}
We also have a menu resource for the word count options and the refresh button:
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/word_count"
app:showAsAction="ifRoom"
android:title="@string/menu_words">
<menu>
<group android:checkableBehavior="single">
<item
android:id="@+id/word_count_4"
android:title="4" />
<item
android:id="@+id/word_count_5"
android:title="5" />
<item
android:id="@+id/word_count_6"
android:checked="true"
android:title="6" />
<item
android:id="@+id/word_count_7"
android:title="7" />
<item
android:id="@+id/word_count_8"
android:title="8" />
<item
android:id="@+id/word_count_9"
android:title="9" />
<item
android:id="@+id/word_count_10"
android:title="10" />
</group>
</menu>
</item>
<item
android:id="@+id/refresh"
android:icon="@drawable/ic_cached_white_24dp"
app:showAsAction="ifRoom"
android:title="@string/menu_refresh" />
</menu>
The word_count
menu item is a bit unusual, following the Android recipe for creating such a selection menu:
- The
<item>
has a<menu>
child… - …which in turn holds a
<group>
withandroid:checkableBehavior="single"
… - … which wraps a set of
<item>
elements, one for each checkable menu item… - … with
android:checked="true"
on theword_count_6
item, to pre-check that one
We then have code in onCreateOptionsMenu()
and onOptionsItemSelected()
on MainActivity
to set up and handle that menu:
@Override
public boolean onCreateOptionsMenu(Menu menu) {
getMenuInflater().inflate(R.menu.actions, menu);
return super.onCreateOptionsMenu(menu);
}
@Override
public boolean onOptionsItemSelected(MenuItem item) {
switch (item.getItemId()) {
case R.id.refresh:
motor.generatePassphrase();
return true;
case R.id.word_count_4:
case R.id.word_count_5:
case R.id.word_count_6:
case R.id.word_count_7:
case R.id.word_count_8:
case R.id.word_count_9:
case R.id.word_count_10:
item.setChecked(!item.isChecked());
motor.generatePassphrase(Integer.parseInt(item.getTitle().toString()));
return true;
}
return super.onOptionsItemSelected(item);
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.actions, menu)
return super.onCreateOptionsMenu(menu)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
R.id.refresh -> {
motor.generatePassphrase()
return true
}
R.id.word_count_4, R.id.word_count_5, R.id.word_count_6, R.id.word_count_7,
R.id.word_count_8, R.id.word_count_9, R.id.word_count_10 -> {
item.isChecked = !item.isChecked
motor.generatePassphrase(Integer.parseInt(item.title.toString()))
return true
}
}
return super.onOptionsItemSelected(item)
}
The refresh
item is simple: we just call generatePassphrase()
again, causing the motor to get a fresh set of words from the repository and emitting another MainViewState
to update our UI.
For the word count, we have more work to do. First, we need to toggle the isChecked
state of the MenuItem
, because Android (inexplicably) does not handle that for us when the user clicks a checkable menu item. Then, we cheat a bit and parse the actual text of the menu item as an Integer
— this only works because this app is English-only, so we know that the menu item captions can be parsed by Integer.parseInt()
. We then call generatePassphrase()
to generate a passphrase with the new number of words.
Revisiting the Unidirectional Data Flow
Our activity takes user input (activity launch, refresh, word-count change) and converts those into function calls on the motor:
The motor then converts those calls into operations to be performed on our repository, in an asynchronous fashion:
The repository does the work and asynchronously delivers the result to the motor:
The motor takes those results and crafts a revised view-state that the activity can use to render the UI:
Obviously, this is a very simple example. There are plenty of MVI frameworks, such as Spotify’s Mobius, that provide a scaffold around this sort of flow that you can plug into. However, in many cases, that level of sophistication (and corresponding complexity) is not needed, and a simple motor-based flow like the one shown here can suffice.
Prev Table of Contents Next
This book is licensed under the Creative Commons Attribution-ShareAlike 4.0 International license.