Recently, my team and me worked on an Android application where MVVM + State Container + Unidirectional Data Flow were the patterns of choice. With more and more requirements coming in, some of the view models however became really big and complex and that often lead to poor code readability.
In addition of being hard to read, these view models often violated the separation of concerns and open-closed principles, because each new feature that was added to a screen also added a new concern to the accompanying view model class, making it even bigger and harder to deal with.
In this post I will show you the technique we used to make such view models extensible, maintainable and fun to develop.
The architecture of the app was simple, yet effective at the same time. The ViewState object represented the state of a screen. The ViewModel received events from the View, created a new modified copy of the ViewState and returned it back to the View, which then rendered it. The same cycle of events happened again and again.
Notice that arrows in the diagram depict the data flow, not the dependencies between the components. The app architecture allowed the View to have a dependency to the ViewModel, but not the other way around. The ViewState was implemented as an observable property of the ViewModel.
As you can see, adding new features to the View made ViewModel more complex, too, and - after some time - extending and maintaining the monolithic ViewModel was a tough task.
We saw a clear need for breaking the ViewModel up into smaller pieces, where each piece would be responsible for a certain feature or concern, and that piece could be added or removed independently. The idea worked well. We extracted pieces of the ViewModel into small classes that we called view model delegates, or just Delegates for short.
Each Delegate in this design acts as a mini ViewModel - it handles events and creates a new ViewState. The main difference to a regular ViewModel is that a Delegate receives and handles only the events related to the concern it implements. Thus, for instance, when adding a new button to the screen, we would also add a new Delegate responsible for maintaining the events and the state of that button, but keeping the main ViewModel unchanged.
As you might notice, the only shared thing between the Delegates is the ViewState. In all other aspects Delegates are independent.
Thus the actual responsibility of the ViewModel shifts from handling events and maintaining the state into dispatching the right events to the right delegates.
The composition technique described above is simple enough and can be implemented with virtually any state container library out there, or even without one. In the code examples of this post I will use Knot, a library supporting this kind of view model composition off the shelf.
I am going to practice the composition technique by implementing a single-screen application, capable of loading a list of books. The app will start with an Empty state, then go through a Loading state into either a Content state with a list of books, or an Error state showing an error message.
These states can be modeled using a sealed class as shown below.
sealed class State {
object Empty : State()
object Loading : State()
data class Content(val books: List<Book>) : State()
data class Error(val message: String) : State()
}
There are just two events the View can send to the ViewModel. These are the Load and Clear events, which can also be modelled using a sealed class.
sealed class Event {
object Load : Event() // tap to load, try again and reload
object Clear : Event()
}
Once ViewState and events are modelled, the ViewModel can be declared as follows.
abstract class BooksViewModel : ViewModel() {
abstract val state: Observable<State>
abstract val event: Consumer<Event>
}
Observable
and Consumer
are the RxJava types. The View will observe changes happening to the ViewState and send Events to the consumer.
I have everything prepared for implementing the ViewModel. As mentioned above, I will use the Knot library for that. The library lets us declare a state machine and a set of changes for modifying its state.
For the sake of brevity I omitted details like converting events into changes and the implementation of the loading action in the code snippets below. Check out the Knot samples (monolithic) to see the complete code in action.
Here is my monolithic view model, which handles all the events.
class MonolithicBooksViewModel : BooksViewModel() {
override val state: Observable<State>
get() = knot.state
override val event: Consumer<Event> =
Consumer<Event> { knot.change.accept(it.toChange()) }
private val knot = knot<State, Change, Action> {
state { initial = State.Empty }
changes {
reduce { change ->
when (change) {
Change.Load -> when (this) {
State.Empty,
is State.Content,
is State.Error -> State.Loading + Action.Load
else -> only
}
is Change.Load.Success -> when (this) {
State.Loading -> State.Content(change.books).only
else -> unexpected(change)
}
is Change.Load.Failure -> when (this) {
State.Loading -> State.Error(change.message).only
else -> unexpected(change)
}
Change.Clear -> when (this) {
is State.Content -> State.Empty.only
is State.Empty -> only
else -> unexpected(change)
}
}
}
}
actions { ... }
}
}
Even though the code is pretty well structured, you should be able to imagine how complex it can become when more features are added to the screen.
If we look closer, we will recognize two separate concerns that we can extract from the MonolithicBooksViewModel
- loading and clearing. I am going to extract those into two separate delegate classes now.
Firstly, I need to make the ViewModel extensible. This can be achieved by declaring a delegate interface allowing delegates to receive events and change the state. Here is my interface.
interface Delegate {
fun CompositeKnot<State>.register()
fun CompositeKnot<State>.onEvent(event: Event): Boolean
}
Now the ViewModel should be able to receive a list of delegates in its constructor and delegate work to them. By doing so, new delegates can be added to the list without the necessity to modify the actual ViewModel.
class CompositeBooksViewModel(
private val delegates: List<Delegate>
) : BooksViewModel() {
override val state: Observable<State>
get() = knot.state
override val event: Consumer<Event> =
Consumer<Event> { event ->
delegates.any {
with(it) { knot.onEvent(event) }
}
}
private val knot = compositeKnot<State> {
state { initial = State.Empty }
}
init {
for (delegate in delegates) {
with(delegate) { knot.register() }
}
knot.compose()
}
}
As you can see, the only remaining logic of the state machine is to set the initial state. The actual functionality will be moved into the delegates and here they are.
class ClearButtonDelegate : Delegate {
override fun CompositeKnot<State>.register() {
registerDelegate<Change, Nothing> {
changes {
reduce<Change.Clear> { change ->
when (this) {
is State.Content -> State.Empty.only
is State.Empty -> only
else -> unexpected(change)
}
}
}
}
}
}
class LoadButtonDelegate : Delegate {
override fun CompositeKnot<State>.register() {
registerDelegate<Change, Action> {
changes {
reduce<Change.Load> {
when (this) {
State.Empty,
is State.Content,
is State.Error -> State.Loading + Load
else -> only
}
}
reduce<Change.Load.Success> { change ->
when (this) {
State.Loading -> State.Content(change.books).only
else -> unexpected(change)
}
}
reduce<Change.Load.Failure> { change ->
when (this) {
State.Loading -> State.Error(change.message).only
else -> unexpected(change)
}
}
}
actions { ... }
}
}
}
Omitted implementation details can be found in the fully functional example of the composite ViewModel in Knot samples (composite).
Here we go! We had a monolithic ViewModel, and then we turned it into an extensible ViewModel and two independent Delegates.
The composition technique described in the post saved many hours of work in my recent project and I hope you will find it useful as well. The biggest composite ViewModel I’ve written had about 30 delegates and handled dozens of events in 3 states. That was a hero screen which nonetheless was perfectly maintainable and extensible.
Try it out, improve it, practice more and have fun! ✌️
Thanks to Thomas Keller for the review and proofreading!