Android: Architecture with MVVM and UIState

MrAndroid
9 min readJan 13, 2023

--

Application architecture — is the structural principle by which an application is built.

Nowadays, we simply can’t help but care about the architecture when we start development.

There are many examples of Clean Architecture or MVI based application architecture. These approaches have many advantages and it is hard not to agree that they are excellent solutions.

But what if we don’t have a big application, the team consists of several developers, and our application is basically a request for data from the server and displaying it to the user.

I do not think that in this case it makes sense to design a complex architecture and split your application into smaller components.

We can’t completely give up on the architecture, but we don’t want to spend a lot of time on its development.

What do we do in such a case?

It’s a great idea to separate our UI layer logic from our data manipulation logic.

Okay, we have separated two modules: UI, Core.

Perhaps you have a complex UI or you write a lot of utilities, then there are modules such as uikit or tools but we will omit this.

Core Layer:

Let’s move away from the classic definition of the repository pattern and imagine that this is some kind of entity responsible for the logic of working with data. That is, our Repository decides where and how we take our data.

The Repository calls remote or local services, decides whether to cache the data and chooses on which stream to work with the data.

Those our repository is the entity responsible for the logic of the data layer.

UI Layer:

When we design the architecture of an android application, we simply cannot help but think about the life cycle of the application.

Not a bad solution is to use the ViewModel from Android Jetpack. The ViewModel helps to store and manage data that is related to the UI, also the ViewModel helps to handle the Lifecycle android components.

We have determined that our ViewModel stores data that is responsible for the state of the UI.

We define how we store data inside the ViewModel.

UI state:

Add UI state, an object that is responsible for our UI state. It is much more convenient to store all the data that is needed for our UI in one object. This results in fewer inconsistencies and makes the code easier to understand.

In addition, some business logic may require a combination of sources. For example, you might only want to show a button if the user is logged in and subscribes to a premium news service.

We can define a UI state class like this:

data class UiState(
val isLoading: Boolean = false,
val isError: Boolean = false,
val newsItems: List<NewsItemUiState> = listOf(),
)

Single live events:
Using the ViewModel, there may be a need for single live events that can cause some single events to be shown or for navigation.

To implement single live events, we use Channel and sealed interface to describe specific events.

UI State Delegate:

In order to simplify the work with UIState and SingleLiveEvent we add to our architecture: ViewStateDelegate<ViewState, Event>

We define the contract of our Delegate :

interface ViewStateDelegate<UIState, Event> {

/**
* Declarative description of the UI based on the current state.
*/
val uiState: Flow<ViewState>

val singleEvents: Flow<Event>

/**
* State is read-only
* The only way to change the state is to emit[reduce] an action,
* an object describing what happened.
*/
val stateValue: UIState

/**
* Reduce are functions that take the current state and an action as arguments,
* and changed a new state result. In other words, (state: ViewState) => newState.
*/
suspend fun ViewStateDelegate<UIState, Event>.reduce(action: (state: UIState) -> UIState)


fun CoroutineScope.asyncReduce(action: (state: UIState) -> UIState)

suspend fun ViewStateDelegate<UIState, Event>.sendEvent(event: Event)
}

Adding an implementation:

class ViewStateDelegateImpl<UIState, Event>(
initialUIState: UIState,
singleLiveEventCapacity: Int = Channel.BUFFERED,
) : ViewStateDelegate<UIState, Event> {

/**
* The source of truth that drives our app.
*/
private val stateFlow = MutableStateFlow(initialUIState)

override val uiState: Flow<UIState>
get() = stateFlow.asStateFlow()

override val stateValue: ViewState
get() = stateFlow.value

private val singleEventsChannel = Channel<Event>(singleLiveEventCapacity)

override val singleEvents: Flow<Event>
get() = singleEventsChannel.receiveAsFlow()

private val mutex = Mutex()

override suspend fun ViewStateDelegate<UIState, Event>.reduce(action: (state: UIState) -> UIState) {
mutex.withLock {
stateFlow.emit(action(stateValue))
}
}

override suspend fun ViewStateDelegate<UIState, Event>.sendEvent(event: Event) {
singleEventsChannel.send(event)
}


override fun CoroutineScope.asyncReduce(action: (state: UIState) -> UIState) {
launch {
reduce(action)
}
}
}

In our delegate, we create a kotlinFlow that is responsible for storing our UIState and add a Channel that will be responsible for delivering single events.

We add a reduce method to update our state and a sendEvent method to send events.

Let’s see how we can use our Delegate in the ViewModel:

In UIState, we define all the data that we need on the screen:

data class UiState(
val isLoading: Boolean = false,
val title: String = "",
val login: String = "",
val password: String = "",
)

Add sealed interface Event for single live events.

sealed interface Event {
object GoToHome : Event
}

When creating the ViewModel, we define our Delegate :

ViewStateDelegate<UiState, Event> by ViewStateDelegateImpl(UiState())

