DEV Community

loading...

Jetpack Compose – Don't throw your presenters off

alyssoncs profile image alyssoncs ・8 min read

Prelude

In the beginning there was Context and XML all over the place, no architecture or testes what so ever, even enums weren't allowed—God have mercy.

We—as a community—came a long way to be able to be discussing things like the ones on this article. Be proud.

Introduction

Compose is very refreshing for the Android development community, it comes with a change in paradigm to describe the UI, and this change comes with some costs, we are going to look at some ramifications that the adoption of Jetpack Compose has on the architectural patterns of our UI layer.

As of this writing we can read the following on Jetpack Compose docs

Unidirectional Data Flow (UDF) architecture patterns work seamlessly with Compose. If the app uses other types of architecture patterns instead, like Model View Presenter (MVP), we recommend you migrate that part of the UI to UDF before or whilst adopting Compose.
[emphasis added]

Google is right (as usual). It is true that MVP doesn't work well with compose out of the box, but, throwing well functioning and tested presenters (you got tests right?) off is not the only option on the table.

I'm going to present (got it?) an alternative for you out there that are still using MVP on Android and just want to convert some screen to compose while reusing your UI logic and move on with your life. In this journey we are going to use a clever technique that dates before 1994, one that all Android developers should be very familiar with.

Disclaimer #1

I'm not suggesting that you should stick with MVP on your projects instead of rewriting it to MVVM or similar patterns, all that I'm saying is that you don't have to if you don't want to.

I'm going to assume that you are familiar with both MVP and MVVM through the article.

Back in time a few decades

We got ourselves a problem, we have all the UI logic written in MVP, and Compose really wants to work with MVVM, what can we do about it?

Well, it is an instance of a well known problem in software engineer, it is so common that we have a pre-made solution for it, and we can found it here:

cover of the book: design patterns, 1994

Back to 1994, we are going to use the Adapter design pattern.

In software engineering, the adapter pattern is a software design pattern that allows the interface of an existing class to be used as another interface. It is often used to make existing classes work with others without modifying their source code.

Let's give Compose want it wants. We are going to create an Adapter that will adapt MVP to MVVM.

You've read it, we are going to use a pattern to transform a pattern into another pattern. A true pattern abuse.

But can you do it?

Yes!! Look at this masterpiece:

a tic-tac-toe app gif

You can grab the code here:

But how?

A ViewModel is usually composed of two "parts".

It has a set of public methods which are called by the view controller (Activity/Fragment). It also has a set of observables that are observed by the same view controller.

The first part is analogous to the Presenter in MVP, so, that is going to be the first axis of adaptation we'll make.

In MVP, the View is an interface that is usually implemented by the Activity/Fragment, and that is where things change in the approach of this article. We are going to adapt the View to be the second part of the ViewModel, i.e., the observables.

So, in our solution here, the ViewModel will adapt both the Presenter and the View. It will be something like this:

uml diagram

But I like to be more explicit, so I'm breaking the ViewModel in two, I think it make things easier to understand, but both ways will work fine.

uml diagram

You can see that I called the presenter part of the diagram a "Wrapper" instead of an "Adapter", That's because it actually doesn't adapt anything, it just "wraps" the existing presenter implementation (not shown in the above diagram) inside a Android ViewModel, that way the presenter will share the same lifecycle than the ViewModel, which will make things so much easier.

I have stated that you don't have to throw away your presenters, but looking at the diagram, it looks like we'll have to implement all the presenter logic all over again inside the PresenterViewModelWrapper. Fear no more, delegation comes to the rescue.

uml diagram

Let's break down this diagram for a moment.

The PresenterViewModelWrapper will hold an instance of a Presenter—which will actually be the PresenterImpl—that is what the diamond shaped arrow means.

Every Presenter method call dispatched to the PresenterViewModelWrapper will be dispatched further, that is, delegated to, the Presenter instance that it holds. And that is the way that the PresenterViewModelWrapper will realize its own Presenter implementation.

Does it seems hard to understand? Take a look at the code.

class PresenterViewModelWrapper(
    presenter: Presenter
) : ViewModel(), Presenter by presenter

Enter fullscreen mode Exit fullscreen mode

And that's, my friend, might be the reason that we were creating the Presenter interface this whole time instead of just creating a concrete Presenter class without interfaces. There are two presenters implementations now 😎

If you are not familiar with the Presenter by presenter part of the code, is kotlin syntactic sugar to the following code:

class PresenterViewModelWrapper(
    private val presenter: Presenter
) : ViewModel(), Presenter {
    override fun onStart() = presenter.onStart()
    override fun onClickItem(position: Int) = presenter.onClickItem(position)
    override fun onSaveButtonClicked() = presenter.onSaveButtonClicked()
}
Enter fullscreen mode Exit fullscreen mode

If you don't know me, I'm a TDD practitioner (yeah, that kind of person) and even I wouldn't unit test a class like that. It's too simple to be tested.

When we build our application, we just pass our PresenterImpl, the one that has all the logic implemented and is well tested, to our PresenterViewModelWrapper.

If you like to, you can take a look at the PresenterImpl equivalent in my sample app here.

What about the View?

That's where the real Adapter thing happens, but is really simple as well, all we have to do is adapt every call to the View methods to a operation on an observable exposed by this ViewModel. Like so:

class ViewToViewModelAdapter : ViewModel(), View {
    val isLoading = MutableLiveData<Boolean>(false)
    val userScore = MutableLiveData<Int>(0)

    override fun showLoadingAnimation() {
       isLoading.value = true
    }

    override fun hideLoadingAnimation() {
       isLoading.value = false
    }

