DEV Community

loading...
Cover image for Kotlin Coroutines and Swift, revisited
Touchlab

Kotlin Coroutines and Swift, revisited

Russell Wolf
โŒฅโ‡งโŒ˜K
Originally published at touchlab.co ใƒป6 min read

Last year I wrote about a pattern for interop between Kotlin coroutines and RxSwift. I appreciate the attention it received, particularly where people have applied it to other reactive frameworks, and even including a code-generation plugin using the same ideas. I figure it's about time I talk about my own updated thinking on these patterns.

If you haven't read the previous article, I suggest going through that first for context.

Kotlin updates

We'll stick to the same repository class as the original article, and walk through exposing it to iOS.

class ThingRepository {
    suspend fun getThing(succeed: Boolean): Thing {
        delay(100)
        if (succeed) {
            return Thing(0)
        } else {
            error("oh no!")
        }
    }

    fun getThingStream(
        count: Int,
        succeed: Boolean
    ): Flow<Thing> = flow {
        repeat(count) {
            delay(100)
            emit(Thing(it))
        }
        if (!succeed) error("oops!")
    }
}
Enter fullscreen mode Exit fullscreen mode

In the previous article, I suggested the following pattern for wrapping suspend functions with callbacks that could run on iOS

class SuspendWrapper<T>(private val suspender: suspend () -> T) {
    init {
        freeze()
    }

    fun subscribe(
        scope: CoroutineScope,
        onSuccess: (item: T) -> Unit,
        onThrow: (error: Throwable) -> Unit
    ): Job = scope.launch {
        try {
            onSuccess(suspender().freeze())
        } catch (error: Throwable) {
            onThrow(error.freeze())
        }
    }.freeze()
}
Enter fullscreen mode Exit fullscreen mode

After having played with these patterns more, a drawback to this emerged. This class expects a CoroutineScope to be supplied by the caller (which will be in Swift) at subscription time. This can be nice for flexibility if it might be called in different contexts, but in the vast majority of cases in practice this will live in some sort of class with its own scope, and it's much more pleasant to work with the scope from Kotlin than from Swift. So let's make the scope a constructor parameter instead.

class SuspendWrapper<T : Any>(
    private val scope: CoroutineScope,
    private val suspender: suspend () -> T
) {
    init {
        freeze()
    }

    fun subscribe(
        onSuccess: (item: T) -> Unit,
        onThrow: (error: Throwable) -> Unit
    ) = scope.launch {
        try {
            onSuccess(suspender().freeze())
        } catch (error: Throwable) {
            onThrow(error.freeze())
        }
    }.freeze()
}
Enter fullscreen mode Exit fullscreen mode

A similar change can be made for FlowWrapper. Now we can manage that scope in the iOS repository class, at the Kotlin level.

class ThingRepositoryIos(private val repository: ThingRepository) {
    private val scope: CoroutineScope =
        CoroutineScope(SupervisorJob() + Dispatchers.Default)

    init {
        freeze()
    }

    fun getThingWrapper(succeed: Boolean) =
        SuspendWrapper(scope) { repository.getThing(succeed) }

    fun getThingStreamWrapper(count: Int, succeed: Boolean) =
        FlowWrapper(
            scope, 
            repository.getThingStream(count, succeed)
        )
}
Enter fullscreen mode Exit fullscreen mode

Now we can drop the scope parameter from the RxSwift subscribe calls.

Swift repository wrappers

The previous article didn't consider Swift-side architecture beyond the createObservable() and createSingle() functions. But in practice you aren't likely to want to call these inline at every call-site. You can add one more wrapper layer in Swift so that the rest of the Swift side of the codebase doesn't need to know about the Kotlin classes at all. For example:

class ThingRepositoryRxSwift {
    private let delegate: ThingRepositoryIos

    init() {
        self.delegate = ThingRepositoryIos(repository: ThingRepository())
    }

    func getThing(succeed: Bool) -> Single<Thing> {
        createSingle(suspendWrapper: delegate.getThingWrapper(succeed: succeed))
    }

    func getThingStream(count: Int32, succeed: Bool) -> Observable<Thing> {
        createObservable(flowWrapper: delegate.getThingStreamWrapper(count: count, succeed: succeed))
    }
}
Enter fullscreen mode Exit fullscreen mode

Now the rest of the code sees only ThingRepositoryRxSwift and its RxSwift API and the Kotlin essentially becomes an implementation detail.

Combine

The other thing I've done more thinking about since the original article is how this can apply to the Combine framework. Combine has a Publisher type which represents an observable stream with a particular type of event and error.

