Skip to content

Commit d151db8

Browse files
Try converting more workflows to Compose.
Found a bug in timing: Dungeon is crashing because tracing calls seem to be misordered, I think the stopped callback is happening too early and removing a workflow from the map before render is called looking for it.
1 parent 718344d commit d151db8

File tree

9 files changed

+132
-115
lines changed

9 files changed

+132
-115
lines changed

samples/compose-samples/src/main/java/com/squareup/sample/compose/inlinerendering/InlineRenderingWorkflow.kt

Lines changed: 13 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -14,36 +14,32 @@ import androidx.compose.material.Button
1414
import androidx.compose.material.Text
1515
import androidx.compose.runtime.Composable
1616
import androidx.compose.runtime.getValue
17+
import androidx.compose.runtime.mutableIntStateOf
18+
import androidx.compose.runtime.saveable.rememberSaveable
19+
import androidx.compose.runtime.setValue
1720
import androidx.compose.ui.tooling.preview.Preview
18-
import com.squareup.workflow1.Snapshot
19-
import com.squareup.workflow1.StatefulWorkflow
21+
import com.squareup.workflow1.WorkflowExperimentalApi
2022
import com.squareup.workflow1.WorkflowExperimentalRuntime
23+
import com.squareup.workflow1.compose.ComposeWorkflow
2124
import com.squareup.workflow1.config.AndroidRuntimeConfigTools
22-
import com.squareup.workflow1.parse
2325
import com.squareup.workflow1.ui.Screen
2426
import com.squareup.workflow1.ui.compose.ComposeScreen
2527
import com.squareup.workflow1.ui.compose.WorkflowRendering
2628
import com.squareup.workflow1.ui.compose.renderAsState
2729

