Recently after starting to migrate to Jetpack Compose, we decided to migrate the CLEAN + MVVM architecture to the CLEAN + MVI architecture. For this purpose, we launched a playground project on Github to test it in action. This article explains the approach we took.
Photo by mostafa meraji on Unsplash
At GityMarket, the main initial architecture was almost CLEAN. It had domain, data, and presentation layers. For the implementation of the presentation layer, the MVVM architecture recommended by Google had been used. The Model layer of the MVVM architecture was mapped to the domain + data layers of the CLEAN architecture and the View and ViewModel layers of the MVVM architecture were mapped to the presentation layer of the CLEAN architecture.
The MVVM diagram
The problem
During this time, when I was going to refactor variants parts of the code-base, my attention was drawn to something. I saw some methods in several ViewModels are too smart and do more than one thing. This issue violates the single responsibility of the SOLID principle and also makes testing so hard!
The first solution for the mentioned problem is to break down all smart functions, but I was seeking a solution to prevent this issue from happening ever again.
After a while, at the beginning of 2022, the Developer’s architecture guideline was updated and they introduced Unidirectional Data Flow (UDF) architecture. Now, you could choose any architecture for the UI layer that fits your needs such as MVVM, MVI, etc.
Unidirectional Data Flow architecture recommended by Google
The third thing I faced was migrating the UI layer from XML to Jetpack Compose. When I went deeper, I was fascinated by the concept of State in the State-holder. At that point, in my research, I was confronted with MVI architecture. For more detail about MVI, you can read this article by Rim Gazzah.
The Solution
The MVI provide some benefits that fulfill our requirement and give us extra things for free!
- The Intent-based interaction between UI and ViewModel has more restrictions for comply Single responsibility.
- All ViewModel’s methods become private. This means The Encapsulation (one of the principles of OOP) has been observed more. From now, you less care about the actual implementation of ViewModel; You just fire intent from UI (That’s soooo beautiful! 😍).
- The second benefit of Intent-based interaction is increasing Abstraction (the other principle of OOP) of communication between UI and ViewModel.
- Finally, I founded MVI more suitable for using Jetpack Compose than MVVM. I know that you can have multiple states in old fashion of MVVM’s ViewModel and pass ViewModel’s method call as lambda to Composable functions, But when there is MVI, why you force yourself to use MVVM?!?😁
Me after getting familiar with the MVI! 😍🥰
The Implementation
The MVI impacts mostly the UI layer, so in this section, I only focus on this layer. For every Screen
, first of all, we need a Contract
; An interface that wraps State, Event(Intent in MVI), and Effect(Special Intent that ViewModel fires in UI, for example showing a Snackbar
that the UI must handle it).
interface NewsListContract : UnidirectionalViewModel<NewsListContract.State, NewsListContract.Event, NewsListContract.Effect> { data class State( val news: List<News> = listOf(), val refreshing: Boolean = false, val showFavoriteList: Boolean = false, ) sealed class Event { data class OnFavoriteClick(val news: News) : Event() data class OnGetNewsList(val showFavoriteList: Boolean) : Event() data class OnSetShowFavoriteList(val showFavoriteList: Boolean) : Event() object OnRefresh: Event() object OnBackPressed : Event() data class ShowToast(val message: String) : Event() } sealed class Effect { object OnBackPressed : Effect() data class ShowToast(val message: String) : Effect() } }
Note: The UnidirectionalViewModel
is an Interface:
interface UnidirectionalViewModel<STATE, EVENT, EFFECT> { val state: StateFlow<STATE> val effect: SharedFlow<EFFECT> fun event(event: EVENT) }
In ViewModel, we need three main things:
- A variable for holding the State
- A flow for the Effect
- And override the
event
function
Now, let’s implement the ViewModel:
@HiltViewModel class NewsListViewModel @Inject constructor( private val getNewsUseCase: GetNewsUseCase, private val getFavoriteNewsUseCase: GetFavoriteNewsUseCase, private val toggleFavoriteNewsUseCase: ToggleFavoriteNewsUseCase, ) : NewsListContract { private val mutableState = MutableStateFlow(NewsListContract.State()) override val state: StateFlow<NewsListContract.State> = mutableState.asStateFlow() private val effectFlow = MutableSharedFlow<NewsListContract.Effect>() override val effect: SharedFlow<NewsListContract.Effect> = effectFlow.asSharedFlow() override fun event(event: NewsListContract.Event) = when (event) { is NewsListContract.Event.OnSetShowFavoriteList -> onSetShowFavoriteList(showFavoriteList = event.showFavoriteList) is NewsListContract.Event.OnGetNewsList -> getData(showFavoriteList = mutableState.value.showFavoriteList) is NewsListContract.Event.OnFavoriteClick -> onFavoriteClick(news = event.news) NewsListContract.Event.OnRefresh -> getData(isRefreshing = true) NewsListContract.Event.OnBackPressed -> onBackPressed() is NewsListContract.Event.ShowToast -> showToast(event.message) } private fun onSetShowFavoriteList(showFavoriteList: Boolean) { mutableState.update { it.copy(showFavoriteList = showFavoriteList) } } private fun getData( isRefreshing: Boolean = false, showFavoriteList: Boolean = false, ) { if (isRefreshing) mutableState.update { NewsListContract.State( refreshing = true, ) } viewModelScope.launch { if (showFavoriteList) getFavoriteNews() else getNewsList() } } private suspend fun getNewsList() = getNewsUseCase() .catch { exception -> mutableBaseState.update { BaseContract.BaseState.OnError( errorMessage = exception.localizedMessage ?: "An unexpected error occurred." ) } } .onEach { result -> mutableState.update { NewsListContract.State(news = result) } } .launchIn(viewModelScope) private fun getFavoriteNews() = getFavoriteNewsUseCase() .onEach { newList -> mutableState.update { it.copy(news = newList) } }.launchIn(viewModelScope) private fun onFavoriteClick(news: News) { viewModelScope.launch(Dispatchers.IO) { toggleFavoriteNewsUseCase(news) } } private fun onBackPressed() { viewModelScope.launch { effectFlow.emit(NewsListContract.Effect.OnBackPressed) } } private fun showToast(message: String) { viewModelScope.launch { effectFlow.emit( NewsListContract.Effect.ShowToast(message = message) ) } } }
Job Offers
Finally, In the Screen
:
- The data in the State pass to every Composable function which needed
- The Effect flow begins to be collected
- And the
event
function be invoked anywhere needed and passed the corresponding event
let’s implement the UI:
@Composable fun NewsListRoute( viewModel: NewsListViewModel = hiltViewModel(), showFavoriteList: Boolean = false, onNavigateToDetailScreen: (news: News) -> Unit, ) { // Implementation of `use` in the note section val (state, effect, event) = use(viewModel = viewModel) val activity = LocalContext.current as? Activity /* Get initial data Don't use the init block in ViewModel as much as possible This makes testing harder! */ LaunchedEffect(key1 = Unit) { event.invoke( NewsListContract.Event.OnSetShowFavoriteList( showFavoriteList = showFavoriteList, ) ) event.invoke( NewsListContract.Event.OnGetNewsList( showFavoriteList = showFavoriteList, ) ) } // Implementation of `collectInLaunchedEffect` in the note section effect.collectInLaunchedEffect { when (it) { NewsListContract.Effect.OnBackPressed -> { activity?.onBackPressed() } is NewsListContract.Effect.ShowToast -> { Toast.makeText(activity, it.message, Toast.LENGTH_LONG).show() } } } NewsListScreen( newsListState = state, onNavigateToDetailScreen = onNavigateToDetailScreen, onFavoriteClick = { news -> event.invoke(NewsListContract.Event.OnFavoriteClick(news = news)) }, onRefresh = { event.invoke(NewsListContract.Event.OnRefresh) }, onBackPressed = { event.invoke(NewsListContract.Event.OnBackPressed) }, showToast = { message -> event.invoke(NewsListContract.Event.ShowToast(message)) }, ) } @OptIn(ExperimentalMaterialApi::class) @Composable private fun NewsListScreen( newsListState: NewsListContract.State, onNavigateToDetailScreen: (news: News) -> Unit, onFavoriteClick: (news: News) -> Unit, onRefresh: () -> Unit, onBackPressed: () -> Unit, showToast: (message: String) -> Unit, ) { val refreshState = rememberPullRefreshState( refreshing = newsListState.refreshing, onRefresh = onRefresh, ) Box( modifier = Modifier .fillMaxWidth() .pullRefresh(refreshState) ) { AnimatedVisibility( visible = !newsListState.refreshing, enter = fadeIn(), exit = fadeOut(), ) { Row { Button(onClick = { onBackPressed() }) { Text(text = "onBackPressed") } Spacer(modifier = Modifier.width(16.dp)) Button(onClick = { showToast(message = "Hiiiiiii!") }) { Text(text = "Show Toast") } } LazyColumn(modifier = Modifier.fillMaxWidth()) { items(newsListState.news) { news -> NewsListItem( news = news, onItemClick = { onNavigateToDetailScreen(news) }, onFavoriteClick = { onFavoriteClick(news) } ) } } } PullRefreshIndicator( newsListState.refreshing, refreshState, Modifier.align(Alignment.TopCenter) ) } } @SuppressLint("UnusedMaterialScaffoldPaddingParameter") @ThemePreviews @Composable private fun NewsListScreenPrev( @PreviewParameter(NewsListStateProvider::class) newsListState: NewsListContract.State ) { ComposeNewsTheme { Scaffold { NewsListScreen( newsListState = newsListState, onNavigateToDetailScreen = {}, onFavoriteClick = {}, onRefresh = {}, onBackPressed = {}, showToast = {}, ) } } }
Note: The implementation of use
:
@Composable inline fun <reified STATE, EVENT, EFFECT> use( viewModel: UnidirectionalViewModel<STATE, EVENT, EFFECT>, ): StateDispatchEffect<STATE, EVENT, EFFECT> { val state by viewModel.state.collectAsStateWithLifecycle() val dispatch: (EVENT) -> Unit = { event -> viewModel.event(event) } return StateDispatchEffect( state = state, effectFlow = viewModel.effect, dispatch = dispatch, ) } data class StateDispatchEffect<STATE, EVENT, EFFECT>( val state: STATE, val dispatch: (EVENT) -> Unit, val effectFlow: SharedFlow<EFFECT>, )
And the implementation of collectInLaunchedEffect
:
@Suppress("ComposableNaming") @Composable fun <T> SharedFlow<T>.collectInLaunchedEffect(function: suspend (value: T) -> Unit) { val sharedFlow = this LaunchedEffect(key1 = sharedFlow) { sharedFlow.collectLatest(function) } }
For better overview of the implementation, you can check out this repository:
Special thanks to my dear friends for their reviews: Fatemeh Zirakit, Ali Baha-Abadi, Kourosh Mashhadi, Negin Ebrahimi, and salar taheri.
Happy coding! 👋
This article was previously published on proandroiddev.com