packages/SystemUI/utils/kairos/docs/flow-to-kairos-cheatsheet.md
Kairos evaluates all events (TFlow emissions + observers) in a transaction.
Kairos splits Flow APIs into two distinct types: TFlow and TState
TFlow is roughly equivalent to SharedFlow w/ a replay cache that
exists for the duration of the current Kairos transaction and shared with
SharingStarted.WhileSubscribed()
TState is roughly equivalent to StateFlow shared with
SharingStarted.Eagerly, but the current value can only be queried within
a Kairos transaction, and the value is only updated at the end of the
transaction
Kairos further divides Flow APIs based on how they internally use state:
FrpTransactionScope: APIs that internally query some state need to be performed within an Kairos transaction
FrpStateScope: APIs that internally accumulate state in reaction to
events need to be performed within an FRP State scope (akin to a
CoroutineScope)
FrpBuildScope: APIs that perform external side-effects (Flow.collect)
need to be performed within an FRP Build scope (akin to a CoroutineScope)
FrpNetwork.activateSpec { … }All other APIs can be used anywhere
Use emptyTFlow
// this TFlow emits nothing
val noEvents: TFlow<Int> = emptyTFlow
Use TFlow.map / TState.map
val anInt: TState<Int> = …
val squared: TState<Int> = anInt.map { it * it }
val messages: TFlow<String> = …
val messageLengths: TFlow<Int> = messages.map { it.size }
Use TFlow.filter / TFlow.mapNotNull
val messages: TFlow<String> = …
val nonEmpty: TFlow<String> = messages.filter { it.isNotEmpty() }
Convert the TState to TFlow using TState.stateChanges, then use
TFlow.filter / TFlow.mapNotNull
If you need to convert back to TState, use TFlow.hold(initialValue) on the
result.
tState.stateChanges.filter { … }.hold(initialValue)
Note that TFlow.hold is only available within an FrpStateScope in order to
track the lifetime of the state accumulation.
Use combine(TStates)
val someInt: TState<Int> = …
val someString: TState<String> = …
val model: TState<MyModel> = combine(someInt, someString) { i, s -> MyModel(i, s) }
Convert the TFlows to TStates using TFlow.hold(initialValue), then use
combine(TStates)
If you want the behavior of Flow.combine where nothing is emitted until each TFlow has emitted at least once, you can use filter:
// null used as an example, can use a different sentinel if needed
combine(tFlowA.hold(null), tFlowB.hold(null)) { a, b ->
a?.let { b?.let { … } }
}
.filterNotNull()
Note that TFlow.hold is only available within an FrpStateScope in order to
track the lifetime of the state accumulation.
Flow.combine always tracks the last-emitted value of each Flow it's
combining. This is a form of state-accumulation; internally, it collects from
each Flow, tracks the latest-emitted value, and when anything changes, it
re-runs the lambda to combine the latest values.
An effect of this is that Flow.combine doesn't emit until each combined Flow
has emitted at least once. This often bites developers. As a workaround,
developers generally append .onStart { emit(initialValue) } to the Flows
that don't immediately emit.
Kairos avoids this gotcha by forcing usage of TState for combine, thus
ensuring that there is always a current value to be combined for each input.
Use observe { … }
val job: Job = tFlow.observe { println("observed: $it") }
Note that observe is only available within an FrpBuildScope in order to
track the lifetime of the observer. FrpBuildScope can only come from a
top-level FrpNetwork.transaction { … }, or a sub-scope created by using a
-Latest operator.
Use TState.sample() to get the current value of a TState. This can be
invoked anywhere you have access to an FrpTransactionScope.
// the lambda passed to map receives an FrpTransactionScope, so it can invoke
// sample
tFlow.map { tState.sample() }
To keep all state-reads consistent, the current value of a TState can only be
queried within a Kairos transaction, modeled with FrpTransactionScope. Note
that both FrpStateScope and FrpBuildScope extend FrpTransactionScope.
Convert to a TState by using TFlow.hold(initialValue), then use sample.
Note that hold is only available within an FrpStateScope in order to track
the lifetime of the state accumulation.
Use TFlow.hold(initialValue). There is no need to supply a sharingStarted
argument; all states are accumulated eagerly.
val ints: TFlow<Int> = …
val lastSeenInt: TState<Int> = ints.hold(initialValue = 0)
Note that hold is only available within an FrpStateScope in order to track
the lifetime of the state accumulation (akin to the scope parameter of
Flow.stateIn). FrpStateScope can only come from a top-level
FrpNetwork.transaction { … }, or a sub-scope created by using a -Latest
operator. Also note that FrpBuildScope extends FrpStateScope.
Use distinctUntilChanged like normal. This is only available for TFlow;
TStates are already distinctUntilChanged.
Use merge(TFlows) { … }. The lambda argument is used to disambiguate multiple
simultaneous emissions within the same transaction.
Under Kairos's rules, a TFlow may only emit up to once per transaction. This
means that if we are merging two or more TFlows that are emitting at the same
time (within the same transaction), the resulting merged TFlow must emit a
single value. The lambda argument allows the developer to decide what to do in
this case.
If combine doesn't satisfy your needs, you can use TState.stateChanges to
convert to a TFlow, and then merge.
Use tFlow { … }.
As a shortcut, if you already have a conflatedCallbackFlow { … }, you can
convert it to a TFlow via Flow.toTFlow().
Note that tFlow is only available within an FrpBuildScope in order to track
the lifetime of the input registration.
Use TState.sample.
Use TFlow.nextOnly, which works exactly like Flow.first but instead of
suspending it returns a TFlow that emits once.
The naming is intentionally different because first implies that it is the
first-ever value emitted from the Flow (which makes sense for cold Flows),
whereas nextOnly indicates that only the next value relative to the current
transaction (the one nextOnly is being invoked in) will be emitted.
Note that nextOnly is only available within an FrpStateScope in order to
track the lifetime of the state accumulation.
If you want to use -Latest to cancel old side-effects, similar to what the Flow
-Latest operators offer for coroutines, see mapLatest.
Use TState.flatMap
val flattened = tState.flatMap { a -> getTState(a) }
Use TState<TFlow<T>>.switch()
val tFlow = tState.map { a -> getTFlow(a) }.switch()
Use hold to convert to a TState<TFlow<T>>, then use switch to switch to
the latest TFlow.
val tFlow = tFlowOfFlows.hold(emptyTFlow).switch()
Use hold to convert to a TState<TState<T>>, then use flatMap to switch to
the latest TState.
val tState = tFlowOfStates.hold(tStateOf(initialValue)).flatMap { it }
FrpStateScope and FrpBuildScope both provide -Latest operators that
automatically cancel old work when new values are emitted.
val currentModel: TState<SomeModel> = …
val mapped: TState<...> = currentModel.mapLatestBuild { model ->
effect { "new model in the house: $model" }
model.someState.observe { "someState: $it" }
val someData: TState<SomeInfo> =
getBroadcasts(model.uri)
.map { extractInfo(it) }
.hold(initialInfo)
…
}
Use tStateOf(initialValue).
Use now.map { initialValue }
Note that now is only available within an FrpTransactionScope.
TFlows are not cold, and so there isn't a notion of "emit this value once
there is a collector" like there is for Flow. The closest analog would be
TState, since the initial value is retained indefinitely until there is an
observer. However, it is often useful to immediately emit a value within the
current transaction, usually when using a flatMap or switch. In these cases,
using now explicitly models that the emission will occur within the current
transaction.
fun <T> FrpTransactionScope.tFlowOf(value: T): TFlow<T> = now.map { value }
Use MutableTState(frpNetwork, initialValue) and MutableTFlow(frpNetwork).