Skip to content

Instantly share code, notes, and snippets.

@ZakTaccardi
Last active October 20, 2023 02:27
Show Gist options
  • Star 7 You must be signed in to star a gist
  • Fork 2 You must be signed in to fork a gist
  • Save ZakTaccardi/e91584e265994c9e55bb0cd65ac0bdab to your computer and use it in GitHub Desktop.
Save ZakTaccardi/e91584e265994c9e55bb0cd65ac0bdab to your computer and use it in GitHub Desktop.
Example MVI Implementation with Coroutines
import kotlinx.coroutines.experimental.android.Main
import kotlinx.coroutines.experimental.CoroutineScope
class ExampleActivity : Activity(), CoroutineScope by CoroutineScope(Dispatchers.Main) {
override fun onCreate(savedInstanceState: Bundle?)
super.onCreate(savedInstanceState)
val ui = Ui(this) // bind views, etc
viewModel.observeState(this, ui::render)
viewModel.observeSideEffects(this, ui::onSideEffect)
}
override fun onDestroy() {
coroutineContext[Job]!!.cancel()
super.onDestroy()
}
}
import kotlinx.coroutines.experimental.CoroutineScope
import kotlinx.coroutines.experimental.channels.Channel
import kotlinx.coroutines.experimental.channels.ConflatedBroadcastChannel
import kotlinx.coroutines.experimental.channels.ReceiveChannel
import kotlinx.coroutines.experimental.channels.actor
/**
* A default implementation of a [MviViewModel], where View State ([VS]) is updated via Intentions ([I])
* using the [reduce] function.
*
* The general pattern with this type of View Model is that [reduce] receives Intentions
* from the UI, network, or repository layer which may do any of the following:
* - return a new state
* - return the existing state (to no-op)
* - launch a new coroutine and send results back via a new [send]
* - generate a Side Effect, which the UI will receive via [sideEffects]
*
* [I] - Intention (usually a `sealed class`)
* [VS] - View State (usually a `data class`)
* [SE] - Side Effect (usually a `sealed class`)
*/
abstract class IntentionViewModel<I : Any, VS : Any, SE : Any> internal constructor(
initialState: VS,
protected val scope: CoroutineScope
) : MviViewModel<I, VS, SE> {
@Suppress("MemberVisibilityCanBePrivate")
protected val stateChannel = ConflatedBroadcastChannel(initialState)
@Suppress("MemberVisibilityCanBePrivate")
private val sideEffectChannel = Channel<SE>(Channel.UNLIMITED)
private val intentions = scope.actor<I>(capacity = Channel.UNLIMITED) {
var currentState: VS = stateChannel.value
for (intention in channel) {
// this actor's channel emits whenever it receives a new `Intention`. `Intentions` act
// as messages that are used to update the `currentState`.
//
// note: all these messages are processed in order, so thread safety is guaranteed
currentState = reduce(currentState, intention)
if (stateChannel.value === currentState) {
// no need to update the channel, new state is the same instance as the old state
} else {
stateChannel.send(currentState)
}
}
}
/**
* Reducer function - converts the [currentState] + [intention] = new state.
*
* Subclass should implement this to control logic when an [intention] is received.
*
* The reducer coroutine should not be suspended for a long time (like disk load or network
* call), otherwise the state will not be updatable - so this function is synchronous. This
* means that every Intention [I] is the only way to update the state - therefore things like
* the result of a network call should be piped back to [send] as an Intention [I].
*/
protected abstract fun reduce(currentState: VS, intention: I): VS
override fun states() = stateChannel.openSubscription()
override fun sideEffects(): ReceiveChannel<SE> = sideEffectChannel
override fun send(intention: I) {
intentions.offer(intention)
}
protected fun sideEffect(sideEffect: SE) {
sideEffectChannel.offer(sideEffect)
}
}
import kotlinx.coroutines.experimental.CoroutineScope
import kotlinx.coroutines.experimental.Job
import kotlinx.coroutines.experimental.channels.Channel
import kotlinx.coroutines.experimental.channels.ReceiveChannel
import kotlinx.coroutines.experimental.channels.consumeEach
import kotlinx.coroutines.experimental.launch
import kotlin.coroutines.experimental.CoroutineContext
/**
* Allows you to observe state in our MVI pattern (model view intention).
*
* @param I Intention - see [MviViewModel.send]
* @param VS View State - see [MviViewModel.states]
* @param SE side effect - see [MviViewModel.sideEffects]
*/
interface MviViewModel<in I : Any, out VS : Any, out SE : Any> {
/**
* States
*
* Observe the View State of the [MviViewModel]. A State is an immutable representation of how
* the UI should look at any given time.
*
* When rendering a state, your UI should cache what is currently rendered to avoid re-rendering
* as states are emitted frequently. How you decide
*
* States are often represented a `data class` to allow the [MviViewModel] to collect
* information over time.
*
* Example designing states:
*
* ```
* data class ViewState(val showErrorDialog: Boolean = false)
* ```
*
* 1. States should always have a default value. It's common practice to use the default
* constructor for this - `State()`.
* 2. Your UI reacts to the most recent emission of `State` when it subscribes, and receives
* frequent emissions of `State` over time. This is why it's important that `State` ([VS]) is an
* immutable representation of how the UI should appear at any time. Using the example above,
* `showErrorDialog == false` indicates that the UI should:
*
* 1. Dismiss the error dialog if it is showing
* 2. Show it if it is not showing
* 3. Do nothing if it is already showing.
*
* note: The implementing class should always emit the most recent state on subscription. This
* allows multiple subscribers if so desired. The [ReceiveChannel] returned here should function
* similar to Rx's BehaviorSubject, where the most recent state is always emitted.
*
* @see observeState to see how to safely consume a stream of states.
*/
fun states(): ReceiveChannel<VS>
/**
* Side effects
*
* Observe side effects. A side effect is a "command" or "event" sent to the UI from the
* [MviViewModel]. This command is disconnected from the state emitted by [states].
*
* Side effects should generally be avoided when possible, because state based rendering is more
* resilient. However, sometimes they make sense. A good candidate for a `SideEffect` is
* temporarily showing snackbar or toast. To do this with state, the dismissal logic for the
* snackbar/toast would need to live in your [MviViewModel]. so the "fire and forget" nature of
* side effects make sense here.
*
* The [ReceiveChannel] should generally be implemented as a [Channel.UNLIMITED] [Channel] so
* it can function like a queue. If no UI (the consumer/subscriber) is attached, the side
* effects in the queue will build up. When a consumer attaches, the queue will be drained and
* the UI will receive all side effects that were queued up.
*
* WARNING: do not have more than one subscriber at a time to this channel. Emissions from this
* [ReceiveChannel] are consumed by the subscriber, so it does not make sense to have more than
* one subscriber.
*
* WARNING: do not call `.consumeEach { }` on this channel, as it closes the channel on
* un-subscription.
*
* @see observeSideEffects to see how to safely consume a stream of side effects.
*/
fun sideEffects(): ReceiveChannel<SE>
/**
* Send an [intention] to `this` [MviViewModel] for processing. An intention represents any
* incoming input for the UI. Most often, this is user input. But it could be things like
* permissions being granted or anything returned from `startActivityForResult`.
*
* Calling this method may result in an update to the
* - ViewModel's view state - emitted by [states]
* - a new side effect, emitted by [sideEffects]
*
* Example usage: `viewModel.send(Intention.LoginButtonClicked(username, password))`
*/
fun send(intention: I)
}
/**
* A convenience function to ensure that states emitted by [MviViewModel] are observed
* correctly.
*
* @param scope the [CoroutineScope] that [renderFunction] will be invoked in. This should be backed
* by the UI dispatcher (ui thread).
* @param renderFunction a function that will be invoked when [MviViewModel.states] emits. Upon
* subscription, this function will be invoked immediately with the latest state. This function will
* be invoked frequently, so make sure your UI caches the latest View State for performance.
*/
inline fun <VS : Any> MviViewModel<*, VS, *>.observeState(
scope: CoroutineScope,
crossinline renderFunction: (VS) -> Unit
) {
scope.launch {
this@observeState.states()
// consumeEach closes the ReceiveChannel emitted by .states() when the scope is
// cancelled, but that's okay because .states() returns a new receive channel every time
.consumeEach { viewState -> renderFunction(viewState) }
}
}
/**
* A convenience function to ensure that side effects emitted by [MviViewModel] are observed
* correctly.
*
* It ensures that the `for (sideEffect in viewModel.sideEffects()` API is used to observe,
* as it does not close the channel on un-subscription..
*
* @param scope the [CoroutineScope] that [sideEffectFunction] will be invoked in. This should be
* backed by the UI dispatcher (ui thread).
* @param sideEffectFunction a function that is invoked whenever [MviViewModel.observeSideEffects]
* emits.
*/
inline fun <SE : Any> MviViewModel<*, *, SE>.observeSideEffects(
scope: CoroutineScope,
crossinline sideEffectFunction: (SE) -> Unit
) {
scope.launch {
// This way of observing does not close the ReceiveChannel emitted by .sideEffects when
// the scope is cancelled. This important to handle scenarios like rotation when the side
// effect channel is still valid.
//
// It's important to understand that the same instance of the ReceiveChannel is returned by
// sideEffects every time it is invoked.
for (sideEffect in this@observeSideEffects.sideEffects()) {
sideEffectFunction(sideEffect)
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment