Kotlin Flow to Swift Combine: A KMP Bridge. Part II

Volodymyr Voiko
Snapp Mobile
Published in
10 min readAug 28, 2024

In the previous part of this article, we explored how to connect Kotlin’s Flows with Swift’s Combine Publishers. We also examined the limitations of Kotlin’s interoperability with Swift/Objective-C and the pitfalls these limitations can create. In this part, we will explore a potential solution to this problem.

Bringing Type Safety for KMP APIs in iOS

As you may remember, Kotlin’s generic interfaces lose type information during translation to Swift/Objective-C. As a result, the API becomes type-unsafe and complex to integrate. Therefore, we need to find a way to preserve the type information.

The Kotlin documentation on generics interoperability states that generics are only supported when defined on classes. It seems like this is the only viable approach.

Building a Generic-Preserving Flow

Let’s create a wrapper class that has a generic parameter and implements the Flow interface. Since we are working on a multiplatform project, we'll name it MultiplatformFlow. As a wrapper, we'll add a constructor parameter to accept the wrapped Flow and use it as a delegate for the Flow interface implementation. For convenience, we'll also add an extension function on the Flow interface to facilitate wrapping.

open class MultiplatformFlow<T : Any>(delegate: Flow<T>) : Flow<T> by delegate

fun <T : Any> Flow<T>.multiplatform(): MultiplatformFlow<T> = MultiplatformFlow(this)

Now, let’s try to apply it to the input of our TextEncoder:

class TextEncoder(rawText: MultiplatformFlow<TextString>) {
@OptIn(ExperimentalEncodingApi::class)
val encodedText: Flow<TextString> // ...
}

Providing Type-Safe Flow from Swift

When we try to build our project, it fails. For Android, all we need to do is call the multiplatform() function on the text input flow, which will wrap it and fix the type mismatch:

class ContentViewModel : ViewModel() {
private val _rawText = MutableStateFlow("")

// ...

private val textEncoder = TextEncoder(
rawText = _rawText.map { TextString(it) }.multiplatform()
)

// ...
}

However, on iOS, we now need to wrap the Publisher into MultiplatformFlow to prevent us from using a type-unsafe implementation. Let me remind you how the basic implementation looks:

extension Publishers {
final class FlowCollector<Output, Failure>: Publisher where Failure: Error {
private let flow: Kotlinx_coroutines_coreFlow

init(flow: Kotlinx_coroutines_coreFlow) {
self.flow = flow
}

func receive<S>(subscriber: S) where S : Subscriber, Failure == S.Failure, Output == S.Input {
let subscription = FlowCollectorSubscription(subscriber: subscriber, flow: flow)
subscriber.receive(subscription: subscription)
subscription.collect()
}
}
}

fileprivate final class FlowCollectorSubscription<S>: Subscription, Kotlinx_coroutines_coreFlowCollector where S: Subscriber {
struct TypeCastError: LocalizedError {
let receivedType: Any.Type
let expectedType: Any.Type

var errorDescription: String? {
"Failed to cast value of type \(receivedType) to expected type \(expectedType)."
}
}

var subscriber: S?
let flow: Kotlinx_coroutines_coreFlow
var collectionTask: Task<Void, Never>?

init(subscriber: S?, flow: Kotlinx_coroutines_coreFlow) {
self.subscriber = subscriber
self.flow = flow
}

func request(_ demand: Subscribers.Demand) { }

func collect() {
collectionTask = .detached { @MainActor [weak self] in
guard let self else { return }
do {
try await flow.collect(collector: self)
} catch {
if let error = error as? S.Failure {
subscriber?.receive(completion: .failure(error))
} else {
subscriber?.receive(completion: .finished)
}
}
}
}

func cancel() {
collectionTask?.cancel()
collectionTask = nil
subscriber = nil
}

func emit(value: Any?) async throws {
guard let expectedValue = value as? S.Input else {
throw TypeCastError(receivedType: type(of: value), expectedType: S.Input.self)
}
_ = subscriber?.receive(expectedValue)
}
}

extension Kotlinx_coroutines_coreFlow {
func publisher<Output, Failure>(
outputType: Output.Type = Output.self,
failureType: Failure.Type = Failure.self) -> Publishers.FlowCollector<Output, Failure> where Failure: Error {
Publishers.FlowCollector(flow: self)
}
}

Since we won’t implement the Kotlinx_coroutines_coreFlowCollector protocol anymore, it makes sense to remove the FlowCollector publisher altogether. While this means we can't reuse our collector publisher in Combine chains, we don't really need that functionality. All we need is to provide a MultiplatformFlow instance to the TextEncoder.

MultiplatformFlow is a class, so we can't implement it as a protocol. This means we need a way to construct it from Swift. If we refer to the Kotlin documentation for flows, we can find several helper functions that create Flow instances based on different needs. One of them is called callbackFlow.

Creates an instance of a cold Flow with elements that are sent to a SendChannel provided to the builder’s block of code via ProducerScope. It allows elements to be produced by code that is running in a different context or concurrently.

It seems that this one best fits our needs. Here is an example of its usage from the documentation:

