Blog Infos
Author
Published
Topics
Author
Published
Topics

Source: https://neural.love/ai-art-generator/1ee519a8-0032-6f08-b8e8-e515f8bde0a6

 

Let me tell you a story.

I was always a strong proponent of good software architecture as it was my belief that a good architecture can solve most of the problems of modern software development for you. But architecture is not easy, and I screwed up in some places, and then decided to fix my mistakes by developing an architecture library. In this article, I want to share with you why our team migrated to the new architecture and what benefits we got.

The demise of the MVVM architecture

Let’s talk about MVVM. On my job, I mostly work as a tech lead: guiding teams, reviewing code, maintaining the quality of code, and implementing the most difficult features in the app.

I try to follow Clean Arch as best as I can, but the UI layer architecture is always a topic of debate, and as a result, is something vague and inconsistent.

But why would we use MVI when we have our le classique MVVM?”

That’s what I and the team thought when we were starting to work on new features for one of our projects. We had this all-new shiny stack — Jetpack Compose, compose-destinations for navigation, freshly created modules and conventions for the app, KMP, et cetera et cetera, so there was no time to think about matters as trivial as UI layer architecture — let’s just do the MVVM thing as always.

But then something bad happened: every. single. developer. on the team. including me… started making the same mistakes over and over. I am going to name just a few.

Passing many unstable parameters to Composables
@Composable  
internal fun OngoingRitualScreen(
    ritualId: UUID, // (1)
    /* 45 more parameters */
)
  1. One parameter made the whole Screen composable unstable.
  2. The number of parameters quickly became overwhelming, reaching 50+ parameters for complex screens, mostly because of state variables and event lambdas (clicks, etc.)
Passing lambdas to composables that captured unstable parameters
// (1)
@Composable
fun AccountScreenContent(
    /* ... */
    onUserAvatarClick: () -> Unit, // innocent-looking
)
// --- 
val viewModel = getViewModel<AccountViewModel>()
AccountScreenContent(
    onUserAvatarClick = { viewModel.onSignIn() } , 
)

// (2)
// or even worse
AccountScreenContent( 
    viewModel: AccountViewModel, 
)
  1. In the first example, the developers created a lambda that was innocent-looking but had captured an unstable parameter in its closure (ViewModel). This resulted in the whole composable’s screen content being non-skippable and non-restartable, killing the performance of our app.
  2. Here the developer commented:
    “We had 30 params in the function, so I just passed the ViewModel everywhere.”
    Then the whole hierarchy of composables had an unstable parameter in its definition, it propagated downwards and worsened the app’s performance even more.
Observing the state in a non-lifecycle-aware manner
val viewModel = getViewModel<AccountViewModel>()
val state by viewModel.uiState.collectAsState()

collectAsState() collects in a non-lifecycle-aware manner (even when the activity is PAUSED). This results in wasted resources – RIP user device battery 🪫.

Leaking background jobs in ViewModels, introducing races to state management
class AccountViewModel(
    val repo: AccountRepository,
) : ViewModel() {
    val state = MutableStateFlow<AccountState>(Loading)
    init {
        repo.userFlow
            .onEach { state.value = produceState(it) } // (1)
            .flowOn(Dispatchers.Default)
            .launchIn(viewModelScope) //(2)
    }
}
  1. The first issue here is that the state is processed on a background thread, but it introduces a race to the code, which will be hard to reproduce and find. This happened because the state value is not updated atomically.
  2. Here the flow is collected in the init block and in the VM’s scope, leaking it to the background when the screen doesn’t need updates (state being produced). It will run when the app is hidden and even when the screen is in the backstack. It will also affect other screens, introducing hard-to-find bugs.
Stateful events nightmare
data class AccountState(
    navigationAction: NavigationAction? = null  // leaked UI layer classes to the Domain layer, bye KMP compatibility.
)

My conversation on VCS with the dev went as follows (oversimplified):

“major: This is not a state, why is this here? Please remove this as this leaks UI to the business logic” → <Changes Requested>

Google said so — don’t do side effects, do states. How else do you think I should handle this?“

“Hmmh I don’t know, okay, makes sense.” → <Approved>

Then devs proceeded to add this to the repo:

// in the ViewModel

fun navigate(to: NavigationAction) { 
    uiState.value = uiState.value.copy(navigationAction = to) // oops, race condition
}
fun finishNavigation() {
    uiState.value = uiState.value.copy(navigationAction = null) // oops, race again
}

// composable
LaunchedEffect(uiState.navigationAction) { // non-lifecycle-aware
    when(val destination = uiState.navigationAction) {
        null -> return@LaunchedEffect // wasted computation
        else -> {
            navigator.navigate(destination)
            viewModel.finishNavigation() // boilerplate
        }  
    }
}

