KaMP Kit with SKIE

Author: Russell Wolf, Sam Hill
SKIE significantly improves the API exported from Kotlin to Swift. It is a critical tool for any iOS developer or team using KMP. This guide will walk you through the basics.

SKIE allows for using some types in the Kotlin/iOS API that were previously difficult. This makes writing shared Kotlin easier, and calling it from Swift more “pleasant”. The interface is more expressive and closer to what a Swift API would look like.

We recently updated KaMPKit to make use of SKIE, and the changes are a useful case-study of the changes involved in architecture and code patterns.

touchlab/KaMPKit

What is KaMPKit?

KaMPKit is a reference project developed by Touchlab and used by many KMP developers to kickstart a new KMP project. It’s a single screen app displaying a list of dog breeds, and showcases some of our preferred libraries and architecture patterns.

What’s changed?

SKIE-related changes are focused on the viewmodel layer and the UI above it. SKIE’s magic happens at the language barrier, where things are being passed between Kotlin and Swift. The “API surface”. Because KaMPKit uses a shared viewmodel, that becomes the main piece that Swift interacts with. We don’t care about the inner layers from the Swift side.

View State

The first change to talk about is the state class exposed by BreedViewModel. Prior to SKIE, it looked like this:

data class BreedViewState(
    val breeds: List<Breed>? = null,
    val error: String? = null,
    val isLoading: Boolean = false,
    val isEmpty: Boolean = false
)

It’s not necessarily clear looking at this class what states are or aren’t valid. Can we have non-null breeds and error at the same time? Can we have loading set to true while other data is also present? A sealed class would make it easier to define these sorts of constraints, but it doesn’t export well to Swift by default. Because of this, we had previously opted to use this data class instead and leave the consuming platforms responsible for checking what data is present and handling it.

Luckily, with SKIE, we can more easily convert between Kotlin sealed classes and Swift enums, allowing us to get more idiomatic exhaustive switches in Swift. Therefore, we refactored BreedViewState to this sealed class:

sealed class BreedViewState {
    abstract val isLoading: Boolean

    object Initial : BreedViewState() {
        override val isLoading: Boolean = true
    }

    data class Empty(override val isLoading: Boolean = false) : BreedViewState()

    data class Content(
        val breeds: List<Breed>,
        override val isLoading: Boolean = false
    ) : BreedViewState()

    data class Error(
        val error: String,
        override val isLoading: Boolean = false
    ) : BreedViewState()
}

Now we have explicit subclasses for the valid states of our viewmodel. The isLoading flag remains a property rather than being its own state, because we might or might not have data already present when loading.

Previously, the iOS UI would pull fields out of the data class individually, then null-check them to determine whether to display the associated UI:

ZStack {
    VStack {
        if let breeds = breeds {
            List(breeds, id: \.id) { breed in
                BreedRowView(breed: breed) {
                    onBreedFavorite(breed)
                }
            }
        }
        if let error = error {
            Text(error)
                .foregroundColor(.red)
        }
        Button("Refresh") {
            refresh()
        }
    }
    if loading { Text("Loading...") }
}

Now, with a SKIE-enhanced sealed class, we can handle states more naturally with a switch statement.

ZStack {
    VStack {
        switch onEnum(of: state) {
        case .Content(let content):
            List(content.breeds, id: \.id) { breed in
                BreedRowView(breed: breed) {
                    onBreedFavorite(breed)
                }
            }
        case .Error(let error):
            Spacer()
            Text(error.error)
                .foregroundColor(.red)
            Spacer()
        case .Empty(_):
            Spacer()
            Text("Sorry, no doggos found")
            Spacer()
        case .Initial(_):
            Spacer()
        }

        Button("Refresh") {
            refresh()
        }
    }
    if state.isLoading { Text("Loading...") }
}
Sealed Classes in Kotlin conceptually do the same thing as enums with associated values in Swift. However, beyond simple “happy path” cases, Sealed Classes do not directly translate to Swift enums well. Note the onEnum function above. This is a compromise the SKIE team has made to bridge the gap. An onEnum is created for each sealed hierarchy and facilitates exhaustive checking.

We now have better handling of the initial and empty states, which weren’t explicitly addressed in the previous code.

SKIE also gives us better handling of default parameters in the BreedViewState classes. In most cases, these classes are being instantiated in Kotlin so the default parameters come free. But the Swift code contains SwiftUI previews of the various states, and these are able to leverate default parameters, for example to avoid needing to pass isLoading.

Coroutines interop

