Last active
October 20, 2023 02:27
-
-
Save ZakTaccardi/e91584e265994c9e55bb0cd65ac0bdab to your computer and use it in GitHub Desktop.
Example MVI Implementation with Coroutines
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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