fun <T> flowFrom(api: CallbackBasedApi<T>): Flow<T> = callbackFlow {
val callback = object : Callback<T> { // Implementation of some callback interface
override fun onNextValue(value: T) {
// To avoid blocking you can configure channel capacity using
// either buffer(Channel. CONFLATED) or buffer(Channel. UNLIMITED) to avoid overfill
trySendBlocking(value).onFailure { throwable ->
// Downstream has been cancelled or failed, can log here
}
}

override fun onApiError(cause: Throwable) {
cancel("API Error", cause)
}

override fun onCompleted() = channel.close()
}
api.register(callback)
/*
* Suspends until either 'onCompleted'/'onApiError' from the callback is invoked
* or flow collector is cancelled (e. g. by 'take(1)' or because a collector's coroutine was cancelled).
* In both cases, callback will be properly unregistered.
*/
awaitClose { api.unregister(callback) }
}

In this example, we can see that callbackFlow allows us to register, receive elements, and complete either with an error or successfully. This is exactly what Swift's Combine Publisher provides as well—you can subscribe, receive value updates, and complete with or without failure.

Unfortunately, global functions, in addition to interfaces, lose type information during translation to Objective-C/Swift. This means we can’t use the callbackFlow builder function. Therefore, we need to create another class that extends MultiplatformFlow and provides a type-safe way to subscribe to the publisher.

What are our requirements? We need to be able to subscribe, unsubscribe, send elements, and/or complete after a subscription is established. Sending elements and completing are tasks that we will handle within the context of ProducerScope. However, similar to the Flow interface, we don't want to expose a typed protocol to iOS because we lose information about the type. Let's start by implementing a wrapper for these operations.

class MultiplatformProducerScope<T>(private val scope: ProducerScope<T>) {
fun trySend(value: T) {
scope.trySend(value)
}

fun cancel(exception: CancellationException? = null) {
scope.cancel(exception)
}

fun close() {
scope.channel.close()
}
}

MultiplatformProducerScope allows us to send elements and complete successfully by canceling the scope without an error, or complete with failure by canceling the scope with a provided CancellationException.

Now, let’s use this wrapper to expose the callbackFlow function for constructing MultiplatformFlow from iOS with type safety.

class MultiplatformCallbackFlow<T : Any>(
subscribe: (MultiplatformProducerScope<T>) -> Unit,
unsubscribe: () -> Unit
) : MultiplatformFlow<T>(
callbackFlow {
subscribe(MultiplatformProducerScope(this))
awaitClose { unsubscribe() }
}
)

With its help, it is now possible to create a MultiplatformFlow instance, which we can then pass to the TextEncoder. This will allow TextEncoder to subscribe to the Publisher and send updates to it.

Here’s how we can now convert a Swift Combine Publisher into a Kotlin Flow:

extension Publisher where Output: AnyObject {
var flow: MultiplatformFlow<Output> {
var cancellable: AnyCancellable?
return MultiplatformCallbackFlow<Output> { scope in
cancellable = sink { completion in
switch completion {
case let .failure(error):
scope.cancel(exception: KotlinCancellationException(message: error.localizedDescription))
case .finished:
scope.close()
}
} receiveValue: { value in
scope.trySend(value: value)
}
} unsubscribe: {
cancellable?.cancel()
cancellable = nil
}
}
}

We subscribe to the Publisher via sink, sending value updates in receiveValue through trySend, and closing the scope if the Publisher finishes, or canceling it with a provided description of the issue if it fails. If the flow collector stops collecting elements, unsubscribe will be called, and we can cancel the subscription using the cancellable instance of AnyCancellable returned by sink.

That’s it! Now the iOS project will compile without any other changes.

Adding Type-Safe Flow Support in Swift

What about the TextEncoder output? Now it's time to make it type-safe as well. First, we need to specify the MultiplatformFlow class instead of the regular Flow interface.

class TextEncoder(rawText: MultiplatformFlow<TextString>) {
@OptIn(ExperimentalEncodingApi::class)
val encodedText: MultiplatformFlow<TextString> = rawText.map { text ->
TextString(Base64.encode(text.body.encodeToByteArray()))
}.multiplatform()
}

For Android, we don’t need to change anything. The project will compile and work similarly to how it did before this change. iOS will compile as well since we’re still using the base Kotlinx_coroutines_coreFlow. Nevertheless, if we want to have a type-safe implementation on iOS as well, we need to make some changes. First, let's expose a callback-style API in KMP to allow iOS to treat Kotlin's Flow as a callback and create a Combine Publisher from it. Unfortunately, we can't expose suspend functions with generics without losing type information. Because of that, we'll handle concurrency in Kotlin and expose the callback to Swift. Here's how this utility function looks:

open class MultiplatformFlow<T : Any> internal constructor(
delegate: Flow<T>
) : Flow<T> by delegate {
fun launchCollect(
onEmit: (T) -> Unit,
onCompletion: (Throwable?) -> Unit
): Job = MainScope().launch {
try {
collect(onEmit)
onCompletion(null)
} catch (e: Throwable) {
onCompletion(e)
}
}
}

