SnapshotFlowManager: How Compose Shares Observation Infrastructure Across Snapshot Flows
SnapshotFlowManager: How Compose Shares Observation Infrastructure Across Snapshot Flows
Compose's snapshotFlow converts snapshot state reads into a Kotlin Flow. Each call to snapshotFlow registers its own apply observer with the snapshot system, watching for state changes that should trigger new emissions. When an app uses dozens of snapshot flows, each flow independently registers and processes apply notifications. This means N flows produce N observers, each iterating over the same set of changed objects to determine whether its own watched states were modified. The new SnapshotFlowManager consolidates this work by sharing a single apply observer across multiple flows, reducing redundant processing at the observation layer.
In this article, you'll explore how snapshotFlow works internally, why per flow apply observers create overhead, how SnapshotFlowManager shares observation infrastructure across flows, the auto promotion from single to multi subscription backing, and practical usage patterns for ViewModels with many state observations.
The fundamental problem: Per flow observation overhead
Consider a ViewModel that observes multiple pieces of snapshot state:
class DashboardViewModel : ViewModel() {
private val userState = mutableStateOf(User.empty())
private val notifications = mutableStateOf(emptyList<Notification>())
private val settings = mutableStateOf(Settings.default())
private val networkStatus = mutableStateOf(NetworkStatus.Connected)
val userFlow = snapshotFlow { userState.value }
val notificationsFlow = snapshotFlow { notifications.value }
val settingsFlow = snapshotFlow { settings.value }
val networkFlow = snapshotFlow { networkStatus.value }
}
Each snapshotFlow call creates its own internal SnapshotFlowManager, and each manager registers its own apply observer via Snapshot.registerApplyObserver. Every time any snapshot is applied anywhere in the application, all four observers run. Each observer receives the full set of changed objects and must check whether any of its watched states are in that set. With four flows, this is manageable. With twenty or thirty flows observing different pieces of state in a complex screen, the overhead of running that many independent observers on every snapshot apply becomes measurable.
The key observation: all of these flows are typically collected on the same thread (the main thread or a single coroutine dispatcher). They don't need independent observation infrastructure. SnapshotFlowManager solves this by letting multiple flows share a single apply observer that handles notifications for all of them.
How snapshotFlow works internally
Before examining the manager, it helps to understand what snapshotFlow does under the hood. The internal implementation lives in snapshotFlowImpl, which builds a cold Flow around a simple loop. The flow takes a snapshot, runs the user's block inside it, records which state objects were read, and emits the result. Then it waits for a notification that one of those state objects changed, and repeats.
Looking at snapshotFlowImpl, the function creates a cold flow that resolves a manager and sets up a conflated channel for change notifications:
private fun <T> snapshotFlowImpl(
externalManager: SnapshotFlowManager?,
block: () -> T
): Flow<T> = flow {
val manager = externalManager ?: SnapshotFlowManager()
val needToRerunBlock = Channel<Unit>(1)
try {
var lastValue = manager.runAndWatch(needToRerunBlock, block)
emit(lastValue)
The initial call to runAndWatch executes the block inside a read only snapshot, records which state objects were read, and emits the first result. From there, the flow enters a loop that waits for change notifications:
while (true) {
needToRerunBlock.receive()
val newValue = manager.runAndWatch(needToRerunBlock, block)
if (newValue != lastValue) {
lastValue = newValue
emit(newValue)
}
}
} finally {
manager.reportSnapshotFlowCancellation(needToRerunBlock)
if (externalManager == null) {
manager.dispose()
}
}
}
The flow proceeds through these steps:
- Manager resolution: If no external manager was provided, it creates a new one. This is the default behavior when you call
snapshotFlow { ... }without a manager argument. - Initial emission:
runAndWatchexecutes the block inside a read only snapshot, records which state objects were read, and subscribes theneedToRerunBlockchannel to be notified when any of those objects change. - Change loop: The flow suspends on
needToRerunBlock.receive(). When a watched state object changes, the apply observer sends aUnitto this channel, waking the flow. It then runs the block again, records the new set of dependencies, and emits only if the result differs from the last emission. - Cleanup: On cancellation, the flow reports its channel to the manager so subscriptions can be cleaned up. If the manager was created internally, it is also disposed.
Notice the if (externalManager == null) check in the finally block. When a manager is supplied externally, the flow does not dispose it, because other flows may still be using it. This is what makes shared managers possible.
This article continues for subscribers
Subscribe to Dove Letter for full access to 40+ deep-dive articles about Android and Kotlin development.
Become a Sponsor