This is part of a series on android fore
Tutorials in Series |
---|
1) Tutorial: Spot the deliberate bug |
2) Tutorial: Android fore basics |
3) Tutorial: Android architecture, full todo app (MVO edition) |
4) Tutorial: Android state v. event |
5) Tutorial: Kotlin Coroutines, Retrofit and fore |
GUIs can be driven by two different types of data: state or events. It's a universal but under-appreciated concept. Let's grok the difference (it'll come in handy whether we're using MVI, MVVM, MVO or something else)...
We’ll make a tiny game using the fore library and the MVO architectural pattern. We'll also touch on the SyncTrigger class which is fore's way of bridging the two worlds of state and events.
Game rules:
- Players start with an identical number of coins
- Each turn, a player gives one of their coins to another player, chosen at random
That's it. Of course if they have no coins to give, they skip their turn. We highlight the number of the player who is about to play (not the player who has just played).
Model
We'll tackle the M in MVO first like we did in the basics tutorial (if you haven't read that tutorial, you should probably check it out first).
See the repo below for the full source of our GameModel, but these are our public functions:
class GameModel() : Observable by ObservableImp(workMode) {
fun next() {
...
}
fun clear() {
...
}
fun getPlayerAmount(playerNumber: Int): CashAmount {
...
}
fun isPlayersTurn(playerNumber: Int): Boolean {
...
}
fun getRound(): Int {
...
}
}
- The model knows nothing about android view layer classes or contexts
- We are calling notifyObservers() whenever any of our model’s state changes
- All the public functions can be called at any time on the UI thread, and we guarantee that their results will be consistent
A checklist for writing models when you’re using fore (or indeed something like MVVM) is maintained here.
View
Previously in these tutorials we've written custom view classes for our view layer, this time we’ll use an activity for our view layer (just because), we’re going to call it GameOfLifeActivity.
As usual, our view layer class (which is now an activity) is going to handle the following things:
- get a reference to all the view elements we need
- get a reference to the GameModel so we can draw our UI based on it
- hook up the button listeners so that they interact with the model
- sync our view so that it matches the state of the GameModel at all times (show the right number of coins for each user etc)
class GameOfLifeActivity : AppCompatActivity {
//models that we need
private lateinit var gm: GameModel
private lateinit var logger: Logger
...
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
//(get view references handled for us by kotlin tools)
getModelReferences()
setClickListeners()
}
private fun getModelReferences() {
gm = App.inst.appComponent.gameModel
logger = App.inst.appComponent.logger
}
private fun setClickListeners() {
life_next_btn.setOnClickListener { gm.next() }
life_reset_btn.setOnClickListener { gm.clear() }
}
...
}
Observer
We want a reactive view, which syncs itself automatically whenever the game model changes, without us having to worry about it. So as we've done before, we add and remove our observer in line with android lifecycle methods.
class GameOfLifeActivity : AppCompatActivity() {
...
override fun onStart() {
super.onStart()
gm.addObserver(observer)
syncView() // <- don't forget this
}
override fun onStop() {
super.onStop()
gm.removeObserver(observer)
}
override fun syncView() {
life_player1cash_img.setImageResource(gm.getPlayerAmount(0).resId)
life_player2cash_img.setImageResource(gm.getPlayerAmount(1).resId)
life_player3cash_img.setImageResource(gm.getPlayerAmount(2).resId)
life_player4cash_img.setImageResource(gm.getPlayerAmount(3).resId)
life_player1icon_img.setImageResource(if (gm.isPlayersTurn(0))
R.drawable.player_01_highlight
else
R.drawable.player_01)
life_player2icon_img.setImageResource(if (gm.isPlayersTurn(1))
R.drawable.player_02_highlight
else
R.drawable.player_02)
life_player3icon_img.setImageResource(if (gm.isPlayersTurn(2))
R.drawable.player_03_highlight
else
R.drawable.player_03)
life_player4icon_img.setImageResource(if (gm.isPlayersTurn(3))
R.drawable.player_04_highlight
else
R.drawable.player_04)
life_round_txt.text =
resources.getString(R.string.round, gm.getRound())
}
}
Dependency Injection
Last time we rolled our own pure DI solution. This time we are using a bare-bones Dagger 2.0 implementation, just because.
gameModel = App.inst.appComponent.gameModel
As before, this provides the view with the same instance of the model each time, so that everything remains consistent, even when we rotate the screen.
As always with MVO and fore: rotation support and testability come as standard.
Introducing an Event
At the moment our UI is driven by state, held in the GameModel. It's because of the "statey-ness" of MVO that we can so easily support rotation on android - all we need to do to keep our UI totally consistent is to re-sync with the model by calling syncView() whenever we want.
But let's add a new design requirement:
When the game transitions to an economy where at least one player has no coins, we want to display a Snackbar indicating that this transition (or event) took place, and the same in reverse.
We can easily add this piece of state to our game model and provide access to it like this: fun hasBankruptPlayers(): Boolean
But the Snackbar UI component is much more suited to event type data, and using syncView() to trigger a Snackbar would cause us problems. A robust implementation of syncView() needs to make no assumptions about the number of times it is called, it might be called more than we expect - we definitely don't want to show 3 duplicate Snackbars by mistake.
It's an issue you find in all "statey" architectures like MVVM, MVO, and MVI (not so much in MVP as that's more "eventy").
What we need here is a way to take us from the state world (where things are set, and remain that way until set again) to the event world (where things are dispatched and then are gone, unless they are dispatched again). There are a number of ways of bridging these two worlds. In fore we use something called a SyncTrigger.
SyncTrigger
We need to specify 2 main things to use a SyncTrigger:
- the threshold required to trip the trigger (network connectivity goes from connected->disconnected; an error state goes from false->true; the amount of money in an account goes below a certain limit; a timer goes beyond a certain duration, etc)
- what we want to happen when it is triggered (typically show a snackbar or a dialog; start an activity; or run an animation)
We are going to add two SyncTriggers to the app:
private fun setupTriggers() {
showHasBankruptciesTrigger = SyncTrigger({ gameModel.hasBankruptPlayers() }){
Snackbar.make(this, context.getString(R.string.bankruptcies_true), LENGTH_SHORT).show()
}
showNoBankruptciesTrigger = SyncTrigger({ !gameModel.hasBankruptPlayers() }){
Snackbar.make(this, context.getString(R.string.bankruptcies_false), LENGTH_SHORT).show()
}
}
We also need to check them each time we run through syncView():
override fun syncView() {
...
showHasBankruptciesTrigger.checkLazy()
showNoBankruptciesTrigger.checkLazy()
}
Because these SyncTriggers exist in our view layer, they will be destroyed and recreated by android if we rotate our view. If their threshold conditions are already breached, they will be fired immediately upon rotation (when syncView() is called). If the device is rotated again, they will be fired again etc. This is probably not what we want. Hence checkLazy() ignores a threshold breach if it happens the first time you check a newly constructed SyncTrigger. If you do actually want the trigger fired the first time it's checked, you can call check() instead of checkLazy().
Reset behaviour
Until it is reset, a SyncTrigger will fire only once, and it will fire the first time the trigger condition goes from being false to true (when checked).
By default, a SyncTrigger is reset according to the ResetRule.ONLY_AFTER_REVERSION, which means that the trigger condition needs to flip back to false, before it is eligible for re-triggering.
Alternatively you can construct a SyncTrigger with the flag: ResetRule.IMMEDIATELY, this means that the SyncTrigger can be continually triggered each time it is checked and the trigger condition is true. Be careful with this one, it could tie your code to depending on syncView() being called a certain number of times - which is a way of writing fragile code.
KISS
SyncTriggers are a great way of firing off nice animations in response to state changes happening in models.
You won't always need them though, button click listeners are event based already for example. I recommend taking a look at the discussion about this in the fore docs.
There's a presentation which examines the effect of treating your data as state or events on the fore documentation site here - click s to see the slide notes as they provide a lot more context
Thanks for reading! If you're thinking of using fore in your team, the fore docs have most of the basics covered in easy to digest sample apps, e.g. adapters, networking or databases.
here’s the complete code for the tutorial
Top comments (0)