Mobius Tutorial for Android: Getting Started

Learn about Mobius, a functional reactive framework for managing state evolution and side effects and see how to connect it to your Android UIs. By Massimo Carli.

Leave a rating/review
Download materials
Save for later
Share
You are currently viewing page 3 of 4 of this article. Click here to view the first page.

Describing Your App

When you have the MoFlow for your app, you can start implementing the Update function. Update is a pure function that receives the event and the current model as input and generates the new model and the set of effects as output. In Mobius, an instance of the Next class encapsulates this information.

To see how this works, you’ll now implement navigation for the app. But first, you have to look at the model. Open CardGameModel.kt in model, and you’ll get the following code:

data class CardGameModel(
  val screen: GameScreen = GameScreen.MENU, // 1
  val board: MutableList<PlayingCardModel> = mutableListOf(), // 2
  val moves: Int = 0, // 3
  val uncovered: MutableList<Pair<Int, Int>> = mutableListOf(), // 4
  val completed: Int = 0 // 5
)

enum class CardState {
  HIDDEN, VISIBLE, DONE;
}

data class PlayingCardModel(
  val cardId: Int,
  val value: Int,
  val state: CardState = CardState.HIDDEN
)

enum class GameScreen {
  MENU, BOARD, END, CREDITS
}

As you can see, CardGameModel is a simple data class whose properties define the app’s current state. In particular, you see that:

  1. screen represents the current screen to display. The GameScreen enum class at the end of the file contains all the possible values.
  2. board contains the state of the game as a mutable list of PlayingCardModel, which represents the state of each card.
  3. moves contains the current number of moves.
  4. uncovered contains the cards that are currently uncovered in each moment. You represent each uncovered card as a pair of its id and value.
  5. completed represents the number of pairs the user has successfully found.

To understand how the specific page is rendered, open MainScreen.kt in ui, getting the following code:

@Composable
fun MainScreen(
  gameModel: CardGameModel, 
  eventConsumer: Consumer<CardGameEvent>
) {
  when (gameModel.screen) { // HERE
    GameScreen.MENU -> GameMenu(eventConsumer)
    GameScreen.BOARD -> GameBoard(gameModel, eventConsumer)
    GameScreen.END -> GameResult(gameModel, eventConsumer)
    GameScreen.CREDITS -> CreditsScreen(eventConsumer)
  }
}

It’s easy now to see how the value of the model’s screen property defines which screen to display by invoking the proper composable function.

Memory Game Update Function

From the description of the Mobius architecture, you understand that you’ll just need to update the screen property when a navigation event is triggered. Open CardGameLogic.kt in mobius.logic, and look at the following code:

val cardGameLogic: CardGameUpdate = object : CardGameUpdate {
  override fun update(
    model: CardGameModel,
    event: CardGameEvent
  ): Next<CardGameModel, CardGameEffect> = when (event) {
    is ShowCredits -> handleShowCredits(model, event)
    is BackPressed -> handleBack(model, event)
    is StartGame -> handleStartGame(model, event)
    is ShowMenu -> handleShowMenu(model, event)
    is FlipCard -> handleFlipCard(model, event)
    is SetPairAsDone -> handleSetPairAsDone(model, event)
    is RestorePair -> handleRestorePair(model, event)
    is EndGame -> handleEndGame(model, event)
  }
}

This is an Update function that handles each event, invoking a related function at the moment not completely implemented. Here, it’s very important to note how Update is a function of type (CardGameModel, CardGameEvent) -> Next<CardGameModel, CardGameEffect>. It accepts a CardGameModel and a CardGameEvent in input and returns a Next<CardGameModel, CardGameEffect>, which is a way to encapsulate the resulting CardGameModel and a set of optional CardGameEffects. Note that CardGameUpdate is a Mobius Update.

To handle navigation, start with the following function:

private fun handleShowCredits(
  model: CardGameModel, 
  event: ShowCredits
): Next<CardGameModel, CardGameEffect> = Next.noChange()

At the moment, this returns a value that tells Mobius to do nothing. To navigate to the credit screen, you just need to replace the previous code with the following:

