Implementing Multiplatform Kotlin Mobile
This is a chapter from the book Advanced Kotlin. You can find it on LeanPub or Amazon.
In the previous part, we discussed the essential concepts of Kotlin multiplatform as well as expected and actual elements.
Kotlin multiplatform mobile (KMM) capabilities are often used to implement shared parts between Android and iOS. The idea is simple: we define a shared module for Android, and one for iOS; then, based on these modules, we generate Android and iOS libraries. In these libraries, thanks to numerous libraries, we can use network clients, databases, serialization, and much more, without writing any platform-specific code ourselves.
Let's see a concrete example of this in action. Let's say you implement an application for morning fitness routines on Android and iOS. You decide to utilize multiplatform Kotlin capabilities, so you define a shared module. Nowadays, it is a common practice to extract application business logic into classes known as View Models, which include observable properties. These properties are observed by views that change when these properties change. You decide to define your WorkoutViewModel
in the shared module. As observable properties, we can use MutableStateFlow
from Kotlin Coroutines4.
Let's discuss some important aspects of the development of such an application as this will teach us some important lessons about KMP development in general.
ViewModel class
Let's start with the ViewModel
class. Android requires that classes representing view models implement ViewModel
from androidx.lifecycle
. iOS does not have such a requirement. To satisfy both platforms, we need to specify the expected class ViewModel
, whose actual class on Android should extend androidx.lifecycle.ViewModel
. On iOS, these actual classes can be empty.
We might add some other capabilities to our ViewModel
class. For instance, we could use it to define coroutine scope. On Android, we use viewModelScope
. On iOS, we need to construct the scope ourselves.
Platform-specific classes
Now consider the parameters of the WorkoutViewModel
constructor. Some of them can be implemented in our shared module using common libraries. LoadTrainingUseCase
is a good example that only needs a network client. Some other dependencies need to be implemented on each platform. SpeakerService
is a good example because I don’t know of a library that would be able to use platform-specific TTS (Text-to-Speech) classes from a shared module.
We could define SpeakerService
as an expected class, but it would be easier just to make it an interface in commonMain
and inject different classes that implement this interface in platform source sets.
// Swift
class iOSSpeaker: Speaker {
private let synthesizer = AVSpeechSynthesizer()
func speak(text: String) {
synthesizer.stopSpeaking(at: .word)
let utterance = AVSpeechUtterance(string: text)
synthesizer.speak(utterance)
}
}
The biggest problem with classes like this is finding a common interface for both platforms. Even in this example, you can see some inconsistencies between Android TextToSpeech
and iOS AVSpeechSynthesizer
. On Android, we need to provide Context
. On iOS, we need to make sure the previous speech request is stopped. What if one of these classes representing a speech synthesizer needs to be initialized before its first use? We would need to add an initialize
method and implement it for both platforms, even though one of them will be empty. Common implementation aggregates specificities from all platforms, which can make common classes complicated.
Observing properties
Android has great support for observing StateFlow
. On XML, we can bind values; on Jetpack Compose, we can simply collect these properties as a state:
This is not so easy in Swift. There are already a few solutions that could help us, but none of them seem to be standard; hopefully, this will change over time. One solution is using a library like MOKO that helps you turn your view model into an observed object, but this approach needs some setup and modifications in your view model.
// iOS Swift
struct LoginScreen: View {
@ObservedObject
var viewModel: WorkoutViewModel = WorkoutViewModel()
// ...
}
You can also turn StateFlow
into an object that can be observed with callback functions. There are also libraries for that, or we can just define a simple wrapper class that will let you collect StateFlow
in Swift.
// Swift
viewModel.title.collect(
onNext: { value in
// ...
},
onCompletion: { error in
// ...
}
)
I guess there might be more options in the future, but for now this seems to be the best approach to multiplatform Kotlin projects.
Summary
In Kotlin, we can implement code for multiple platforms, which gives us amazing possibilities for code reuse. To support common code implementation, Kotlin offers a multiplatform stdlib, and numerous libraries already support network calls, serialization, dependency injection, database usage, and much more. As library creators, we can implement libraries for multiple platforms at the same time with little additional effort. As mobile developers, we can implement the logic for Android and iOS only once and use it on both platforms. Code can also be reused between different platforms according to our needs. I hope you can see how powerful Kotlin multiplatform is.
It is a popular practice to hide this property behind StateFlow
to limit its methods' visibility, but I decided not to do this to simplify this example.