You also need to set the initial state:

ViewStateDelegateImpl(UiState())

Full code ViewModel:

class LoginViewModel(
private val authRepository: AuthRepository,
) : ViewModel(), ViewStateDelegate<UiState, Event> by ViewStateDelegateImpl(UiState()) {

data class UiState(
val isLoading: Boolean = false,
val title: String = "",
val login: String = "",
val password: String = "",
)

sealed interface Event {
object GoToHome : Event
}

init {
viewModelScope.launch {
reduce { state ->
state.copy(
title = "Login screen"
)
}
}
}

fun onLoginChange(login: String) {
viewModelScope.asyncReduce { state -> state.copy(login = login) }
}

fun onPasswordChange(password: String) {
viewModelScope.asyncReduce { state -> state.copy(password = password) }
}

fun onLoginClick() {
viewModelScope.launch {
reduce { state -> state.copy(isLoading = true) }
authRepository.login(login = stateValue.login, password = stateValue.password)
sendEvent(Event.GoToHome)
}.invokeOnCompletion { viewModelScope.asyncReduce { state -> state.copy(isLoading = false) } }
}
}

In the example init method, we are changing the state of the title for our screen.

init {
viewModelScope.launch {
reduce { state ->
state.copy(
title = "Login screen"
)
}
}
}

Let’s take a closer look at how the button is clicked:

fun onLoginClick() {
viewModelScope.launch {
reduce { state -> state.copy(isLoading = true) }
authRepository.login(login = stateValue.login, password = stateValue.password)
sendEvent(Event.GoToHome)
}.invokeOnCompletion { viewModelScope.asyncReduce { state -> state.copy(isLoading = false) } }
}

First, we update our state via the reduce method and show the progress:

reduce { state -> state.copy(isLoading = true) }

State is data class and we have only one change point for this state, method reduce.

Next, we call the method in the repository for authorization, and after successful authorization, we call the sendEvent method to send an event for navigation.

Let’s look at the implementation of our UI:

To subscribe to UIState, use the collectWithLifecycle method

@OptIn(ExperimentalLifecycleComposeApi::class)
@Composable
fun <R> ViewStateDelegate<R, *>.collectWithLifecycle(
minActiveState: Lifecycle.State = Lifecycle.State.STARTED,
) = this.uiState.collectAsStateWithLifecycle(
initialValue = this.stateValue,
minActiveState = minActiveState,
)
val uiState by viewModel.collectWithLifecycle()

For single live event:

LaunchedEffect(key1 = Unit) {
viewModel.collectEvent(lifecycle) { event ->
when (event) {
LoginViewModel.Event.GoToHome -> {
navController.navigateSingleTopTo(NavigationDestination.Home.destination)
}
}
}
}

Event subscription method:

fun <State, Event> ViewStateDelegate<State, Event>.collectEvent(
lifecycleOwner: LifecycleOwner,
lifecycleState: Lifecycle.State = Lifecycle.State.RESUMED,
block: (event: Event) -> Unit
): Job = lifecycleOwner.lifecycleScope.launch {
singleEvents.flowWithLifecycle(
lifecycle = lifecycleOwner.lifecycle,
minActiveState = lifecycleState,
).collect { event ->
block.invoke(event)
}
}

Full code:

@Composable
fun LoginScreen(
navController: NavController,
viewModel: LoginViewModel,
lifecycle: LifecycleOwner = LocalLifecycleOwner.current
) {
val uiState by viewModel.collectWithLifecycle()

LaunchedEffect(key1 = Unit) {
viewModel.collectEvent(lifecycle) { event ->
when (event) {
LoginViewModel.Event.GoToHome -> {
navController.navigateSingleTopTo(NavigationDestination.Home.destination)
}
}
}
}

Column(
modifier = Modifier
.background(MaterialTheme.colors.background)
.fillMaxSize()
.padding(horizontal = 16.dp)
) {
Text(
modifier = Modifier.padding(top = 24.dp),
text = uiState.title
)
if (uiState.isLoading) {
Box(modifier = Modifier.fillMaxWidth()) {
CircularProgressIndicator(
modifier = Modifier
.size(24.dp)
.align(Alignment.Center),
strokeWidth = 2.dp,
color = MaterialTheme.colors.primary,
)
}
}
TextField(
modifier = Modifier
.padding(top = 24.dp)
.padding(horizontal = 16.dp)
.fillMaxWidth(),
value = uiState.login,
onValueChange = viewModel::onLoginChange,
enabled = uiState.isLoading.not(),
)
TextField(
modifier = Modifier
.padding(top = 8.dp)
.padding(horizontal = 16.dp)
.fillMaxWidth(),
value = uiState.password,
onValueChange = viewModel::onPasswordChange,
enabled = uiState.isLoading.not(),
)
Button(
modifier = Modifier
.padding(top = 24.dp)
.fillMaxWidth(),
onClick = viewModel::onLoginClick,
enabled = uiState.isLoading.not(),
) {
Text("Login")
}
}
}

