SENG 440: Lecture 7 – Persisting with Files and JSON
Dear students,
We’ve now written four little apps together. We’ve used buttons, text labels, text boxes, and lists to form their user interfaces. We’ve used ConstraintLayout to arrange the widgets. We’ve seen how to handle events with lambdas and schedule events with Handler
. We’ve seen how to jump from screen to screen or from one app to another with Intent
. Though there’s a massive API to learn, we have touched upon many of the core ideas of Android development. But two big ideas remain: persisting the user’s data and completing tasks off the main/UI thread. We’ll talk about the first of these today in the context of an app that guesses an object that the user is thinking of.
Before we forget, here’s your TODO list for next time:
- Read Data and File Storage Overview and skim Save Files on Device Storage.
- Files have general utility. If you are are specifically persisting a small set of key-value pairs,
SharedPreferences
are a better choice. Find and read a short tutorial onSharedPreferences
. The official reference isn’t that great. - On a quarter sheet of paper to be turned in at the beginning of the next class, write two short snippets of code: one that retrieves your app’s default shared preferences and persists a number, and another that reads the number back out. Each should just be a few lines.
Let’s start making our app, which we’ll call Is Kitten.
Is Kitten
The game Is Kitten is directly inspired by a program I saw in one of those old computer magazines from the 1980s. These magazines would dump an entire project’s source code on their pages, and you would type it all in. We don’t do this anymore.
The premise of the game is this. The program tracks a binary tree of things. At each internal node, we ask a yes/no question to split the things in two buckets. We answer yes to the question for all the objects to the left, and no for all the objects to the right. For instance, my root node might be the question, “Does it have a tail?” Kitten would fall to the left, and egg would fall to the right.
The program invites the player to think of a thing and then tries to guess by walking down the tree, asking the questions it encounters and descending based on the user’s answers. When it reaches a leaf node, the program guesses the thing. If the guessed thing is correct, the game is over. The guessed thing is incorrect, the program grows the tree by asking the player for a new question that distinguishes the new thing from the incorrect thing.
Our app will do just this. We’ll need a model of the tree and just a single screen with a prompt and a couple of buttons. Let’s start with the model.
Model
To model our tree, we will use a single class to represent each node of the tree. (We could use three classes: Thing
, Question
, and an abstract Node
. I think this is overengineering for our problem.) Each node will store the node’s left child, right child, and text. Our class looks like this in Kotlin:
class Node(var text: String, var left: Node?, var right: Node?) {
}
We use var
because the node will change when the program guesses incorrectly and the tree gains a new object. We also allow left
and right
to be null in the case of a leaf node.
Let’s add a few methods that we’ll need for the gameplay. Since the game’s behavior is different for leaf nodes, let’s write a method to determine leafiness. We can use this single-expression function:
fun isLeaf() = left == null && right == null
At each node, we’ll have to ask the player a question. For internal nodes, the question is directly stored in the tree. For leaf nodes, we want to ask if the thing is what the player was thinking of. Our method to form the question might look like this:
fun question() = if (isLeaf()) "Are you thinking of ${text}?" else text
If the game guesses incorrectly at a leaf node, we want to grow the tree. The former leaf node will become a question node, with its left child being the new thing and its right child being the former thing. Let’s put this behavior in a method named bifurcate
:
fun bifurcate(newThing: String, question: String) {
left = Node(newThing)
right = Node(text)
text = question
}
That’s it. This is enough model for our game to be playable. Now let’s create the user interface.
User Interface
For our UI, we need a text prompt, a yes button, and a no button. We won’t build this from scratch, because there are no new ideas to discuss. Here’s the XML:
<?xml version="1.0" encoding="utf-8"?>
<android.support.constraint.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">
<TextView
android:id="@+id/questionText"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:paddingLeft="10dp"
android:paddingRight="10dp"
android:text="Hef dsf asdf sdafa sdfasdjfasdjkf sad fads fds fsd fasd fasd fjkds fasdfasd f asdfasdl"
android:textSize="48sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<Button
android:id="@+id/yesButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginEnd="4dp"
android:layout_marginBottom="8dp"
android:text="Yes"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/noButton"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintStart_toStartOf="parent" />
<Button
android:id="@+id/noButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:layout_marginEnd="8dp"
android:layout_marginBottom="8dp"
android:text="No"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintStart_toEndOf="@+id/yesButton" />
</android.support.constraint.ConstraintLayout>
We use a horizontal chain for the buttons and have them fill their half of the screen.
Our activity’s onCreate
will grab references to these widgets in the usual manner:
private lateinit var questionText: TextView
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
questionText = findViewById(R.id.questionText)
val yesButton: Button = findViewById(R.id.yesButton)
val noButton: Button = findViewById(R.id.noButton)
}
Now we’re ready to program the interaction.
Main Activity
Let’s start the game with this simple tree:
private var root = Node("Does it have a tail?", Node("kitten"), Node("egg"))
We’ll need track our current location in the tree, so let’s create an instance variable for that too:
private var node = root
Every time we visit a node, we show that node’s question in the textview. Let’s add a helper method to move us to a node and show the question:
private fun visit(node: Node) {
questionText.text = node.question()
this.node = node
}
We’ll need to call this on onCreate
to get our game up and running:
visit(root)
If the player hits Yes in response the question, two different things may need to happen. If we’re at a leaf, the game is over and we can start over. If we’re at an internal node, we descend to the left child. Our lambda listener for this button takes this overall shape:
yesButton.setOnClickListener {
if (node.isLeaf()) {
playAgain()
} else {
visit(node.left!!)
}
}
If the player hits No, two different things may need to happen. If we’re at a leaf, we’ll need to get a question and the name of thing from the player so that we can bifurcate. If we’re at an internal node, we descend to the right child. Our lambda takes this shape:
noButton.setOnClickListener {
if (node.isLeaf()) {
bifurcate()
} else {
visit(node.right!!)
}
}
Dialogs
For playAgain
and bifurcate
, let’s use dialogs to prompt the user. What distinguishes a dialog from the standard screen? The content of a dialog is usually temporary—not part of the UI that needs to be visible all the time. It’s used to narrow the user’s focus to a smaller collection of prompts.
Android’s AlertDialog.Builder
is a highly extensible generator of dialogs. We can prompt the player to play again with a single button with this code:
private fun playAgain() {
val builder = AlertDialog.Builder(this)
builder.setMessage("Shall we play again?")
builder.setPositiveButton("Play") { _, _ ->
visit(root)
}
builder.show()
}
Bifurcating requires more prompting. We need to grab both the name of thing that the player was thinking of and a new question. AlertDialog
doesn’t have a canned way of showing two textboxes, but it can accept an arbitrary layout. So let’s create our form in its own layout resource:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="20dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Shucks. What thing were you thinking of?"
android:textSize="24sp" />
<EditText
android:id="@+id/thingBox"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ems="10" />
<TextView
android:id="@+id/questionPrompt"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="40dp"
android:text="What's a question that's yes for the thing above but no for OLD?"
android:textSize="24sp" />
<EditText
android:id="@+id/questionBox"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ems="10"
android:inputType="textPersonName" />
</LinearLayout>
We expand this XML to a view hierarchy with a LayoutInflater
. We point our dialog to this hierarchy with the setView
method. We can grab references to the textboxes just like we did in onCreate
, but we query on the form hierarchy. Our setup code looks like this:
private fun bifurcate() {
val builder = AlertDialog.Builder(this)
val form = layoutInflater.inflate(R.layout.dialog, null, false)
builder.setView(form)
val thingBox: EditText = form.findViewById(R.id.thingBox)
val questionBox: EditText = form.findViewById(R.id.questionBox)
val questionPrompt: TextView = form.findViewById(R.id.questionPrompt)
}
We left a placeholder for the name of the displaced thing in the question prompt. We substitute in its name:
questionPrompt.text = questionPrompt.text.toString().replace("OLD", node.text)
Finally, we add a single button at the button for the user to okay the dialog and grow the tree. When they click it, we then ask the player if they want to start over:
builder.setPositiveButton("Add") { _, _ ->
node.bifurcate(thingBox.text.toString(), questionBox.text.toString())
playAgain()
}
builder.show()
We are now able to play the game. But something terrible happens if we rotate the screen or click away, our tree is lost!
Persistence
Mobile developers generally need to expect that their app will close outside of their power. The battery may die, a backgrounded app might get force-closed or evicted when memory is low, or the device might restart. Further, an Android device can undergo configuration changes like switching languages or orientations, and our apps by default will be closed and restarted when these changes occur. We must save the user’s work before any of this happens.
We have several options to store data locally on Android:
- Internal storage, which is small and private to the app. When an app gets uninstalled, its internal files get removed.
- External storage, which is large and possibly shared with other apps. When an gets uninstalled, its external files do not get removed.
SharedPreferences
, which stores lightweight, cookie-like information.- An SQLite database, which supports the storage and querying of complex information.
We could also store things remotely, but that’s a topic for another day. In fact, we only discuss internal storage today. In further fact, there’s actually not a lot more to say. To grab a file from internal storage, we use the methods openFileInput
and openFileOutput
. We will write our tree to tree.json
in onStop
:
override fun onStop() {
super.onStop()
val file = openFileOutput("tree.json", Context.MODE_PRIVATE)
// write tree
}
And read it in onStart
:
override fun onStart() {
super.onStart()
val file = openFileInput("tree.json")
// read tree
}
For the actual reading and writing, let’s use JSON. Unlike binary and XML, it’s human-readable.
JSON
There are many libraries that make working with JSON easier, and there are several JSON solutions available within the Android API itself. We will use Android’s JsonWriter
and JsonReader
utilities, which allow us to write custom serialization for our own types. (The classes in org.json.*
are reasonable, but they create their types to model a JSON structure.) We can write our tree to a JSON file in onStop
with this code:
val writer = JsonWriter(OutputStreamWriter(file))
writer.setIndent(" ")
root.write(writer)
writer.close()
We need to add Node.write
method method for this work. The JSON calls directly mirror the structure of the tree:
fun write(writer: JsonWriter) {
writer.beginObject()
writer.name("text").value(text)
if (!isLeaf()) {
writer.name("left")
left?.write(writer)
writer.name("right")
right?.write(writer)
}
writer.endObject()
}
Because left
and right
might be null (so thinks the type system, but the code itself disallows this), we use the ?
operator to guard against null dereferencing.
We can test that this works. First we run the app and get it to save the file. Then in Android Studio, we open View / Tool Windows / Device File Explorer. Inside /data/data/your.package.name/files/tree.json
, we’ll find tree.json
.
We can read the tree back in with this code:
try {
val file = openFileInput("tree.json")
val reader = JsonReader(InputStreamReader(file))
root = Node.read(reader)
reader.close()
} catch (e: FileNotFoundException) {
root = Node("Does it have a tail?", Node("kitten"), Node("egg"))
}
visit(root)
We must not forget that there won’t be a file there when the app is run for the first time.
Node.read
is sort of a factory method, and factory methods are generally static. We used object
to encapsulate static data in an earlier app. But this code really does belong to Node
. We can use Kotlin’s companion object
for this. It’s much like object
, but it’s associated with a normal class. We put this block inside of Node
:
class Node(...) {
...
companion object {
fun read(reader: JsonReader): Node {
val node = Node("")
reader.beginObject()
while (reader.hasNext()) {
val key = reader.nextName()
when (key) {
"text" -> node.text = reader.nextString()
"left" -> node.left = read(reader)
"right" -> node.right = read(reader)
}
}
reader.endObject()
return node
}
}
}
We use a loop to iterate through they key-value pairs, as they may appear in any order.
Now our game persists the tree not just for a single session, but all sessions.
That’s enough for today. See you next time!
P.S. It’s time for a haiku!
Write down who you are
So that when tomorrow comes
You won’t start from scratch