Skip to content
academia.sh

Lesson 04 / 14

Observable State

Observable state is a state flow that publishes a new value to collectors as its value changes; a flow always has an initial value. The lesson measures the origin of the value a collector reads before loading finishes and after it finishes.

Published Updated

In this lesson

  • Explains that observable state starts with an initial value and publishes changes to the collector
  • Measures that the value a collector reads before loading finishes comes from the default
  • Shows that a slow collector does not see the intermediate value but does not change the origin of the final value
  • Classifies that the same flow answers from two different origins while loading runs and after it finishes
Contents

In the previous lesson the fields of the state holder returned after the process died, but the holder did not say whether loading had finished. How does the UI find out the value? It uses a structure that reports as the value changes: observable state. The question is the same: where does the answer to this read come from?

The measuring tool is the core of the previous lessons; we repeat it unchanged.

// Origin.kt — classifies which tier a read's answer came from
class Tier(val origin: String, private val entries: Map<String, String>) {
    init {
        require(origin in listOf("memory", "disk", "network")) { "invalid tier: $origin" }
    }

    fun lookup(key: String): String? = entries[key]
}

class Read(val label: String, val key: String, chain: List<Tier>, fallback: String) {
    private val hit: Tier? = chain.firstOrNull { it.lookup(key) != null }
    val value: String = hit?.lookup(key) ?: fallback
    val origin: String = hit?.origin ?: "default"

    fun show() {
        println("$label: key=$key | value=$value -> $origin")
    }
}

fun countOrigins(reads: List<Read>) {
    for (name in listOf("memory", "disk", "network", "default")) {
        println("$name: ${reads.count { it.origin == name }}")
    }
}

The Initial Value of a Flow

On the platform, observable state is usually a state flow (StateFlow). A flow always carries a value: the initial value given when it is built. The side that changes the state writes the new value to the flow; the side that reads the value, the collector, collects the flow and receives each new value. Collecting runs in a coroutine; if a flow is derived from another source, it is turned into a state flow with stateIn and is usually built with an initial value.

// Platform code: CartViewModel
import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow

class CartViewModel : ViewModel() {
    private val _state = MutableStateFlow(CartUiState())
    val state: StateFlow<CartUiState> = _state

    fun onLoaded(items: List<CartItem>) {
        _state.value = CartUiState(items)
    }
}

The UI collects the flow and is redrawn on every value.

// Platform code: CartScreen
import androidx.compose.runtime.getValue

@Composable
fun CartScreen(viewModel: CartViewModel) {
    val state by viewModel.state.collectAsState()
    Text("Items: ${state.items.size}")
}

CartUiState() is the initial value of the flow: the cart has not been loaded yet and the list is empty. These two pieces are platform code, and here we make no claim about output, timing or file paths. The model builds this rule of the platform; the model is not the platform itself.

In the model we build the flow as a plain list: the initial value is the first element of the list, and each emission adds a new value written over the previous last one. latest returns the last value, fastCollector returns every emission, and slowCollector returns only the last emission. collectReads builds a collector’s four reads; in the collector’s chain there is no tier other than the flow.

// Feed.kt — observable state: there is an initial value, each emission is added to the list
class Feed(initial: Map<String, String>) {
    private val emitted = mutableListOf(initial)

    fun emit(update: Map<String, String>) {
        emitted.add(emitted.last() + update)
    }

    fun latest(): Map<String, String> = emitted.last()
    fun fastCollector(): List<Map<String, String>> = emitted.toList()
    fun slowCollector(): List<Map<String, String>> = listOf(emitted.last())
}

fun collectReads(value: Map<String, String>): List<Read> {
    val chain = listOf(Tier("memory", value))
    return listOf(
        Read("title", "screen.title", chain, "empty"),
        Read("product-price", "product.price", chain, "0"),
        Read("stock", "product.stock", chain, "0"),
        Read("campaign", "campaign.code", chain, "none"),
    )
}

Before loading starts, the collector collects the flow. In the flow’s initial value there is only the screen title.

