Lesson 7
Host composition and frames
Understand the process owner that coordinates composition, snapshot notifications, frame time, rendering, and cleanup.
Learning goal
Understand the process owner that coordinates composition, snapshot notifications, frame time, rendering, and cleanup.
The host is an orchestrator
flowchart TD
Content[AtlasContent] --> Composition
StateWrite[Snapshot state write] --> Notify[Apply notifications]
Notify --> Recomposer
Clock[MonotonicFrameClock] --> Recomposer
Recomposer -->|apply changes| Applier
Applier --> Tree
Applier -->|onEndChanges| RenderRequest
RenderRequest --> Renderer
Tree --> Renderer
Renderer --> Surface
DriverEnd[driver ends or critical work fails] --> TerminalState[terminal-state coordinator]
TerminalState --> Lifecycle
Lifecycle -->|dispose| Composition
Lifecycle -->|cancel/join| Recomposer
Lifecycle -->|cancel| Clock
Lifecycle -->|cancel/join| OwnerJob[owner job]
Lifecycle -->|dispose| NotifyThe host does not decide layout or component behavior. It owns the lifetimes and scheduling relationships between those systems.
Required Runtime objects
val root = RootNode()
val ownerJob = SupervisorJob(parentJob)
val baseContext = ownerJob + ownerDispatcher
val frameClock = BroadcastFrameClock()
val scope = CoroutineScope(baseContext + frameClock)
val recomposer = Recomposer(baseContext)
val renderRequests = Channel<Unit>(Channel.CONFLATED)
val applier = AtlasApplier(root) { renderRequests.trySend(Unit) }
val composition = Composition(applier, recomposer)
fun launchCritical(
block: suspend CoroutineScope.() -> Unit,
) = scope.launch {
try {
block()
} catch (cancelled: CancellationException) {
if (terminalState.isStopping) throw cancelled
// Recomposer effect failure can cancel its runner and retain the
// original failure as this cancellation's cause.
terminateFailed(cancelled.cause ?: cancelled)
} catch (failure: Throwable) {
terminateFailed(failure) // record once, then cancel ownerJob
}
}
val renderJob = launchCritical {
for (ignored in renderRequests) {
renderer.render(root)
}
}
ownerDispatcher represents a sequential owner context supplied by the embedding application. parentJob can cancel the session, while the session-owned SupervisorJob prevents an uncaught child failure from canceling the caller before Atlas records it. Every critical loop runs through launchCritical; the terminal-state coordinator is marked as stopping before normal shutdown cancels Runtime work. An unexpected cancellation while it is not stopping—such as a LaunchedEffect failure canceling the Recomposer runner—is recorded as failure using the cancellation’s cause. terminateFailed then cancels sibling host work. The node tree must not be mutated by recomposition while another coroutine renders it concurrently.
Most importantly, the coroutine calling runRecomposeAndApplyChanges() must itself contain a MonotonicFrameClock:
launchCritical {
recomposer.runRecomposeAndApplyChanges()
}
This works because scope was created with frameClock. Supplying a clock only to the Recomposer constructor while launching the runner in a clockless context is not sufficient.
Snapshot notification bridge
Snapshot writes outside an explicit mutable snapshot must be delivered to apply observers. A custom host commonly coalesces global-write callbacks before calling Snapshot.sendApplyNotifications().
private val snapshotWrites = Channel<Unit>(Channel.CONFLATED)
private val snapshotObserver = Snapshot.registerGlobalWriteObserver {
snapshotWrites.trySend(Unit)
}
private fun launchSnapshotPump() = launchCritical {
for (ignored in snapshotWrites) {
Snapshot.sendApplyNotifications()
}
}
This is conceptual host wiring: the observer callback may run where the write occurs, so it only signals the owner context. It should not mutate the node tree or poll continuously.
Frame driver boundary
fun interface FrameDriver {
/** Returns null when this driver has ended normally. */
suspend fun nextFrameNanos(): Long?
}
private fun launchFrames(
frameDriver: FrameDriver,
frameClock: BroadcastFrameClock,
) = launchCritical {
while (isActive) {
val timeNanos = frameDriver.nextFrameNanos()
?: return@launchCritical terminateClosed()
frameClock.sendFrame(timeNanos)
}
}
Frame times sent to withFrameNanos must be strictly increasing. A test driver can advance deterministically; an adapter can later connect this contract to a real surface lifecycle. A null result enters the same idempotent terminal-state coordinator used by shutdown and failures; merely returning from the frame child would leave its parent and sibling loops active.
Lifecycle skeleton
class AtlasHost(/* contracts injected here */) {
// root, scope, clock, recomposer, applier, composition, observer
suspend fun setContent(content: AtlasContent) =
withContext(scope.coroutineContext) {
composition.setContent(content)
}
suspend fun shutdown() {
snapshotObserver.dispose()
withContext(ownerDispatcher) {
composition.dispose()
recomposer.cancel()
frameClock.cancel()
}
ownerJob.cancelAndJoin()
recomposer.join()
}
}
setContent, disposal, Applier mutation, layout, and rendering must all execute serially on the owner context. Cross-thread callbacks only enqueue work. A full implementation should make start/shutdown idempotence explicit, retain critical jobs, and surface their failure to its owner. Cancellation is cooperative, so shutdown waits rather than merely requesting cancellation; it should not hide runner failure behind an unconditional awaitCancellation().
Two clocks, one important distinction
flowchart LR
FrameOpportunity[frame opportunity] --> ComposeClock[Compose frame clock]
ComposeClock --> Effects[withFrameNanos / animation]
Applied[tree changes applied] --> RenderSchedule[render request]
RenderSchedule --> Present[surface presentation]They may share one driver, but they express different responsibilities:
- the Compose clock resumes frame-aware coroutine work;
- render scheduling decides when committed node state becomes a
SurfaceFrame.
Deterministic test picture
sequenceDiagram
participant Test
participant Driver
participant FrameLoop
participant Clock
participant Recomposer
participant Applier
participant Recorder
Test->>Driver: advance to 16 ms
Driver-->>FrameLoop: nextFrameNanos returns
FrameLoop->>Clock: sendFrame(16_000_000)
Clock-->>Recomposer: resume parent withFrameNanos
Recomposer->>Applier: apply pending changes
Applier->>Recorder: request/render committed tree
Test->>Recorder: inspect latest frameVisual checkpoint
Draw the host diagram without looking. Use different colors for lifecycle ownership, Runtime work, and toolkit work. Ensure snapshot callbacks and frame callbacks enter through the owner context.
Review questions
- Why must the Recomposer runner’s context contain the frame clock?
- Why should the global-write observer only signal the owner context?
- How is a Compose frame clock different from rendering a surface frame?
Recap
The host owns lifetimes and scheduling.
The Recomposer owns recomposition coordination.
The Applier commits tree changes.
Atlas renders only committed tree state.
Sources
Finished this lesson?
Your progress stays only in this browser.