In the last two years of using JetpackCompose we had few iterations of the MVI architecture. As we sharpened our solution to suit us best and we think it is kinda different from most of the architectures, we thought to share it with community.
Basic MVI Architecture
The simplest MVI architecture for Compose has 3 "states" available for ViewModel <-> View communication: State, Action, Effect.
You may ask "that's right, what's wrong with that?". Theoretically, nothing. We have our 3 state architecture.Unfortunately, there are some issues that become real in larger applications.
- Boilerplate code - in this scenario we already have 60 lines of code (even more with imports) in our ViewModel class, that doesn't do much. Imagine having more Effects and Actions, code keeps getting harder and harder to read and maintain. Another issue with this approach is that you have to create such code for each ViewModel you make.
- State as one object - having State as just only one object makes it hard to read and update. Every time that we would want to change something in a state, we have to call update {} method on our StateFlow. Then it requires either setting new state, or like in most cases, copying existing state and changing something inside. For states with only primitives it can be kept more or less clean, but what if we put another data class inside or a value class? Then we would need to call copy or some factory method on another object. This way we will have many lines of code to just change a simple state.
- Action being a simple proxy - in a more complex cases, onAction method and things you do for each single case will grow, and eventually you will have to create a separate private function for it to even be able to read what each Action does. Another case might be to just emit Effect to be handled by a Screen. This way, Actions will become simply a proxies for user and screen logic, being another level of complexity you have to understand.
Do not use _viewState.value = _viewState.copy() method! It might occur with state not changing for some cases and lead to losing part or even whole of your state. Always use update function for MutableStateFlow.
Solution
Boilerplate code
The best solution here derives from SOLID code, Interface segregation principle (I). To avoid keeping everything in one place, let's create an interface for that, or maybe even two of them.
So, what did we do here? We simply took boilerplate code and put it in the interfaces. How does the packed solution look like?
In our case it was just 18 lines of code less, but in more complex screens with way more Actions and Effects, it would be even more beneficial. With our new interfaces, we will also have it easier to create new ViewModels.
State as one object
We did tell you before, that having State as just one object might be hard to maintain and read. Solution to that, is to create each State's value a separate field and use something called snapshotFlow. This function will update originally put there object (in our case a State) every time a single included field changes.
Here we have modified our State, so it will be updated for us automatically every time either points or username fields will change.
And now, we can see that modifying state looks like simple changing class internal fields values (which is, by the way)! Thanks to this, we do have cleaner state change handling, better readability, especially for those that does not know our code and we can avoid issue with wrong state changing timing since snapshotFlow handles that on it's own.
Action as a simple proxy
Alright, Action is a proxy...but what to do with them? That's simple...delete them all! As you might already think, if an Action is proxy to either a function or emitting effect (that as well should be a function), let's go straight functions! To achieve that, we have to modify our contracts a little bit.
As you can see, we've already changed actions to separated functions. We've also lost one state that is not needed anymore, Idle. How does it look like put together?
What changed here is that we no longer have nor action field or function to set it, we do not have to listen to action changes on init and we have simple and clean actions in form of functions. This solution might add a few more seconds to checking what user can do with our screen, but overall ViewModel's readability and operability gives us much more benefits. Also, we've lost some lines in comparison to the view model from previous section, so even less code!
Usage
We've got ourselves a pretty clean code. Now let's check how we can use it and integrate it with screen. At first, let's create contract and view model for our screen.
Now let's create a small screen that will fit our needs.
What happened just now? As we assume, simple compose code is understandable, we will explain only our specific changes.
- We are using specific functions from our contract instead of a one common onAction method.
- We are using specific fields from our state in a functions declarations
- We are using only utility function BaseScreen to handle our state and effect flows.
BaseScreen
As parts 1 & 2 are pretty understandable, we guess the magic happens in the BaseScreen function, right? Not exactly. Let's check it out.
As you can see there is nothing much happening there.
- Simple viewState flow collecting as compose state,
- Our whole screen content happening with already collected state,
- We use LaunchedEffect to listen to our effects.
Code is pretty simple and small but helps us with some boilerplate code that we would need to create for each screen.
The BaseScreen function should always be used only for the most top composable for each screen, so there would be only one of each state and effect collectors!
A video showing how the application works using the architecture presented in the article.
Error handling
Explanation
Our new architecture gives us also one more advantage. Almost every app has some sort of error handling. We deal it in a various ways, one of them is handling errors globally in each screen. Let's see how we can do it with our BaseScreenContract and BaseScreen.
As you can see, we've simply added new field that we will be listening to for any error occurrence.
Now we have an error state class with some utility methods.
- ErrorDialogType - simple enum class that indicates what error should we show, i.g. we could show simple error dialog with retry and close buttons, but we could have single buttons dialog as well.
- ErrorState - data class holding information about error to be shown.
- dismiss - as the name indicates, method that's gonna hide our error.
- createErrorState - factory function that creates our ErrorState for us.
ViewModel usage
In the next step, we have to override our errorState inside UserAccountViewModel and add a function to show our error.
Remember to add fun showError() to the UserAccountContract!
UI usage
To show error dialog now we have to create it. We've prepared a small view as a sample of such dialog.
Now we have to someone be able to show it on our screen. That's also pretty simple. Let's check what we're talking about. What are we going to do is simply add checking for error state change. To do so, we have to add collecting erorrState in our BaseScreen and check if it's not null. Now, with every recomposition Compose will check if error is not null and show appropriate Dialog.
To show dialog and our screen properly, remember to wrap them in a single Box! Thanks to that, dialog will be simply shown at the center of the screen.
Last step would be to add this button to our screen, but to not make this article too long and to not drown it in already wherever existing code we will assume it's been done so we'll just show video with the solution.
A video showing how the application error dialog works.
That's it! We have reached the end. Thank you for reading this article and we hope it will help you make your JetpackCompose architecture cleaner and funnier to use.