· 8 min read Posted by Russell Wolf
SKIE comes to KaMPKit
With SKIE’s recent release to open source on GitHub, we are excited to start taking advantage of it in KaMPKit and will describe the changes made and why in this post. This should be useful to anyone looking to do a similar integration in their own app.
What is SKIE?
SKIE is a new tool developed by Touchlab which improves interoperability between Kotlin and Swift by generating Swift wrappers for Objective-C headers created by the Kotlin compiler. It recreates features supported by both languages that are lost in the translation from Kotlin to Objective-C to Swift. To learn more about SKIE and how it can benefit your project, check out our Getting Started page and SKIE’s Documentation.
What is KaMPKit?
KaMPKit is a template 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. The major benefits of SKIE happen at the language barrier, where things are being passed between from Kotlin to Swift. 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. Specific changes will be discussed below, or you can check out the entire change in this PR
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...") }
}
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 thing 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. 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.