Repeat this 30 times for all side effects devs tried to express as state values. ViewModel has +60 functions that do nothing.

Perhaps the worst of them all — dumping everything into the ViewModel
class GFYViewModel(
    private val gfyRepository: GFYRepository,
) { 
        private val challengeFlow = redirectionRepository.redirectionInfo
            .map { info ->
              info?.challengeID?.let { id ->
                  runCatching { challengeRepository.getChallenge(id) }
                      .onFailure { e ->
                          e.printStackTrace() // No error-handling. Print stack trace, seriously?
                          _challengeRequestState.value = ChallengeRequestState.GetRequestFailed
                      }
                      .getOrNull()
              }
            }
            .shareIn(viewModelScope, SharingStarted.Eagerly, 1) // Leaks the job to the background

        val games = combine(
            gfyRepository.getGames(
                videoDurationUs = gfyConfigurationData.gameDurationMs,
                firstGameId = redirectionRepository.redirectionInfo.value?.gameID,
                challengeId = redirectionRepository.redirectionInfo.value?.challengeID,
            ).cachedIn(viewModelScope).withIndex().onEach {
                if (it.index == 0) onFirstGamesItemRetrieved() // crazy nested leaking broken chains of callbacks
            },
            challengeFlow,
            profileRepository.cachedUserProfile,
        ) { /* 150+ LoC for mapping state. */ }
}

The VM above is responsible for half of the app’s business logic because of customer requirements. Devs proceeded to add 25+ injected parameters to its constructor and 2000+ lines of code in the class body. Everybody then started refusing to work on this screen as it was fragile and frustrating to change. Customer rage ensued.

The developers did this because there was no consistent framework in place to extend the functionality of a ViewModel without introducing another “Manager”, “Controller”, or “Helper” class.

And last but not least — platform lock-in.

At one point, our customer got interested in KMP. After taking a look at the app’s code, I concluded it was time to start saving for my funeral.

All business logic was dumped in ViewModels, which extended Android’s platform ViewModel classes. There were thousands of platform imports in those files. ViewModels then leaked the platform imports to Repos/UseCases.

So I just told the customer that KMP is in beta and so on, that it’s too early and stuff… And then couldn’t sleep because I made such a blatant lie.

So I screwed up. I didn’t create a good architecture for the project. And I was going to fix that.

What I did next

You may say:

bro, just don’t write bad code!

But unfortunately, while everyone tries to do their best, the reality is not like that. Not everyone knows how Jetpack Compose works internally. Not everyone knows everything about the best Clean Arch and Immutability and FP and reactivity… You don’t always have time to spend on architecture when the customer is pressuring you every single standup to release your feature ASAP. And of course, everyone makes mistakes sometimes. It’s just life.

That’s why the best architectures I have ever seen are made in such a way that if you do something wrong, your code just doesn’t compile. You should be free to write code without restrictions as you see fit, but at the right moment, that unsafe property you wanted to change just isn’t there. The wrong function is just not there, so you don’t have to think about all those corner cases, complicated internal mechanics, or runtime safety.

That issue was happening for both Respawn and other projects I work on. It seemed unavoidable, like an impending doom where wherever you look you will soon find something that you must rewrite to make it work.

“Someone introduced a state race while I was on vacation… Jesus, not again…”

And working this out wasn’t easy either. To just pass a click action to the composable you had to make a remembered lambda, wasting 10 lines of clunky code and then trying to debug what happened when you used that lambda in a launched effect but the lambda then has been recomposed… To fix a state race, every other screen started to have Mutexes used in it or just doing everything on the Main thread, we didn’t care anymore at this point. This didn’t seem like the way apps are meant to be made. Certainly, the flaw wasn’t with Compose or anything.

It’s just that with great power comes great responsibility.

So I got to work on FlowMVI. I was resolved to fix all of these problems at once, with a big update to the architecture.

The solution: FlowMVI 2.0

At one of the tech conferences I participated in, I was inspired after attending a talk about a KMP MVI library there.

And after that, I was working on a backend service for my app. I was having a blast developing a web backend with Ktor Server. Its API was so delightful to use. All these plugins that act as interceptors and stuff were bombastic 💣.

And then it hit me:

“What if I add plugins… To an architecture?”

After that realization, it was only a matter of hard work and time before I was ready to release the first alpha of FlowMVI 2.0.

The resulting library was featuring a powerful plugin system, a full-blown KMP compatibility which required almost no effort from the consumers of the library to make their ViewModels multiplatform, and a DSL as delightful as Ktor has.

