DEV Community

Cover image for KMP Native UI networking with Ktro
Saad Alkentar
Saad Alkentar

Posted on

KMP Native UI networking with Ktro

We have our dependency injection ready and have tested our dummy view model on both Android and iOS. It is time to get some data...

We need to pay attention to the project structure at this stage. Clean architecture has 3 layers,

  • data: contains data sources and repository implementation
  • domain: contains the abstract logic of the application
  • presentation or UI, contains the view models, actions, states, and screens

In KMP, the screens themselves are in the Native Projects, while the rest of the data, domain, and presentation are in the shared codebase.

What are the app features?

For our simple project, we will add two features.

  • Products feature: where we can list products and show their details.
  • Auth feature: Where we can fill in the login form and navigate to the home screen.

Where to get it from?

I found a website called dummy json that provides both Auth and Products endpoints. In this tutorial, we will only use the Products api. The auth will be used in another tutorial.

Setup Ktor client library

Let's start by updating the toml file. According to ktor docs we should add those libs.

[versions]
ktor = "3.4.1"

[libraries]
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" }
ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" }

# ktor extensions
ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" }
ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
ktor-client-auth = { module = "io.ktor:ktor-client-auth", version.ref = "ktor" }

[bundles]
ktor = [
    "ktor-client-core",
    "ktor-client-content-negotiation",
    "ktor-client-auth",
    "ktor-client-logging",
    "ktor-serialization-kotlinx-json",
]
Enter fullscreen mode Exit fullscreen mode

The next step is to update build.gradle

shared/build.gradle.kts


plugins {
    ...
    alias(libs.plugins.kotlinSerialization) // for model class serialization
}

kotlin {
    ...
    sourceSets {
        commonMain.dependencies {
            ...
            implementation(libs.koin.ktor)
            implementation(libs.bundles.ktor)
        }

        androidMain.dependencies {
            implementation(libs.ktor.client.okhttp)
        }

        nativeMain.dependencies {
            implementation(libs.ktor.client.darwin)
        }
        ...
    }
}
Enter fullscreen mode Exit fullscreen mode

Set up Ktor with Koin

Let's start by creating the Ktor client and setting up the Logger, JSON serializer, content type, and timeout.

shared/commonMain/core/data/HttpClientFactory.kts

import io.ktor.client.HttpClient
import io.ktor.client.engine.HttpClientEngine
import io.ktor.client.plugins.HttpTimeout
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.plugins.defaultRequest
import io.ktor.client.plugins.logging.LogLevel
import io.ktor.client.plugins.logging.Logger
import io.ktor.client.plugins.logging.Logging
import io.ktor.http.ContentType
import io.ktor.http.contentType
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json

object HttpClientFactory {