// Main.kt — the collector's first read happens before loading finishes
fun main() {
    val feed = Feed(mapOf("screen.title" to "Cart"))
    val reads = collectReads(feed.latest())
    reads.forEach { it.show() }
    countOrigins(reads)
}
title: key=screen.title | value=Cart -> memory
product-price: key=product.price | value=0 -> default
stock: key=product.stock | value=0 -> default
campaign: key=campaign.code | value=none -> default
memory: 1
disk: 0
network: 0
default: 3

The title came from memory because it was in the flow’s initial value. The price, stock and campaign are not in the flow because they have not been loaded: three reads fell to the default. The network response is on its way; a tier that has not answered is not in the collector’s chain.

AA1. The model builds the flow as a plain list and puts only the flow in the collector’s chain. On the platform, loading can also start from disk or the network at the same time; the collector still looks only at the flow.

The Slow Collector

Let the flow make two emissions: first an old price, then the new price. Call the collector that sees every emission fast, and the one that sees only the last slow. Because a state flow conflates values, the slow collector never sees the intermediate value.

// Main.kt — the slow collector does not see the intermediate value
fun main() {
    val feed = Feed(mapOf("screen.title" to "Cart"))
    feed.emit(mapOf("product.price" to "100"))
    feed.emit(mapOf("product.price" to "120"))
    println("fast collector:")
    for (value in feed.fastCollector()) {
        println("  price=${value["product.price"] ?: "none"}")
    }
    println("slow collector:")
    for (value in feed.slowCollector()) {
        println("  price=${value["product.price"] ?: "none"}")
    }
    countOrigins(collectReads(feed.latest()))
}
fast collector:
  price=none
  price=100
  price=120
slow collector:
  price=120
memory: 2
disk: 0
network: 0
default: 2

The fast collector saw three values: no price, 100, 120. The slow collector saw only 120. Both collectors ended up reading the same value, and the origin of that value is the same: every value inside the flow is memory. In the four reads made with the last value, the result was two memory and two default.

AA2. The model builds slowness with two fixed collector types. On the platform a collector’s slowness depends on timing, and the model has no time.

The Bounding Measurement

We read the same flow at two moments: while loading runs and after it finishes. When loading finishes, the price and stock are written to the flow; that is, the keys enter memory.

// Main.kt — the same flow gives two origins at two moments
fun main() {
    val feed = Feed(mapOf("screen.title" to "Cart"))
    println("loading: in progress")
    countOrigins(collectReads(feed.latest()))

    feed.emit(mapOf("product.price" to "120", "product.stock" to "7"))
    println("loading: finished")
    val after = collectReads(feed.latest())
    after.forEach { it.show() }
    countOrigins(after)
}
loading: in progress
memory: 1
disk: 0
network: 0
default: 3
loading: finished
title: key=screen.title | value=Cart -> memory
product-price: key=product.price | value=120 -> memory
stock: key=product.stock | value=7 -> memory
campaign: key=campaign.code | value=none -> default
memory: 3
disk: 0
network: 0
default: 1

While loading ran, one read gave memory and three reads gave default. When loading finished, the price and stock entered the flow: memory rose to three and default dropped to one. Same flow, same collector, same four reads; the only thing that changed is the moment at which the flow was read. campaign stays default because it did not enter the flow even after loading finished.

AA3. The model marks the end of loading with a single emission. On the platform loading finishes piece by piece and each piece can be a separate emission; the model has no such distinction.

Summary

  • This lesson’s origin: default. When the collector read before loading finished, the price, stock and campaign were not in the flow, so the reads’ own defaults became the answer.
  • The lesson’s measure is which moment of the flow the value the collector read came from. While loading ran, memory was 1 and default 3; after it finished, memory was 3 and default 1.
  • The initial value of a flow is an answer: the screen title came from memory because it was in the initial value.
  • The slow collector did not see the intermediate value 100, only 120; the origin of every value in the flow is memory.
  • In the flow’s chain there is no tier that has not answered; the collector sees only the flow.

Next Step

In this lesson the flow took loading from outside and published it. How an event changes the flow is a separate question. The next lesson, Unidirectional Data Flow, measures how an event rebuilds the state in memory and that the value goes down from the top rather than up from the UI.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close