Launching on iOS & Android with Kotlin & Compose Multiplatform

Wonseok Kim
Preat
Published in
8 min readNov 6, 2023

In this post, I’d like to share our journey to the official launch of Preat’s Android and iOS services, using Kotlin and Compose Multiplatform. 🙇‍♂️

🤖 The Original Android Service

Although it wasn’t the official launch, Preat’s service initially debuted on the Google Play Store with an Android version (released on May 31, 2023). After the release of the Android version, the most common question we encountered was:

“So, when is the iOS version coming out?”

We didn’t have any native iOS development talent on our team, and recruiting new iOS developers was not feasible due to time and financial constraints. As a result, I, with my background solely in Android development, took on the challenge of developing for iOS as well.

🤔 Why Kotlin Multiplatform, Not Flutter?

I had my concerns. To implement Preat’s iOS product within a relatively short time frame, I believed that using Kotlin for both business logic and UI (though iOS was still in alpha) with Compose, which I was more familiar with from Android development, would be more manageable than learning iOS native or Flutter development from scratch.

Moreover, with the release of Kotlin 1.9.20, Kotlin Multiplatform(KMP) has reached a stable state, and significant enhancements have been made to Kotlin/Native. These advancements have bolstered my confidence in Kotlin Multiplatform as a robust solution for our cross-platform development needs.

What’s new in Kotlin 1.9.20 (Kotlin by JetBrains)

Preparing for the Transition to Multiplatform

To migrate our existing Android project to Kotlin Multiplatform, we had to switch our key Android libraries to Multiplatform libraries. The process of finding the right Kotlin Multiplatform(KMP) libraries to replace our Android-specific ones was crucial.

In this endeavor, we heavily relied on the curated list of resources found at the kmp-awesome GitHub repository.

terrakok/kmp-awesome github repository

This repository proved to be an invaluable resource for identifying libraries that are compatible with Kotlin Multiplatform and are well-suited for our project needs.

The areas that needed migration included:

  • Network Storage (RDB)
  • Storage (key-value)
  • Navigation
  • Dependency Injection
  • Logging
  • Image Loading
  • Etc.

Network

Familiar to any Android developer, Retrofit cannot be used in a Multiplatform environment. Okhttp, although now written in Kotlin, is still JVM-dependent. Thus, we transitioned to Ktor.

@OptIn(kotlin.experimental.ExperimentalNativeApi::class)
internal fun createHttpClient(): HttpClient {
return createPlatformHttpClient().config {
defaultRequest {
url {
protocol = URLProtocol.HTTPS
host = if (isDebug) PREAT_TEST_HOST else PREAT_SERVER_HOST
port = DEFAULT_PORT
}
header(
HttpHeaders.ContentType,
ContentType.Application.Json
)
}

install(ContentNegotiation) {
json(
Json {
prettyPrint = true
isLenient = true
encodeDefaults = true
}
)
}

ContentEncoding {
gzip()
deflate()
}

install(Logging) {
level = LogLevel.ALL
logger = object : Logger {
override fun log(message: String) {
Napier.i(message)
}
}
}

install(HttpTimeout) {
requestTimeoutMillis = 50_000
}
}
}

Storage (RDB)

In our original Android project, we utilized Room DB for storing and managing local database data. However, migrating to Kotlin Multiplatform necessitated a shift to SQLDelight for local data management. During the application of SQLDelight, we encountered some issues related to SQLite on devices running Android API Level 29 and below.

The following is a simplified version of the upsert query used to store restaurant data in the local database. The INSERT INTO ~ ON CONFLICT DO UPDATE syntax is commonly used in PostgreSQL, and to utilize it, we had to add the following dialect configuration in our build.gradle.kts (with certain details obscured for security reasons):

sqldelight {
databases {
create("PreatDatabase") {
packageName.set("com.***.***.database")
dialect(libs.sqldelight.sqlite.dialect)
schemaOutputDirectory.set(file("***"))
verifyMigrations.set(true)
}
}
}

However, due to the varying versions of SQLite embedded in different Android OS versions, this syntax was not usable on devices with API Level 29 and below, leading to app crashes. To align Android with the latest SQLite releases, we integrated the requery/sqlite-android Support Library. We then implemented an actual solution in the SQLDelight Driver Factory Android source set as follows:

actual class DriverFactory(private val context: Context) {
actual fun createDriver(): SqlDriver {
return AndroidSqliteDriver(
schema = PreatDatabase.Schema,
context = context,
name = DB_NAME,
factory = RequerySQLiteOpenHelperFactory()
)
}
}

