Why am I doing this?
I’ve been working to develop a robust architecture, with a small learning curve and recognizable to all developers.
The aim was to have something project-agnostic. Simply put, we wanted all developers to be comfortable with the architecture, so that they could more easily contribute to other projects (that they may or may not know in detail), if and when needed.
Building an architecture takes time. I’ve spent most of that time learning about the Android ecosystem and its best practices, as well as transition from previous recommendations to new ones such as:
- Recommendation of ViewBinding over DataBinding
- Transition from XML to Jetpack Compose
- Appearance of MVI
- Stable release of KMP
How am I going to do it?
Before writing a single line of code, we need to do a certain number of things to make sure we don’t head in too many different directions:
- Grab a good cup of <insert preferred beverage>
- Research what MVI actually is
- Define the target behaviour
- Code the actual implementation
- Test the result in a real example
For the sake of simplicity, I’ll avoid explaining how to “Grab a good cup of” anything and assume you all know how to do so
Research phase
Although I assume you all know what MVI stands for (or you’ve somehow managed to avoid it so far), I’ll briefly catch everyone up to speed.
MVI stands for Model-View-Intent. This architecture is part of the MV* family along with MVVM and others. The core principle behind it is a state machine that takes input Intents,and produces a View State representing the underlying UI. With all this, MVI respects the SSoT (Single Source of Truth) principle unlike its older brother MVVM.
Target behaviour
First things first, we need a screen that will serve as the foundation for our implementation. We’re going to be using the Now In Android application for this (that you can find here) which looks like the following:
For the sake of simplicity, we’ll focus solely on the first screenshot (the ForYouScreen) which has various different pieces of data to display and interact with.
We now need to explain a core concept of MVI, the Reducer! A Reducer is, in some sense, a contract between the UI and the ViewModel. It is split into two parts:
- The object interfaces : State, Event and Effect
- The
reduce
function (not to be mistaken with the mathematicalreduce operator)
interface Reducer<State : Reducer.ViewState, Event : Reducer.ViewEvent, Effect : Reducer.ViewEffect> { | |
interface ViewState | |
interface ViewEvent | |
interface ViewEffect | |
fun reduce(previousState: State, event: Event): Pair<State, Effect?> | |
} |
ViewState
This is a representation of the UI. In theory, this should contain everything the Compose screen needs to display. This has the added benefit of making it super easy to create multiple previews which each represent a different state of the screen.
ViewEvent
This is the core of the MVI as it holds all the user interactions (and a bit more). This is what will be used by the ViewModel to trigger state changes.
ViewEffect
This is a special kind of ViewEvent. Its role is to be fired into the UI by the ViewModel. Actions such as Navigation or displaying a Snackbar/Toast.
It can also be triggered as a response to a ViewEvent (Updating something then navigating to a Success or Error screen based on the result).
reduce function
The reduce
function takes a ViewState and a ViewEvent and produces a new ViewState and optionally a ViewEffect linked to the provided event.
Now we have the Reducer, we need to define our BaseViewModel that will be implemented by all our ViewModels:
abstract class BaseViewModel<State : Reducer.ViewState, Event : Reducer.ViewEvent, Effect : Reducer.ViewEffect>( | |
initialState: State, | |
private val reducer: Reducer<State, Event, Effect> | |
) : ViewModel() { | |
private val _state: MutableStateFlow<State> = MutableStateFlow(initialState) | |
val state: StateFlow<State> | |
get() = _state.asStateFlow() | |
private val _event: MutableSharedFlow<Event> = MutableSharedFlow() | |
val event: SharedFlow<Event> | |
get() = _event.asSharedFlow() | |
private val _effects = Channel<Effect>(capacity = Channel.CONFLATED) | |
val effect = _effects.receiveAsFlow() | |
val timeCapsule: TimeCapsule<State> = TimeTravelCapsule { storedState -> | |
_state.tryEmit(storedState) | |
} | |
init { | |
timeCapsule.addState(initialState) | |
} | |
fun sendEffect(effect: Effect) { | |
_effects.trySend(effect) | |
} | |
fun sendEvent(event: Event) { | |
val (newState, _) = reducer.reduce(_state.value, event) | |
val success = _state.tryEmit(newState) | |
if (success) { | |
timeCapsule.addState(newState) | |
} | |
} | |
fun sendEventForEffect(event: Event) { | |
val (newState, effect) = reducer.reduce(_state.value, event) | |
val success = _state.tryEmit(newState) | |
if (success) { | |
timeCapsule.addState(newState) | |
} | |
effect?.let { | |
sendEffect(it) | |
} | |
} | |
} |
Implementation
Now we have the base structure set up and available, it’s now time to actually implement these for our screen!
Reducer
We’ll start by defining our ViewState, allowing us to identify some of our events easily.
@Immutable | |
data class ForYouState( | |
val topicsLoading: Boolean, // Whether the topics section is in the loading state | |
val newsLoading: Boolean, // Whether the news section is in the loading state | |
val topicsVisible: Boolean, // Whether the topics section is visible | |
val topics: List<FollowableTopic>, // The list of topics to display | |
val news: List<UserNewsResource> // The list of news to display | |
) : Reducer.ViewState |
Now we have the ViewState, we can define the ViewEvent to handle the user interactions and update the state accordingly.
Job Offers
@Immutable | |
sealed class ForYouEvent : Reducer.ViewEvent { | |
data class UpdateTopicsLoading(val isLoading: Boolean) : ForYouEvent() | |
data class UpdateTopics(val topics: List<FollowableTopic>) : ForYouEvent() | |
data class UpdateNewsLoading(val isLoading: Boolean) : ForYouEvent() | |
data class UpdateNews(val news: List<UserNewsResource>) : ForYouEvent() | |
data class UpdateTopicsVisible(val isVisible: Boolean) : ForYouEvent() | |
data class UpdateTopicIsFollowed(val topicId: String, val isFollowed: Boolean) : ForYouEvent() | |
data class UpdateNewsIsSaved(val newsId: String, val isSaved: Boolean) : ForYouEvent() | |
data class UpdateNewsIsViewed(val newsId: String, val isViewed: Boolean) : ForYouEvent() | |
} |
I consider the above to be self-explanatory alongside the ViewState. The last 3 events, however, may not be.
They are linked to the clickable elements on the screen in the following manner:
UpdateTopicIsFollowed
is associated to the elements in the Green outlineUpdateNewsIsSaved
is associated to the element in the Orange outlineUpdateNewsIsViewed
is associated to the element in the Purple outline
Now we need to define the final part of the MVI architecture, the ViewEffect! Luckily for us, on this screen, it’s relatively simple as we only have two possible effects.
@Immutable | |
sealed class ForYouEffect : Reducer.ViewEffect { | |
data class NavigateToTopic(val topicId: String) : ForYouEffect() | |
data class NavigateToNews(val newsUrl: String) : ForYouEffect() | |
} |
Finally, we have the reduce
function to implement. In most cases, the reduce
function will be very simple and map the input ViewEvent data to a modified ViewState.
override fun reduce( | |
previousState: ForYouState, | |
event: ForYouEvent | |
): Pair<ForYouState, ForYouEffect?> { | |
return when (event) { | |
// An Event that has NO associated Effect | |
is ForYouEvent.UpdateTopicsLoading -> { | |
previousState.copy( | |
topicsLoading = event.isLoading | |
) to null | |
} | |
// An Event that has an associated Effect | |
is ForYouEvent.UpdateNewsIsViewed -> { | |
val updatedNews = previousState.news.map { news -> | |
if (news.id == event.newsId) { | |
news.copy(hasBeenViewed = event.isViewed) | |
} else { | |
news | |
} | |
} | |
previousState.copy( | |
news = updatedNews | |
) to ForYouEffect.NavigateToNews(updatedNews.first { it.id == event.newsId }.url) | |
} | |
// All other Events go here | |
} | |
} |
One important thing to note here (specifically for the last 3 ViewEvent cases) is that in a real production application, you would very rarely modify the actual data being displayed directly in the Reducer.
A follow-up article is currently being written which builds upon this one and talks about the architecture in terms of APIs (the Data and Domain layers in Clean Architecture)
ViewModel
Now we have our Reducer, we also need to use it in a ViewModel which looks like the following:
override fun reduce( | |
previousState: ForYouState, | |
event: ForYouEvent | |
): Pair<ForYouState, ForYouEffect?> { | |
return when (event) { | |
// An Event that has NO associated Effect | |
is ForYouEvent.UpdateTopicsLoading -> { | |
previousState.copy( | |
topicsLoading = event.isLoading | |
) to null | |
} | |
// An Event that has an associated Effect | |
is ForYouEvent.UpdateNewsIsViewed -> { | |
val updatedNews = previousState.news.map { news -> | |
if (news.id == event.newsId) { | |
news.copy(hasBeenViewed = event.isViewed) | |
} else { | |
news | |
} | |
} | |
previousState.copy( | |
news = updatedNews | |
) to ForYouEffect.NavigateToNews(updatedNews.first { it.id == event.newsId }.url) | |
} | |
// All other Events go here | |
} | |
} |
You will notice that our ViewModel contains very little code and this is thanks to the use of our BaseViewModel which contains the main functions we will use.
Screen
Finally, we can plug all of this into our screen and see how simple it is to handle the different cases we can have in our UI.
@Composable | |
fun ForYouScreen( | |
modifier: Modifier = Modifier, | |
viewModel: ForYouViewModel = hiltViewModel() | |
) { | |
val state = viewModel.state.collectAsStateWithLifecycle() | |
val effect = rememberFlowWithLifecycle(viewModel.effect) | |
val context = LocalContext.current | |
val backgroundColor = MaterialTheme.colorScheme.background.toArgb() | |
LaunchedEffect(effect) { | |
effect.collect { action -> | |
when (action) { | |
is ForYouEffect.NavigateToTopic -> { | |
// This effect would result in a navigation to another screen of the application | |
// with the topicId as a parameter. | |
Log.d("ForYouScreen", "Navigate to topic with id: ${action.topicId}") | |
} | |
is ForYouEffect.NavigateToNews -> launchCustomChromeTab( | |
context, | |
Uri.parse(action.newsUrl), | |
backgroundColor | |
) | |
} | |
} | |
} | |
ForYouScreenContent( | |
modifier = modifier, | |
topicsLoading = state.value.topicsLoading, | |
topics = state.value.topics, | |
topicsVisible = state.value.topicsVisible, | |
newsLoading = state.value.newsLoading, | |
news = state.value.news, | |
onTopicCheckedChanged = { topicId, isChecked -> | |
viewModel.sendEvent( | |
event = ForYouScreenReducer.ForYouEvent.UpdateTopicIsFollowed( | |
topicId = topicId, | |
isFollowed = isChecked, | |
) | |
) | |
}, | |
onTopicClick = viewModel::onTopicClick, | |
saveFollowedTopics = { | |
viewModel.sendEvent( | |
event = ForYouScreenReducer.ForYouEvent.UpdateTopicsVisible( | |
isVisible = false | |
) | |
) | |
}, | |
onNewsResourcesCheckedChanged = { newsResourceId, isChecked -> | |
viewModel.sendEvent( | |
event = ForYouScreenReducer.ForYouEvent.UpdateNewsIsSaved( | |
newsId = newsResourceId, | |
isSaved = isChecked, | |
) | |
) | |
}, | |
onNewsResourceViewed = { newsResourceId -> | |
viewModel.sendEvent( | |
event = ForYouScreenReducer.ForYouEvent.UpdateNewsIsViewed( | |
newsId = newsResourceId, | |
isViewed = true, | |
) | |
) | |
}, | |
) | |
} | |
@Composable | |
fun ForYouScreenContent( | |
topicsLoading: Boolean, | |
topics: List<FollowableTopic>, | |
topicsVisible: Boolean, | |
newsLoading: Boolean, | |
news: List<UserNewsResource>, | |
onTopicCheckedChanged: (String, Boolean) -> Unit, | |
onTopicClick: (String) -> Unit, | |
saveFollowedTopics: () -> Unit, | |
onNewsResourcesCheckedChanged: (String, Boolean) -> Unit, | |
onNewsResourceViewed: (String) -> Unit, | |
modifier: Modifier = Modifier, | |
) { | |
// The actual implementation is omitted as it adds no value here | |
} |
That’s all folks!
With everything we have done, we now have a fully defined MVI Architecture that takes advantage of respecting the SSoT principle and also a ViewState that directly represents the UI!
Some of you may still be wondering why I went through all this R&D when existing architectures have been tried and tested and have been proven to work. Well, simply put, because I needed to. I work on multiple projects, under various project managers, with a number of other developers.
Being the Lead Developer on these projects means I am often required to spend time reviewing PRs (Or MRs for you GitLab users). I often have to direct comments to small architectural mistakes that could be avoided with a greater knowledge of said architecture.
Hence, imagining an architecture that is compatible with all the projects I work on and also known and recognised by my colleagues, makes the overall review process faster and easier for all parties involved and speeds up the overall development!
You can find the full project on GitHub at the following link:
https://github.com/worldline/Compose-MVI/tree/MVI?source=post_page—–e08882d2c4ff——————————–
The above article explains the use of the
TimeCapsule
in this implementation and is where this was discovered
This article is previously published on proandroiddev.com