Sharing codebase between platforms — a look at Kotlin and Compose multiplatform

William Mortensen
Boozt Tech
Published in
10 min readNov 29, 2023

Jetpack Compose is Android’s newest framework for building native UI components. Ever since Compose was released, it has revolutionised the Android industry due to how easily and fast you can build new UI components and even full applications. But why stop only at Android? What if we could use this same framework to speed up the process of creating UI for multiple platforms?

Compose multiplatform is a recently released framework from Jetbrains that intends to overcome this obstacle and allow developers to create UI from Compose for iOS, web and even desktop applications, whilst only having to write the code once. In this post we will explore the wonders of Compose multiplatform as well as Kotlin multiplatform (KMP) / Kotlin multiplatform mobile (KMM).

Before we start looking into source code and build an application, we should probably try to understand how Compose multiplatform and KMM works. Kotlin is generally the main coding language to create Android applications, but iOS applications are written in Swift and similarly web applications are written with HTML, Typescript etc. So how can our Kotlin code as well as our Compose UI components run on other platforms?

Building a multiplatform app, we have to declare a set of targets that should be supported by our application. For now these targets include Android, iOS, desktop and web. Here we can also specify exactly which type should be supported from each target, for example iosArm64 and iosX64 defines support for both iOS phones and emulators.

KMP targets

If we start out by looking at the Android/iOS interoperability, we will create some shared Kotlin code MyModule. During compilation the Kotlin code will be compiled to .jar files to be run on the JVM for Android and will be compiled to a .framework to be run for iOS.

KMM compilation

The .framework file includes the Kotlin code converted to objective-c as well as including any additional resources such as images, strings or similar. When importing the objective-c headers into a Xcode project, it will create a bridging header file, such that the objective-c code is exposed to the native iOS application.

Taking a look at the example project we will be creating later on, we have a file KoinIosModule.kt containing a function that is called initKoinIos written in Kotlin in our shared module.

fun initKoinIos(
doOnStartup: () -> Unit
): KoinApplication = initKoin(
module {
single { doOnStartup }
}
)

The file is compiled into our objective-c header. Here we can see that the class is being renamed to KoinIosModuleKt and the function is being renamed to doInitKoinIos indicated by the swift_name values.

__attribute__((objc_subclassing_restricted))
__attribute__((swift_name("KoinIosModuleKt")))
@interface SharedKoinIosModuleKt : SharedBase
@property (class, readonly) SharedKoin_coreModule *platformModule __attribute__((swift_name("platformModule")));
+ (SharedKoin_coreKoinApplication *)doInitKoinIosDoOnStartup:(void (^)(void))doOnStartup __attribute__((swift_name("doInitKoinIos(doOnStartup:)")));
@end

The objective-c header is bridged together with our Xcode project and we can simply interact with the kotlin code through the header names.

@main
struct iOSApp: App {
init() {
KoinIosModuleKt.doInitKoinIos(
doOnStartup: { NSLog("Hello from iOS") }
)
}
[...]
}

Similarly to how the iOS target is being compiled into objective-c code/headers, a web target will be compiled into web assembly and desktop applications will be compiled into jar files and being run on the JVM.

Since Compose multiplatform is built on top of Kotlin multiplatform it will behave similarly whilst keeping the same amount of functionality as KMP.

Now let’s get into implementing a multiplatform program. Jetbrains has already provided a few templates for building multiplatform apps for different targets; we will be using the Compose multiplatform template that targets iOS and Android. Building the template, we will get 3 main packages; shared, iosApp & androidApp.

Package structure

As the name suggest, the shared package is where all the shared codebase is located and inside this package we have packages for how each platform should implement the shared code. The iosApp and androidApp packages are respectively where each application for iOS and Android is created.

Taking a look at the pre-made example, a simple screen is to be shared across both platforms. The screen has a button with some text that should update once clicked, but the text should differ between platforms.

@OptIn(ExperimentalResourceApi::class)
@Composable
fun App() {
MaterialTheme {
var greetingText by remember { mutableStateOf("Hello, World!") }
var showImage by remember { mutableStateOf(false) }
Column(Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) {
Button(onClick = {
greetingText = "Hello, ${getPlatformName()}"
showImage = !showImage
}) {
Text(greetingText)
}
AnimatedVisibility(showImage) {
Image(
painterResource("compose-multiplatform.xml"),
null
)
}
}
}
}

expect fun getPlatformName(): String