Of course, we do not forget that we have a lot code with xml.

Let’s look at an example using Fragment and xml.

Adding a feature to our ForgotPassword example.

We define the ViewModel in the same way. To work in the ViewModel, we do not change anything:

1) Create a ViewModel
2) Determine UiState
3) Add a sealed interface Event for events, if necessary.
4) Add ViewStateDelegate.

Full code:

class ForgotPasswordViewModel(
private val authRepository: AuthRepository,
) : ViewModel(),
ViewStateDelegate<UiState, Event> by ViewStateDelegateImpl(UiState()) {

data class UiState(
val isLoading: Boolean = false,
val title: String = "",
val login: String = "",
)

sealed interface Event {
object FinishFlow : Event
}

init {
viewModelScope.launch {
reduce { state -> state.copy(title = "Forgot Password") }
}
}

fun onLoginTextChanged(value: Editable?) {
viewModelScope.asyncReduce { state -> state.copy(login = value.toString()) }
}

fun onForgotPasswordClick() {
viewModelScope.launch {
reduce { state -> state.copy(isLoading = true) }
authRepository.forgotPassword(stateValue.login)
sendEvent(Event.FinishFlow)
}.invokeOnCompletion { viewModelScope.asyncReduce { state -> state.copy(isLoading = false) } }
}
}

What do we have now with UI?

To receive a single live event, we do not change our logic much. We also use the collectEvent method.

Add a new render method for subscribing to UIState:

with(viewModel) {
render(
lifecycleOwner = viewLifecycleOwner,
watcher = watcher
)
collectEvent(lifecycle) { event ->
return@collectEvent when (event) {
ForgotPasswordViewModel.Event.FinishFlow -> requireActivity().finish()
}
}
}
/**
* render [State] with [lifecycleState]
* The UI re-renders based on the new state
**/
fun <State, Event> ViewStateDelegate<State, Event>.render(
lifecycleOwner: LifecycleOwner,
lifecycleState: Lifecycle.State = Lifecycle.State.STARTED,
render: UiStateDiffRender<State>
): Job = lifecycleOwner.lifecycleScope.launch {
uiState.flowWithLifecycle(
lifecycle = lifecycleOwner.lifecycle,
minActiveState = lifecycleState,
).collectLatest(render::render)
}

We can note that we pass the UiStateDiffRender<State> object to the render method.

UiStateDiffRender checks what fields have been changed in UIStete so as not to render the all UI fields.

When we use Compose, the compiler helps us with this, but when working with xml, there is no such feature and we have to handle this case ourselves.

If we omit the use of UiStateDiffRender, then we will have problems with performance.

Let’s see how we use UiStateDiffRender.

private val watcher = uiStateDiffRender {
UiState::isLoading { isLoading ->
with(binding) {
progress.isVisible = isLoading
button.isEnabled = isLoading.not()
loginInputFiled.isEnabled = isLoading.not()
}
}

UiState::title { title ->
binding.title.text = title
}
}

Each field that we use on our UI needs to be described in uiStateDiffRender.

UiStateDiffRender store the last received state and check through reflection whether the field in new UIState has been changed.

Let’s see the title field:

UiState::title { title ->
binding.title.text = title
}

Initially, our UISatate stores an empty string. The first time we initialize our UI, we will get an empty string. After we change our title in UIState to “Forgot Password”, UiStateDiffRender will check the last received UiState for the title field with the new UIState and call the lambda to change the title.

Full code UI:

class ForgotPasswordFragment : Fragment(R.layout.fragment_forgot_password) {

private var _binding: FragmentForgotPasswordBinding? = null

private val binding get() = _binding!!

private val viewModel: ForgotPasswordViewModel by viewModels {
ForgotPasswordViewModelFactory(
authRepository = (requireContext().applicationContext as AppApplication).appProvider.provideAuthRepository()
)
}


private val render = uiStateDiffRender {
UiState::isLoading { isLoading ->
with(binding) {
progress.isVisible = isLoading
button.isEnabled = isLoading.not()
loginInputFiled.isEnabled = isLoading.not()
}
}

UiState::title { title ->
binding.title.text = title
}

UiState::login { login ->
binding.loginInputFiled.apply {
setText(login)
setSelection(login.length)
}
}
}

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
_binding = FragmentForgotPasswordBinding.bind(view)

with(binding) {
button.setOnClickListener { viewModel.onForgotPasswordClick() }
loginInputFiled.doAfterTextChanged(viewModel::onLoginTextChanged)
}

with(viewModel) {
render(
lifecycleOwner = viewLifecycleOwner,
render = render
)
collectEvent(lifecycle) { event ->
return@collectEvent when (event) {
ForgotPasswordViewModel.Event.FinishFlow -> requireActivity().finish()
}
}
}
}

override fun onDestroyView() {
_binding = null
super.onDestroyView()
}
}

Example:

--

--