This approach resolved the issues by ensuring that even on older Android versions, the app could support the latest SQLite features.

Storage (key-value)

Instead of SharedPreferences API or Preferences DataStore, we used the Multiplatform-Settings library.

// expect 
expect class SettingsFactory {
fun createSettings(): FlowSettings
}

// actual (android)
actual class SettingsFactory(
private val context: Context
) {
actual fun createSettings(): FlowSettings =
DataStoreSettings(context.dataStore)

private val Context.dataStore: DataStore<Preferences>
by preferencesDataStore(name = "settings")
}

// actual (ios)
actual class SettingsFactory {
actual fun createSettings(): FlowSettings {
return NSUserDefaultsSettings(NSUserDefaults.standardUserDefaults).toFlowSettings()
}
}

Navigation & Architecture

We moved from Jetpack Navigation to Decompose and MVIKotlin libraries for stack navigation and architecture.

class SearchRootComponent internal constructor(
componentContext: ComponentContext,
private val panel: (ComponentContext, (PanelComponent.Output) -> Unit) -> PanelComponent,
private val result: (ComponentContext, keyword: String, id: Long?, (ResultComponent.Output) -> Unit) -> ResultComponent,
private val navigateBackToMain: () -> Unit
) : ComponentContext by componentContext {

constructor(
componentContext: ComponentContext,
storeFactory: StoreFactory,
navigateBackToMain: () -> Unit
) : this(
componentContext = componentContext,
panel = { childContext, output ->
PanelComponent(
componentContext = childContext,
storeFactory = storeFactory,
output = output
)
},
result = { childContext, keyword, id, output ->
ResultComponent(
componentContext = childContext,
storeFactory = storeFactory,
keyword = keyword,
id = id,
output = output
)
},
navigateBackToMain = navigateBackToMain,
)

private val navigation = StackNavigation<Configuration>()

private val stack =
childStack(
source = navigation,
initialConfiguration = Configuration.Panel,
handleBackButton = true,
childFactory = ::createChild
)

val childStack: Value<ChildStack<*, Child>> = stack

private fun createChild(
configuration: Configuration,
componentContext: ComponentContext
): Child =
when (configuration) {
Configuration.Panel -> Child.Panel(
panel(
componentContext,
::onPanelOutput
)
)

is Configuration.Result -> Child.Result(
result(
componentContext,
configuration.keyword,
configuration.id,
::onResultOutput
)
)
}

private fun onPanelOutput(output: PanelComponent.Output): Unit =
when (output) {
PanelComponent.Output.NavigateBack -> navigateBackToMain()

is PanelComponent.Output.NavigateToResult -> navigation.push(
Configuration.Result(
output.keyword,
output.id
)
)
}

private fun onResultOutput(output: ResultComponent.Output): Unit =
when (output) {
ResultComponent.Output.NavigateBack -> navigation.pop()
}

private sealed class Configuration : Parcelable {
@Parcelize
data object Panel : Configuration()
@Parcelize
data class Result(val keyword: String, val id: Long?) : Configuration()
}

sealed class Child {
data class Panel(val component: PanelComponent) : Child()
data class Result(val component: ResultComponent) : Child()
}
}

For those interested in these libraries, I’ve included a link to a JetBrains podcast and related resources.

MVIKotlin and Decompose with Arkadii Ivanov (Kotlin by JetBrains)

Dependency Injection (DI)

While Dagger Hilt was applied in the original Android version, we had to choose between kotlin-inject and Koin for Multiplatform.

We opted for Koin considering the development cycle and potential runtime issues, with a plan to migrate to kotlin-inject in the long term.

Logging

We used Napier for effective logging, providing an API similar to Android’s Timber.

Image Loading

Initially, we used Kamel, but due to high-resolution image loading issues, we migrated to Compose-imageloader. We’re also addressing memory issues with the latest Kotlin memory allocator and garbage collector improvements.

ETC..

For mapping functionalities, we adopted the expect/actual pattern to integrate the Naver Map SDK, which offers similar capabilities to Google's Map Compose but is specifically designed for the South Korean market.

(Naver, while not as globally recognized in terms of market capitalization compared to giants like Google, holds a comparable stature within South Korea for its extensive range of services and its dominance in the local internet industry.)

In the realm of social integration, we addressed the challenge of incorporating pure Swift modules in the KakaoSDK for iOS.

(Kakao, similar to Naver, may not match the market capitalization of a behemoth like Facebook but is nonetheless a powerhouse in South Korea, offering a broad spectrum of services from messaging to financial platforms.)