    override fun setUserScore(score: Int) {
       userScore.value = score
    }
}
Enter fullscreen mode Exit fullscreen mode

It is a very simplistic example, so I'll show the actual Adapter that I've implemented on the sample app:

Click here to see the code
class TicTacToeViewToViewModelAdapter : ViewModel(), TicTacToeView {
    companion object {
        private const val BOARD_SIZE = 3
    }

    sealed class Snackbar {
        data class InvalidMove(val x: Int, val y: Int): Snackbar()
        object Tie: Snackbar()
        object PlayerOneVictory : Snackbar()
        object PlayerTwoVictory : Snackbar()
    }

    enum class Tile {
        Empty,
        PlayerOne,
        PlayerTwo,
    }

    private val _board = List(BOARD_SIZE) { List(BOARD_SIZE) { MutableLiveData(Tile.Empty) } }
    val board: List<List<LiveData<Tile>>> = _board

    private val _playerOneScore = MutableLiveData<Int>()
    val playerOneScore: LiveData<Int> = _playerOneScore

    private val _playerTwoScore = MutableLiveData<Int>()
    val playerTwoScore: LiveData<Int> = _playerTwoScore

    private val _snackbar = MutableLiveData<MvvmEvent<Snackbar>>()
    val snackbar: LiveData<MvvmEvent<Snackbar>> = _snackbar

    private val _isBoardEnabled = MutableLiveData<Boolean>(true)
    val isBoardEnabled: LiveData<Boolean> = _isBoardEnabled

    override fun clearBoard() {
        _board.forEach { row ->
            row.forEach { tile ->
                tile.value = Tile.Empty
            }
        }
    }

    override fun enableBoard() {
        _isBoardEnabled.value = true
    }

    override fun disableBoard() {
        _isBoardEnabled.value = false
    }

    override fun setPlayerOneScore(score: Int) {
        _playerOneScore.value = score
    }

    override fun setPlayerTwoScore(score: Int) {
        _playerTwoScore.value = score
    }

    override fun updatePlayerOneTile(x: Int, y: Int) {
        _board[x][y].value = Tile.PlayerOne
    }

    override fun updatePlayerTwoTile(x: Int, y: Int) {
        _board[x][y].value = Tile.PlayerTwo
    }

    override fun notifyInvalidMove(x: Int, y: Int) {
        _snackbar.value = MvvmEvent(Snackbar.InvalidMove(x, y))
    }

    override fun notifyTie() {
        _snackbar.value = MvvmEvent(Snackbar.Tie)
    }

    override fun notifyPlayerOneVictory() {
        _snackbar.value = MvvmEvent(Snackbar.PlayerOneVictory)
    }

    override fun notifyPlayerTwoVictory() {
        _snackbar.value = MvvmEvent(Snackbar.PlayerTwoVictory)
    }
}
Enter fullscreen mode Exit fullscreen mode

If you look close, that isn't a lot of logic going on here, thats because we try hard to write Dumb Views™ in MVX patterns. It also means that I didn't feel the need to test this either, but hey, you are not me, you can test the hell out of this if you want to.

Although MVP takes a lot of logic out of the View, it still puts a lot of state on it, if we use a ViewModel as the View the state is being saved on the right place.

Are you really using Jetpack Compose?

Of course! You can take a look here

Disclaimer #2

If you decide to click on the link above, you are going to witness the worse Jetpack Compose sample on the internet.

Seriously, I just skim through the 5 first pages of the Compose docs, learned about Columns and Rows, stole this poor man's code, and hacked my way into that UI.

Don't take my Compose code as an example of nothing!

There is one thing that I want to draw attention, take a look at this snippet:

private val presenter by viewModel<TicTacToePresenterWrapper>()
private val viewState by viewModel<TicTacToeViewToViewModelAdapter>()

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContent {
        TicTacToe()
    }

    if (savedInstanceState == null) {
        //take a look at this line
        presenter.setView(viewState)
        presenter.onStart()
    }
}
Enter fullscreen mode Exit fullscreen mode

Because I'm using two separate ViewModels, I have to pass the one that implements the View to the one that implements the Presenter, like we would do in plain MVP. If we were using a single ViewModel that implemented both the Presenter and the View, we could pass itself as the view on the setView(), we could even do this in the init block of the ViewModel, like this:

init {
    setView(this)
}
Enter fullscreen mode Exit fullscreen mode

After that, for every action, I call the presenter.onAction() function, and the composables just observe the LiveData exposed by the viewState.

Beyond Jetpack Compose

This technique is not only useful when working with MVP in Compose, you can use this to work with apps that are using XML as well.

If you are familiar with MVP in Android, you might have notice that this pattern makes the presenters much simpler. There is no need to "unbind" the View when the activity is not in the suitable state, nor do we have to save the state of the Presenter or the View at every configuration change.

The presenter and the View now live on the ViewModel lifecycle.

Speculation

I think that maybe there is value in writing your presentation logic in MVP in the first place, only to adapt to another pattern in the end. MVP is very simple, we don't need LiveData/ViewModel or fancy things to encode our view logic.

We might even manage to write the presentation logic in MVP with KMM (Kotlin Multiplatform Mobile) and each platform just adapts it to the preferred pattern, like MVVM/MVI/VIPER/etc.

I'm not claiming that we can do this, because I know virtually nothing about KMM, MVI or VIPER. It might just be impossible to adapt MVP to VIPER, or I might be under a big misapprehension about what KMM can do.

I'll let this possibilities to folks that are much more versed in those concepts than me.

Discussion (1)

Collapse
mfklcp profile image
Marcio Franklin

Perfect!

Forem Open with the Forem app