How to write your
own MVI system
and why you shouldn't
Matthew Dolan
Mikolaj Leszczynski
Introducing MVI
Introducing MVI
State
UI
Action
(ViewModel)
(Activity/Fragment)
User
Intent
Model
View
Reducer
State
Data source
Intent
Transformer
View
Model
Key concepts
- Unidirectional cycle of data
- Non-blocking
- Immutable state
Building MVI
Inspirational blogs
by Hannes Dorfmann
Inspirational talks
Inspirational libraries
Why write your own?
Why write your own?
- Learning curve
- Inheritance over composition
- Boilerplate
Choosing your base
RxJava
Coroutines
Choosing your base
RxJava
Coroutines
Live coding
class PostListViewModel : ViewModel() {
private val _state = MutableStateFlow(PostListState())
val state: StateFlow<PostListState> = _state
}
data class PostOverview(
val id: Int,
val title: String,
val author: String
)
data class PostListState(
val overviews: List<PostOverview> = emptyList()
)
class PostListViewModel : ViewModel() {
private val _state = MutableStateFlow(PostListState())
val state: StateFlow<PostListState> = _state
fun loadPosts() {
viewModelScope.launch {
val posts = repo.getPosts()
_state.value = _state.value.copy(overviews = posts)
}
}
}
class PostListViewModel : ViewModel() {
private val _state = MutableStateFlow(PostListState())
val state: StateFlow<PostListState> = _state
fun loadPosts() {
viewModelScope.launch(SINGLE_THREAD) {
val posts = repo.getPosts()
withContext(SINGLE_THREAD) {
_state.value = _state.value.copy(overviews = posts)
}
}
}
companion object {
private val SINGLE_THREAD = newSingleThreadContext("mvi")
}
}
class PostListViewModel : ViewModel() {
private val _state = MutableStateFlow(PostListState())
val state: StateFlow<PostListState> = _state
fun loadPosts() {
viewModelScope.launch(SINGLE_THREAD) {
val posts = repo.getPosts()
reduce {
copy(overviews = posts)
}
}
}
private suspend fun reduce(reducer: PostListState.() -> PostListState) =
withContext(SINGLE_THREAD) {
_state.value = _state.value.reducer()
}
companion object {
private val SINGLE_THREAD = newSingleThreadContext("mvi")
}
}
class PostListViewModel : ViewModel() {
...
fun viewDetails(postId: Int) {
viewModelScope.launch(SINGLE_THREAD) {
// Navigate to post details
}
}
...
}
data class PostListState(
val overviews: List<PostOverview> = emptyList(),
val navigateToPost: Int? = null
)
class PostListViewModel : ViewModel() {
private val _sideEffect = Channel<NavigationEvent>(Channel.BUFFERED)
val sideEffect: Flow<NavigationEvent> = _sideEffect.receiveAsFlow()
fun viewDetails(postId: Int) {
viewModelScope.launch(SINGLE_THREAD) {
_sideEffect.send(OpenPostNavigationEvent(postId))
}
}
...
}
class PostListViewModel : ViewModel() {
private val _sideEffect = Channel<NavigationEvent>(Channel.BUFFERED)
val sideEffect: Flow<NavigationEvent> = _sideEffect.receiveAsFlow()
fun viewDetails(postId: Int) {
viewModelScope.launch(SINGLE_THREAD) {
postSideEffect(OpenPostNavigationEvent(postId))
}
}
private suspend fun postSideEffect(event: NavigationEvent) =
_sideEffect.send(event)
...
}
class PostListViewModel : ViewModel() {
...
fun loadPosts() = intent {
val posts = repo.getPosts()
reduce {
_state.value.copy(overviews = posts)
}
}
fun viewDetails(postId: Int) = intent {
postSideEffect(OpenPostNavigationEvent(postId))
}
private fun intent(transform: suspend PostListViewModel.() -> Unit) =
viewModelScope.launch(SINGLE_THREAD) {
this@PostListViewModel.transform()
}
...
}
class PostListViewModel : ViewModel() {
private val _state = MutableStateFlow(PostListState())
val state: StateFlow<PostListState> = _state
private val _sideEffect = Channel<NavigationEvent>(Channel.BUFFERED)
val sideEffect: Flow<NavigationEvent> = _sideEffect.receiveAsFlow()
private fun intent(transform: suspend PostListViewModel.() -> Unit) =
viewModelScope.launch(SINGLE_THREAD) {
this@PostListViewModel.transform()
}
private suspend fun reduce(reducer: PostListState.() -> PostListState) =
withContext(SINGLE_THREAD) {
_state.value = _state.value.reducer()
}
private suspend fun postSideEffect(event: NavigationEvent) =
_sideEffect.send(event)
companion object {
private val SINGLE_THREAD = newSingleThreadContext("mvi")
}
...
class Container(
private val scope: CoroutineScope
) {
private val _state = MutableStateFlow(PostListState())
val state: StateFlow<PostListState> = _state
private val _sideEffect = Channel<NavigationEvent>(Channel.BUFFERED)
val sideEffect: Flow<NavigationEvent> = _sideEffect.receiveAsFlow()
fun intent(transform: suspend Container.() -> Unit) =
scope.launch(SINGLE_THREAD) {
this@Container.transform()
}
suspend fun reduce(reducer: PostListState.() -> PostListState) =
withContext(SINGLE_THREAD) {
_state.value = _state.value.reducer()
}
suspend fun postSideEffect(event: NavigationEvent) =
_sideEffect.send(event)
...
}
class Container<STATE, SIDE_EFFECT>(
private val scope: CoroutineScope,
initialState: STATE
) {
private val _state = MutableStateFlow(initialState)
val state: StateFlow<STATE> = _state
private val _sideEffect = Channel<SIDE_EFFECT>(Channel.BUFFERED)
val sideEffect: Flow<SIDE_EFFECT> = _sideEffect.receiveAsFlow()
fun intent(transform: suspend Container<STATE, SIDE_EFFECT>.() -> Unit) =
scope.launch(SINGLE_THREAD) {
this@Container.transform()
}
suspend fun reduce(reducer: STATE.() -> STATE) =
withContext(SINGLE_THREAD) {
_state.value = _state.value.reducer()
}
suspend fun postSideEffect(event: SIDE_EFFECT) =
_sideEffect.send(event)
...
}
class PostListViewModel : ViewModel() {
val container = Container<PostListState, NavigationEvent>(
viewModelScope,
PostListState()
)
fun loadPosts() = container.intent {
val posts = repo.getPosts()
reduce {
copy(overviews = posts)
}
}
fun viewDetails(post: PostOverview) = container.intent {
postSideEffect(OpenPostNavigationEvent(post))
}
}
Intent
Intent
Intent
Intent
Stream
Dispatcher
Trans
former
Trans
former
Trans
former
Intent
Intent
Intent
Trans
former
Trans
former
Trans
former
class PostListState(
val overviews: List<PostOverview> = emptyList,
val navigateToPostId: Int?
)
class PostListViewModel {
fun onPostClicked(postOverview: PostOverview) {
viewModelScope.launch {
reduce {
copy(navigateToPostId = postOverview.id)
}
}
}
fun clearPostIdNavigation() {
viewModelScope.launch {
reduce {
copy(navigateToPostId = null)
}
}
}
}
class PostListFragment {
private fun navigateToDetails(postId: Int) {
viewModel.clearPostIdNavigation()
// Navigation code
}
}
class Container<STATE, SIDE_EFFECT>(
private val scope: CoroutineScope,
initialState: STATE
) {
private val _stateFlow = MutableStateFlow(initialState)
val state: StateFlow<STATE> = _stateFlow
private val _sideEffect = Channel<SIDE_EFFECT>(Channel.BUFFERED)
val sideEffect: Flow<SIDE_EFFECT> = _sideEffect.receiveAsFlow()
fun intent(transform: suspend Container<STATE, SIDE_EFFECT>.() -> Unit) =
scope.launch(SINGLE_THREAD) {
this@Container.transform()
}
suspend fun reduce(reducer: STATE.() -> STATE) =
withContext(SINGLE_THREAD) {
_stateFlow.value = _stateFlow.value.reducer()
}
suspend fun postSideEffect(event: SIDE_EFFECT) =
_sideEffect.send(event)
companion object {
private val SINGLE_THREAD = newSingleThreadContext("mvi")
}
}
Just MVI
- No inheritance
- No boilerplate
- < 30 lines of code
Don't write your own
Missing features
- Stricter DSL scoping
- Improved threading model
- Unit tests
- Testing framework
- Idling resource support
- Saved state support
Introducing Orbit
- Stricter DSL scoping
- Improved threading model
- Unit tests
- Testing framework
- Idling resource support
- Saved state support
Future Orbit
- Time-travel debugging
- Screenshot and interaction testing
- Multi-platform support
How to write your own MVI system and why you shouldn't
By Mikołaj Leszczyński
How to write your own MVI system and why you shouldn't
Model-View-Intent is a simple architectural pattern in principle, but questions come up when you try to implement it yourself. We draw on our 2+ years of experience with orbit-mvi, our MVI library, to show best practices for using an MVI system in your application. How do you integrate with Android? What happens when you rotate your device? What about navigation or one-off events? How do you make the system type-safe? What about developer experience? If you’ve ever had similar questions come to our talk!
- 1,447