Skip to main content

Flows

The problem with Flows in the context of Swift interop is that it is a regular Kotlin interface. While Swift also has its own implementation of Reactive Streams API in the form of AsyncSequence, these two are incompatible. Therefore, there is not only no direct interop between them, but there is also no easy way to manually cast one to the other.

SKIE automatically converts Flows to custom Swift classes that implement AsyncSequence, eliminating the need to convert between the two types. Because of this conversion, Swift handles the converted Flows exactly like real AsyncSequences with all the expected semantics. Therefore, you can use the same techniques and libraries with Flows as you would with AsyncSequences. As a bonus, the transformation preserves the type argument T of the Flow<T>, which was previously lost due to Flows being interfaces.

tip

Because the custom classes are actual Swift classes, their type parameters don't have to be an AnyObject. This fact allows us to better support some Objective-C types that are bridged to Swift types (by the Swift compiler).
So, for instance, a SomeKotlinClass<String> would usually be converted to SomeKotlinClass<NSString> by the Kotlin compiler. But in the case of Flows, it is automatically converted by Swift to the equivalent of SomeKotlinClass<String>.

As with suspend functions, the concurrency contexts of both Kotlin and Swift cooperate, so there is the two-way cancellation and no thread restriction. If the Flow is canceled from Kotlin, SKIE automatically cancels the Swift task that collects the Flow. Contrary to Kotlin Coroutines, SKIE does not throw a CancellationException or its equivalent. Instead, the cancellation is handled by Swift which skips over the for-await loop as if it has already collected all the elements of the Flow. This behavior is consistent with how Swift handles the cancellation of AsyncSequences. If you need to handle the cancellation in Swift, you can use the withTaskCancellationHandler.

SKIE currently supports all the frequently used Flow types from the kotlinx-coroutines library, namely:

  • Flow, which is converted to SkieSwiftFlow
  • SharedFlow -> SkieSwiftSharedFlow
  • MutableSharedFlow -> SkieSwiftMutableSharedFlow
  • StateFlow -> SkieSwiftStateFlow
  • MutableStateFlow -> SkieSwiftMutableStateFlow

Example

Suppose Kotlin exposes a StateFlow with a List<String> as a type argument:

Kotlin
class ChatRoom {
private val _messages = MutableStateFlow(emptyList<String>())
val messages: StateFlow<List<String>> = _messages
// etc
}

With SKIE, Swift sees the StateFlow<List<String>> as a class conforming to AsyncSequence, with an array of strings ([String]), as its type argument.

Swift with SKIE
class ChatRoomViewModel: ObservableObject {
let chatRoom = ChatRoom()

@Published
private(set) var messages: [String] = []

@MainActor
func activate() async {
for await messages in chatRoom.messages {
// No type cast (eg `it as! [String]`) is needed because the generic type is preserved.
self.messages = messages
}
}
}

The converted Flows also seamlessly integrate with Swift's concurrency lifecycle. In our example, a task modifier starts the loop in activate(), which is automatically canceled when the user leaves the screen.

Swift with SKIE
VStack {
// SwiftUI views ..
}
.task {
await viewModel.activate()
}
tip

Because the converted Flows implement AsyncSequence, you can seamlessly use Swift's higher-order functions like map() and filter() and libraries like AsyncExtensions.

Limitations

Due to how the flow conversion works, there are some limitations that you need to be aware of:

  • Custom exceptions originating in Flow cannot be propagated to Swift and will cause a runtime crash.
  • The SKIE Flow classes do not support regular type casting.
  • The SKIE Flow classes do not inherit from each other.
  • Not all instances of Flow can be converted automatically.
  • Conversions in the other direction (from AsyncSequence to Flow) are not supported.
  • Custom Flow types are not supported.

To better understand these limitations, let's look at how the conversion works:

SKIE provides many different classes that represent different Flows in Swift. These classes are grouped into two families:

  • SkieKotlin___Flow
  • SkieSwift___Flow

