DEV Community

Cover image for Tic-tac-toe: from MVP to Jetpack Compose
Eric Donovan
Eric Donovan

Posted on • Updated on

Tic-tac-toe: from MVP to Jetpack Compose

There are two fundamental ways of thinking about data that drives a UI.

We can think of our data as state or as events/messages. Here's what I mean:

I am 3 years old

That's a "state-y" way to represent someone's age, instead you could put it like this:

I was born
I had a birthday
I had a birthday
I had a birthday

That's an "event-y" way to represent the same thing.

Notice how the events tend to occur at the state change boundaries (a birthday event happens when your age state changes from 2 to 3). It's almost like we have two parallel worlds representing the same thing, the state world and the event world.

Erm... okay. Why are you telling me this? MVP is a very event-y style architecture (yes I did just make that word up), whereas Jetpack Compose does really well with state-y style data. That's what makes migrating from MVP to Jetpack Compose a little tricky. But if we recognise this state vs event difference first, migrating can be relatively pain free.


Event-y

I'm not going to go into the details of how MVP is typically set up on android. There has been a long slow march away from MVP in android for almost 10 years, but there are still a lot of MVP apps around and chances are, if you're reading this article, you probably already have one in mind.

tic tac toe game in progress

Let's just imagine the view interface / contract we might have with an MVP version of our tic-tac-toe app. Maybe something like this:

fun showLoadingSpinner()
fun hideLoadingSpinner()
fun populateTile(xPos: Int, yPos: Int, mark: Player)
fun showError()
fun hideError()
fun showNextPlayer(player: Player)
fun showWinner(player: Player)
fun hideWinner()
fun resetGameView()
Enter fullscreen mode Exit fullscreen mode

This is why I say most MVP apps are event-y, they tell the view what to do or what happened (not what is).

Take a look at your own MVP app, you'll probably find the same thing.


State-y

So how would we re-write this in a state-y way? We just need to come up with a state that covers every possible situation our view will need to display. Kotlin data classes are pretty good for this, but by no means mandatory. This should do the trick:

data class GameViewState {
   val isLoading: Boolean,
   val error: Error?,
   val nextPlayer: Player,
   val winner: Player,
   val board: List<List<Player>>
}

sealed class Player {
    object X : Player()
    object O : Player()
    object Nobody : Player()
}
Enter fullscreen mode Exit fullscreen mode

This happens to be an immutable state, so it's easy to guarantee that it won't change while the view is updating the screen.

It doesn't have to be immutable though, we could also manage this state inside a regular class. The view just needs to be told to sync itself whenever any of the state changes (you have to be aware of threading issues with this one, but it also works fine):

class Game {

   ...

   fun isLoading(): Boolean {...}
   fun error(): Error? {...}
   fun winner(): Player {...}
   fun tile(xPos: Int, yPos: Int): Player {...}
}
Enter fullscreen mode Exit fullscreen mode

The point is, with state-y driven code we are telling the view what is (not what to do or what happened).

How we calculate this state (the actual logic of the game) is kept safely away from the view layer. The logic of a tic-tac-toe game doesn't need to know anything about android activities or fragments. It's plain logic, and it would be the same logic if it was driving an iOS tic-tac-toe app (it would be a good contender for keeping in a kotlin only domain module if we were writing a clean architecture app).


Imperative vs Declaritive

Take a moment to notice that event-y style code sounds a lot like imperative programming - telling your view what to do. And state-y style code sounds a lot like declaritive programming - telling your view what is


Drawing the Non-Compose UI

Back to our pre-compose tic-tac-toe app for the moment. We've changed the type of data that is driving our view, so now we also need to change how the view is drawn.

Let's take the loading spinner, our event-y MVP code looks like this:

fun showLoadingSpinner() {
   spinner.visibility = View.VISIBLE
}

fun hideLoadingSpinner() {
   spinner.visibility = View.GONE
}
Enter fullscreen mode Exit fullscreen mode

it's fairly easy to write a more state-y version like this:

If (viewState.isLoading) {
   spinner.visibility = View.VISIBLE
} else {
   spinner.visibility = View.GONE
}
Enter fullscreen mode Exit fullscreen mode

But the secret is to put the focus on the UI attribute itself, so we can instead write:

spinner.visibility = if (viewState.isLoading) VISIBLE else GONE
Enter fullscreen mode Exit fullscreen mode

If you do this correctly you can have a single line for each UI attribute (and typically that means far fewer lines of code than the event-y version)

We've actually seen this trick before when we re-wrote the android architecture blue prints todo sample app. In that case the event-y view code went from over 100 lines to an almost embarrassing 8 lines of state-y code. If you haven't read that article, you probably find that hard to believe - but it's not an exaggeration.


Reactivity

The final piece of the puzzle is that we need some way of letting the view know that it's time to redraw itself. There are loads of ways to do this, basically some kind of observer implementation: RxJava, Flow, LiveData, etc. I'm going to use fore because a) I wrote it 😬 and b) it's impossible to do it with less boiler plate.