private fun handleShowCredits(
  model: CardGameModel, 
  event: ShowCredits
): Next<CardGameModel, CardGameEffect> {
  return Next.next( // 1
    model.copy( // 2
      screen = GameScreen.CREDITS,
    )
  )
}

In this code, you:

  1. Return a new instance of Next you create using the next factory method.
  2. Pass to next a copy of the current model with a new value for the screen property.

That’s it! Mobius will handle all the updates for you. To see this, run the app and click the CREDITS button in the menu, landing on the following screen:

Credits Screen

Credits Screen in app

Congratulations! You just implemented the first logic in Mobius’s Update function.

Implementing Back-Out Functionality

You need to add another thing to navigation. If you press the back button, nothing happens. To handle this, replace the following code:

private fun handleBack(
  model: CardGameModel, 
  event: BackPressed
): Next<CardGameModel, CardGameEffect> = Next.noChange()

With:

private fun handleBack(
  model: CardGameModel, 
  event: BackPressed
): Next<CardGameModel, CardGameEffect> =
  when (model.screen) {
    GameScreen.BOARD -> Next.noChange() // 1
    GameScreen.MENU -> Next.next(model, setOf(ExitApplication)) // 2
    else -> Next.next(model.copy(screen = GameScreen.MENU)) // 3
  }

In this case, the logic is just a little bit more interesting because:

  1. You can’t exit from the board.
  2. When in the menu, back triggers the ExitApplication effect.
  3. In all other cases, you return to the menu.

Later, you’ll see how to handle the ExitApplication effect. Build and run the app, and check that you can actually go to the credits screen and go back. Unfortunately, you can’t play yet, but you already know how to fix it. Just replace:

private fun handleStartGame(
  model: CardGameModel, 
  event: StartGame
): Next<CardGameModel, CardGameEffect> = Next.noChange()

With:

private fun handleStartGame(
  model: CardGameModel, 
  event: StartGame
): Next<CardGameModel, CardGameEffect> {
  return Next.next(
    model.copy(
      screen = GameScreen.BOARD, // 1
      board = createRandomValues(), // 2
      moves = 0, // 3
      completed = 0, // 3
      uncovered = mutableListOf() // 3
    )
  )
}

In this case, the new model adds some complexity related to the initialization of the game. To note that, you:

  1. Change the current screen to GameScreen.BOARD.
  2. Invoke createRandomValues to generate a random distribution of the cards.
  3. Reset all the counters related to the game state.

Build and run the app, and you can now open the board and start playing. Arg! Something’s wrong there. That’s because the effects you designed in the MoFlow aren’t implemented yet.

The Game Screen

The Game Screen with 20 cards

Handling Effects

To revise which effects you need to handle, it’s useful to look at the implementation of the game logic. Open CardGameLogic.kt and look at the following code:

fun handleFlipCard(
  model: CardGameModel, 
  event: FlipCard
): Next<CardGameModel, CardGameEffect> {
  val (pos, currentModel) = model.findModelById(event.cardId) // 1
  val newFlipState =
    if (currentModel.state == CardState.HIDDEN) {
      CardState.VISIBLE
    } else { CardState.HIDDEN } // 2
  val newModel = currentModel.copy(
    state = newFlipState
  )
  model.board[pos] = newModel
  val uncovered = model.uncovered // 3
  uncovered.add(currentModel.cardId to currentModel.value)
  val effects = mutableSetOf<CardGameEffect>() 
  if (uncovered.size == 2) {
    // Check they have the same value
    if (uncovered[0].second == uncovered[1].second) { // 4
      effects.add(DelayedCompletedPair(
        uncovered[0].first, 
        uncovered[1].first
      ))
    } else { // 5
      effects.add(DelayedWrongPair(
        uncovered[0].first, 
        uncovered[1].first
      ))
    }
  }
  return Next.next(
    model.copy(
      moves = model.moves + 1
    ), effects
  )
}