28-
object InlineRenderingWorkflow : StatefulWorkflow<Unit, Int, Nothing, Screen>() {
30+
@OptIn(WorkflowExperimentalApi::class)
31+
object InlineRenderingWorkflow : ComposeWorkflow<Unit, Nothing, Screen>() {
2932

30-
override fun initialState(
33+
@Composable
34+
override fun produceRendering(
3135
props: Unit,
32-
snapshot: Snapshot?
33-
): Int = snapshot?.bytes?.parse { it.readInt() } ?: 0
34-
35-
override fun render(
36-
renderProps: Unit,
37-
renderState: Int,
38-
context: RenderContext<Unit, Int, Nothing>
39-
): ComposeScreen {
40-
val onClick = context.eventHandler("increment") { state += 1 }
36+
emitOutput: (Nothing) -> Unit
37+
): Screen {
38+
var state by rememberSaveable { mutableIntStateOf(0) }
4139
return ComposeScreen {
42-
Content(renderState, onClick)
40+
Content(state, onClick = { state++ })
4341
}
4442
}
45-
46-
override fun snapshotState(state: Int): Snapshot = Snapshot.of(state)
4743
}
4844

4945
@Composable

samples/dungeon/common/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
plugins {
22
id("kotlin-jvm")
33
id("kotlinx-serialization")
4+
alias(libs.plugins.compose.compiler)
45
}
56

67
dependencies {

samples/dungeon/common/src/main/java/com/squareup/sample/dungeon/GameWorkflow.kt

Lines changed: 64 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
package com.squareup.sample.dungeon
22

3+
import androidx.compose.runtime.Composable
4+
import androidx.compose.runtime.LaunchedEffect
5+
import androidx.compose.runtime.getValue
6+
import androidx.compose.runtime.key
7+
import androidx.compose.runtime.mutableStateOf
8+
import androidx.compose.runtime.remember
9+
import androidx.compose.runtime.rememberUpdatedState
10+
import androidx.compose.runtime.setValue
311
import com.squareup.sample.dungeon.ActorWorkflow.ActorProps
412
import com.squareup.sample.dungeon.ActorWorkflow.ActorRendering
513
import com.squareup.sample.dungeon.Direction.DOWN
@@ -11,16 +19,13 @@ import com.squareup.sample.dungeon.GameWorkflow.Output
1119
import com.squareup.sample.dungeon.GameWorkflow.Output.PlayerWasEaten
1220
import com.squareup.sample.dungeon.GameWorkflow.Output.Vibrate
1321
import com.squareup.sample.dungeon.GameWorkflow.Props
14-
import com.squareup.sample.dungeon.GameWorkflow.State
1522
import com.squareup.sample.dungeon.PlayerWorkflow.Rendering
1623
import com.squareup.sample.dungeon.board.Board
1724
import com.squareup.sample.dungeon.board.Board.Location
18-
import com.squareup.workflow1.Snapshot
19-
import com.squareup.workflow1.StatefulWorkflow
2025
import com.squareup.workflow1.Worker
21-
import com.squareup.workflow1.action
22-
import com.squareup.workflow1.renderChild
23-
import com.squareup.workflow1.runningWorker
26+
import com.squareup.workflow1.WorkflowExperimentalApi
27+
import com.squareup.workflow1.compose.ComposeWorkflow
28+
import com.squareup.workflow1.compose.renderChild
2429
import com.squareup.workflow1.ui.Screen
2530
import kotlinx.coroutines.delay
2631
import kotlinx.coroutines.flow.Flow
@@ -30,11 +35,12 @@ import kotlin.random.Random
3035

3136
private val ignoreInput: (Direction) -> Unit = {}
3237

38+
@OptIn(WorkflowExperimentalApi::class)
3339
class GameWorkflow(
3440
private val playerWorkflow: PlayerWorkflow,
3541
private val aiWorkflows: List<ActorWorkflow>,
3642
private val random: Random
37-
) : StatefulWorkflow<Props, State, Output, GameRendering>() {
43+
) : ComposeWorkflow<Props, Output, GameRendering>() {
3844

3945
/**
4046
* @param board Should not change while the game is running.
@@ -56,9 +62,9 @@ class GameWorkflow(
5662
/**
5763
* Emitted by [GameWorkflow] if the controller should be vibrated.
5864
*/
59-
object Vibrate : Output()
65+
data object Vibrate : Output()
6066

61-
object PlayerWasEaten : Output()
67+
data object PlayerWasEaten : Output()
6268
}
6369

6470
data class GameRendering(
@@ -68,67 +74,68 @@ class GameWorkflow(
6874
val onStopMoving: (Direction) -> Unit
6975
) : Screen
7076

71-
override fun initialState(
77+
@Composable
78+
override fun produceRendering(
7279
props: Props,
73-
snapshot: Snapshot?
74-
): State {
75-
val board = props.board
76-
return State(
77-
game = Game(
78-
playerLocation = random.nextEmptyLocation(board),
79-
aiLocations = aiWorkflows.map { random.nextEmptyLocation(board) }
80+
emitOutput: (Output) -> Unit
81+
): GameRendering {
82+
var state by remember {
83+
mutableStateOf(
84+
State(
85+
game = Game(
86+
playerLocation = random.nextEmptyLocation(props.board),
87+
aiLocations = aiWorkflows.map { random.nextEmptyLocation(props.board) }
88+
)
89+
)
8090
)
81-
)
82-
}
83-
84-
override fun onPropsChanged(
85-
old: Props,
86-
new: Props,
87-
state: State
88-
): State {
89-
check(old.board == new.board) { "Expected board to not change during the game." }
90-
return state
91-
}
91+
}
9292

93-
override fun render(
94-
renderProps: Props,
95-
renderState: State,
96-
context: RenderContext<Props, State, Output>
97-
): GameRendering {
98-
val running = !renderProps.paused && !renderState.game.isPlayerEaten
93+
val running = !props.paused && !state.game.isPlayerEaten
9994
// Stop actors from ticking if the game is paused or finished.
100-
val ticker: Worker<Long> =
101-
if (running) TickerWorker(renderProps.ticksPerSecond) else Worker.finished()
102-
val game = renderState.game
103-
val board = renderProps.board
95+
val ticker: Worker<Long> = if (running) {
96+
remember { TickerWorker(props.ticksPerSecond) }
97+
} else {
98+
Worker.finished()
99+
}
100+
val game = state.game
101+
val board = props.board
104102

105103
// Render the player.
106104
val playerInput = ActorProps(board, game.playerLocation, ticker)
107-
val playerRendering = context.renderChild(playerWorkflow, playerInput)
105+
val playerRendering = renderChild(playerWorkflow, playerInput)
106+
val updatedPR by rememberUpdatedState(playerRendering)
108107

109108
// Render all the other actors.
110109
val aiRenderings = aiWorkflows.zip(game.aiLocations)
111110
.mapIndexed { index, (aiWorkflow, aiLocation) ->
112-
val aiInput = ActorProps(board, aiLocation, ticker)
113-
aiLocation to context.renderChild(aiWorkflow, aiInput, key = index.toString())
111+
key(index) {
112+
val aiInput = ActorProps(board, aiLocation, ticker)
113+
aiLocation to renderChild(aiWorkflow, aiInput)
114+
}
114115
}
116+
val updatedAIR by rememberUpdatedState(aiRenderings)
115117

116118
// If the game is paused or finished, just render the board without ticking.
117119
if (running) {
118-
context.runningWorker(ticker) { tick ->
119-
return@runningWorker updateGame(
120-
renderProps.ticksPerSecond,
121-
tick,
122-
playerRendering,
123-
aiRenderings
124-
)
120+
LaunchedEffect(ticker) {
121+
ticker.run().collect { tick ->
122+
state = updateGame(
123+
props,
124+
state,
125+
props.ticksPerSecond,
126+
tick,
127+
updatedPR,
128+
updatedAIR,
129+
emitOutput
130+
)
131+
}
125132
}
126133
}
127134

128135
val aiOverlay = aiRenderings.map { (a, b) -> a to b.avatar }
129136
.toMap()
130137
val renderedBoard = board.withOverlay(
131-
aiOverlay + (game.playerLocation to playerRendering.actorRendering.avatar)
138+
aiOverlay + mapOf(game.playerLocation to playerRendering.actorRendering.avatar)
132139
)
133140
return GameRendering(
134141
board = renderedBoard,
@@ -137,17 +144,18 @@ class GameWorkflow(
137144
)
138145
}
139146

140-
override fun snapshotState(state: State): Snapshot? = null
141-
142147
/**
143148
* Calculate new locations for player and other actors.
144149
*/
145150
private fun updateGame(
151+
props: Props,
152+
state: State,
146153
ticksPerSecond: Int,
147154
tick: Long,
148155
playerRendering: Rendering,
149-
aiRenderings: List<Pair<Location, ActorRendering>>
150-
) = action("updateGame") {
156+
aiRenderings: List<Pair<Location, ActorRendering>>,
157+
emitOutput: (Output) -> Unit
158+
): State {
151159
// Calculate if this tick should result in movement based on the movement's speed.
152160
fun Movement.isTimeToMove(): Boolean {
153161
val ticksPerCell = (ticksPerSecond / cellsPerSecond).roundToLong()
@@ -184,12 +192,11 @@ class GameWorkflow(
184192

185193
// Check if AI captured player.
186194
if (newGame.isPlayerEaten) {
187-
state = state.copy(game = newGame)
188-
setOutput(PlayerWasEaten)
195+
emitOutput(PlayerWasEaten)
189196
} else {
190-
state = state.copy(game = newGame)
191-
output?.let { setOutput(it) }
197+
output?.let { emitOutput(it) }
192198
}
199+
return state.copy(game = newGame)
193200
}
194201
}
195202

samples/tictactoe/common/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
plugins {
22
id("kotlin-jvm")
3+
alias(libs.plugins.compose.compiler)
34
}
45

56
dependencies {

samples/tictactoe/common/src/main/java/com/squareup/sample/mainworkflow/MainState.kt

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.squareup.sample.mainworkflow
22

3+
import androidx.compose.runtime.saveable.SaverScope
34
import com.squareup.workflow1.Snapshot
45
import com.squareup.workflow1.parse
56
import com.squareup.workflow1.readUtf8WithLength
@@ -12,9 +13,9 @@ import okio.ByteString
1213
*/
1314
sealed class MainState {
1415

15-
internal object Authenticating : MainState()
16+
internal data object Authenticating : MainState()
1617

17-
internal object RunningGame : MainState()
18+
internal data object RunningGame : MainState()
1819

1920
fun toSnapshot(): Snapshot {
2021
return Snapshot.write { sink -> sink.writeUtf8WithLength(this::class.java.name) }
@@ -29,4 +30,9 @@ sealed class MainState {
2930
}
3031
}
3132
}
33+
34+
object Saver : androidx.compose.runtime.saveable.Saver<MainState, ByteString> {
35+
override fun SaverScope.save(value: MainState) = value.toSnapshot().bytes
36+
override fun restore(value: ByteString) = fromSnapshot(value)
37+
}
3238
}

0 commit comments

Comments
 (0)