Skip to content

Commit 912b1c7

Browse files
Refactor RecomposerDriver -> SynchronizedMolecule.
SynchronizedMolecule fully encapsulates the Compose runtime into a clean, minimal interface with a single entry point, much like launchMolecule does. This should be much more testable, and we could potentially even pull it out into the Molecule library. It also cleans up the code inside ComposeWorkflowNodeAdapter even more.
1 parent af9f971 commit 912b1c7

File tree

12 files changed

+367
-360
lines changed

12 files changed

+367
-360
lines changed
Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,7 @@
1-
package com.squareup.workflow1.internal.compose.coroutines
1+
package com.squareup.workflow1.internal
22

33
import kotlinx.coroutines.channels.SendChannel
44

5-
internal expect class Lock()
6-
7-
internal expect inline fun <R> Lock.withLock(block: () -> R): R
8-
95
/**
106
* Tries to send [element] to this channel and throws an [IllegalStateException] if the channel is
117
* full or closed.

workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/ComposeWorkflowChildNode.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ import com.squareup.workflow1.compose.WorkflowComposableRenderer
2929
import com.squareup.workflow1.identifier
3030
import com.squareup.workflow1.internal.IdCounter
3131
import com.squareup.workflow1.internal.WorkflowNodeId
32-
import com.squareup.workflow1.internal.compose.coroutines.requireSend
32+
import com.squareup.workflow1.internal.requireSend
3333
import com.squareup.workflow1.internal.createId
3434
import com.squareup.workflow1.workflowSessionToString
3535
import kotlinx.coroutines.CoroutineName
Lines changed: 71 additions & 140 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,9 @@
11
package com.squareup.workflow1.internal.compose
22

3-
import androidx.compose.runtime.Composition
4-
import androidx.compose.runtime.Recomposer
5-
import androidx.compose.runtime.SideEffect
6-
import androidx.compose.runtime.getValue
7-
import androidx.compose.runtime.mutableStateOf
8-
import androidx.compose.runtime.setValue
93
import androidx.compose.runtime.snapshots.Snapshot
104
import com.squareup.workflow1.ActionApplied
115
import com.squareup.workflow1.ActionProcessingResult
126
import com.squareup.workflow1.NoopWorkflowInterceptor
13-
import com.squareup.workflow1.NullableInitBox
147
import com.squareup.workflow1.RuntimeConfig
158
import com.squareup.workflow1.RuntimeConfigOptions
169
import com.squareup.workflow1.TreeSnapshot
@@ -22,11 +15,10 @@ import com.squareup.workflow1.WorkflowTracer
2215
import com.squareup.workflow1.compose.ComposeWorkflow
2316
import com.squareup.workflow1.internal.AbstractWorkflowNode
2417
import com.squareup.workflow1.internal.IdCounter
25-
import com.squareup.workflow1.internal.UnitApplier
2618
import com.squareup.workflow1.internal.WorkflowNodeId
27-
import kotlinx.coroutines.CoroutineStart.ATOMIC
28-
import kotlinx.coroutines.ExperimentalCoroutinesApi
29-
import kotlinx.coroutines.launch
19+
import com.squareup.workflow1.internal.compose.runtime.SynchronizedMolecule
20+
import com.squareup.workflow1.internal.compose.runtime.launchSynchronizedMolecule
21+
import kotlinx.coroutines.channels.Channel
3022
import kotlinx.coroutines.selects.SelectBuilder
3123
import kotlin.coroutines.CoroutineContext
3224

@@ -69,154 +61,104 @@ internal class ComposeWorkflowNodeAdapter<PropsT, OutputT, RenderingT>(
6961
emitAppliedActionToParent = emitAppliedActionToParent,
7062
) {
7163

72-
private val recomposer: Recomposer = Recomposer(coroutineContext)
73-
74-
private val recomposerDriver = RecomposerDriver(recomposer)
75-
private val composition: Composition = Composition(UnitApplier, recomposer)
76-
77-
private var cachedComposeWorkflow: ComposeWorkflow<PropsT, OutputT, RenderingT> by
78-
mutableStateOf(workflow)
79-
private var lastProps by mutableStateOf(initialProps)
80-
private var lastRendering = NullableInitBox<RenderingT>()
64+
private val recomposeChannel = Channel<Unit>(capacity = 1)
65+
private val molecule: SynchronizedMolecule<RenderingT> = launchSynchronizedMolecule(
66+
onNeedsRecomposition = { recomposeChannel.trySend(Unit) }
67+
)
68+
69+
private val childNode = ComposeWorkflowChildNode<PropsT, OutputT, RenderingT>(
70+
id = id,
71+
initialProps = initialProps,
72+
snapshot = snapshot,
73+
baseContext = coroutineContext,
74+
parent = parent,
75+
workflowTracer = workflowTracer,
76+
runtimeConfig = runtimeConfig,
77+
interceptor = interceptor,
78+
idCounter = idCounter,
79+
emitAppliedActionToParent = { actionApplied ->
80+
// Ensure any state updates performed by the output sender gets to invalidate any
81+
// compositions that read them, so we can check needsRecompose below.
82+
Snapshot.sendApplyNotifications()
83+
log(
84+
"adapter node sent apply notifications from action cascade (" +
85+
"actionApplied=$actionApplied, needsRecompose=${molecule.needsRecomposition})"
86+
)
87+
88+
// ComposeWorkflowChildNode can't tell if its own state changed since that information about
89+
// specific composables/recompose scopes is only visible inside the compose runtime, so
90+
// individual ComposeWorkflow nodes always report no state changes (unless they have a
91+
// traditional child that reported a state change).
92+
// However, we *can* check if any state changed that was read by anything in the
93+
// composition, so when an action bubbles up to here, the top of the composition, we use
94+
// that information to set the state changed flag if necessary.
95+
val aggregateAction = if (molecule.needsRecomposition && !actionApplied.stateChanged) {
96+
actionApplied.copy(stateChanged = true)
97+
} else {
98+
actionApplied
99+
}
81100

82-
/**
83-
* This is initialized to null so we don't render the workflow when initially calling
84-
* [composition.setContent]. It is then set, and never nulled out again.
85-
*/
86-
private var childNode: ComposeWorkflowChildNode<PropsT, OutputT, RenderingT>? = null
101+
// Don't bubble up if no state changed and there was no output.
102+
if (aggregateAction.stateChanged || aggregateAction.output != null) {
103+
log("adapter node propagating action cascade up (aggregateAction=$aggregateAction)")
104+
emitAppliedActionToParent(aggregateAction)
105+
} else {
106+
log("adapter node not propagating action cascade since nothing happened (aggregateAction=$aggregateAction)")
107+
aggregateAction
108+
}
109+
}
110+
)
87111

