Model-View-Intent Design Pattern on Android

Model-View-Intent (MVI) is one of the latest design patterns for Android, which relies heavily on reactive and functional programming.

MVI Pattern

As shown in the image above, MVI works like this:

  1. The user sees what is rendered by the View, and interacts with the app.
  2. The user actions are translated into Intents, telling the app what to do.
  3. The Model then interacts with business logics, and computes UI states which are used by the View to render the screen.

As you can easily notice, this forms a unidirectional and circular data flow, making it easier to understand, maintain, and test. Now, let’s see how to implement it using Kotlin Flow on Android.

Emitting Intents

Suppose we’re developing a RSS reader app. In the feed screen, the user can pull-to-refresh, add / remove articles to / from favorites, and click to open an article. We can model these actions into the following Intents:

sealed class FeedIntent {
    object Initial : FeedIntent() // When user just opens the screen.

    object Refresh : FeedIntent()

    class AddToFavorite(val article: FeedArticle) : FeedIntent()

    class RemoveFromFavorite(val article: FeedArticle) : FeedIntent()

    class Open(val article: FeedArticle) : FeedIntent()
}

We can set up the UI listeners, and emit the intents from an Activity or a Fragment:

class MainActivity : AppCompatActivity() {
    fun intents(): Flow<FeedIntent> = merge(
        initialIntent(),
        refreshIntent(),
        addToFavoriteIntent(),
        removeFromFavoriteIntent(),
        openArticleIntent()
    )

    private fun initialIntent(): Flow<FeedIntent> = flowOf(FeedIntent.Initial)

    private fun refreshIntent(): Flow<FeedIntent> = callbackFlow {
        swipeRefreshLayout.setOnRefreshListener {
            trySend(FeedIntent.Refresh)
        }

        awaitClose()
    }

    // ...
}

Processing Intents

To process the intents by the Model, we will first map them to processors:

fun Flow<FeedIntent>.toProcessor(): Flow<FeedProcessor> = map { intent ->
    // FeedIntent is a sealed class, so the compiler
    // can make sure the when expression is exhaustive.
    when (intent) {
        FeedIntent.Initial, FeedIntent.Refresh -> FeedProcessor.LoadArticles
        is FeedIntent.AddToFavorite -> FeedProcessor.AddToFavorite(intent.article)
        is FeedIntent.RemoveFromFavorite -> FeedProcessor.RemoveFromFavorite(intent.article)
        is FeedIntent.Open -> FeedProcessor.Open(intent.article)
    }
}

Note that different intents can be mapped to the same processor, making it easier to reuse the code.

The processors are interacting with business logics and emitting results, e.g.:

sealed class FeedProcessor {
    abstract suspend fun process(): Flow<FeedResult>

    object LoadArticles : FeedProcessor() {
        override suspend fun process(): Flow<FeedResult> =
            combine<List<FeedArticle>, List<String>, FeedResult>(
                loadArticles(),
                loadFavorites()
            ) { articles, favorites ->
                FeedResult.ArticlesLoaded(articles, favorites)
            }.onStart {
                emit(FeedResult.Loading)
            }.catch {
                emit(FeedResult.Error(it))
            }
    }

    // ...
}

sealed class FeedResult {
    object Loading : FeedResult()

    class ArticlesLoaded(
        val articles: List<FeedArticle>, val favorites: List<String>
    ) : FeedResult()

    class ArticleAddedToFavorites(val article: FeedArticle)

    class ArticleRemovedFromFavorites(val article: FeedArticle)

    class Error(val error: Throwable) : FeedResult()
}

Here, for the loading articles processor, it will first emit FeedResult.Loading to indicate the loading is started, and emit FeedResult.ArticlesLoaded() if articles are loaded successfully, or FeedResult.Error() if error occurs during loading.

Computing View States

However, the emitted results can only tell what happens with that specific processor, but not all the information needed to render the whole screen. Therefore, we need to create view states to provide all the needed information, e.g.:

data class FeedViewState(
    val isLoading: Boolean,
    val articles: List<FeedArticle>,
    val favorites: Set<String>,
    val error: Throwable?
) {
    companion object {
        // The state when user just launches the screen.
        val initial = FeedViewState(
            isLoading = false,
            articles = emptyList(),
            favorites = emptySet(),
            error = null
        )
    }
}

Whenever a new result is emitted, we will use that and the previous state to compute the new state to be rendered on the screen, e.g.:

fun Flow<FeedResult>.toViewState(): Flow<FeedViewState> =
    scan(FeedViewState.initial) { acc, result ->
        when (result) {
            FeedResult.Loading -> acc.copy(isLoading = true)
            is FeedResult.ArticlesLoaded -> acc.copy(
                isLoading = false,
                articles = result.articles,
                favorites = result.favorites.toSet()
            )
            is FeedResult.ArticleAddedToFavorites -> acc.copy(
                favorites = acc.favorites.plus(result.article.uuid)
            )
            is FeedResult.ArticleRemovedFromFavorites -> acc.copy(
                favorites = acc.favorites.minus(result.article.uuid)
            )
            is FeedResult.Error -> acc.copy(
                isLoading = false,
                error = result.error
            )
        }
    }

Now, the Activity can easily render the screen in one single place with all necessary information available:

class MainActivity : AppCompatActivity() {
    fun render(state: FeedViewState) {
        swipeRefreshLayout.isRefreshing = state.isLoading

        // ...
    }

    // ...
}

Alternatively, we can use Jetpack Compose to implement the UI in a declarative way.

Put It Together

Now, we have all pieces ready, and can put everything together:

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        // ...

        intents()
            .toProcessor()
            .process()
            .toViewState()
            .render()
            .launchIn(lifecycleScope)
    }

    // ...
}

However, if e.g. the screen is rotated, the while flow will be rebuilt, and the app needs to reload all the data (from backend). To solve this issue, we can move this to a ViewModel with both latest intent and view state cached, and hook it up in the Activity:

class FeedViewModel: ViewModel() {
    private val _intents: MutableStateFlow<FeedIntent?> = MutableStateFlow(null)
    private val _viewStates: MutableStateFlow<FeedViewState> =
        MutableStateFlow(FeedViewState.initial)

    init {
        _intents
            .filterNotNull()
            .toProcessor()
            .process()
            .toViewState()
            .onEach { _viewStates.value = it }
            .launchIn(viewModelScope)
    }

    fun processIntent(intent: FeedIntent) {
        _intents.value = intent
    }

    fun viewStates(): Flow<FeedViewState> = _viewStates

    // ...
}

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        // ...

        lifecycleScope.launchWhenStarted {
            intents().collect(feedViewModel::processIntent)
        }
        lifecycleScope.launchWhenStarted {
            feedViewModel.viewStates().collect(::render)
        }
    }

    // ...
}

This is everything what we need to know about MVI to get started. Happy coding!


See also

comments powered by Disqus