Each family provides implementations for each Flow type with names derived from the corresponding interface name. The SkieSwift___Flow classes are written in Swift and implement the AsyncSequence protocol (but not the Flow interface). The SkieKotlin___Flow classes are from Kotlin and implement the Flow interface.

note

Because SkieKotlin___Flow are Kotlin classes, they have type arguments even in Swift, which is not the case with the Flow interfaces.

The SkieKotlin___Flow classes are bridged to SkieSwift___Flow using the Swift bridging mechanism (similarly to how NSString is converted to String). In order for the bridging to work, SKIE replaces most occurrences of Flow in the exported Kotlin code with SkieKotlin___Flow. Swift then automatically converts these instances to SkieSwift___Flow when possible.

Type casting

The problem with type casting is that SKIE replaces Flows only during compile time, not in runtime. Therefore, it's not possible to use as!, as? or is with SkieKotlin___Flow - it may result in unpredictable behavior or a runtime crash. The only exception (when it is safe and might be required) is right after calling one of the SkieKotlin___Flow constructors, which perform the conversion in runtime.

So, for example, you can write SkieKotlinFlow<KotlinInt>(swiftFlow) as! SkieKotlinFlow<KotlinNumber> to change the generic argument (which is impossible with SkieSwift___Flow). However, you cannot safely do KotlinA().flow() as! SkieKotlinFlow<KotlinInt>. If you try to force cast such an object, you will get approximately this runtime error Expected xyz but found Kotlin_kobjcc0.

The proper way to convert Flows is to use the conversion constructors provided by SKIE. In the above example, the SkieKotlinFlow<KotlinInt>(swiftFlow) calls one of these constructors. Similar constructors are available for all SkieKotlin___Flow and SkieSwift___Flow classes and support all valid conversions.

Nullable type arguments

All SKIE Flow classes have their optional counterparts. For example, SkieSwiftStateFlow has a corresponding SkieSwiftOptionalStateFlow. The only difference between them is whether the type argument is nullable or not. For example, Flow<Int> is mapped to SkieSwiftFlow<Int>, but Flow<Int?> is mapped to SkieSwiftOptionalFlow<Int>.

note

This distinction is necessary because of limitations in Objective-C generics.

These two types of classes do not inherit from each other, so you cannot use SkieSwiftFlow<Int> instead of SkieSwiftOptionalFlow<Int>. However, you can convert the non-optional variant to the optional one via the conversion constructors.

Unsupported automatic conversions

There are two main situations in which SKIE cannot automatically convert Flows:

  • In type arguments of certain generic types:
    • List<Flow<*>>
    • Map<*, Flow<*>>
    • Flow<Flow<*>>
  • In return types of suspend functions created by SKIE.

In both cases, the problem is that the internal implementation uses typecasting, which would result in a runtime crash, as mentioned above. To avoid this issue, SKIE does not translate Flows in these situations, but you can still do it manually using the conversion constructors. For example: listOfFlows.map { SkieSwiftFlow(SkieKotlinFlow<KotlinInt>($0)) }

Migration and Compatibility

This feature completely changes how Flows works in Swift, so migration will likely be significantly more involved than with other features. The exact needed changes will depend on your project and what solution to the Coroutines interop it currently uses. This makes it difficult to provide a general guide for migration, but you will likely need to do the following:

  • Remove any manual conversions between Flow and AsyncSequence in Swift.
  • Remove all unnecessary type casts in Swift previously required because of the lost type argument.
  • Replace all type casts of Flows in Swift with manual conversions (see Limitations).
  • Fix new issues with incompatible types by manually converting the remaining Flows (see Limitations).
  • If your current solution requires some conversions in Kotlin, you can remove those as well.
  • Check that your code correctly handles the newly supported cancellation.
tip

Projects that use a different implementation of Reactive Streams API (like Combine or RxSwift) can also use this feature. In order to do so, you will need to implement wrappers that bridge the AsyncSequence API to your API of choice. Writing these wrappers is usually relatively straightforward because all Reactive Streams APIs are conceptually similar. You can then use these wrappers to convert the AsyncSequence objects provided by SKIE to your Reactive Streams library.