    fun create(engine: HttpClientEngine): HttpClient {
        return HttpClient(engine) {
            install(ContentNegotiation) {
                json(
                    json = Json {
                        ignoreUnknownKeys = true
                    }
                )
            }
            install(HttpTimeout) {
                socketTimeoutMillis = 20_000L
                requestTimeoutMillis = 20_000L
            }
            install(Logging) {
                logger = object : Logger {
                    override fun log(message: String) {
                        println(message)
                    }
                }
                level = LogLevel.ALL
            }
            defaultRequest {
                contentType(ContentType.Application.Json)
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Then, we create the client factory singleton to be used in data sources later

shared/commonMain/di/KoinModules.kt

val sharedModule = module {
    single { HttpClientFactory.create(get()) }
    ...
}

expect val platformModule: Module
Enter fullscreen mode Exit fullscreen mode

Now for the module-level setup

shared/androidMain/di/PlatformModule.android.kt

import io.ktor.client.engine.HttpClientEngine
import io.ktor.client.engine.okhttp.OkHttp
import org.koin.dsl.module

actual val platformModule = module {
    single<HttpClientEngine> { OkHttp.create() }
}
Enter fullscreen mode Exit fullscreen mode

shared/iosMain/di/PlatformModule.ios.kt

import io.ktor.client.engine.HttpClientEngine
import io.ktor.client.engine.darwin.Darwin
import org.koin.dsl.module

actual val platformModule = module {
    single<HttpClientEngine> { Darwin.create() }
}
Enter fullscreen mode Exit fullscreen mode

Products Clean Architecture shared module

Create the product structure in the shared modules

-shared
--commonMein

---product

----data
-----ProductDataSource.kt
-----ProductRepositoryImpl.kt

----domain
-----models
-----ProductRepository.kt

----ui
-----ProductActions.kt
-----ProductState.kt
-----ProductViewModel.kt
Enter fullscreen mode Exit fullscreen mode

JSON objects to Kotlin classes

Where to get the products from? For this tutorial, we can get it from dummy json api.
As always in clean architecture, we start from the domain layer.
We will use JSON To Kotlin Class to convert JSON to Kotlin classes

JSON to Kotlin extension

To parse JSON to Kotlin models, we start by left-clicking the models folder. With one click, we get the Product class (I've renamed every model to have model postfix. Make sure to annotate the classes as @Serializable
I grow not to trust the backend devs (even if it's me!). So I usually edit the advanced setting to make all fields nullable and to annotate them with Kotlin serialization

Advanced parser settings

shared/commonMain/product/domain/models/ProductModel.kt

import kotlinx.serialization.Serializable

@Serializable
data class ProductModel(
    @SerialName("availabilityStatus")
    val availabilityStatus: String? = null,
    @SerialName("brand")
    val brand: String? = null,
    @SerialName("category")
    val category: String? = null,
    @SerialName("description")
    val description: String? = null,
    @SerialName("dimensions")
    val dimensions: DimensionsModel? = null,
    @SerialName("discountPercentage")
    val discountPercentage: Double? = null,
    @SerialName("id")
    val id: Int? = null,
    @SerialName("images")
    val images: List<String>? = null,
    @SerialName("meta")
    val meta: MetaModel? = null,
    @SerialName("minimumOrderQuantity")
    val minimumOrderQuantity: Int? = null,
    @SerialName("price")
    val price: Double? = null,
    @SerialName("rating")
    val rating: Double? = null,
    @SerialName("returnPolicy")
    val returnPolicy: String? = null,
    @SerialName("reviews")
    val reviews: List<ReviewModel>? = null,
    @SerialName("shippingInformation")
    val shippingInformation: String? = null,
    @SerialName("sku")
    val sku: String? = null,
    @SerialName("stock")
    val stock: Int? = null,
    @SerialName("tags")
    val tags: List<String>? = null,
    @SerialName("thumbnail")
    val thumbnail: String? = null,
    @SerialName("title")
    val title: String? = null,
    @SerialName("warrantyInformation")
    val warrantyInformation: String? = null,
    @SerialName("weight")
    val weight: Int? = null
)
Enter fullscreen mode Exit fullscreen mode

Now for the product response model

shared/commonMain/product/domain/models/ProductResponse.kt

import kotlinx.serialization.Serializable

@Serializable
data class ProductResponse(
    @SerialName("limit")
    val limit: Int? = null,
    @SerialName("products")
    val products: List<ProductModel>? = null,
    @SerialName("skip")
    val skip: Int? = null,
    @SerialName("total")
    val total: Int? = null
)
Enter fullscreen mode Exit fullscreen mode

Products repository

shared/commonMain/product/domain/ProductRepository.kt

interface ProductRepository {

    suspend fun getProductsList() : List<ProductModel>
    suspend fun getProductsItem(id: Int) : ProductModel

}
Enter fullscreen mode Exit fullscreen mode

Data layer, Products DataSource

It is time to start with the data layer.

shared/commonMain/product/data/ProductDataSource.kt

import io.ktor.client.HttpClient
import io.ktor.client.request.get
import io.ktor.client.request.parameter
import io.ktor.client.statement.HttpResponse

class ProductDataSource(
    private val httpClient: HttpClient
) {
    suspend fun productList(
        limit: Int = 10,
        skip: Int = 0,
        sortBy: String = "title",
        order: String = "asc",
    ): HttpResponse {
        return httpClient.get(
            urlString = "$BASE_URL/products/"
        ) {
            parameter("limit", limit)
            parameter("skip", skip)
            parameter("sortBy", sortBy)
            parameter("order", order)
        }
    }

    suspend fun productGet(
        id: Int,
    ): HttpResponse {
        return httpClient.get(
            urlString = "$BASE_URL/products/$id"
        )
    }
}
Enter fullscreen mode Exit fullscreen mode

It is time to implement the products repository in the data layer

shared/commonMain/product/data/ProductRepositoryImpl.kt

class ProductRepositoryImpl(
    private val dataSource: ProductDataSource
) : ProductRepository {

    override suspend fun getProductsList(): List<ProductModel> {
        TODO("Not yet implemented")
    }

    override suspend fun getProductsItem(id: Int): ProductModel {
        TODO("Not yet implemented")
    }

}
Enter fullscreen mode Exit fullscreen mode

We need a way to map the HttpResponse to the desired response. And how to inform the UI of any possible errors? I found a great solution from Philipp Lackner videos and tweaked it a little here

Let's create a helper model to allow that. I'll call it DataState

shared/commonMain/core/domain/DataState.kt

// the basic class
sealed interface DataState<out D, out E: DataError> {
    data class Success<out D>(val data: D): DataState<D, Nothing>
    data class Error<out E: DataError>(val error: E, val message: String? = null):
        DataState<Nothing, E>
}

// class extension to map response
inline fun <T, E: DataError, R> DataState<T, E>.map(map: (T) -> R): DataState<R, E> {
    return when(this) {
        is DataState.Error -> DataState.Error(error = error, message = message)
        is DataState.Success -> DataState.Success(map(data))
    }
}

// another extension for empty response
fun <T, E: DataError> DataState<T, E>.asEmptyDataResult(): EmptyResult<E> {
    return map {  }
}

// an extension to map success state
inline fun <T, E: DataError> DataState<T, E>.onSuccess(action: (T) -> Unit): DataState<T, E> {
    return when(this) {
        is DataState.Error -> this
        is DataState.Success -> {
            action(data)
            this
        }
    }
}

// an extension to map error state
inline fun <T, E: DataError> DataState<T, E>.onError(action: (E, String?) -> Unit): DataState<T, E> {
    return when(this) {
        is DataState.Error -> {
            action(error, message)
            this
        }
        is DataState.Success -> this
    }
}

typealias EmptyResult<E> = DataState<Unit, E>
Enter fullscreen mode Exit fullscreen mode

shared/commonMain/core/domain/DataError.kt

sealed interface DataError {
    enum class Remote: DataError {
        REQUEST_TIMEOUT,
        TOO_MANY_REQUESTS,
        NO_INTERNET,
        SERVER,
        SERIALIZATION,
        UNKNOWN
    }

}
Enter fullscreen mode Exit fullscreen mode

Let's create a wrapper function to make network calls safer to handle

shared/commonMain/core/data/HttpClientExt.kt

import io.ktor.client.call.NoTransformationFoundException
import io.ktor.client.call.body
import io.ktor.client.network.sockets.SocketTimeoutException
import io.ktor.client.statement.HttpResponse
import io.ktor.client.statement.bodyAsText
import io.ktor.util.network.UnresolvedAddressException
import kotlinx.coroutines.ensureActive
import kotlin.coroutines.coroutineContext
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive


suspend inline fun <reified T> safeCall(
    execute: () -> HttpResponse
): DataState<T, DataError.Remote> {
    val response = try {
        execute()
    } catch(e: SocketTimeoutException) {
        return DataState.Error(DataError.Remote.REQUEST_TIMEOUT, e.message)
    } catch(e: UnresolvedAddressException) {
        return DataState.Error(DataError.Remote.NO_INTERNET, e.message)
    } catch (e: Exception) {
        coroutineContext.ensureActive()
        return DataState.Error(DataError.Remote.UNKNOWN, e.message)
    }

    return responseToResult(response)
}

suspend inline fun <reified T> responseToResult(
    response: HttpResponse
): DataState<T, DataError.Remote> {
    return when(response.status.value) {
        in 200..299 -> {
            try {
                DataState.Success(response.body<T>())
            } catch(e: NoTransformationFoundException) {
                DataState.Error(DataError.Remote.SERIALIZATION)
            }
        }
        400 -> DataState.Error(DataError.Remote.SERVER, response.extractErrorMessage())
        401 -> DataState.Error(DataError.Remote.SERVER, response.extractErrorMessage())
        408 -> DataState.Error(DataError.Remote.REQUEST_TIMEOUT, response.extractErrorMessage())
        429 -> DataState.Error(DataError.Remote.TOO_MANY_REQUESTS, response.extractErrorMessage())
        in 500..599 -> DataState.Error(DataError.Remote.SERVER, response.extractErrorMessage())
        else -> DataState.Error(DataError.Remote.UNKNOWN, response.extractErrorMessage())
    }
}


// this function gets the error message from the error response based on the response schema
// a dummyjson error response contains one field `details` which contains the error message
// update this function to match your api error response
suspend fun HttpResponse.extractErrorMessage(): String? {
    return runCatching {
        val rawBody = bodyAsText()
        Json.parseToJsonElement(rawBody)
            .jsonObject["details"]
            ?.jsonPrimitive
            ?.contentOrNull
            ?.takeIf { it.isNotBlank() }
    }.getOrNull()
}
Enter fullscreen mode Exit fullscreen mode

With our new helper model, we must update the repository and the repository implementation

shared/commonMain/product/domain/ProductRepository.kt

interface ProductRepository {

    suspend fun getProductsList() : DataState<List<ProductModel>, DataError.Remote>
    suspend fun getProductsItem(id: Int) : DataState<ProductModel, DataError.Remote>
}
Enter fullscreen mode Exit fullscreen mode

and the implementation should look like

shared/commonMain/product/data/ProductRepositoryImpl.kt

class ProductRepositoryImpl(
    private val dataSource: ProductDataSource
) : ProductRepository {
    override suspend fun getProductsList(): DataState<List<ProductModel>, DataError.Remote> =
        safeCall<ProductResponse> {
            dataSource.productList()
        }.map { it.products ?: emptyList() }

    override suspend fun getProductsItem(id: Int): DataState<ProductModel, DataError.Remote> =
        safeCall<ProductModel> {
            dataSource.productGet(id=id)
        }
}
Enter fullscreen mode Exit fullscreen mode

We used the map extension function in the getProductsList to return the list directly. We can pass the limit, skip, sortBy, order parameters if needed, but we are not passing them to simplify this tutorial.

Presentation layer, Bloc ViewModel

I'm a fan of Bloc library in Flutter, so I'll try to build a similar logic with the View-Model. Best practices suggest one state per screen

There is only one action for this screen: getting data.
You might have a filter action, a search action, and a pagination action for the actual app.

shared/commonMain/product/ui/ProductActions.kt

sealed interface ProductAction {

    class GetProductsList: ProductAction
    data class GetProductsItem(val id: Int): ProductAction
    data class GetProductsPage(val limit: Int, val skip: Int): ProductAction

}
Enter fullscreen mode Exit fullscreen mode

shared/commonMain/product/ui/ProductState.kt

data class ProductState (
    val productsList: List<ProductModel> = emptyList(),
    val isLoading: Boolean = false,
    val errorMessage: String? = null
)
Enter fullscreen mode Exit fullscreen mode

shared/commonMain/product/ui/ProductViewModel.kt

class ProductViewModel(
    private val repository: ProductRepository
) : ViewModel() {

    private var productsJob: Job? = null

    private val _state = MutableStateFlow(
        ProductState(
            productsList = emptyList(),
            isLoading = false,
            errorMessage = null
        )
    )

    @NativeCoroutinesState
    val state: StateFlow<ProductState> = _state
        .stateIn(
            viewModelScope,
            SharingStarted.WhileSubscribed(5000L),
            _state.value
        )


    fun onAction(action: ProductAction) {
        when (action) {
            is ProductAction.GetProductsList -> {
                productsJob?.cancel()
                productsJob = getProductsList()
            }

            is ProductAction.GetProductsItem -> {
                TODO("Not yet implemented")
            }

            is ProductAction.GetProductsPage -> {
                TODO("Not yet implemented")
            }

        }

    }


    private fun getProductsList() = viewModelScope.launch {
        _state.update {
            it.copy(
                isLoading = true
            )
        }

        repository.getProductsList()
            .onSuccess { response ->
                _state.update {
                    it.copy(
                        isLoading = false,
                        errorMessage = null,
                        productsList = response
                    )
                }
            }
            .onError { error, message ->
                _state.update {
                    it.copy(
                        isLoading = false,
                        productsList = emptyList(),
                        errorMessage = message ?: error.toString()
                    )
                }
            }
    }
}
Enter fullscreen mode Exit fullscreen mode

Dependency Injection for Products repository

We should provide the required dependencies for HttpClientFactory, ProductDataSource, and ProductRepositoryImpl

shared/commonMain/di/KoinModules.kt

val sharedModule = module {
    single { HttpClientFactory.create(get()) }
    singleOf(::ProductDataSource)
    singleOf(::ProductRepositoryImpl).bind<ProductRepository>()

    viewModelOf(::ProductViewModel)
    // ...
}

expect val platformModule: Module
Enter fullscreen mode Exit fullscreen mode

We have two options for view models injection, either as a singleton or as a view model. View model injection keeps the model alive with the view context. The view dies, the model dies along with it, but singleton DI keeps it alive the whole time.
We will use viewModelOf, since it is cleaner, but you can use either.

For iOS DI to work, we need to use the iOS helper class

shared/iosMain/di/KoinHelper.kt

class KoinHelper : KoinComponent {
    fun productsViewModel() = get<ProductViewModel>()
    // ...
}
Enter fullscreen mode Exit fullscreen mode

Finally ... it is time for the UI

Products list in Android (Finally)

I'll be short on this; The native code is part of the UI (presentation) layer. It will reflect the same project structure, a folder for every app module (Product). It will include the ProductScreen and its components.

Make sure to implement the Koin view model in the Android module before using it in the screen

composeApp/build.gradle.kts

kotlin {
    ...
    sourceSets {
        ...
        commonMain.dependencies {
            ...
            implementation(libs.koin.compose)
            implementation(libs.koin.compose.viewmodel)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Now, for the screen design... I promis to improve it in later tutorials, so accept this basic design for now.

composeApp/kotlin/product/ProductScreen.kt

import org.koin.compose.viewmodel.koinViewModel

@Composable
fun ProductScreen(
    modifier: Modifier = Modifier,
    viewModel: ProductViewModel = koinViewModel()
) {
    val state by viewModel.state.collectAsState()

    LaunchedEffect(Unit) {
        viewModel.onAction(ProductAction.GetProductsList())
    }

    LazyColumn(
        modifier = modifier
            .fillMaxSize()
    ) {
        items(state.productsList.size) { index ->
            val product = state.productsList[index]
            ProductItem(product = product)
        }
    }
}

@Composable
fun ProductItem(product: ProductModel, modifier: Modifier = Modifier) {

    val imageUrl = product.thumbnail ?: product.images?.firstOrNull { it.isNotBlank() }

    Row(
        modifier = modifier
            .padding(vertical = 5.dp, horizontal = 5.dp)
    ) {
        Image(
            modifier = Modifier.padding(end = 5.dp).width(80.dp),
            painter = painterResource(id = R.drawable.ic_launcher_background),
            contentDescription = product.title,
        )

        Column {
            Text(text = product.title ?: "")
            Text(text = product.description ?: "")
        }
    }

}
Enter fullscreen mode Exit fullscreen mode

We are using LaunchedEffect to issue a "get products list" action. We can do it in the init function of the view model too

shared/commonMain/product/ui/ProductViewModel.kt

class ProductViewModel(
    private val repository: ProductRepository
) : ViewModel() {

    private var productsJob: Job? = null

    private val _state = MutableStateFlow(
        ProductState(
            productsList = emptyList(),
            isLoading = false,
            errorMessage = null
        )
    )

    init {
        onAction(ProductAction.GetProductsList()) // new
    }
    //...
}
Enter fullscreen mode Exit fullscreen mode

Then we won't need the LaunchedEffect, but I prefer it with LaunchedEffect

To show the screen ...

composeApp/kotlin/MainActivity.kt

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        initKoin()
        enableEdgeToEdge()
        super.onCreate(savedInstanceState)

        setContent {
            ProductScreen()
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Products list in iOS (Finally * 2)

We start by creating ProductViewModel wrapper for iOS

iosApp/product/ProductViewModelWrapper.swift

import Foundation
import Shared
import KMPNativeCoroutinesCombine
import Combine

final class ProductViewModelWrapper: ObservableObject {
    @Published var lastUpdated = Date()

    let viewModel: ProductViewModel = KoinHelper().productsViewModel()
    private var cancellables = Set<AnyCancellable>()

    init() {
        createPublisher(for: viewModel.stateFlow)
        .receive(on: DispatchQueue.main)
        .sink { _ in } receiveValue: { [weak self] _ in
            self?.lastUpdated = Date()
        }
        .store(in: &cancellables)
    }
}
Enter fullscreen mode Exit fullscreen mode

and the list screen

iosApp/product/ProductScreen.swift

import SwiftUI
import Shared

struct ProductScreen: View {
    @StateObject private var wrapper = ProductViewModelWrapper()
    @State private var showErrorAlert = false
    @State private var errorAlertText = ""
    @State private var lastErrorMessage: String?

    var body: some View {
        let state = wrapper.viewModel.state

        ZStack {
            List {
                ForEach(Array(state.productsList.enumerated()), id: \.offset) { _, product in
                    ProductItemView(product: product)
                }
            }
            .listStyle(.plain)

            if state.isLoading {
                Color.black.opacity(0.5)
                    .ignoresSafeArea()
                ProgressView()
                    .scaleEffect(1.4)
                    .tint(.white)
            }
        }
        .onAppear {
            wrapper.viewModel.onAction(action: ProductActionGetProductsList())
        }
        .onChange(of: wrapper.lastUpdated) { _, _ in
            let current = wrapper.viewModel.state
            if let message = current.errorMessage,
               !message.isEmpty,
               message != lastErrorMessage {
                lastErrorMessage = message
                errorAlertText = message
                showErrorAlert = true
            }
        }
        .alert("Error", isPresented: $showErrorAlert) {
            Button("OK", role: .cancel) {}
        } message: {
            Text(errorAlertText)
        }
    }
}

private struct ProductItemView: View {
    let product: ProductModel

    var body: some View {
        HStack(alignment: .top, spacing: 8) {
            AsyncImage(url: URL(string: product.thumbnail ?? product.images?.first ?? "")) { phase in
                switch phase {
                case .success(let image):
                    image
                        .resizable()
                        .scaledToFill()
                case .failure(_), .empty:
                    Image(systemName: "photo")
                        .resizable()
                        .scaledToFit()
                        .padding(16)
                        .foregroundStyle(.secondary)
                @unknown default:
                    EmptyView()
                }
            }
            .frame(width: 80, height: 80)
            .background(Color.gray.opacity(0.15))
            .clipShape(RoundedRectangle(cornerRadius: 8))

            VStack(alignment: .leading, spacing: 4) {
                Text(product.title ?? "")
                    .font(.headline)
                    .lineLimit(1)
                Text(product.description_ ?? "")
                    .font(.subheadline)
                    .foregroundStyle(.secondary)
                    .lineLimit(2)
            }
        }
        .padding(.vertical, 4)
    }
}
Enter fullscreen mode Exit fullscreen mode

and to show it

iosApp/iOSApp.swift

@main
struct iOSApp: App {
    init() {
        KoinInitIosKt.doInitKoinIos()
    }
    var body: some Scene {
        WindowGroup {
            ProductScreen()
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Products list screen

Thanks for reading this very very long article

Top comments (0)