Lesson 06 / 17
Immutability and Stability
If the platform knows whether a value changed, it can skip during recomposition. The lesson measures that a list modified in place and an event that produces a new list go in the same direction, up, but that only one of them is observed.
Published Updated
In this lesson
- Explains the difference between a stable and an unstable type by the criterion of what the platform can know
- Measures the direction of a list modified in place and of an event that produces a new list
- Shows in the model that an observed change depends on the reference changing
- Shows that producing a new list with an event consists of two moves, up and down
Contents
The previous lesson assumed that a composable function whose inputs did not change is skipped. This lesson opens up that assumption: how does the platform know whether a value changed? The answer does not change the direction of the move, but it changes whether the move is seen. The question of the lesson is again the same: which direction does this value travel in the UI tree? It can go up in two different ways, and only in one of them does the platform notice.
// Direction.kt — classifies which direction a value travels in the UI tree
class Tree(private val parentOf: Map<String, String>) {
fun contains(node: String): Boolean = node in parentOf.keys || node in parentOf.values
fun parent(node: String): String? = parentOf[node]
fun ancestors(node: String): List<String> {
val chain = mutableListOf<String>()
var current = parentOf[node]
while (current != null) {
chain.add(current)
current = parentOf[current]
}
return chain
}
}
class Move(val label: String, val from: String, val to: String, tree: Tree) {
val direction: String = when {
!tree.contains(from) || !tree.contains(to) -> "outside"
to in tree.ancestors(from) -> "up"
tree.parent(to) == from -> "down"
from in tree.ancestors(to) -> "deep"
else -> error("unrelated: $from and $to")
}
fun describe() {
println("$label: from=$from | to=$to -> $direction")
}
}
fun countDirections(moves: List<Move>) {
for (name in listOf("down", "up", "deep", "outside")) {
println("$name: ${moves.count { it.direction == name }}")
}
}
What the Platform Knows
According to the platform documentation, a type is stable if it is immutable, or if the platform can know whether its value changed between recompositions. A type whose value the platform cannot know is unstable. The documentation’s example: a data class whose properties are val is immutable; in the version whose properties are var, the platform would not become aware if the properties changed, because the platform tracks only the changes in its own state objects.
For list interfaces the documentation says this: the standard collection interfaces (List, Set, Map) are considered unstable, because the implementation behind them may be mutable. According to the documentation, strong skipping mode is on by default in Kotlin 2.0.20 and above. With the mode on, unstable parameters are compared by reference equality (===), and if the same object is passed, the composable function is skipped.
// Platform code: MutableList and data class
import androidx.compose.runtime.*
class CartState(val items: MutableList<String>)
@Composable
fun CartRow(state: CartState) {
Button(onClick = { state.items.add("c") }) {
Text("Add: ${state.items.size}")
}
}
data class CartItems(val items: List<String>)
@Composable
fun CartList(cart: CartItems, onAdd: (CartItems) -> Unit) {
Button(onClick = { onAdd(cart.copy(items = cart.items + "c")) }) {
Text("Add: ${cart.items.size}")
}
}
The first composable function modifies the list where it stands: state and its list remain the same object. The second produces a new CartItems and gives the change to its owner as an event. This fragment is platform code, and here we make no claim about output. The model builds this rule of the platform; the model is not the platform itself.
The model builds “observed” with a simple rule: if the lists before and after the change are the same object, the platform cannot notice; if they are different objects, it does. An Edit carries the one who makes the change, the owner of the list, and the lists before and after.
// Edits.kt — the direction of a change and whether it was observed
class Edit(
val label: String,
val by: String,
val owner: String,
before: List<String>,
val after: List<String>,
tree: Tree,
) {
val move = Move(label, by, owner, tree)
val observed: Boolean = before !== after
fun report() {
move.describe()
println(" observed=$observed | content=$after")
}
}
CM11. The model builds being observed with a reference difference. How the platform infers stability for which types, the effect of the compiler setting, and the cost of the equality comparison were not measured.
Modifying in Place
The child modifies the owner’s list where it stands. The data goes from the child to the owner; the owner is Screen, the one who modifies is CartRow.
// Main.kt — modifying in place: the direction is up, not observed
fun main() {
val tree = Tree(mapOf(
"Screen" to "App",
"CartRow" to "Screen",
))
val list = mutableListOf("a", "b")
val before: List<String> = list
list.add("c")
val edit = Edit("modify", "CartRow", "Screen", before, list, tree)
edit.report()
}
modify: from=CartRow | to=Screen -> up observed=false | content=[a, b, c]
The direction of the move is up, because the target is an ancestor of the source. But because the list remained the same object, observed=false: the content changed, the platform did not learn of the change. The screen may stay with the old content.
A New List with an Event
The child does not modify the list; it gives an event to its owner, and the owner produces a new list.
// Main.kt — a new list with an event: the direction is up, observed
fun main() {
val tree = Tree(mapOf(
"Screen" to "App",
"CartRow" to "Screen",
))
val before = listOf("a", "b")
val after = before + "c"
val edit = Edit("event", "CartRow", "Screen", before, after, tree)
edit.report()
}
event: from=CartRow | to=Screen -> up observed=true | content=[a, b, c]
The direction is the same: up. Because the list is a new object, observed=true. This is the direction of the lesson: up.
The story of the event does not end here. When the owner knows the new list, it gives it down to the reader.
// Main.kt — the full flow: one up, one down
fun main() {
val tree = Tree(mapOf(
"Screen" to "App",
"CartRow" to "Screen",
))
val moves = listOf(
Move("event", "CartRow", "Screen", tree),
Move("new-list", "Screen", "CartRow", tree),
)
moves.forEach { it.describe() }
countDirections(moves)
}
event: from=CartRow | to=Screen -> up new-list: from=Screen | to=CartRow -> down down: 1 up: 1 deep: 0 outside: 0
In modifying in place there is only one move and no return. A new list with an event consists of two moves: the event up, the new list down. It is the same flow as in Lesson 03. The two lines above are written to show the rule; they are not a measurement: both ends are written by hand.
This lesson differs from M31/K01. That course built whether two values would be counted as equal. Here equality is not measured; what is measured is the direction of the move and whether the change was seen.
CM12. The owner giving the new list down is built in the model as a move. Which scopes the new list causes to recompose on the platform was not measured.
The Bounding Measurement
The same one who modifies, the same owner, the same content. The two methods of change side by side.
// Main.kt — the same direction, different observation
fun main() {
val tree = Tree(mapOf(
"Screen" to "App",
"CartRow" to "Screen",
))
val inPlace = mutableListOf("a", "b")
val beforeInPlace: List<String> = inPlace
inPlace.add("c")
val copied = listOf("a", "b")
val a = Edit("in-place", "CartRow", "Screen", beforeInPlace, inPlace, tree)
val b = Edit("copy", "CartRow", "Screen", copied, copied + "c", tree)
println("in-place: direction=${a.move.direction} observed=${a.observed}")
println("copy: direction=${b.move.direction} observed=${b.observed}")
}
in-place: direction=up observed=false copy: direction=up observed=true
The two changes go in the same direction. The difference is not in the direction but in the observation. Because the rule looks at the direction, it gives the same answer to both; whether it is seen depends on the list changing as an object.
Summary
- This lesson’s direction: up. A child modifying the owner’s list and a child giving an event to its owner both go up.
- In modifying in place the content changed but it was not observed: up 1, observed false.
- In producing a new list with an event, the same direction was measured with observed true.
- Producing a new list with an event is shown as two moves: the event up, the new list down. This line is written to show the rule; it is not a measurement, since both ends are written by hand.
- The two methods of the same direction were measured side by side: both up, and only the copy was observed.
Next Step
This lesson closes the composable function model: composable function, state, state hoisting, recomposition, side effects, and stability. The next topic, Basic Layouts, builds the layout on the screen on top of this model.
To keep your progress and take notes, Log in
My notes
Log in to take notes.