Skip to content

Commit 5cf7cb6

Browse files
WIP: Add the ability to update the input for a WorkflowHost.
Closes #247.
1 parent 40766df commit 5cf7cb6

File tree

14 files changed

+284
-76
lines changed

14 files changed

+284
-76
lines changed

kotlin/samples/tictactoe/common/src/test/java/com/squareup/sample/gameworkflow/TakeTurnsWorkflowTest.kt

+1-1
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,6 @@ class TakeTurnsWorkflowTest {
6969
}
7070
}
7171

72-
private fun WorkflowTester<*, GamePlayScreen>.takeSquare(event: TakeSquare) {
72+
private fun WorkflowTester<*, *, GamePlayScreen>.takeSquare(event: TakeSquare) {
7373
withNextRendering { it.onEvent(event) }
7474
}

kotlin/settings.gradle

+1
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ include ':samples:tictactoe:common'
2323
include ':workflow-core'
2424
include ':workflow-runtime'
2525
include ':workflow-rx2'
26+
include ':workflow-rx2-runtime'
2627
include ':workflow-testing'
2728
include ':workflow-ui-core'
2829
include ':workflow-ui-android'

kotlin/workflow-runtime/src/main/java/com/squareup/workflow/WorkflowHost.kt

+97-50
Original file line numberDiff line numberDiff line change
@@ -19,25 +19,28 @@ package com.squareup.workflow
1919

2020
import com.squareup.workflow.WorkflowHost.Factory
2121
import com.squareup.workflow.WorkflowHost.Update
22-
import com.squareup.workflow.internal.WorkflowId
2322
import com.squareup.workflow.internal.WorkflowNode
2423
import com.squareup.workflow.internal.id
2524
import kotlinx.coroutines.CancellationException
25+
import kotlinx.coroutines.CoroutineScope
26+
import kotlinx.coroutines.GlobalScope
2627
import kotlinx.coroutines.cancel
28+
import kotlinx.coroutines.channels.Channel
2729
import kotlinx.coroutines.channels.ReceiveChannel
2830
import kotlinx.coroutines.channels.produce
29-
import kotlinx.coroutines.isActive
31+
import kotlinx.coroutines.ensureActive
3032
import kotlinx.coroutines.selects.select
3133
import org.jetbrains.annotations.TestOnly
3234
import kotlin.coroutines.CoroutineContext
3335
import kotlin.coroutines.EmptyCoroutineContext
36+
import kotlin.coroutines.coroutineContext
3437

3538
/**
3639
* Provides a stream of [updates][Update] from a tree of [Workflow]s.
3740
*
3841
* Create these by injecting a [Factory] and calling [run][Factory.run].
3942
*/
40-
interface WorkflowHost<out OutputT : Any, out RenderingT : Any> {
43+
interface WorkflowHost<in InputT : Any, out OutputT : Any, out RenderingT : Any> {
4144

4245
/**
4346
* Output from a [WorkflowHost]. Emitted from [WorkflowHost.updates] after every compose pass.
@@ -63,88 +66,132 @@ interface WorkflowHost<out OutputT : Any, out RenderingT : Any> {
6366
/**
6467
* Creates a [WorkflowHost] to run [workflow].
6568
*
66-
* The workflow's initial state is determined by passing [input] to
67-
* [StatefulWorkflow.initialState].
69+
* The workflow's initial state is determined by passing the first value emitted by [inputs] to
70+
* [StatefulWorkflow.initialState]. Subsequent values emitted from [inputs] will be used to
71+
* re-render the workflow.
6872
*
6973
* @param workflow The workflow to start.
70-
* @param input Passed to [StatefulWorkflow.initialState] to determine the root workflow's
71-
* initial state. If [InputT] is `Unit`, you can just omit this argument.
74+
* @param inputs Passed to [StatefulWorkflow.initialState] to determine the root workflow's
75+
* initial state, and to pass input updates to the root workflow.
76+
* If [InputT] is `Unit`, you can just omit this argument.
7277
* @param snapshot If not null, used to restore the workflow.
7378
* @param context The [CoroutineContext] used to run the workflow tree. Added to the [Factory]'s
7479
* context.
7580
*/
7681
fun <InputT : Any, OutputT : Any, RenderingT : Any> run(
7782
workflow: Workflow<InputT, OutputT, RenderingT>,
78-
input: InputT,
83+
inputs: ReceiveChannel<InputT>,
7984
snapshot: Snapshot? = null,
8085
context: CoroutineContext = EmptyCoroutineContext
81-
): WorkflowHost<OutputT, RenderingT> = run(workflow.id(), workflow, input, snapshot, context)
86+
): WorkflowHost<InputT, OutputT, RenderingT> =
87+
object : WorkflowHost<InputT, OutputT, RenderingT> {
88+
private val scope = CoroutineScope(context)
89+
90+
override val updates: ReceiveChannel<Update<OutputT, RenderingT>> =
91+
scope.produce(capacity = 0) {
92+
runWorkflowTree(
93+
workflow = workflow.asStatefulWorkflow(),
94+
inputs = inputs,
95+
initialSnapshot = snapshot,
96+
onUpdate = ::send
97+
)
98+
}
99+
}
82100

83101
fun <OutputT : Any, RenderingT : Any> run(
84102
workflow: Workflow<Unit, OutputT, RenderingT>,
85103
snapshot: Snapshot? = null,
86104
context: CoroutineContext = EmptyCoroutineContext
87-
): WorkflowHost<OutputT, RenderingT> = run(workflow.id(), workflow, Unit, snapshot, context)
105+
): WorkflowHost<Unit, OutputT, RenderingT> = run(workflow, channelOf(Unit), snapshot, context)
88106

89107
/**
90108
* Creates a [WorkflowHost] that runs [workflow] starting from [initialState].
91109
*
92110
* **Don't call this directly.**
93111
*
94-
* Instead, your module should have a test dependency on `pure-v2-testing` and you should call the
95-
* testing extension method defined there on your workflow itself.
112+
* Instead, your module should have a test dependency on `pure-v2-testing` and you should call
113+
* the testing extension method defined there on your workflow itself.
96114
*/
97115
@TestOnly
98116
fun <InputT : Any, StateT : Any, OutputT : Any, RenderingT : Any> runTestFromState(
99117
workflow: StatefulWorkflow<InputT, StateT, OutputT, RenderingT>,
100118
input: InputT,
101119
initialState: StateT
102-
): WorkflowHost<OutputT, RenderingT> {
103-
val workflowId = workflow.id()
104-
return object : WorkflowHost<OutputT, RenderingT> {
105-
val node = WorkflowNode(workflowId, workflow, input, null, baseContext, initialState)
120+
): WorkflowHost<InputT, OutputT, RenderingT> =
121+
object : WorkflowHost<InputT, OutputT, RenderingT> {
106122
override val updates: ReceiveChannel<Update<OutputT, RenderingT>> =
107-
node.start(workflow, input)
123+
GlobalScope.produce(capacity = 0, context = baseContext) {
124+
runWorkflowTree(
125+
workflow = workflow.asStatefulWorkflow(),
126+
inputs = channelOf(input),
127+
initialSnapshot = null,
128+
initialState = initialState,
129+
onUpdate = ::send
130+
)
131+
}
108132
}
109-
}
110133

111-
internal fun <InputT : Any, OutputT : Any, RenderingT : Any> run(
112-
id: WorkflowId<InputT, OutputT, RenderingT>,
113-
workflow: Workflow<InputT, OutputT, RenderingT>,
114-
input: InputT,
115-
snapshot: Snapshot?,
116-
context: CoroutineContext
117-
): WorkflowHost<OutputT, RenderingT> = object : WorkflowHost<OutputT, RenderingT> {
118-
val node = WorkflowNode(
119-
id = id,
120-
workflow = workflow.asStatefulWorkflow(),
121-
initialInput = input,
122-
snapshot = snapshot,
123-
baseContext = baseContext + context
124-
)
125-
override val updates: ReceiveChannel<Update<OutputT, RenderingT>> =
126-
node.start(workflow.asStatefulWorkflow(), input)
127-
}
134+
private fun <T> channelOf(value: T) = Channel<T>(capacity = 1)
135+
.apply { offer(value) }
128136
}
129137
}
130138

