Sharing View Models in Kotlin Multiplatform Mobile (iOS and Android)
Kotlin Multiplatform Mobile (KMM) stands out as a unique cross-platform technology, distinguishing itself from other solutions available in the market. Unlike many other frameworks, KMM enables the sharing of only the business logic between platforms, while the user interface remains fully native — a remarkable advantage. This approach typically involves forming three distinct teams to develop your application: iOS, Android, and shared developers. The iOS team concentrates on the native UI for the iOS platform, the Android team takes care of the Android UI, and the shared developers focus on crafting the business logic. This collaborative model facilitates efficient development, particularly when you strive to maximize code sharing. KMM simplifies sharing repositories, services, and databases, provided that your technology stack aligns with its principles.
One of the intriguing challenges KMM presents is the sharing of view models between iOS and Android, a task that brings increased complexity but offers substantial rewards.
The Case for Sharing View Models
I embarked on my journey with Kotlin Multiplatform Mobile while working on my side project, Wolfie.app, an innovative pet companion app designed for both iOS and Android platforms. When considering the best technology for my app, I weighed the merits of Flutter, React Native, and Kotlin Multiplatform Mobile. Opting for Flutter or React Native would have entailed compromising the native user experience — an outcome I was keen to avoid. My vision was to provide iOS and Android users with a seamlessly native experience. Additionally, taking the route of full native development would likely have consumed all my available time, given that I was a one-person team handling web, backend, and mobile development. This context made Kotlin Multiplatform Mobile the ideal choice.
As an iOS developer primarily, I found the initial steps in adopting KMM a bit steeper than they might have been for an Android developer. For those more inclined towards Android development, the checklist would be relatively shorter. Here are some key components to consider:
- Kotlin: A fundamental requirement. Even if you have a background in iOS development, Kotlin is approachable and can be easily learned, even on your inaugural project.
- Swift: An obvious necessity for iOS development. If you lack a Mac, acquiring one becomes imperative.
- SwiftUI and Jetpack Compose: Declarative development paradigms that bring a new level of sophistication to UI design.
- Coroutines: This concurrency framework plays a pivotal role, as most of your logic, repositories, and database operations will rely on it. If you possess experience with reactive programming, this will serve as a valuable foundation. Notably, coroutines also find application in iOS development.
- KTOR: Your steadfast ally for any API connections.
Beyond these essentials, a plethora of libraries awaits your consideration. However, this article will specifically delve into the topic of sharing view models, exploring two noteworthy libraries and the do-it-yourself approach.
## moko-MVVM github
tl;dr
Licence: Apache 2.0
First public release was on Oct, 2019.
760 stars on GitHub
34 open issues
14 contributors
When it comes to sharing view models in Kotlin Multiplatform Mobile (KMM), the moko-MVVM library (GitHub) has garnered significant attention. Boasting an Apache 2.0 license, this library made its initial public release in October 2019. With an impressive count of 760 stars on GitHub, moko-MVVM showcases its popularity within the developer community. Backed by a team that has honed their focus on KMM for years, this library presents itself as a potent contender. However, while it holds great promise, there are some considerations to weigh before adoption.
A key advantage of moko-MVVM is its robust backing by a company committed to advancing the KMM ecosystem. Yet, an inherent trade-off exists, centered around the library’s alignment with declarative UI implementations. It’s important to note that moko-MVVM leans more towards UIKit rather than SwiftUI, potentially impacting its compatibility with the latest technologies. As of my writing, there appears to be no library compatible with Xcode 14.3.1, or at least the compatibility cannot be achieved seamlessly following the documentation. Given these circumstances, I’ve opted to defer my utilization of moko-MVVM for the time being.
Notably, moko-MVVM finds synergy with another noteworthy library from the same developer group: kswift (GitHub). This complementary library offers a wealth of Kotlin/Native API translations to Swift, enhancing cross-platform development. Additionally, it facilitates coroutine support on iOS, further streamlining your development workflow.
The promise held by moko-MVVM is undeniable, and its alignment with a company devoted to KMM adds to its credibility. However, for those heavily invested in SwiftUI and seeking seamless compatibility with the latest iOS technologies, careful consideration of its UIKit-centric approach is warranted. As the KMM landscape continues to evolve, the synergy between libraries like moko-MVVM and kswift could become increasingly compelling.
KMM-ViewModel github
tl;dr
Licence: MIT
First public release was on Dec, 2022.
353 stars on GitHub
4 open issues 2 contributors
In the realm of sharing view models across platforms with Kotlin Multiplatform Mobile (KMM), the KMM-ViewModel library (GitHub) emerges as a notable contender. With a MIT license and its initial public release in December 2022, KMM-ViewModel is a relatively new player in the field, yet it has already garnered attention with 353 stars on GitHub. While it’s in the alpha phase, its compatibility with SwiftUI right out of the box sets it apart. Moreover, its MIT licensing lends an advantageous flexibility in terms of potential maintenance efforts, particularly in the event of shifts in support.
Despite its current developmental stage, KMM-ViewModel offers a remarkable feature: seamless integration with SwiftUI, a key framework in modern iOS app development. This attribute could be particularly enticing for those who prioritize SwiftUI’s declarative and intuitive approach to user interface design. By providing a direct bridge between the KMM-ViewModel library and SwiftUI, developers can potentially achieve a harmonious synergy between platforms, promoting a consistent and native-like experience for users across iOS and Android.
It’s worth noting that KMM-ViewModel is an evolving library, still traversing its alpha phase. However, its alignment with SwiftUI underscores its potential to be a powerful tool for cross-platform development. Given its relatively recent entry into the landscape, it’s prudent to monitor its progression as it matures further.
An interesting facet of KMM-ViewModel lies in its reliance on the KMP-NativeCoroutines library (GitHub). This auxiliary library complements KMM-ViewModel by providing essential coroutine support on iOS — a critical component of modern mobile app development. Coroutines are pivotal for asynchronous programming and managing concurrency, and the inclusion of KMP-NativeCoroutines underscores the commitment to a robust development experience across platforms.
While KMM-ViewModel remains in alpha, its compatibility with SwiftUI and emphasis on coroutine support signal a potential breakthrough for seamless cross-platform view model sharing. As it continues to mature and gather feedback from the developer community, it could become a vital asset in the KMM toolkit.
Custom Solutions: Crafting Coroutines Support
In the pursuit of harmonizing coroutine support for both iOS and Android platforms within your Kotlin Multiplatform Mobile (KMM) project, custom solutions offer a versatile approach. By crafting shared functions and classes, you can bridge the gap and ensure seamless integration of asynchronous operations.
Let’s break down the custom solution step by step:
1. StateFlowClass
: Shared State Flow
// shared/src/commonMain/kotlin/me/blanik/sample/Couritines.kt
package me.blanik.sample
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
class StateFlowClass<T>(private val delegate: StateFlow<T>) : StateFlow<T> by delegate {
fun subscribe(block: (T) -> Unit) = GlobalScope.launch(Dispatchers.IO) {
delegate.collect {
block(it)
}
}
}
fun <T> StateFlow<T>.asStateFlowClass(): StateFlowClass<T> = StateFlowClass(this)
This snippet establishes a StateFlowClass
that wraps a StateFlow
and offers a subscribe
function. It employs coroutines to bridge the gap between platforms, ensuring that data collection happens on the appropriate thread.
2. Shared Dispatchers
// shared/src/commonMain/kotlin/me/blanik/sample/Dispatchers.kt
package me.blanik.sample.database
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
expect val Dispatchers.IO: CoroutineDispatcher
In this section, shared dispatchers are set up for both platforms. On Android, we have a straightforward mapping to Android’s Dispatchers.IO
. For iOS, we'll require more context:
3. Custom Coroutine Dispatcher for iOS
// shared/src/iosMain/kotlin/me/blanik/sample/Disaptchers.kt
package me.blanik.sample
import kotlinx.coroutines.*
import platform.darwin.*
actual val Dispatchers.IO: CoroutineDispatcher
get() = IODispatcher
@OptIn(InternalCoroutinesApi::class)
private object IODispatcher : CoroutineDispatcher(), Delay {
// Implementation details...
}
In this code block, we define a custom coroutine dispatcher for iOS. This dispatcher takes advantage of iOS’s dispatch mechanisms to ensure that coroutines run on the main queue and handle delays appropriately.
4. Flow Utilities
// shared/src/commonMain/kotlin/me/blanik/sample/FlowUtils.kt
package me.blanik.sample.database
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
class CFlow<T>(private val origin: Flow<T>) : Flow<T> by origin {
// Implementation details...
}
// Helper extension
internal fun <T> Flow<T>.wrap(): CFlow<T> = CFlow(this)
// Remove when Kotlin's Closeable is supported in K/N
interface Closeable {
fun close()
}
This snippet introduces CFlow
, a class that enables the consumption of Flow-based APIs from Swift/Objective-C. It offers a clean way to handle subscriptions from these languages.
5. Platform-Specific Implementations
Android:
// shared/src/androidMain/kotlin/me/blanik/sample/Dispatchers.kt
package me.blanik.sample
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
actual val Dispatchers.IO: CoroutineDispatcher
get() = Dispatchers.IO
iOS:
// shared/src/iosMain/kotlin/me/blanik/sample/Disaptchers.kt
package me.blanik.sample
import kotlinx.coroutines.*
import platform.darwin.*
actual val Dispatchers.IO: CoroutineDispatcher
get() = IODispatcher
@OptIn(InternalCoroutinesApi::class)
private object IODispatcher : CoroutineDispatcher(), Delay {
// Implementation details...
}
Here, we finalize the setup by providing platform-specific implementations for dispatchers. For Android, we simply map to Android’s built-in dispatcher. For iOS, we delve into the custom dispatcher that bridges the gap between Kotlin coroutines and iOS’s dispatch mechanism.
With these components in place, you’ve created a comprehensive solution for managing coroutines and asynchronous operations within your KMM project. This approach empowers your project to handle concurrency seamlessly on both iOS and Android platforms, promoting consistent functionality and performance.
Implementing KMM-ViewModel: A Step-by-Step Guide
Incorporating KMM-ViewModel into your Kotlin Multiplatform Mobile (KMM) project opens up new avenues for seamless view model sharing across platforms. Let’s dive into the implementation process step by step:
- Upgrade Your Project to Kotlin 1.9:
In your build.gradle.kts
file, update your Kotlin version to 1.9.0:
plugins {
id("com.android.application").version("8.1.0").apply(false)
id("com.android.library").version("8.1.0").apply(false)
kotlin("android").version("1.9.0").apply(false)
kotlin("multiplatform").version("1.9.0").apply(false)
}
2. Add Required Libraries to Your Shared Module:
Update your shared/build.gradle.kts
file to include the necessary dependencies:
sourceSets {
val multiplatformSettingsVersion = "1.0.0"
val kmmViewModelVersion = "1.0.0-ALPHA-12"
all {
languageSettings.optIn("kotlin.experimental.ExperimentalObjCName")
}
val commonMain by getting {
dependencies {
implementation("com.russhwolf:multiplatform-settings-no-arg:$multiplatformSettingsVersion")
implementation("com.russhwolf:multiplatform-settings-serialization:$multiplatformSettingsVersion")
implementation("com.russhwolf:multiplatform-settings-coroutines:$multiplatformSettingsVersion")
implementation("com.rickclephas.kmm:kmm-viewmodel-core:$kmmViewModelVersion")
}
}
}
3. Sync Your Gradle Files:
Make sure to sync your Gradle files after adding the new dependencies.
4. Update Podfile
for iOS:
In your iosApp
directory, update the Podfile
to include the required pods:
target 'iosApp' do
use_frameworks!
platform :ios, '14.1'
pod 'shared', :path => '../shared'
pod 'KMPNativeCoroutinesAsync', '1.0.0-ALPHA-13'
pod 'KMPNativeCoroutinesCombine', '1.0.0-ALPHA-13'
pod 'KMPNativeCoroutinesRxSwift', '1.0.0-ALPHA-13'
pod 'KMMViewModelSwiftUI', '1.0.0-ALPHA-12'
end
Run pod install
in your iosApp
directory.
5. Create Your First Shared View Model:
Create a new Kotlin file, SignInViewModel.kt
, in your shared module:
package me.blanik.sample
import com.rickclephas.kmm.viewmodel.*
import com.rickclephas.kmp.nativecoroutines.NativeCoroutinesState
import kotlinx.coroutines.flow.*
open class SignInViewModel: KMMViewModel() {
private val _email = MutableStateFlow(viewModelScope, "")
private val _password = MutableStateFlow(viewModelScope, "")
@NativeCoroutinesState
val email = _email.asStateFlow()
@NativeCoroutinesState
val password = _password.asStateFlow()
fun setEmail(email: String) {
_email.value = email
}
fun setPassword(password: String) {
_password.value = password
}
}
6. Add iOS Implementation:
In your iosApp
directory, create a Swift file named KMMViewModel.swift
:
import KMMViewModelCore
import shared
extension Kmm_viewmodel_coreKMMViewModel: KMMViewModel { }
Also you need to update your ContentView.swift
file:
import SwiftUI
import KMMViewModelSwiftUI
import shared
extension ContentView {
class ViewModel: shared.SignInViewModel {}
}
struct ContentView: View {
@StateViewModel var viewModel = ViewModel()
var body: some View {
VStack {
List {
Section(header: Text("Input")) {
HStack {
Text("Email")
TextField("Email here", text: Binding(get: {
viewModel.email
}, set: { value in
viewModel.setEmail(email: value)
}))
}
HStack {
Text("Password")
SecureField("Type here", text: Binding(get: {
viewModel.password
}, set: { value in
viewModel.setPassword(password: value)
}))
}
}
Section(header: Text("Output")) {
HStack {
Text("Email")
Text(viewModel.email)
}
HStack {
Text("Password")
Text(viewModel.password)
}
}
}
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
Binding kotlin couritines to SwiftUI TextField
components:
Couritines is one way communication. You need to attach them as the @State
(or @Published
) values which two-way communication and natively supported by iOS. To archive that you can use create custom binding for getter and setter.
Binding(get: {
…
}, set: { value in
viewModel.…(…)
})
6. Add Android Implementation:
In your Android module, create a Composable function for the sign-in screen:
@Composable
fun SignInScreen(
signInViewModel: SignInViewModel = SignInViewModel()
) {
val emailState by signInViewModel.email.collectAsState()
val passwordState by signInViewModel.password.collectAsState()
Column {
Text("Input")
OutlinedTextField(
label = { Text(text = "Email") },
value = emailState,
onValueChange = signInViewModel::setEmail
)
OutlinedTextField(
label = { Text(text = "Password") },
value = passwordState,
onValueChange = signInViewModel::setPassword,
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password)
)
Text("Output")
Text(text = emailState)
Text(text = passwordState)
}
}
In your Android MainActivity
, use the SignInScreen
Composable function:
class MainActivity : ComponentActivity() {
private val viewModel: SignInViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MyApplicationTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colors.background
) {
Column {
SignInScreen(viewModel)
}
}
}
}
}
}
With these steps, you’ve successfully integrated KMM-ViewModel into your project, enabling seamless view model sharing between iOS and Android platforms. This comprehensive guide should help you navigate through the implementation process and leverage the power of shared view models in your Kotlin Multiplatform Mobile development journey.
Extra — API Connection in KMM
Adding API connectivity to your Kotlin Multiplatform Mobile (KMM) project through Ktor can greatly enhance your app’s functionality. Let’s go through the steps of integrating API calls into your existing KMM project:
- Update Shared Build Gradle Configuration:
In your shared/build.gradle.kts
file, add Ktor dependencies to your common source set:
// shared/build.gradle.kts
kotlin {
// ...
sourceSets {
val ktorVersion = "2.3.3"
val commonMain by getting {
dependencies {
implementation("io.ktor:ktor-client-core:$ktorVersion")
implementation("io.ktor:ktor-client-content-negotiation:$ktorVersion")
implementation("io.ktor:ktor-serialization-kotlinx-json:$ktorVersion")
implementation("io.ktor:ktor-client-serialization:$ktorVersion")
implementation("io.ktor:ktor-client-logging:$ktorVersion")
implementation("io.ktor:ktor-client-auth:$ktorVersion")
}
}
// ...
}
}
2. Create API Models:
Define your API response models and payloads. For example, create ApiAuthSignInResponse
and ApiAuthSignInPayload
classes in your shared module:
// shared/src/commonMain/kotlin/me/blanik/sample/network/ApiAuthSignIn.kt
package me.blanik.sample.network
import kotlinx.serialization.Serializable
@Serializable
data class ApiAuthSignInResponse(
val accessToken: String,
val refreshToken: String? = null
)
@Serializable
data class ApiAuthSignInPayload(
val username: String,
val password: String,
val keepSignIn: Boolean? = null,
val device: String? = null
)
3. Create API Response Handling:
Define a class to represent API error responses and responses containing data. For example:
// shared/src/commonMain/kotlin/me/blanik/sample/network/ApiResponse.kt
package me.blanik.sample.network
import io.ktor.util.date.GMTDate
import kotlinx.serialization.Serializable
@Serializable
data class ApiError(
val statusCode: Int = 400,
val message: String = "",
val timestamp: String = GMTDate().toString(),
val errors: List<ApiErrorErrors>? = null
)
@Serializable
data class ApiErrorErrors(
val property: String = "",
val children: List<ApiErrorErrors> = emptyList(),
val constraints: Map<String, String> = emptyMap()
)
class ApiResponse<T>(success: T?, error: ApiError?) {
var success: T? = success
var failure: ApiError? = error
}
4. Create API Service:
Implement your API service using Ktor’s HTTP client. Define a class that encapsulates API endpoints and handles API requests. For example:
// shared/src/commonMain/kotlin/me/blanik/sample/network/WolfieApi.kt
package me.blanik.sample.network
import io.ktor.client.*
import io.ktor.client.call.receive
import io.ktor.client.request.*
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.contentType
import io.ktor.http.isSuccess
import io.ktor.serialization.Serializable
import kotlinx.serialization.json.Json
class WolfieApi {
private val client by lazy {
HttpClient {
install(JsonFeature) {
serializer = KotlinxSerializer(Json {
ignoreUnknownKeys = true
prettyPrint = true
})
}
}
}
suspend fun authSignIn(payload: ApiAuthSignInPayload): ApiResponse<ApiAuthSignInResponse> {
val response = client.post<HttpResponse>("$API_BASE_URL$AUTH_SIGN_IN") {
contentType(ContentType.Application.Json)
body = payload
}
return if (response.status.isSuccess()) {
ApiResponse(response.receive(), null)
} else {
ApiResponse(null, response.receive())
}
}
companion object {
private const val API_BASE_URL = "https://api-x.wolfie.app/v2"
private const val AUTH_SIGN_IN = "/auth/sign-in"
}
}
This class defines a function authSignIn
that sends a POST request to the specified API endpoint, using the provided payload. It returns an ApiResponse
containing either a successful response or an error response.
5. Use API in Shared View Model:
Integrate the API service into your shared view model. Use the WolfieApi
class to make API calls, handle responses, and update the view model's state accordingly. For instance:
// shared/src/commonMain/kotlin/me/blanik/sample/SignInViewModel.kt
import me.blanik.sample.network.ApiAuthSignInPayload
import me.blanik.sample.network.WolfieApi
enum class ApiState {
INIT,
PENDING,
SUCCESS,
FAILURE
}
open class SignInViewModel : KMMViewModel() {
private val wolfieApi = WolfieApi()
// ...
@NativeCoroutinesState
val state = _state.asStateFlow()
@NativeCoroutinesState
val errorMessage = _errorMessage.asStateFlow()
// ...
suspend fun signIn() {
_state.value = ApiState.PENDING
val response = wolfieApi.authSignIn(
ApiAuthSignInPayload(
username = _email.value,
password = _password.value
)
)
if (response.success != null) {
_state.value = ApiState.SUCCESS
} else {
_state.value = ApiState.FAILURE
_errorMessage.value = response.failure?.message ?: null
}
}
}
6. Update iOS Implementation:
In your iOS implementation (Swift), you can display the API state and error message:
// iosApp/iosApp/ContentView.swift
// ...
struct ContentView: View {
// ...
var body: some View {
VStack {
// ...
Section(header: Text("Output")) {
// ...
HStack {
Text("State")
Text(viewModel.state.rawValue)
}
HStack {
Text("Error message")
Text(viewModel.errorMessage ?? "—")
}
}
// ...
Section(header: Text("Action")) {
Button("Sign in") {
Task {
try await viewModel.signIn()
}
}
}
}
}
}
7. Update Android Implementation:
In your Android implementation, you can also display the API state and error message:
// androidApp/src/main/java/me/blanik/sample/android/MainActivity.kt
// ...
@Composable
fun SignInScreen(
signInViewModel: SignInViewModel = SignInViewModel()
) {
// ...
val stateState by signInViewModel.state.collectAsState()
val errorMessage by signInViewModel.errorMessage.collectAsState()
Column {
// ...
Text("Output")
Text(text = stateState.name)
Text(text = errorMessage ?: "—")
// ...
Button(onClick = {
GlobalScope.async(Dispatchers.Main) {
signInViewModel.signIn()
}
}) {
Text(text = "Sign in")
}
}
}
With these steps, you’ve successfully integrated API connectivity into your KMM project using Ktor. You can now make API calls from shared view models and handle responses across both iOS and Android platforms.
Summary
Kotlin Multiplatform Mobile (KMM) is rapidly advancing and has become a viable option for production applications. The community is growing, and various libraries are emerging to address different use cases, each with its own set of advantages and considerations. When adopting KMM, it’s important to carefully evaluate your project’s needs and choose the right solutions.
In this guide, we’ve explored how to share view models between iOS and Android applications using the KMM-ViewModel library. By creating shared view models and using the KMM-ViewModel library, you can ensure that your business logic is consistent across platforms while allowing native developers to focus on UI development. Keep in mind that shared module developers need to be well-versed in both platforms to effectively support and maintain the shared codebase.
You can find the code samples and implementation details covered in this guide on GitHub. If you have any further questions or need assistance, feel free to comment or contact me via LinkedIn.
KMM offers a promising approach to building cross-platform applications, and as the ecosystem continues to evolve, it’s an exciting space to be a part of.