The sealed class refactor is nice, but by far the bigger change is around interop with coroutine APIs. Previously, this included a number of layers. The BreedViewModel exposed some flows, as well as some functions that internally launched one-off coroutines to perform operations like triggering a refresh or marking a breed as a favorite. In the iOS code, this was wrapped in a BreedCallbackViewModel, which converted the Flow to a FlowAdapter which could be wired with callbacks for usage from Swift. An ObservableBreedModel in Swift then extracted the FlowAdapter to @Published properties in an ObservableObject, allowing them to be subscribed to from SwiftUI. Then the BreedListScreen view subscribed to changes in that model and rendered UI for whatever state was present. This plethora of layers, while requiring some boilerplate code, allowed us to ensure control over things like cancellation and threading.

class ObservableBreedModel: ObservableObject {
    private var viewModel: BreedCallbackViewModel?

    @Published
    var loading = false

    @Published
    var breeds: [Breed]?

    @Published
    var error: String?
    
    ...
}

struct BreedListScreen: View {
    @StateObject
    var observableModel = ObservableBreedModel()

    var body: some View {
        ...
    }
}

With SKIE, a lot of these layers go away. Now, the iOS code can reference the BreedViewModel directly, and suspend functions and flows are automatically converted to Swift-style async functions or streams. This means that FlowAdapter and BreedCallbackViewModel are no longer necessary on the Kotlin side, and we can even drop ObservableBreedModel on the Swift side if we’re willing to do some subscription work in the view instead.

struct BreedListScreen: View {

    @State
    var viewModel: BreedViewModel?

    @State
    var breedState: BreedViewState = .Initial.shared

    var body: some View {
        ...
    }
}

The above code samples elide how we call async operations and subscribe to flow updates, so lets look at that next. Previously we made use of a coroutine scope in the viewmodel to handle async operations at the Kotlin level. But now, because SKIE naturally runs Kotlin’s async code in the calling Swift context, it’s more natural to avoid this internal scope so the platforms can bind operations to their own UI context and lifecycle. Thus, the refreshBreeds() and updateBreedFavorite() functions are now suspend functions rather than launching coroutines internally.

To get similar behavior for the breedState flow, we had to do a little refactoring. Previously we were doing work in the viewmodel’s init block to subscribe to the database and start emissions from that flow. We’ve now moved that to a suspend fun activate() so that activating the viewmodel is explicit. Internally in activate(), a collect() call happens (and suspends as long as the viewmdoel is alive) which feeds data into breedState.

(Naively you might think we could just do something like breedState = database.getBreeds().map { ... }, but activate() (and the pre-SKIE init {} block) also triggers an immediate refresh to ensure we don’t launch with stale data. To coordinate that, we instead pass everything through an intermediate MutableStateFlow which then drives breedState.)

On the Android side, we now add an explicit call to activate() in our top-level composible.

LaunchedEffect(viewModel) {
    viewModel.activate()
}

We also use a compose-level scope to launch suspend functions.

val scope = rememberCoroutineScope()
...
scope.launch { viewModel.refreshBreeds() }

On iOS, we use the task modifier to run an async function while the view is active. This gives us a block where we can manage the viewmodel.

var body: some View {
    ...
    .task {
        let viewModel = KotlinDependencies.shared.getBreedViewModel() // 1
        await withTaskCancellationHandler(
            operation: {
                self.viewModel = viewModel
                Task {
                    try? await viewModel.activate()
                }
                for await breedState in viewModel.breedState {
                    self.breedState = breedState
                }
            },
            onCancel: {
                viewModel.clear()
                self.viewModel = nil
            }
        )
    }
}

This is a big chunk of code, so let’s break it down. The first line grabs an instance of the viewmodel from Kotlin. Next, we call withTaskCancellationHandler(), which allows us to register a cancellation callback where we’ll clear out the viewmodel. In the operation lambda, we assign our viewModel field, then call activate() inside a Task block. The Task block parallelizes the work, because activate() will suspend indefinitely but we want to do other work as well. Following the Task block, we launch a for await loop, which is the Swift idiom for collecting from an AsyncStream. There we grab updates from the viewmodel’s breedState field and forward them to the view state. This collection will also continue until cancellation.

In the onCancel block, we call viewModel.clear() in order to catch any cleanup logic the viewmodel might do (in KaMPKit, this just prints a log, but in real code there might be other resources to tear down). Then we nil out the viewModel state in the view.

For the other suspend operations (ie refreshBreeds() and updateBreedFavorite()), we can just wrap Task to launch an asynchronous context from inside our UI callbacks

Task {
    try? await viewModel?.refreshBreeds()
}

Be careful with the try? await idiom we’ve used here. These functions throw mainly to support cancellation, but they could also throw other errors. This will ignore any errors thrown by our suspend functions. In real code, depending on how your suspend functions can throw, you may want to catch errors instead.

Conclusion

Hopefully you can now see the potential for SKIE to greatly improve your Kotlin/Native iOS development experience. Visit https://skie.touchlab.co/ to get started yourself and we’ll be continuing to make updates to both SKIE and KaMPKit.