131139
/**
132-
* Starts the coroutine that runs the coroutine loop.
140+
* Loops forever, or until the coroutine is cancelled, processing the workflow tree and emitting
141+
* updates by calling [onUpdate].
142+
*
143+
* This function is the lowest-level entry point into the runtime. Don't call this directly, instead
144+
* use [WorkflowHost.Factory] to create a [WorkflowHost], or one of the stream operators for your
145+
* favorite Rx library to map a stream of [InputT]s into [Update]s.
133146
*/
134-
internal fun <I : Any, O : Any, R : Any> WorkflowNode<I, *, O, R>.start(
135-
workflow: StatefulWorkflow<I, *, O, R>,
136-
input: I
137-
): ReceiveChannel<Update<O, R>> = produce(capacity = 0) {
147+
suspend fun <InputT : Any, StateT : Any, OutputT : Any, RenderingT : Any> runWorkflowTree(
148+
workflow: StatefulWorkflow<InputT, StateT, OutputT, RenderingT>,
149+
inputs: ReceiveChannel<InputT>,
150+
initialSnapshot: Snapshot?,
151+
initialState: StateT? = null,
152+
onUpdate: suspend (Update<OutputT, RenderingT>) -> Unit
153+
): Nothing {
154+
var output: OutputT? = null
155+
var input: InputT = inputs.receive()
156+
var inputsClosed = false
157+
val rootNode = WorkflowNode(
158+
id = workflow.id(),
159+
workflow = workflow,
160+
initialInput = input,
161+
snapshot = initialSnapshot,
162+
baseContext = coroutineContext,
163+
initialState = initialState
164+
)
165+
138166
try {
139-
var output: O? = null
140-
while (isActive) {
141-
val rendering = compose(workflow, input)
142-
val snapshot = snapshot(workflow)
143-
send(Update(rendering, snapshot, output))
167+
while (true) {
168+
coroutineContext.ensureActive()
169+
170+
val rendering = rootNode.compose(workflow, input)
171+
val snapshot = rootNode.snapshot(workflow)
172+
173+
onUpdate(Update(rendering, snapshot, output))
174+
144175
// Tick _might_ return an output, but if it returns null, it means the state or a child
145176
// probably changed, so we should re-compose/snapshot and emit again.
146177
output = select {
147-
tick(this) { it }
178+
// Stop trying to read from the inputs channel after it's closed.
179+
if (!inputsClosed) {
180+
@Suppress("EXPERIMENTAL_API_USAGE")
181+
inputs.onReceiveOrNull { newInput ->
182+
if (newInput == null) {
183+
inputsClosed = true
184+
} else {
185+
input = newInput
186+
}
187+
// No output. Returning from the select will go to the top of the loop to do another
188+
// compose pass.
189+
return@onReceiveOrNull null
190+
}
191+
}
192+
193+
// Tick the workflow tree.
194+
rootNode.tick(this) { it }
148195
}
149196
}
150197
} catch (e: Throwable) {
@@ -153,9 +200,9 @@ internal fun <I : Any, O : Any, R : Any> WorkflowNode<I, *, O, R>.start(
153200
coroutineContext.cancel(if (e is CancellationException) e else CancellationException(null, e))
154201
throw e
155202
} finally {
156-
// There's a potential race condition if the producer coroutine is cancelled before it has a chance
157-
// to enter the try block, since we can't use CoroutineStart.ATOMIC. However, until we actually
158-
// see this cause problems, I'm not too worried about it.
159-
cancel()
203+
// There's a potential race condition if the producer coroutine is cancelled before it has a
204+
// chance to enter the try block, since we can't use CoroutineStart.ATOMIC. However, until we
205+
// actually see this cause problems, I'm not too worried about it.
206+
rootNode.cancel()
160207
}
161208
}

kotlin/workflow-rx2-runtime/README.md

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# workflow-rx2-runtime
2+
3+
This module contains adapters to use the Workflow runtime with RxJava2.
+38
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
/*
2+
* Copyright 2019 Square Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
apply plugin: 'java-library'
17+
apply plugin: 'kotlin'
18+
apply plugin: 'com.vanniktech.maven.publish'
19+
apply plugin: 'org.jetbrains.dokka'
20+
21+
sourceCompatibility = JavaVersion.VERSION_1_7
22+
targetCompatibility = JavaVersion.VERSION_1_7
23+
24+
dokka rootProject.ext.defaultDokkaConfig
25+
26+
dependencies {
27+
compileOnly deps.annotations.intellij
28+
29+
api deps.kotlin.stdLib.jdk6
30+
api deps.kotlin.coroutines.core
31+
api deps.rxjava2.rxjava2
32+
33+
implementation project(':workflow-runtime')
34+
implementation deps.kotlin.coroutines.rx2
35+
36+
testImplementation project(':workflow-testing')
37+
testImplementation deps.kotlin.test.jdk
38+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
#
2+
# Copyright 2019 Square Inc.
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
#
16+
POM_ARTIFACT_ID=workflow-rx2-runtime
17+
POM_NAME=Workflow RxJava2 Runtime
18+
POM_PACKAGING=jar
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
/*
2+
* Copyright 2019 Square Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
@file:Suppress("EXPERIMENTAL_API_USAGE")
17+
18+
package com.squareup.workflow.rx2
19+
20+
import com.squareup.workflow.Snapshot
21+
import com.squareup.workflow.Workflow
22+
import com.squareup.workflow.WorkflowHost.Update
23+
import com.squareup.workflow.runWorkflowTree
24+
import io.reactivex.Flowable
25+
import io.reactivex.Observable
26+
import kotlinx.coroutines.CoroutineDispatcher
27+
import kotlinx.coroutines.Dispatchers
28+
import kotlinx.coroutines.GlobalScope
29+
import kotlinx.coroutines.channels.consume
30+
import kotlinx.coroutines.reactive.openSubscription
31+
import kotlinx.coroutines.rx2.rxFlowable
32+
33+
/**
34+
* Given a stream of [InputT] values to use as inputs for the top-level [workflow], returns a
35+
* [Flowable] that, when subscribed to, will start [workflow] and emit its [Update]s. When the
36+
* subscription is disposed, the workflow will be terminated.
37+
*
38+
* The returned [Flowable] is _not_ multicasted – **every subscription will start a new workflow
39+
* session.** It is recommended to use a multicasting operator on the resulting stream, such as
40+
* [Flowable.replay], to share the updates from a single workflow session.
41+
*
42+
* The workflow's logic will run in whatever threading context the source [Flowable] is being
43+
* observed on.
44+
*
45+
* This operator is an alternative to using
46+
* [WorkflowHost.Factory][com.squareup.workflow.WorkflowHost.Factory] that is more convenient to
47+
* use with a stream of inputs.
48+
*
49+
* This function operates on [Flowable] instead of [Observable] because the workflow runtime
50+
* inherently supports backpressure. [Flowable] supports backpressure, [Observable] does not.
51+
* RxJava provides operators to adapt between [Flowable]s and [Observable]s by explicitly specifying
52+
* how to use backpressure. By operating on [Flowable], this operator leaves it up to the caller to
53+
* specify strategies for handling backpressure, instead of assuming any particular behavior.
54+
*/
55+
fun <InputT : Any, OutputT : Any, RenderingT : Any> Flowable<InputT>.flatMapWorkflow(
56+
workflow: Workflow<InputT, OutputT, RenderingT>,
57+
initialSnapshot: Snapshot? = null,
58+
dispatcher: CoroutineDispatcher = Dispatchers.Unconfined
59+
): Flowable<Update<OutputT, RenderingT>> =
60+
// We're ok not having a job here because the lifetime of the coroutine will be controlled by the
61+
// subscription to the resulting flowable.
62+
GlobalScope.rxFlowable(context = dispatcher) {
63+
// Convert the input stream into a channel.
64+
openSubscription().consume {
65+
runWorkflowTree(
66+
workflow = workflow.asStatefulWorkflow(),
67+
inputs = this,
68+
initialSnapshot = initialSnapshot,
69+
onUpdate = ::send
70+
)
71+
}
72+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
/*
2+
* Copyright 2019 Square Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package com.squareup.workflow.rx2
17+
18+
import kotlin.test.Test
19+
import kotlin.test.fail
20+
21+
class FlatMapWorkflowTest {
22+
23+
@Test fun stuff() {
24+
fail()
25+
}
26+
}

0 commit comments

Comments
 (0)