func createPublisher<T>(
    flowWrapper: FlowWrapper<T>
) -> AnyPublisher<T, KotlinError> {
    return Deferred<Publishers.HandleEvents<PassthroughSubject<T, KotlinError>>> {
        let subject = PassthroughSubject<T, KotlinError>()
        let job = flowWrapper.subscribe { (item) in
            let _ = subject.send(item)
        } onComplete: {
            subject.send(completion: .finished)
        } onThrow: { (error) in
            subject.send(completion: .failure(KotlinError(error)))
        }
        return subject.handleEvents(receiveCancel: {
            job.cancel(cause: nil)
        })
    }.eraseToAnyPublisher()
}
Enter fullscreen mode Exit fullscreen mode

This makes use of a PassthroughSubject to handle the heavy lifting, and simply forwards the Flow events from our FlowWrapper callbacks to it. It makes use of the same KotlinError error type as the previous article. Note the eraseToAnyPublisher() call at the end, which cleans up our return type to AnyPublisher<T, KotlinError> instead of Deferred<Publishers.HandleEvents<PassthroughSubject<T, KotlinError>>>. Combine's generic internals are weird but they give us this utility to hide them.

For single-event streams, Combine has a Future type. Unfortunately, there's no eraseToAnyFuture() helper that I could find, so we still end up typed as a multi-event Publisher instead.

func createFuture<T>(
    suspendWrapper: SuspendWrapper<T>
) -> AnyPublisher<T, KotlinError> {
    return Deferred<Publishers.HandleEvents<Future<T, KotlinError>>> {
        var job: Kotlinx_coroutines_coreJob? = nil
        return Future { promise in
            job = suspendWrapper.subscribe(
                onSuccess: { item in promise(.success(item)) },
                onThrow: { error in promise(.failure(KotlinError(error))) }
            )
        }.handleEvents(receiveCancel: {
            job?.cancel(cause: nil)
        })
    }
    .eraseToAnyPublisher()
}
Enter fullscreen mode Exit fullscreen mode

You could also do something similar with a PassthroughSubject, but I suspect (without having profiled extensively) that this version is probably lighter. There's also async/await support on the horizon for Swift, which would be nice to integrate here in the future.

SwiftUI

One of the nice things about using Combine is it integrates well with SwiftUI. You can create a model class like

class ThingModel: ObservableObject {
    @Published
    var thing: Thing = Thing(count: -1)

    var cancellables = Set<AnyCancellable>()

    init(_ repository: ThingRepositoryIos) {
        createPublisher(
            flowWrapper: repository.getThingStreamWrapper(
                count: 100, 
                succeed: true)
            )
            .replaceError(with: Thing(count: -1))
            .receive(on: DispatchQueue.main)
            .sink { thing in self.thing = thing }
            .store(in: &cancellables)
    }
}
Enter fullscreen mode Exit fullscreen mode

and then observe the Thing in a view like

struct ThingView : View {
    @ObservedObject
    var thingModel: ThingModel

    init(_ repository: ThingRepositoryIos) {
        thingModel = ThingModel(repository)
    }

    var body: some View {
        Text("Count: \(thingModel.thing.count)")
    }
}
Enter fullscreen mode Exit fullscreen mode

Isn't this a lot of boilerplate?

There's obviously a lot of layers here, and that can be a bit off-putting. However, I think this sort of layering is a helpful pattern in a lot of Swift/Kotlin interop.

We have two different languages that we're trying to make talk to each other. While for most simple use-cases the interop is handled for us by the compiler, in more complex situations we need to carve out a shared cross-language interface by hand. This will often take the form of a Kotlin wrapper layer that's less idiomatic to Kotlin consumers but is possible for Swift to consume, and then an extra Swift layer to take those Kotlin wrappers and massage them into more idiomatic Swift code.

We shouldn't be surprised that this extra glue code is needed, given the differences in language and environment. Most of it is either things we only need to write once (like the SuspendWrapper class in Kotlin or the createObservable() or createPublisher() functions in Swift), or things that follow very consistent patterns that we might be able to codegen (like creating ThingRepositoryIos based on ThingRepository in Kotlin, or ThingModel in Swift).

Sure, this extra infrastructure wouldn't be needed if we were writing a pure native iOS app in Swift. But we've gained the ability to share Kotlin code into Swift in a more idiomatic way. I tend to think the tradeoff is worth it.

Final thoughts

I'd like to further iterate on all these patterns in some form of codegen library in the future, as compiler plugins start to mature. I'm hopeful for a multiplatform version of Kotlin Symbol Processing to help with this, though this won't be possible before Kotlin 1.5.20 at the earliest due to missing extension points in the Kotlin compiler. Fingers crossed for something this summer I guess.

If you'd like to play with all of this yourself, updated code samples are available at the following repo:

As before, this includes nullable and non-null versions of both single-event and multiple-event streams, and Swift unit tests to verify it all works correctly. In addition, there's now a SwiftUI display to demo the Combine code, as well as log-based demos of RxSwift and Combine bindings.


Hope you enjoyed this! I'd love to hear feedback, either in the comments or on Slack or Twitter. If you'd like to go deeper, feel free to reach out to Touchlab. We're also hiring!

Discussion (0)