This function contains the logic for the game handling the FlipCard event. Here, you:

  1. Find the position of the current card.
  2. Update the model for the selected card.
  3. Handle the uncovered state.
  4. If you uncovered two cards with the same value, add DelayedCompletedPair as an effect to handle.
  5. If the values are different, add the DelayedWrongPair effect.

But how do you handle the DelayedCompletedPair and DelayedWrongPair effects? Open MobiusModule.kt in di and look at the following code:

  @Provides
  fun provideEffectHandler(
    gameHandler: GameEffectHandler,
  ): CardGameEffectHandler =
    RxMobius.subtypeEffectHandler<CardGameEffect, CardGameEvent>()
      .addTransformer(
        DelayedCompletedPair::class.java, 
        gameHandler::handlePairCompleted
      ) // HERE
      .addTransformer(
        DelayedWrongPair::class.java, 
        gameHandler::handleWrongPair
      ) // HERE
      .addTransformer(
        GameFinished::class.java, 
        gameHandler::handleGameFinished
      ) // HERE
      .addConsumer( // HERE
        ExitApplication::class.java,
        gameHandler::handleExitApp,
        AndroidSchedulers.mainThread()
      )
      .build();

Here, you basically register a function as the one Mobius executes when a specific effect needs to be handled. You do this in different ways because the ExitApplication effect just consumes an event and doesn’t trigger a feedback event.

Now, open GameEffectHandlerImpl.kt and replace the existing code with the following:

class GameEffectHandlerImpl @Inject constructor(
  @ActivityContext val context: Context
) : GameEffectHandler {

  override fun handlePairCompleted(
    request: Observable<DelayedCompletedPair>
  ): Observable<CardGameEvent> = // 1
    request
      .map { req -> // 2
        waitShort() // 3
        SetPairAsDone(req.firstId, req.secondId) // 4
      }

  override fun handleWrongPair(
    request: Observable<DelayedWrongPair>
  ): Observable<CardGameEvent> = // 1
    request
      .map { req -> // 2
        waitShort() // 3
        RestorePair(req.firstId, req.secondId) // 4
      }

  override fun handleGameFinished(
    request: Observable<GameFinished>
  ): Observable<CardGameEvent> = // 1
    request
      .map { req -> // 2
        waitShort() // 3
        EndGame // 4
      }

  override fun handleExitApp(extEffect: ExitApplication) {
    (context as Activity).finish()
  }

  private fun waitShort() = try {
    Thread.sleep(800)
  } catch (ie: InterruptedException) {
  }
}

In this code, you handle the effects with feedback events in the same way, and basically, you:

  1. Receive the effect through an Observable<CardGameEvent>.
  2. Use map to transform the CardGameEvent into the one to return to notify the completion or result of the effect.
  3. Wait a while.
  4. Return the feedback event.

handleExitApp is much simpler because it’s just a Consumer for the events, and it doesn’t need to return anything. In this case, you just use the context of the activity you receive from Dagger and invoke finish.

Now, Mobius sends the feedback event to the same Upate function you edited earlier. Build and run the app, and check how it works. Now, when you select two cards, depending on whether they have the same values, they’re either flipped to a yellow color or flipped back.

To see how you handle the end of the game, look at the following code in CardGameLogic.kt:

private fun handleSetPairAsDone(
  model: CardGameModel, 
  event: SetPairAsDone
): Next<CardGameModel, CardGameEffect> {
  val (pos1, model1) = model.findModelById(event.firstId)
  val (pos2, model2) = model.findModelById(event.secondId)
  val newBoard = model.board
  model.board[pos1] = model1.copy(state = CardState.DONE)
  model.board[pos2] = model2.copy(state = CardState.DONE)
  val effects = mutableSetOf<CardGameEffect>()
  val completed = model.completed + 2
  if (completed == 20) {
    effects.add(GameFinished) // HERE
  }
  return Next.next(
    model.copy(
      board = newBoard,
      completed = model.completed + 2,
      moves = model.moves - 2,
      uncovered = mutableListOf()
    ), effects
  )
}

Just note how you add the GameFinished effect, in case all the card pairs have been found.