So here’s how it ended up looking:

I already had experience with complicated MVI architectures that lock you into a family of base classes, interfaces, mappers, entities, and whatnot. I hated that experience. I am going to talk about FlowMVI 1.0 vs. 2.0 vs other libraries in detail in another article, but let’s just say that I tried to keep it as simple as possible.

There are just 3 entities in the whole architecture — a Subscriber, which is any class that can render states and consume side effects (I called them Actions), the Plugin that intercepts and sends intents (Commands), states, and actions, and a Store, which is a simple wrapper that executes plugins and holds state. You don’t have to implement any interfaces to use the architecture. You don’t have base classes, inheritance, mappers, processors, events, or anything else.

Instead, you do two things:

  • Write your UI, Models, and business logic using a DSL.
  • Install plugins.

That’s it.

Here’s what the business logic looks like now:

private typealias Ctx = PipelineContext<OngoingRitualState, OngoingRitualIntent, OngoingRitualAction>

internal class OngoingRitualContainer(
    private val ritualRepo: RitualRepository,
    private val analytics: Analytics,
) {
    private val jobs = JobManager()

    val store by lazyStore(State.Loading) { 
        name = "OngoingRitualStore"
        debuggable = BuildFlags.debuggable 
        actionShareBehavior = Distribute() 
        parallelIntents = true
        install(
            jobManagerPlugin(jobs),
            platformLoggingPlugin(),
            analyticsPlugin(analytics),
            soundsPlugin(),
            timerPlugin(),
            ritualServiceManagerPlugin(),
        )
        recover { e: Exception ->
            updateState { 
                State.Error(e)
            }
            null
        }
        reduce { intent -> 
            when (intent) {
                is BackClicked -> action(GoBack)
                is SkipClicked -> withState<DisplayingRitual, _> { 
                    if (!canSkip) {
                        stopRitual()
                        return@withState
                    }
                    intent(PageChanged(currentItemIndex + 1))
                }
            }
            /* ... more ... */
        }
    }

    private suspend fun Ctx.stopRitual() {
        jobs.cancelAndJoin(Jobs.Ritual)
        updateState {
            val state = typed<DisplayingRitual>() // use typed() instead of writing "(this as? State)?.let { }"
            action(StopService)
            if (state != null) {
                DisplayingSummary(state)
            } else {
                action(GoBack)
                Idle
            }
        }
    }
}

This looks weird at first, but it reads like English and makes sense quickly as you start to use it.

1. Configuring our store.

This was extracted to a simple extension function, but I have copied and pasted the body here to explain it better.

Job Offers

Job Offers

There are currently no vacancies.

OUR VIDEO RECOMMENDATION

No results found.

Jobs

debuggable = BuildFlags.debuggable 
actionShareBehavior = Distribute() 
parallelIntents = true
  • The debuggable flag enables some debug checks that make working with Stores safer. The store will throw in case of developer errors on debug and ignore errors when debuggable is false
  • ActionShareBehavior specifies how to handle side effects if you are using them. Side effects are optional and can be disabled with this option. More on this option in a future article or in the documentation.
  • parallelIntents is a feature I like the most of these three. It launches a new coroutine for each Intent (Command) that the store receives. It’s super powerful as it allows us to safely suspend in plugins. Developers often make mistakes when it comes to whether or not you can suspend in your code. This option solves that. You can just write your code as if you were writing a single-threaded program.

There are some more options of course, but I won’t cover all of them here. These are just the most helpful ones.

2. Installing plugins

Then there is this scary block

install(
    jobManagerPlugin(jobs),
    platformLoggingPlugin(),
    analyticsPlugin(analytics),
    soundsPlugin(),
    timerPlugin(),
    ritualServiceManagerPlugin(),
)

This is just a install() function that installs plugins with a vararg parameter. Some plugins are defined as singletons, some use injected dependencies, and some are predefined by the library.

3. Creating plugins

Below is the code of a plugin mentioned earlier. You can write them on the fly in the store’s body, but I prefer to move them to separate files as top-level properties or functions. There are a multitude of events you can intercept with plugins:

internal fun timerPlugin() = plugin<OngoingRitualState, OngoingRitualIntent, OngoingRitualAction> {
    
    val habitTimer = timer()

    onIntent { intent ->
        when (intent) {
            is PageChanged -> launch { /* ... */ }
            else -> Unit
        }
        intent // pass intents down the chain of responsibility
    }

    onStart {
        // ... stuff that happens when store starts, ya know, all that jazz...
    }

    onUnsubscribe { subscriberCount ->
        if (subscriberCount == 0) habitTimer.stop()
    }

    onAction { action ->
        when (action) {
            is StopService -> habitTimer.stop()
            else -> Unit
        }
        action
    }
    onState { old, new ->
        when {
            // was active, became inactive
            new !is DisplayingRitual -> habitTimer.stop()
            // was inactive but became active
            old !is DisplayingRitual -> {
                habitTimer.start(new.currentItem.elapsedTime)
                return@onState new.copy(isPaused = habitTimer.isStopped)
            }
        }
        new
    }
}

