11package 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
93import androidx.compose.runtime.snapshots.Snapshot
104import com.squareup.workflow1.ActionApplied
115import com.squareup.workflow1.ActionProcessingResult
126import com.squareup.workflow1.NoopWorkflowInterceptor
13- import com.squareup.workflow1.NullableInitBox
147import com.squareup.workflow1.RuntimeConfig
158import com.squareup.workflow1.RuntimeConfigOptions
169import com.squareup.workflow1.TreeSnapshot
@@ -22,11 +15,10 @@ import com.squareup.workflow1.WorkflowTracer
2215import com.squareup.workflow1.compose.ComposeWorkflow
2316import com.squareup.workflow1.internal.AbstractWorkflowNode
2417import com.squareup.workflow1.internal.IdCounter
25- import com.squareup.workflow1.internal.UnitApplier
2618import 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
3022import kotlinx.coroutines.selects.SelectBuilder
3123import 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