In our commonMain package we have defined a composable, which is a UI element in Compose, and declared an expect function. The expect declarations are how we declare a contract between the platforms, that something needs to be implemented/provided - in this case we want a function created on both platforms that should return the platform name.

Now in the androidMain and iosMain packages we declare the platform specific implementations as an actual declaration.

Android:

actual fun getPlatformName(): String = "Android"

@Composable fun MainView() = App()

iOS:

actual fun getPlatformName(): String = "iOS"

fun MainViewController() = ComposeUIViewController { App() }

When running the code and clicking on the button we will see that each platform will display a different text, depending on how the actual function was implemented.

Now that we’ve seen a simple example of how to create a multiplatform app, let’s try to take it a step further and implement a more complicated app to see how well it performs. The app we will be creating is simple in terms of functionality; displaying a list of pokemon. The full source code is available on GitHub.

First of all we need to get the data from backend, and for this we will be using Ktor, which is multiplatform framework for building HTTP calls on mobile devices. First we need to define a client, which we will be using to perform the backend calls.

class PokemonApiImpl(
engine: HttpClientEngine
): PokemonApi {
private val client = HttpClient(engine) {
[...]
}

[...]
}

Then we simply create functions for retrieving the data we need. In our case we will be loading all pokemon using pagination as well as retrieving some specific details about each pokemon, such as their type and sprites. In case the data should be cached locally, it is also possible to create multiplatform databases either using Realm or SQDelight - the latter having an example in the source code.

class PokemonApiImpl(
engine: HttpClientEngine
): PokemonApi {
[...]

override suspend fun getPokemonList(offset: String, limit: String): HttpResponse {
return client.get("https://pokeapi.co/api/v2/pokemon/") {
url {
parameters.append("offset", offset)
parameters.append("limit", limit)
}
}
}

[...]
}

Normally for app development we want to separate the UI from the UI state, such that we can recreate the UI views without having to reload the data associated with it. For that we will be using viewmodels, which is a lifecycle aware state holder for the views. For Android development, viewmodels are tightly coupled together with the Android viewModelScope, but since that is not a possibility for iOS we will therefore need to create separate implementations for each platform.

We start by defining the contract between the platforms by creating an expect class that has a coroutine scope. The coroutine scope is what we will be using to retrieve data asynchronously, to create a smoother experience for the user.

expect abstract class BaseViewModel() {
val scope: CoroutineScope
protected open fun onCleared()
}

For Android we will create the implementation, simply by inheriting the existing viewmodel implementation from Android.

actual abstract class BaseViewModel: ViewModel() {
actual val scope = viewModelScope
actual override fun onCleared() {
super.onCleared()
}
}

For iOS we do not have access to the Android viewmodel, and therefore need to create our own coroutine scope; MainScope.

actual abstract class BaseViewModel {
actual val scope = MainScope()

protected actual open fun onCleared() {}

fun clear() {
onCleared()
scope.cancel()
}
}

Now that we have provided a coroutine scope for each platform, we can start building shared viewmodels. Since we are using pagination in the endpoint, we will need to create a paging object. For now pagination is not fully supported in Kotlin multiplatform, so we will be using CashApp’s implementation that also supports iOS pagination.

class PokemonViewModel(
pagingSource: PokemonPagingSource
): BaseViewModel() {
val pokemon: Flow<PagingData<Pokemon>> = Pager(
PagingConfig(
pageSize = PAGE_SIZE,
prefetchDistance = PREFETCH_DISTANCE,
initialLoadSize = INITIAL_LOAD_SIZE,
enablePlaceholders = true
)
) {
pagingSource
}.flow.cachedIn(scope = scope)
}

Now that we have a view state holder that receives data from a remote data source, we would like to display it nicely for the user. As mentioned earlier, we will be using Jetpack Compose to create the shared UI for our apps.

The first thing we do is create our main composable that should be called from each app. This composable will take a PokemonViewModel so that we can receive the data from the state holder and reflect it in our view. We then create a vertical list where each element in the data we receive will have its own UI component.

@Composable
fun App(
viewModel: PokemonViewModel
) {
MaterialTheme {
val pokemonList = viewModel.pokemon.collectAsLazyPagingItems()
PokemonList(pokemonList = pokemonList)
}
}

@Composable
internal fun PokemonList(
pokemonList: LazyPagingItems<Pokemon>
) {
LazyColumn (
verticalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = PaddingValues(all = 24.dp)
) {
items(pokemonList.itemCount) { index ->
val pokemon = pokemonList[index]
pokemon?.let {
PokemonListItem(
pokemon = it,
index = index + 1
)
}
}
}
}