88112
/**
89-
* Function invoked when [onNextAction] receives a frame request from [withFrameNanos].
113+
* Function invoked when [onNextAction] receives a recompose request.
90114
* This handles the case where some state read by the composition is changed but emitOutput is
91115
* not called.
92116
*/
93-
private val processFrameRequestFromChannel: () -> ActionProcessingResult = {
117+
private val processRecompositionRequestFromChannel: (Unit) -> ActionProcessingResult = {
94118
// A pure frame request means compose state was updated that the composition read, but
95119
// emitOutput was not called, so we don't have any outputs to report.
96120
val applied = ActionApplied<OutputT>(
97121
output = null,
98-
stateChanged = recomposerDriver.needsRecompose
122+
// needsRecomposition should always be true now since the runtime explicitly requested
123+
// recomposition, but check anyway.
124+
stateChanged = molecule.needsRecomposition
99125
)
100126

101127
// Propagate the action up the workflow tree.
102128
log("frame request received from channel, sending no output to parent: $applied")
103129
emitAppliedActionToParent(applied)
104130
}
105131

106-
init {
107-
GlobalSnapshotManager.ensureStarted()
108-
109-
// By not calling setContent directly every time, we ensure that if neither the workflow
110-
// instance nor input changed, we don't recompose.
111-
// setContent will synchronously perform the first recomposition before returning, which is why
112-
// we leave cachedComposeWorkflow null for now: we don't want its produceRendering to be called
113-
// until we're actually doing a render pass.
114-
// We also need to set the composition content before calling startComposition so it doesn't
115-
// need to suspend to wait for it.
116-
composition.setContent {
117-
// childNode isn't snapshot state but that's fine, since when the recomposer is started it
118-
// will always recompose, childNode will be non-null by then, and it will never change again.
119-
val childNode = this.childNode
120-
if (childNode != null) {
121-
val rendering = childNode.produceRendering(
122-
workflow = cachedComposeWorkflow,
123-
props = lastProps
124-
)
125-
126-
SideEffect {
127-
this.lastRendering = NullableInitBox(rendering)
128-
}
129-
}
130-
}
131-
132-
childNode = ComposeWorkflowChildNode(
133-
id = id,
134-
initialProps = initialProps,
135-
snapshot = snapshot,
136-
baseContext = coroutineContext,
137-
parent = parent,
138-
workflowTracer = workflowTracer,
139-
runtimeConfig = runtimeConfig,
140-
interceptor = interceptor,
141-
idCounter = idCounter,
142-
emitAppliedActionToParent = { actionApplied ->
143-
// Ensure any state updates performed by the output sender gets to invalidate any
144-
// compositions that read them, so we can check needsRecompose below.
145-
Snapshot.sendApplyNotifications()
146-
log(
147-
"adapter node sent apply notifications from action cascade (" +
148-
"actionApplied=$actionApplied, needsRecompose=${recomposerDriver.needsRecompose})"
149-
)
150-
151-
// ComposeWorkflowChildNode can't tell if its own state changed since that information about
152-
// specific composables/recompose scopes is only visible inside the compose runtime, so
153-
// individual ComposeWorkflow nodes always report no state changes (unless they have a
154-
// traditional child that reported a state change).
155-
// However, we *can* check if any state changed that was read by anything in the
156-
// composition, so when an action bubbles up to here, the top of the composition, we use
157-
// that information to set the state changed flag if necessary.
158-
val aggregateAction = if (recomposerDriver.needsRecompose && !actionApplied.stateChanged) {
159-
actionApplied.copy(stateChanged = true)
160-
} else {
161-
actionApplied
162-
}
163-
164-
// Don't bubble up if no state changed and there was no output.
165-
if (aggregateAction.stateChanged || aggregateAction.output != null) {
166-
log("adapter node propagating action cascade up (aggregateAction=$aggregateAction)")
167-
emitAppliedActionToParent(aggregateAction)
168-
} else {
169-
log("adapter node not propagating action cascade since nothing happened (aggregateAction=$aggregateAction)")
170-
aggregateAction
171-
}
172-
}
173-
)
174-
}
175-
176132
override fun render(
177133
workflow: Workflow<PropsT, OutputT, RenderingT>,
178134
input: PropsT
179135
): RenderingT {
180-
this.cachedComposeWorkflow = workflow as ComposeWorkflow
181-
this.lastProps = input
182-
183136
// Ensure that recomposer has a chance to process any state changes from the action cascade that
184137
// triggered this render before we check for a frame.
185-
log("render sending apply notifications again needsRecompose=${recomposerDriver.needsRecompose}")
138+
log("render sending apply notifications again needsRecompose=${molecule.needsRecomposition}")
186139
// TODO Consider pulling this up into the workflow runtime loop, since we only need to run it
187140
// once before the entire tree renders, not at every level. In fact, if this is only here to
188141
// ensure cachedComposeWorkflow and lastProps are seen, that will only work if this
189142
// ComposeWorkflow is not nested below another traditional and compose workflow, since anything
190143
// rendering under the first CW will be in a snapshot.
191144
Snapshot.sendApplyNotifications()
192-
log("sent apply notifications, needsRecompose=${recomposerDriver.needsRecompose}")
145+
log("sent apply notifications, needsRecompose=${molecule.needsRecomposition}")
193146

194-
val initialRender = !lastRendering.isInitialized
195-
if (initialRender) {
196-
// Initial render kicks off the render loop. This should synchronously request a frame.
197-
startComposition()
198-
}
147+
// If this re-render was not triggered by the channel handler, then clear it so we don't
148+
// immediately trigger another redundant render pass after this.
149+
recomposeChannel.tryReceive()
199150

200-
// Synchronously recompose any invalidated composables, if any, and update lastRendering.
201-
// It is very likely that trySendFrame will fail: any time the workflow runtime is doing a
151+
// It is very likely that this will be a noop: any time the workflow runtime is doing a
202152
// render pass and no state read by our composition changed, there shouldn't be a frame request.
203-
// Hard-code unchanging frame time since there's no actual frame time and workflow code
204-
// shouldn't rely on this value.
205-
log("renderFrame")
206-
val recomposed = recomposerDriver.tryPerformRecompose(frameTimeNanos = 0L)
207-
if (recomposed) {
208-
log("renderFrame finished executing frame")
209-
} else {
210-
log("no frame request at time of render!")
211-
if (initialRender) {
212-
error("Expected initial composition to synchronously request initial frame.")
213-
}
153+
return molecule.recomposeWithContent {
154+
childNode.produceRendering(
155+
workflow = workflow,
156+
props = input
157+
)
214158
}
215-
216-
return lastRendering.getOrThrow()
217159
}
218160

219-
override fun snapshot(): TreeSnapshot = childNode!!.snapshot()
161+
override fun snapshot(): TreeSnapshot = childNode.snapshot()
220162

221163
override fun onNextAction(selector: SelectBuilder<ActionProcessingResult>): Boolean {
222164
// We must register for child actions before frame requests, because selection is
@@ -225,26 +167,15 @@ internal class ComposeWorkflowNodeAdapter<PropsT, OutputT, RenderingT>(
225167
// the output handler will implicitly also handle frame requests. If a frame request happens at
226168
// the same time or the output handler enqueues a frame request, then the subsequent render pass
227169
// will dequeue the frame request itself before the next call to onNextAction.
228-
var empty = childNode!!.onNextAction(selector)
170+
var empty = childNode.onNextAction(selector)
229171

230172
// If there's a frame request, then some state changed, which is equivalent to the traditional
231173
// case of a WorkflowAction being enqueued that just modifies state.
232-
empty = empty && !recomposerDriver.needsRecompose
233-
recomposerDriver.onAwaitFrameAvailable(selector, processFrameRequestFromChannel)
174+
empty = empty && !molecule.needsRecomposition
175+
with(selector) {
176+
recomposeChannel.onReceive(processRecompositionRequestFromChannel)
177+
}
234178

235179
return empty
236180
}
237-
238-
@OptIn(ExperimentalCoroutinesApi::class)
239-
private fun startComposition() {
240-
// Launch as atomic to ensure the composition is always disposed, even if our job is cancelled
241-
// before this coroutine has a chance to start running.
242-
launch(start = ATOMIC) {
243-
try {
244-
recomposerDriver.runRecomposeAndApplyChanges()
245-
} finally {
246-
composition.dispose()
247-
}
248-
}
249-
}
250181
}

0 commit comments

Comments
 (0)