class TicTacToeActivity : FragmentActivity(R.layout.activity_tictactoe), SyncableView {

  //models that we want to observe
  private val gameModel: GameModel = OG[GameModel::class.java]

  override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)

    //setup observers
    lifecycle.addObserver(LifecycleObserver(this, gameModel))

    //setup click listeners
    viewResetBtn.setOnClickListener { gameModel.reset() }
    ...

  }

  //called on the UI thread, whenever the gameModel state changes
  //you could instead observe some LiveData state, or collect a Flow here
  override fun syncView() {
    gameModel.state.apply {
      viewResetBtn.enabled = !loading
      viewIsBusyProg.showOrInvisible(loading)
      viewWinnerText.text = winner
      viewWinnerText.showOrInvisible(winner != Player.Nobody)

      // more ui elements
      ...
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Congratulations πŸŽ‰ you now have "state-y" data driving your UI 😬 It's performant, supports rotation, and doesn't have any memory leaks - so this is a good place to be already. Access your GameModel via a ViewModel, and you'll have a basic MVVM app.

Now we're driving our UI with state, the entire view layer of our app (the Activity class in this case) takes up around 70 lines of code including imports. If we had started out with something like MVI rather than MVP, we would probably be a lot closer to this level already.


Replacing the view layer with Compose

If you get to this point in your migration, you can now focus just on Compose itself. The game model won't need to change, and the rest of the app won't even be aware that it's now driving a compose view layer.

The whole of Jetpack Compose is driven by state, and state is what we already have - but first we need to convert our state to an instance of Google's State<> class.

Most flavours of observable have an extension function that will do the conversion for you.

// Fore
val gameVS by gameModel.observeAsState { gameModel.state }
// Flow
val gameVS by gameModel.gameFlow.collectAsState(GameViewState())
// LiveData
val gameVS by gameModel.gameLiveData.observeAsState(GameViewState())
Enter fullscreen mode Exit fullscreen mode

(While writing the extension function for fore I used a demo app to investigate the behaviour of the LiveData and Flow implementations, it's here in case you find it useful.)

Here's (most of) the Compose UI code for an equivalent view. It's a lot of code, but we'll discuss that in a sec:

@Composable
fun GameScreen(
    game: Game,
    modifier: Modifier = Modifier,
    switchToNonComposeUI: () -> Unit
) {

    val viewState by game.observeAsState { game.state }

    Box(
        modifier = Modifier
            .fillMaxSize()
            .padding(dimensionResource(id = R.dimen.common_space_small))
            .then(modifier),
    ) {

        HeaderLayout(viewState, retry = { game.retryAutoPlayer() }, Modifier.align(TopCenter))

        BoardLayout(
            viewState.isLoading,
            viewState.error,
            viewState.board,
            Modifier.align(Center)
        ) { x, y ->
            game.play(x, y)
        }

        Column(
            modifier = Modifier
                .wrapContentSize()
                .align(BottomCenter),
            horizontalAlignment = Alignment.CenterHorizontally
        ) {
            Button(
                modifier = Modifier
                    .wrapContentSize(),
                onClick = { game.newGame() },
                enabled = !viewState.isLoading
            ) {
                Text(stringResource(id = R.string.reset))
            }
            Spacer(modifier = Modifier.width(dimensionResource(id = R.dimen.common_space_small)))
            Button(
                modifier = Modifier
                    .wrapContentSize(),
                onClick = { switchToNonComposeUI() },
            ) {
                Text(stringResource(id = R.string.non_compose_ui))
            }
        }
    }
}

@Composable
fun HeaderLayout(
    viewState: GameState,
    retry: () -> Unit,
    modifier: Modifier = Modifier
) {
    Column(
        modifier = Modifier
            .wrapContentSize()
            .then(modifier),
        verticalArrangement = Arrangement.Top,
        horizontalAlignment = Alignment.CenterHorizontally
    ) {

        if (viewState.gameFinished()) {
            Text(
                text = stringResource(viewState.winner.winMessageRes),
                color = colorResource(id = R.color.colorTextTitle),
                fontSize = fontSizeResource(id = R.dimen.common_textsize_extra_large),
                fontWeight = Bold
            )
        } else {
            Text(
                text = "${viewState.whoseTurn().label} ${stringResource(id = R.string.to_play)}",
                color = colorResource(id = R.color.colorTextTitle),
                fontSize = fontSizeResource(id = R.dimen.common_textsize_extra_large)
            )
        }

        if (viewState.isLoading) {
            CircularProgressIndicator(
                modifier = Modifier
                    .wrapContentSize()
                    .padding(dimensionResource(id = R.dimen.common_space_small)),
            )
        }

        if (viewState.error != ErrorMsg.NoError) {
            Text(
                modifier = Modifier.wrapContentSize(),
                text = stringResource(viewState.error.msgRes),
                color = colorResource(R.color.colorWarning),
                fontSize = fontSizeResource(id = R.dimen.common_textsize_extra_large)
            )
            Button(
                modifier = Modifier.wrapContentSize(),
                onClick = { retry() }
            ) {
                Text(stringResource(id = R.string.retry_autoplayer))
            }
        }
    }
}

@Composable
fun BoardLayout(
    isLoading: Boolean,
    error: ErrorMsg?,
    board: List<List<Player>>,
    modifier: Modifier = Modifier,
    tileClick: (x: Int, y: Int) -> Unit
) {
    Column(
        modifier = Modifier
            .wrapContentSize()
            .then(modifier)
    ) {
        var colorIndex = 0
        for (y in board.size - 1 downTo 0) {
            Row {
                for (x in board.indices) {
                    if (isLoading || error != ErrorMsg.NoError) {
                        GameSquare(colorPastels[colorIndex++ % colorPastels.size], board[x][y]) { }
                    } else {
                        GameSquare(colorPastels[colorIndex++ % colorPastels.size], board[x][y]) {
                            tileClick(x, y)
                        }
                    }
                }
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Compose Yourself!

Wait a second... that legacy state-y view layer code looked clearer than the Compose version πŸ€” It was also smaller and slightly more performant, so what's going on here?

While that might be true (even if we allow for the fact that the compose code did away with the XML layouts), I believe that's somewhat missing the point of Compose.

Dynamic UIs are the future

Compose is looking to a future where devices are more performant, not less. The UIs of tomorrow are likely to be much more dynamic and animated. And animating changes are the achilles heel of state-y driven UIs.

Let me explain that. If you think about when you might want to animate something on screen, it's typically at the state change boundaries. When someone goes from being 2 years old to 3 years old, you might wish to run a "happy 3rd birthday" animation. But that's a one time thing, if you ever get in to a situation where you're wishing someone happy birthday twice in the same day, well... that's weird.

That's something event-y driven architectures excel at because they're already event based. When the UI layer receives the "3rd birthday" event -> it runs the "happy 3rd birthday" animation. Easy.

State-y driven architectures have a problem here. When our UI layer receives the latest state, it knows that person is 3 years old (age=3). But did they become 3 just now?, have they been 3 for 6 months already? Did the UI already wish them happy birthday, and then they just left the room and came back (i.e. rotated the screen)?. The UI layer doesn't know without resorting to storing the latest state and looking for changes. There are many, many, many ways to handle this of course (and fore has its own solution too). It's a universal, if not very often discussed, problem.

Well this is where Compose really shines, that whole looking-for-changes-to-state thing is what Compose does for us (Compose is actually two systems, but that's outside the scope of this article), we just provide it with our latest state whenever we have it, and compose handles the rest for us. As the number of animations in a typical UI increases, you can probably appreciate how this could start to be the more scalable solution from a complexity point of view.

A few more thoughts

  • Composables are functions, and are very well suited for reuse provided they're written with that in mind. Sure we can do that with XML based layouts too, but it's not pretty.

  • A lot of state driven UIs have a function where all the UI states are set. In the example above we have syncView(), in an MVI app you might have a render() function. If this function is written correctly, most UI inconsistencies can be quickly narrowed down to a single line of code in a 10-20 line function. It's an incredibly powerful way to debug UIs, but I don't see a way that would be possible with Compose at the moment.

But Compose is pretty new! I'm interested to know what you think about it in the comments


Thanks for reading, hope you get a chance to play a few rounds πŸ˜‚

Note: if you do, the auto player makes a real network connection to mocky.io so it will take up to 30 seconds to wake up on first play

Also, the auto player is not a particularly smart opponent, but know that when playing testing this game, I still lost to it several times 🀦

Clone the app on github

Discussion (5)

Collapse
alyssoncs profile image
alyssoncs • Edited on

Hey man, really great article, you manged to articulate somethings that was really hard for me to explain to other people.

There is also the fact that in MVP the state is kinda split into the presenter and the view, the presenter can keep state inside of itself, but everytime it sends the message showLoadingSpinner() it is actually setting part of the state into the view, if another view register itself into the presenter after this call, this new view will not show loading behaviour.

Also, I wrote an article about adapting MVP to MVVM, and it looks like that we wrote very similar applications hehe: dev.to/alyssoncs/jetpack-compose-d...

Collapse
erdo profile image
Eric Donovan Author

Hey, thanks for the comment and the link. Yeah a tic tac toe game is the perfect example πŸ˜‚

Collapse
simaofelgueiras profile image
Simao Felgueiras

Great article, out of my curiosity and I never heard the "expression" state-y. What state-y stands for?

Collapse
erdo profile image
Eric Donovan Author

Hey thanks for the comment. I'm afraid that "state-y" is just my own personal slang to mean "state like / involved with state" and its opposite would be "event-y" to mean "event like / involved with events". I couldn't think of a clearer way to express it to be honest πŸ€·β€β™‚οΈ

Collapse
simaofelgueiras profile image
Simao Felgueiras

Thanks for the reply. It makes sense, when I do not know something, I am always up to learn new stuff :)