Kotlin Multiplatform presenters (or ViewModels): the lean way

How to take advantage of Android ViewModels in KMM projects

Featured on Hashnode

When I joined 🌱 Klima more than 2 years ago, I had my first real contact with Kotlin Multiplatform. Even though we weren't sharing a lot of code across different platforms back then, the Android app started by Leandro was highly inspired by Jake Wharton's SdkSearch, with KMM as a first-class citizen. So not only did we have multiplatform modules (e.g. for our SQLDelight database), but our presenters were also (mostly) multiplatform.

Fastforward 2 years and we've released our new app: 🌍 Planet Wild. Now our presenters are truly multiplatform and are actually used on iOS! Sharing presenters across platforms is not easy and that's what this article is about.

Presenters vs ViewModels

We have to start with an important disclaimer: when we say presenter, it doesn't mean we're talking about MVP (Model-View-Presenter)! We're simply talking about the object responsible for dealing with the view (i.e. exposing view state, handling user input), whatever the view is, however the plumbing is implemented. The goal isn't to pick any particular architecture, and what we cover here can be used on MVVM, MVI, and any other letter variations you can think of.

The differentiation is important because if we want to have real multiplatform presenters (living in the commonMain source set), they can't be a Jetpack ViewModel as that's an Android specific dependency. So if we name them ViewModel, but they're not a Jetpack ViewModel… it gets confusing really fast. ViewModel has now become a loaded expression. From now on, any time we mention it here, it's supposed to mean Jetpack ViewModel exclusively.

Having to avoid Android references in presenters so we can share them with iOS doesn't mean we must ditch the ViewModel, though! There are a few good reasons why we might want to keep it:

Everything together means we have a component that can reliably power our Android views, out-of-the-box. None of this is essential, though, and we can build it all ourselves. We can actually see how SdkSearch took advantage of the lastNonConfigurationInstance API to survive configuration change without having to rely on a ViewModel. But wouldn't it be better if we could have all of this with no extra effort?

The status quo

There are already a few different existing methods that allow us to have multiplatform presenters that can be used on iOS without sacrificing our ViewModel.

DIY expect/actual

The first approach doesn't require any libraries and is what Touchlab is doing in their KaMPKit repository. They define their own ViewModel as an expect class:

The Android implementation of that is trivial. The one interesting thing about it is the name clash being resolved at the import level since they're already using the ViewModel name in their multiplatform abstract class and platform-specific implementations like this one:

The iOS implementation is a bit more interesting:

We don't have a built-in scope here, so instead we create one ourselves and make sure it's cancelled when the ViewModel is cleared. And that's it! With just this tiny bit of glue code it's now possible to create a ViewModel in the commonMain source set like this one.

Using that on iOS is still not trivial, so they wrap the common ViewModel in an iOS only ViewModel wrapper that allows consuming a Flow on the iOS side as a callback. So in the end iOS actually consumes a wrapper of the ViewModel that lives in the common source set. Writing a thin Swift layer to make things easier on the iOS side is a common and encouraged practice, so there are no surprises here.

This strategy is great for Android developers as nothing really changes on the Android side — we're basically translating Android's ViewModel to iOS so we can have a multiplatform version of it. On the other hand, we're now implicitly embedding this Android knowledge underneath all presenters, and we're imposing this Android-style mental model of what a ViewModel is regardless of what might be the current perception of the rest of the team. If you're ever planning to share presenters on the web, then this becomes even more relevant.

This might not be a big deal as ultimately what we're carrying from an Android ViewModel there is quite trivial. But it still adds up to the cognitive load iOS developers need to face when working on KMM projects.

KMM-ViewModel

Rick Clephas released this great library a few months ago, and just as KMP-NativeCoroutines helps us consuming coroutines on the iOS side, KMM-ViewModel helps us sharing ViewModels.

This is basically the previous approach turned into a library. It also starts with a multiplatform definition of a ViewModelthat looks pretty close to what we saw in KaMPKit. The library goes beyond, though, and it also exposes its own multiplatform MutableStateFlow implementation and SwiftUI property wrappers to help working with the ViewModel on the iOS side (similarly to KaMPKit's wrapper).

There are similar tradeoffs here with what KaMPKit is doing, but with the big advantage of having everything nicely packaged into a library. If all you want is to be able to consume an Android ViewModel on the iOS side, especially for brownfield apps that are built on top of ViewModel, this might be a good pick — you can see it in action in the Confetti app. But there are still other ways!

Architecture components libraries

Another option is to embrace libraries offering multiplatform architecture components like moko-mvvm and Decompose. They have a significantly larger scope than simply providing a way to share presenters, but they're still capable of doing that so I thought it would be fair to mention them here.

Slack's Circuit is another interesting option for the future (they only just started working on iOS support), and I'm sure many more will pop up. Adopting a framework is always a more delicate decision, though, especially when we're already dealing with a framework still in beta (hopefully not for too long!). But even if you decide not to go with them, it's always a good idea to keep an eye out and potentially learn from what they're doing.

✨ Introducing Retained

We've been using Retained in production for over two years now. It's maintained by Marcello since 2019 and it's been stable for a while. It provides a great API for wrapping arbitrary objects (i.e. our multiplatform presenters) in a ViewModel in a way that we're able to take advantage of everything it brings without having to keep any direct references to it (or to anything wrapping it).

It has extensions on top of Android components just like a ViewModel so we can easily retain our object in whatever scope we want. Here's how it looks like retaining a presenter in an activity or in the scope of a NavBackStackEntry:

When retaining an instance, we have access to a RetainedEntry that basically exposes everything we need, including the viewModelScope, the savedStateHandle, and a way to listen to onClear() calls. You can check examples of these being used in the docs.

This means even though we're able to access everything we need from ViewModel, our presenter has no ViewModel or any other Android references in it, and we can keep it in commonMain without any expect/actual plumbing. Whatever we get from Android's ViewModel is only relevant on the Android side, and with Retained we're able to keep it all contained there without leaking anything to the iOS side.

🍎 The iOS side

This is a significantly lighter and leaner solution, and it keeps the presenters in a more “pure” multiplatform state — iOS developers will be able to look at them without having to understand or abstract away any Android-specific references.

However, there's nothing here to help with the consumption on the iOS side. This is by design as that's not in the scope of the library, and it actually matches our needs and expectation as the way we consume our multiplatform presenters on iOS is very particular to our architecture. For us, it's better to handle that in our own custom (and thin) Swift layer written according to our specific needs.

How to consume a multiplatform presenter on the iOS side is a topic on its own worthy of its own article. We've scratched the surface when we looked at KaMPKit and KMM-ViewModel, but it'll really depend on the architecture in place, so it's not something we'll explore further here. But regardless of any implementation details, our main goal was to find a lean and non-opinionated way to keep our presenters multiplatform where we could still take advantage of ViewModel features on the Android side, and Retained proved to be the best tool for the job.


If you've found this article helpful, you might be interested in reading about how we're dealing with dependency injection in our KMM project:

👋 Feel free to reach out to me on Twitter or Mastodon, or drop a comment here if I missed anything or if you have any questions!