Each item in the list will contain an expandable card. In its collapsed state it will contain the index number as well as the name of the pokemon. Clicking the card will expand it to also display the type and sprites of the pokemon. For loading the images/sprites asynchronously, we will be using the multiplatform library called Kamel that is built on top of Ktor.

@Composable
private fun Images(
pokemon: Pokemon
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
KamelImage(
resource = asyncPainterResource(data = pokemon.imageFrontUrl ?: ""),
contentDescription = "Image of ${pokemon.name}",
contentScale = ContentScale.FillWidth,
modifier = Modifier.weight(1f),
onLoading = {
LinearProgressIndicator(progress = it)
}
)
KamelImage(
resource = asyncPainterResource(data = pokemon.shinyImageFrontUrl ?: ""),
contentDescription = "Image of ${pokemon.name}",
contentScale = ContentScale.FillWidth,
modifier = Modifier.weight(1f),
onLoading = {
LinearProgressIndicator(progress = it)
}
)
}
}

Now to glue everything together, we want to be able to provide all of these classes with minimal setup from both applications. For that we will be using dependency injection, which is a way to easily provide class dependencies synonymously in a lifecycle-aware manner. In this example we will be using the Koin dependency injection framework to provide the class dependencies.

In our shared Koin module we provide the core class dependencies that can be created for all platforms as well as define an expectation for each platform to provide their own module.

fun initKoin(appModule: Module): KoinApplication {
val koinApplication = startKoin {
modules(
appModule,
platformModule,
coreModule
)
}
[...]
return koinApplication
}

private val coreModule = module {
single { DatabaseHelper(get(),Dispatchers.Default) }
single<PokemonApi> { PokemonApiImpl(get()) }
single { PokemonPagingSource(get()) }
}

expect val platformModule: Module

Then in our Android/iOS modules we will provide the platform specific implementations, such as the ktor client and the database driver.

actual val platformModule: Module = module {
single<SqlDriver> { AndroidSqliteDriver(schema = ColleDB.Schema,context = get(),name = "ColleDB") }
single { OkHttp.create() }
}

For the iOS implementation we also need to define a way to initialise the koin injections from our Swift application.

kotlfun initKoinIos(
doOnStartup: () -> Unit
): KoinApplication = initKoin(
module {
single { doOnStartup }
}
)

actual val platformModule = module {
single<SqlDriver> { NativeSqliteDriver(schema = ColleDB.Schema, name = "ColleDB") }
single { Darwin.create() }
single { PokemonViewModel(get()) }
}

We have now finished building an application solely written in Kotlin using KMM and Compose, that supports both Android and iOS! And now time to see the end result.

Android:

https://github.com/ColleDK/cmp/assets/55872600/a33abd23-6ed7-4da8-85ba-ebb375e3ca16

iOS:

https://github.com/ColleDK/cmp/assets/55872600/d9021519-7ea8-4f4c-8aec-d923c453be00

Before I started working on this post and example app, I’ve had no prior experience with building anything with Kotlin multiplatform, and even using the already given template for building a multiplatform app, I must say that my experience has been very mixed. So let’s get to the nitty gritty of why KMM is both a marvel and a curse to use.

First things first, being able to target a larger audience without the need of learning multiple coding languages, only needing knowledge and experience in a single one, is amazing and will probably be the preferred solution for smaller businesses and startups. It can easily speed up development process and reduce the amount of bugs and inconsistencies present in the applications.

However it does not come withouts its drawbacks. Both Kotlin and Compose multiplatform has only very recently been released in a stable version and there is still a long way to go until we will see its full potential. Currently, a lot of features and highly used frameworks are not supported in KMP without the use of 3rd party solutions from either Touchlab or CashApp. Being reliant on 3rd party workarounds will not be an ideal solution and should hopefully be supported natively by Kotlin in the future. Looking at the memory usage compared to natively building UI in Swift, it seems to be fairly performant in most cases, but still worse than the most optimal solutions that can be built in Swift.

Debugging is another troublesome aspect of KMP currently. Sometimes an application can work fine on one platform, but not on the other and it will be near impossible to figure out where the issue is. Especially when debugging on iOS through Xcode, out-of-the-box you will only be able to see the objective-c headers and stacktraces will point to said headers and give little to no meaningful information.

Coming to terms with the issues arisen, KMP should still be acknowledged for its intent; to align common logic across platforms. Even just sharing a small part of some code, if that is remote data models, some business logic or even just creating contracts by using interfaces, it seems to be very advantageous to not sharing anything.

--

--