Blog Infos
Author
Published
Topics
, , ,
Published

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.

MVI as a function. Credit to Hannes Dorfmann

 

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:

Now In Android screenshots

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 : StateEvent and Effect
  • The reduce function (not to be mistaken with the mathematical reduce 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?>
}
view raw MVIReducer.kt hosted with ❤ by GitHub
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)
}
}
}
For more information on the TimeCapsule, check out this article.
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
For more information on the Immutable annotation, check out this article.

Now we have the ViewState, we can define the ViewEvent to handle the user interactions and update the state accordingly.

Job Offers

Job Offers


    Senior Android Engineer

    Carly Solutions GmbH
    Munich
    • Full Time
    apply now

    Senior Android Developer

    SumUp
    Berlin
    • Full Time
    apply now

OUR VIDEO RECOMMENDATION

Jobs

@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 outline
  • UpdateNewsIsSaved is associated to the element in the Orange outline
  • UpdateNewsIsViewed 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——————————–

https://medium.com/@VolodymyrSch/android-simple-mvi-implementation-with-jetpack-compose-5ee5d6fc4908?source=post_page—–e08882d2c4ff——————————–

The above article explains the use of the TimeCapsule in this implementation and is where this was discovered

https://medium.com/swlh/mvi-architecture-with-android-fcde123e3c4a?source=post_page—–e08882d2c4ff——————————–

This article is previously published on proandroiddev.com

YOU MAY BE INTERESTED IN

YOU MAY BE INTERESTED IN

blog
It’s one of the common UX across apps to provide swipe to dismiss so…
READ MORE
blog
In this part of our series on introducing Jetpack Compose into an existing project,…
READ MORE
blog
In the world of Jetpack Compose, where designing reusable and customizable UI components is…
READ MORE
blog
Hi, today I come to you with a quick tip on how to update…
READ MORE

Leave a Reply

Your email address will not be published. Required fields are marked *

Fill out this field
Fill out this field
Please enter a valid email address.

Menu