1
1
package com.squareup.workflow1.internal.compose
2
2
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
9
3
import androidx.compose.runtime.snapshots.Snapshot
10
4
import com.squareup.workflow1.ActionApplied
11
5
import com.squareup.workflow1.ActionProcessingResult
12
6
import com.squareup.workflow1.NoopWorkflowInterceptor
13
- import com.squareup.workflow1.NullableInitBox
14
7
import com.squareup.workflow1.RuntimeConfig
15
8
import com.squareup.workflow1.RuntimeConfigOptions
16
9
import com.squareup.workflow1.TreeSnapshot
@@ -22,11 +15,10 @@ import com.squareup.workflow1.WorkflowTracer
22
15
import com.squareup.workflow1.compose.ComposeWorkflow
23
16
import com.squareup.workflow1.internal.AbstractWorkflowNode
24
17
import com.squareup.workflow1.internal.IdCounter
25
- import com.squareup.workflow1.internal.UnitApplier
26
18
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
30
22
import kotlinx.coroutines.selects.SelectBuilder
31
23
import kotlin.coroutines.CoroutineContext
32
24
@@ -69,154 +61,104 @@ internal class ComposeWorkflowNodeAdapter<PropsT, OutputT, RenderingT>(
69
61
emitAppliedActionToParent = emitAppliedActionToParent,
70
62
) {
71
63
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
+ }
81
100
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
+ )
87
111
88
112
/* *
89
- * Function invoked when [onNextAction] receives a frame request from [withFrameNanos] .
113
+ * Function invoked when [onNextAction] receives a recompose request.
90
114
* This handles the case where some state read by the composition is changed but emitOutput is
91
115
* not called.
92
116
*/
93
- private val processFrameRequestFromChannel : () -> ActionProcessingResult = {
117
+ private val processRecompositionRequestFromChannel : (Unit ) -> ActionProcessingResult = {
94
118
// A pure frame request means compose state was updated that the composition read, but
95
119
// emitOutput was not called, so we don't have any outputs to report.
96
120
val applied = ActionApplied <OutputT >(
97
121
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
99
125
)
100
126
101
127
// Propagate the action up the workflow tree.
102
128
log(" frame request received from channel, sending no output to parent: $applied " )
103
129
emitAppliedActionToParent(applied)
104
130
}
105
131
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
-
176
132
override fun render (
177
133
workflow : Workflow <PropsT , OutputT , RenderingT >,
178
134
input : PropsT
179
135
): RenderingT {
180
- this .cachedComposeWorkflow = workflow as ComposeWorkflow
181
- this .lastProps = input
182
-
183
136
// Ensure that recomposer has a chance to process any state changes from the action cascade that
184
137
// 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 } " )
186
139
// TODO Consider pulling this up into the workflow runtime loop, since we only need to run it
187
140
// once before the entire tree renders, not at every level. In fact, if this is only here to
188
141
// ensure cachedComposeWorkflow and lastProps are seen, that will only work if this
189
142
// ComposeWorkflow is not nested below another traditional and compose workflow, since anything
190
143
// rendering under the first CW will be in a snapshot.
191
144
Snapshot .sendApplyNotifications()
192
- log(" sent apply notifications, needsRecompose=${recomposerDriver.needsRecompose } " )
145
+ log(" sent apply notifications, needsRecompose=${molecule.needsRecomposition } " )
193
146
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()
199
150
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
202
152
// 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
+ )
214
158
}
215
-
216
- return lastRendering.getOrThrow()
217
159
}
218
160
219
- override fun snapshot (): TreeSnapshot = childNode!! .snapshot()
161
+ override fun snapshot (): TreeSnapshot = childNode.snapshot()
220
162
221
163
override fun onNextAction (selector : SelectBuilder <ActionProcessingResult >): Boolean {
222
164
// We must register for child actions before frame requests, because selection is
@@ -225,26 +167,15 @@ internal class ComposeWorkflowNodeAdapter<PropsT, OutputT, RenderingT>(
225
167
// the output handler will implicitly also handle frame requests. If a frame request happens at
226
168
// the same time or the output handler enqueues a frame request, then the subsequent render pass
227
169
// will dequeue the frame request itself before the next call to onNextAction.
228
- var empty = childNode!! .onNextAction(selector)
170
+ var empty = childNode.onNextAction(selector)
229
171
230
172
// If there's a frame request, then some state changed, which is equivalent to the traditional
231
173
// 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
+ }
234
178
235
179
return empty
236
180
}
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
- }
250
181
}
0 commit comments