We launch a new coroutine in the MainScope and return a reference to the Job instance, so we can cancel it if needed. We provide a closure for new elements with onEmit and for completion with onCompletion. Using this, we can now rewrite our FlowCollector publisher and accept a type-safe MultiplatformFlow as input.

struct FlowError: LocalizedError {
let throwable: KotlinThrowable

var errorDescription: String? { throwable.message }
}

extension Publishers {
final class FlowCollector<Output: AnyObject>: Publisher {
typealias Failure = FlowError
private let flow: MultiplatformFlow<Output>

init(flow: MultiplatformFlow<Output>) {
self.flow = flow
}

func receive<S>(subscriber: S) where S : Subscriber, Failure == S.Failure, Output == S.Input {
let subscription = FlowCollectorSubscription(subscriber: subscriber, flow: flow)
subscriber.receive(subscription: subscription)
subscription.collect()
}
}
}

First, we declared a specific FlowError type that will wrap Kotlin's Throwable. Then, we removed the Failure type parameter and specified FlowError. We also replaced Kotlinx_coroutines_coreFlow with MultiplatformFlow.

Now, let’s update FlowCollectorSubscription as well. We need to use the type-safe MultiplatformFlow. Specify the Failure type parameter as FlowError, and we should remove the Kotlinx_coroutines_coreFlow conformance.

fileprivate final class FlowCollectorSubscription<S>: Subscription where S: Subscriber, S.Input: AnyObject, S.Failure == FlowError {
// ...
let flow: MultiplatformFlow<S.Input>
// ...

init(subscriber: S?, flow: MultiplatformFlow<S.Input>) {
self.subscriber = subscriber
self.flow = flow
}

// ...
}

The final step will be to update the collect function to use MultiplatformFlow.

fileprivate final class FlowCollectorSubscription<S>: Subscription where S: Subscriber, S.Input: AnyObject, S.Failure == FlowError {
// ...
var job: Kotlinx_coroutines_coreJob?

// ...

func collect() {
job = flow.launchCollect { [weak self] value in
_ = self?.subscriber?.receive(value)
} onCompletion: { [weak self] error in
if let error = error {
self?.subscriber?.receive(completion: .failure(FlowError(throwable: error)))
} else {
self?.subscriber?.receive(completion: .finished)
}
}
}

func cancel() {
job?.cancel(cause: nil)
job = nil
}
}

Now the FlowCollector publisher is type-safe as well! To make the project compile again, all we need to do is remove the unnecessary Failure type parameter when instantiating the publisher from Flow. We could even remove the Outputtype parameter as well, since the KMP shared library now provides a type-safe Flow that includes type information.

final class ContentViewModel: ObservableObject {
// ...

private var encodedTextSubscription: AnyCancellable?

init() {
let textEncoder = TextEncoder(rawText: $rawText.map(TextString.init(body:)).flow)
encodedTextSubscription = Publishers.FlowCollector(flow: textEncoder.encodedText)
// ...
}
}

We’re all done! Now it’s time to see our improvements in action!

Benefits of Type-Safe Implementation: A Practical Example

Writing software is always about making improvements. We strive to fix bugs, expand functionality, and make our code more stable and robust. Let’s consider how we can enhance our TextEncoder. We’ve already used specific types for TextString in both the input and output of the encoder. Now, let’s make TextEncoder more precise about what it accepts as input and what it produces as output.

Enhancing TextEncoder with Distinct Input and Output Types

Let’s create different classes for storing raw text obtained from the text field and encoded text produced by the API for display on the screen:

data class RawText(val body: String)
data class EncodedText(val value: String)

Both are simple wrappers around the standard String, but using such types adds a type constraint that helps prevent developers from using the API incorrectly.

Now we can update our TextEncoder to replace TextString with these specific types:

class TextEncoder(rawText: MultiplatformFlow<RawText>) {
@OptIn(ExperimentalEncodingApi::class)
val encodedText: MultiplatformFlow<EncodedText> = rawText.map { text ->
EncodedText(Base64.encode(text.body.encodeToByteArray()))
}.multiplatform()
}

This change will result in compilation errors on both iOS and Android.

That’s exactly what we wanted! Now both platforms behave identically. More importantly, on iOS, there is no longer a risk of breaking the app due to missed changes in the shared codebase.

Conclusion

Writing cross-platform shared code in Kotlin for use in Objective-C/Swift can be challenging, particularly when dealing with generic components in your API. Kotlin’s interoperability limitations can reduce the features supported on iOS. However, there are always workarounds and alternatives to achieve native functionality.

In this article, we explored how to overcome some of these limitations by applying type-safe practices and adjusting implementations to work seamlessly across both platforms. By using specific types and custom wrappers, we can enhance the robustness of our cross-platform code and ensure that it behaves consistently on both iOS and Android.

I hope this article inspires you to tackle similar challenges in your KMP projects and provides you with practical strategies for working around interoperability issues.

For further reading and examples, check out the following resources:

A complete sample project demonstrating the concepts discussed in this article is available on GitHub.

--

--

Volodymyr Voiko
Snapp Mobile

iOS developer @ Snapp Mobile. Working remotely from Zaporizhzhya, Ukraine 🇺🇦.