You can see how insanely powerful plugins are here. They allow us to extract any logic we want into a separate entity that doesn’t have to be contained in the Store. Here I’m managing timers. This allowed me to extract all this code from the Container (was ViewModel), solving the problem of the Single Responsibility Principle at its root. I am not limited here because I can suspend, launch jobs, emit events, veto changes, modify events, or change the state that is being processed by other plugins.

4. Recovering from errors
recover { e ->
    updateState { 
        State.Error(e)
    }
}

The library can handle any exceptions that happen in the store, be it a background job, a coroutine, or even a random plugin that throws. For that, we can install (yet another) plugin to handle exceptions. For this simple feature, I’m just showing a generic error layout.

5. Reducing Intents

When all other plugins have finished their work, we can now process what’s left of our remaining intents in the store itself by using a reduce plugin.

reduce { intent -> 
    when (intent) {
        is BackClicked -> action(GoBack)
        is SkipClicked -> withState<DisplayingRitual, _> { // (1)
            if (!canSkip) {
                stopRitual()
                return@withState
            }
            intent(PageChanged(currentItemIndex + 1)) // (2)
        }
    }     
}
  1. Here we’re using withState (and updateState) to make the update atomic, which means we can safely operate on it and update the state without ever being afraid that the state will be changed while our coroutine is running.
  2. Another nice trick is to delegate to other reduce branches and plugins by sending intents from the store to itself.

I won’t go into much detail on the reduce function. If you are familiar with MVI, you know that reducer is a function that handles Intents. In this case, reduce is also a plugin. Just let me show you how the plugin looks in the library’s code:

fun <S : MVIState, I : MVIIntent, A : MVIAction> reducePlugin(
    reduce: Reduce<S, I, A>, // lambda
) = plugin<S, I, A> {
    onIntent {
        reduce(it)
        null
    }
}

Writing this plugin was a jaw-dropping experience. I was astonished at this elegance and simplicity. This is literally all you need to reduce an intent. At first I thought I would be implementing reducers by hand in the library’s code with a million lines of code, as it was with FlowMVI 1.0. When I saw this, I knew it was The Thing.

Bonus: Job Manager

I want to talk a bit about the JobManager plugin. Here’s the entire code of the plugin:

fun <S : MVIState, I : MVIIntent, A : MVIAction> jobManagerPlugin(
    manager: JobManager,
): StorePlugin<S, I, A> = plugin {
    onStop {
        manager.cancelAll()
    }
}

JobManager itself is a simple wrapper around a Map of Jobs.

At first, I thought JobManager was BS. I saw managed jobs in other libraries and I was skeptical of it. But I encourage you to use it, as it turns out it’s incredibly useful for all kinds of things. No more non-thread-safeprivate var myAnotherJob: Job? = nulls! The best thing is that you can use it with other plugins to cancel the jobs on the store’s lifecycle events, like when all subscribers disappear. That’s my primary use case for the plugin.

The UI Layer

I was resolved to support any UI framework, be it SwiftUI, XML, or Jetpack Compose. And I did it. This article has become an ultra-long-read, so let’s not dive into too many details here and look at Compose only. It’s almost the same for others.

Here’s how the UI looks with Jetpack Compose:

private typealias Scope = ConsumerScope<OngoingRitualIntent, OngoingRitualAction>

@Composable
fun OngoingRitualScreen(
    nav: RespawnNavigator,
) = MVIComposable(storeViewModel<OngoingRitualContainer, _, _, _>()) { state -> // (1)

    Subscribe { action -> // (2)
        when (action) {
            is GoBack -> nav.back()
        }
    }

    Scaffold {
        OngoingRitualScreenContent(state = state)
    }
}

@Composable
private fun Scope.OngoingRitualScreenContent( // (3)
    state: OngoingRitualState,
    modifier: Modifier = Modifier,
) {
    /* ...after a when block with possible states... */
    Fab(
      icon = Icon.Rounded.Check,
      onClick = { intent(FinishRitualClicked) },
      modifier = Modifier.padding(12.dp)
    )
}
1. The MVIComposable
private typealias Scope = ConsumerScope<CounterIntent, CounterAction>

