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

Model-View-Intent on Android

by Hannes Dorfmann

Inspirational talks

Inspirational libraries

MvRx

by AirBnB

 

Mosby MVI

by Hannes Dorfmann

 

Redux

by Dan Abramov and Andrew Clark

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