By integrating these SDKs, Preat is tailored to deliver a user experience that aligns with the cultural and technological preferences of our South Korean audience. At the same time, we’ve built the service with the capacity to expand and adapt to the needs of international markets, should the opportunity arise.

// expect

@OptIn(ExperimentalMaterialApi::class)
@Composable
expect fun SearchNaverMap(
modifier: Modifier,
state: ResultStore.State,
bottomSheetState: ModalBottomSheetState,
onMarkerClick: (ResultStore.State.SearchMarker) -> Unit
)

// actual (android)
@OptIn(ExperimentalNaverMapApi::class, ExperimentalMaterialApi::class)
@Composable
actual fun SearchNaverMap(
modifier: Modifier,
state: ResultStore.State,
bottomSheetState: ModalBottomSheetState,
onMarkerClick: (ResultStore.State.SearchMarker) -> Unit
) {
val mapUiSettings by remember {
mutableStateOf(
MapUiSettings(
isLocationButtonEnabled = false,
isZoomControlEnabled = false
)
)
}
val cameraPositionState = rememberCameraPositionState()
val markerImage =
remember { OverlayImage.fromResource(SharedRes.images.ic_marker.drawableResId) }
val idleMarkerImage =
remember { OverlayImage.fromResource(SharedRes.images.ic_marker_idle.drawableResId) }

LaunchedEffect(key1 = state.cameraPosition) {
// some logic
}

LaunchedEffect(key1 = Unit) {
// some logic
}

NaverMap(
modifier = modifier,
cameraPositionState = cameraPositionState,
uiSettings = mapUiSettings
) {
for (marker in state.markers) {
Marker(
state = MarkerState(
position = LatLng(
marker.latitude,
marker.longitude
)
),
// some logic
onClick = {
onMarkerClick(marker)
true
}
)
}
}
}

// actual (ios)
@OptIn(ExperimentalForeignApi::class, ExperimentalMaterialApi::class)
@Composable
actual fun SearchNaverMap(
modifier: Modifier,
state: ResultStore.State,
bottomSheetState: ModalBottomSheetState,
onMarkerClick: (ResultStore.State.SearchMarker) -> Unit
) {
val naverMapView = remember { NMFMapView() }
val markers = remember { mutableMapOf<Long, NMFMarker>() }
val markerImage = remember {
SharedRes.images.ic_marker.toUIImage()?.let {
NMFOverlayImage.overlayImageWithImage(it)
}
}
val idleMarkerImage = remember {
SharedRes.images.ic_marker_idle.toUIImage()?.let {
NMFOverlayImage.overlayImageWithImage(it)
}
}

LaunchedEffect(key1 = state.markers, key2 = state.clickedMarker) {
// some logic
}

LaunchedEffect(key1 = Unit) {
// some logic
}

UIKitView(
modifier = modifier,
factory = {
naverMapView
},
update = {
// some logic
}
)
}

@ExperimentalForeignApi
private fun createMarker(
iconImage: NMFOverlayImage?,
size: Double,
zIndex: Long,
marker: ResultStore.State.SearchMarker,
onMarkerClick: (ResultStore.State.SearchMarker) -> Unit
): NMFMarker {
return when (iconImage) {
null -> NMFMarker.markerWithPosition(
position = NMGLatLng.latLngWithLat(
lat = marker.latitude,
lng = marker.longitude
)
)

else -> NMFMarker.markerWithPosition(
position = NMGLatLng.latLngWithLat(
lat = marker.latitude,
lng = marker.longitude
),
iconImage = iconImage
)
}.apply {
width = size
height = size
setZIndex(zIndex)
setTouchHandler { _ ->
onMarkerClick(marker)
true // 오버레이 터치 이벤트 소비 (지도로 전파되지 않음)
}
}
}

In Conclusion

Preat, which is a portmanteau of “Pre” and “Eat” indicating the service’s aim to recommend restaurants before you dine, started in South Korea with a mission to tailor restaurant recommendations to each user’s unique taste. We recognized the challenges people faced in distinguishing genuine reviews and finding restaurants that truly match their personal preferences, despite high ratings.

Preat’s service stands out by not relying solely on reviews or ratings. Instead, it offers up to eight restaurant recommendations at a time, based on the user’s location, reducing the paradox of choice and allowing faster access to a taste-matched dining experience. The service emphasizes personal taste by showing only the user’s own ratings and a predicted rating for them, not others’ ratings.

We are proud to say that since its launch, Preat has consistently recorded meaningful user numbers and we anticipate further growth as we improve service stability and enhance features.

Additionally, we are planning an English service update specifically targeting international students residing in South Korea.

If you have technical advice or interest, I’m always open to a coffee chat! ☕️

--

--