@Composable
fun OngoingRitualScreen(
    nav: RespawnNavigator,
) = MVIComposable(storeViewModel<OngoingRitualContainer, _, _, _>()) { state -> }

You can see here that we wrap our screen in an MVIComposable. That composable is just a wrapper that subscribes to the store and exposes a receiver parameter named ConsumerScope . That receiver is all the magic – it has a function named intent (or send, the same thing) that sends intents to the store. The best part about that is that you can define any composable using that Scope receiver and get the ability to send intents inside of it. Of course, all that jazz is stable, restartable, skippable, donut-hole optimizable, and everything, because it is inline and does not “exist” in the code at all.

Thus, the problem with instability is solved. I’m so happy I don’t have to think about remembering a gazillion lambdas ever again.

You may be asking:

Wait, where did that storeViewModel come from? We haven’t defined a ViewModel at all!

The answer is — it does not exist. I won’t ever have to touch those pesky android-specific ViewModels again! We use Koin for our DI, so I’m going to show how I defined the module, but you’ll have to implement your preferred DI approach yourself in about 30 lines of code using qualifiers.

val accountModule = module {
    factoryOf(::OngoingRitualContainer)
    storeViewModel<SignInContainer>()
}

inline fun <reified T : Container<*, *, *>> Module.storeViewModel() {
    viewModel(qualifier<T>()) { params ->
        StoreViewModel(get<T> { params })
    }
}

@Composable
inline fun <reified T : Container<S, I, A>, S : MVIState, I : MVIIntent, A : MVIAction> storeViewModel(
    noinline params: ParametersDefinition? = null,
) = getViewModel<StoreViewModel<S, I, A>>(qualifier<T>(), parameters = params)

StoreViewModel is a VM class that is defined in the flowmvi:android artifact. It does nothing, just wraps the store and launches it. I am using an interface called Container from the library for easier type resolution. I’m so glad that I am no longer bound to the Android architecture and can just write multiplatform code as if it were a regular view model code, without introducing weird frameworks or dependencies to wrap that logic for me. Make sure though that you are using qualifiers, or the DI framework won’t be able to distinguish between all the StoreViewModels.

2. Subscribe
Subscribe { action ->
    when (action) {
        is GoBack -> nav.back()
    }
}

This is simple. This block just subscribes to the store behind the scenes and exposes a lambda that will run in a separate coroutine when side effects arrive from the store, if any.

The magical thing about this is that this invocation is stable, does not recompose unnecessarily, and is lifecycle-aware. By default, the subscribe block will cancel when the activity goes into Lifecycle.State.PAUSED and resubscribe when it resumes. We will never have to worry about lifecycle again, because a store is lifecycle and even coroutine scope-agnostic, and composables follow the system lifecycle. I also tried to mitigate the issue where events can be dropped as much as possible internally.

3. Immutable layout
@Composable
private fun Scope.OngoingRitualScreen(
    state: OngoingRitualState,
    modifier: Modifier = Modifier
) {
    /* after a when block with possible states */
    Fab(
      icon = Icon.Rounded.Check,
      onClick = { intent(FinishRitualClicked) },
      modifier = Modifier.padding(12.dp)
    )
}

All we are left with is to use the state parameter and simply draw our screen declaratively. We are using the Scope mentioned earlier to send intents and render the immutable, stable state as if it is just a snapshot of our UI in time. The composables will only recompose when the state changes, and the nested ones – when their properties change, thus solving the performance issue of our UI. The lambdas we pass are stable because the consumer scope is stable itself, thus there will be no need to remember lambdas anymore.

This solution also reduces the parameter count significantly, as we no longer need to list and pass all the possible click actions and other events in our composable’s declaration and can just use intent() directly, passing just the scope downwards.

That’s all there is to it for this part of my article series about FlowMVI.

Conclusion. Does it stay?

It’s non-negotiable. We are keeping FlowMVI for our future development and will be rewriting our MVVM junk to the new approach soon. In my opinion, this project has given amazing results. We have not only solved the problems we faced before, but also found many other benefits. I wonder what other surprises will the new version bring us.

I definitely will be getting normal sleep now.

As the library is still new, I am, however, still not sure how usable it is for a wider audience. I will be happy if you decide to try it out and tell me what you think of it. I hope that FlowMVI will make your life as a developer easier 💚.

Meanwhile, I will be working on Compose Multiplatform support, proper iOS/Swift interop, and more articles explaining how to use FlowMVI.

This article was 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
This is the second article in an article series that will discuss the dependency…
READ MORE
blog
Let’s suppose that for some reason we are interested in doing some tests with…
READ MORE
Menu