This content originally appeared on DEV Community and was authored by Russell Wolf
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!")
}
}
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()
}
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()
}
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)
)
}
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))
}
}
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()
}
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()
}
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)
}
}
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)")
}
}
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!
This content originally appeared on DEV Community and was authored by Russell Wolf
Russell Wolf | Sciencx (2021-06-04T20:50:38+00:00) Kotlin Coroutines and Swift, revisited. Retrieved from https://www.scien.cx/2021/06/04/kotlin-coroutines-and-swift-revisited/
Please log in to upload a file.
There are no updates yet.
Click the Upload button above to add an update.