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.
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Contents
Mobius Tutorial for Android: Getting Started
30 mins
- Getting Started
- Understanding Mobius Principles and Concepts
- Mobius Update Function
- The Mobius Workflow
- Modeling Your App
- Defining External Events
- Capturing User Interactions
- Defining Effects
- Defining Your Model
- Defining Effects Feedback Events
- Describing Your App
- Memory Game Update Function
- Implementing Back-Out Functionality
- Handling Effects
- Mobius and Android
- Where to Go From Here?
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:
-
screen represents the current screen to display. The
GameScreen
enum class at the end of the file contains all the possible values. -
board contains the state of the game as a mutable list of
PlayingCardModel
, which represents the state of each card. - moves contains the current number of moves.
-
uncovered contains the cards that are currently uncovered in each moment. You represent each uncovered card as a pair of its
id
andvalue
. - 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 CardGameEffect
s. 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:
- Return a new instance of
Next
you create using thenext
factory method. - Pass to
next
a copy of the current model with a new value for thescreen
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:
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:
- You can’t exit from the board.
- When in the menu, back triggers the
ExitApplication
effect. - 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:
- Change the current screen to
GameScreen.BOARD
. - Invoke
createRandomValues
to generate a random distribution of the cards. - 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.
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:
- Find the position of the current card.
- Update the model for the selected card.
- Handle the uncovered state.
- If you uncovered two cards with the same value, add
DelayedCompletedPair
as an effect to handle. - 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:
- Receive the effect through an
Observable<CardGameEvent>
. - Use
map
to transform theCardGameEvent
into the one to return to notify the completion or result of the effect. - Wait a while.
- 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.