article banner

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.

class WorkoutViewModel( private val timer: TimerService, private val speaker: SpeakerService, private val loadTrainingUseCase: LoadTrainingUseCase // ... ) : ViewModel() { private var state: WorkoutState = ... val title = MutableStateFlow("") val imgUrl = MutableStateFlow("") val progress = MutableStateFlow(0) val timerText = MutableStateFlow("") init { loadTraining() } fun onNext() { // ... } // ... }

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.

// commonMain expect abstract class ViewModel() { open fun onCleared() } // androidMain abstract class ViewModel : androidx.lifecycle.ViewModel() { val scope = viewModelScope override fun onCleared() { super.onCleared() } } // iOS source sets actual abstract class ViewModel actual constructor() { actual open fun onCleared() { } }

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.

// commonMain expect abstract class ViewModel() { val scope: CoroutineScope open fun onCleared() } // androidMain abstract class ViewModel : androidx.lifecycle.ViewModel() { val scope = viewModelScope override fun onCleared() { super.onCleared() } } // iOS source sets actual abstract class ViewModel actual constructor() { actual val scope: CoroutineScope = MainScope() actual open fun onCleared() { scope.cancel() } }

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.

// commonMain interface SpeakerService { fun speak(text: String) } // Android application class AndroidSpeaker(context: Context) : SpeakerService { private var tts = TextToSpeech(context, null) override fun speak(text: String) { tts.speak(text, TextToSpeech.QUEUE_FLUSH, null, null) } }
// 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:

val title: String by viewModel.title.collectAsState() val imgUrl: String by viewModel.imgUrl.collectAsState() val progress: Int by viewModel.progress.collectAsState() val timerText: String by viewModel.timerText.collectAsState()

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.

4:

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.