Engineering

A Flow does not cross to Swift. A callback does.

Exporting a Flow hands Swift something it has no way to collect.

MobileByteSensei 10 min read
kmpioscoroutines

TL;DR

  • A StateFlow<SearchState> exported to Swift arrives as id<SharedKotlinx_coroutines_coreStateFlow> with no collect method Swift could call — collect is suspend and takes a FlowCollector, and neither crosses usefully.
  • The workaround that shipped — a watch(onEach:) callback — leaked: after fourteen navigations, fourteen Kotlin controllers were still live and deinit had never run on any of them, because nothing returned a way to stop watching.
  • The shape that works is a subscribe call returning a cancellation handle, plus [weak self] at the capture site. Both halves are required; either alone still leaks.
  • What reading on buys: why Flow cannot be exported as a collectable type, the handle's implementation, and the measurement that distinguishes a missing cancel from a retain cycle.

The case this post is about

A search controller in shared code, exposing state the way every Kotlin consumer wants it:

// commonMain/kotlin/search/SearchController.kt
class SearchController(private val repo: SearchRepo) {

    private val _state = MutableStateFlow<SearchState>(SearchState.Idle)
    val state: StateFlow<SearchState> = _state.asStateFlow()

    suspend fun search(q: String) {
        _state.value = SearchState.Loading
        _state.value = SearchState.Ready(repo.search(q))
    }
}

Android collects it in one line:

val state by controller.state.collectAsStateWithLifecycle()

And Swift gets this in the generated header:

@interface SharedSearchController : SharedBase
@property (readonly) id<SharedKotlinx_coroutines_coreStateFlow> state;
@end

@protocol SharedKotlinx_coroutines_coreFlow
- (void)collect:(id<SharedKotlinx_coroutines_coreFlowCollector>)collector
   completionHandler:(void (^)(SharedKotlinUnit * _Nullable, NSError * _Nullable))completionHandler;
@end

A collect that wants a FlowCollector — which is a Kotlin interface with a single suspend method, and implementing a suspend-returning Obj-C protocol method from Swift is not a thing you do.

The symptom

The workaround was the obvious one, and it shipped:

// commonMain — the first attempt
class SearchController(private val repo: SearchRepo) {
    private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)

    fun watch(onEach: (SearchState) -> Unit) {
        scope.launch { state.collect { onEach(it) } }
    }
}
// iosApp — what every screen did
final class SearchViewModel: ObservableObject {
    @Published var state: SearchState = .idle
    private let controller = SearchController(repo: repo)

    init() {
        controller.watch { [self] s in self.state = s }
    }
}

That works, visibly, immediately. And:

# Xcode ▸ Debug Memory Graph, after navigating into and out of search 14 times
SearchViewModel          14 instances      ← one visible screen
SharedSearchController   14 instances

Fourteen controllers, each with a live coroutine collecting a flow and writing into a view model whose screen was gone. deinit never ran on any of them.

The second symptom took longer to connect: the search API was being called from screens the user had left. Our own server logs showed it:

$ grep 'GET /search' access.log | awk '{print $7}' | sort | uniq -c | sort -rn | head -3
   14  /search?q=kotlin        ← one typed query, fourteen requests

Each retained controller was still subscribed, so a shared repository refresh fanned out to all of them.

What a consumer must be able to do with a stream checked against the exported signature, showing that subscribe and receive cross while stop does not, because the subscribe function returns no handle and the collect method wants a suspend-returning collector

Why the obvious fix failed

The first fix was [weak self], which is the correct Swift-side half and is not sufficient:

controller.watch { [weak self] s in self?.state = s }
SearchViewModel          1 instance       ← fixed
SharedSearchController   14 instances     ← not fixed

The view model is released and the controller is not, because the controller's own scope holds the collecting coroutine and nothing ever cancels it. [weak self] breaks the cycle back to Swift; it does nothing about the Kotlin side holding a live subscription to its own flow.

The second fix was to cancel the scope in deinit:

deinit { controller.close() }

Which works and relies on deinit running — and deinit is exactly what was not running before the weak capture. So this fix is correct only in combination with the first, and on its own it is a no-op. That ordering caught me: I applied them one at a time, saw each fail, and nearly concluded neither was the answer.

The third attempt was SKIE, which generates Swift-friendly wrappers over Flow and does solve this well. We did not adopt it for this module, for a reason worth stating: it is a compiler plugin in the build of a library consumed by a team that did not want another plugin in their toolchain. That is a constraint, not a technical verdict — if you can use it, the rest of this post is a description of what it generates.

The actual mechanism

Flow is a Kotlin abstraction whose entire contract is expressed in suspend functions, and suspend functions cross the Obj-C boundary as completion handlers.

collect is suspend and takes a FlowCollector. The exporter turns the suspend into a completion handler, which is fine — and the parameter is a Kotlin interface whose single method is also suspend, so Swift would have to implement a protocol method that takes a completion handler and calls it when it has finished emitting. Possible to write and not how anyone wants to consume a stream.

There is no cancellation token in the exported shape. A Kotlin collector is cancelled by cancelling the coroutine that is collecting. That coroutine lives in a Job, and nothing referencing the Job crosses the boundary unless you put it there. So a watch(onEach:) function hands Swift a subscription with no off switch — the API is missing a parameter, and the compiler cannot say so because "returns Unit" is a legal signature.

And the scope's lifetime is the controller's, which on iOS nothing manages. Dispatchers.Main.immediate makes the callbacks arrive on the right thread and has no bearing on when the scope dies.

So the leak has two independent causes and both must be fixed:

Swift → Kotlin   the view model holds the controller          → ARC strong reference
Kotlin → Swift   the controller's coroutine holds the closure → a Kotlin heap reference
                 which captured `self`                        → the cycle
Kotlin → Kotlin  the scope holds the coroutine                → never cancelled

[weak self] cuts the second edge. Nothing cuts the third except an explicit cancel, and nothing can call that cancel unless the subscribe returned something to call it on.

Three reference edges holding the leak, with the weak capture cutting only the Kotlin-to-Swift one and the uncancelled coroutine held by its own scope needing an explicit handle that the subscribe call must return

The fix

A subscribe function returning a cancellation handle, and a Swift wrapper that owns it.

// commonMain/kotlin/interop/Cancellable.kt
//
// A one-method CLASS rather than a `fun interface`. Both export and both can be held from
// Swift — a `fun interface` becomes an Obj-C protocol, and `any SharedCancellable` is a
// perfectly storable property. The class earns its place for a smaller reason: it gives the
// handle a concrete exported type, so the Swift property is `Cancellable?` rather than an
// existential, and the idempotence below lives in one place instead of in every implementor.
class Cancellable internal constructor(private val onCancel: () -> Unit) {
    private val cancelled = atomic(false)

    /** Idempotent: deinit and an explicit stop may both call this, in either order. */
    fun cancel() {
        if (cancelled.compareAndSet(expect = false, update = true)) onCancel()
    }
}
// commonMain/kotlin/search/SearchController.kt
class SearchController(private val repo: SearchRepo) {

    private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
    private val _state = MutableStateFlow<SearchState>(SearchState.Idle)

    /** For Kotlin callers — the real thing, unchanged. */
    val state: StateFlow<SearchState> = _state.asStateFlow()

    /**
     * For Swift. Returns a handle, which is the parameter the first version was missing:
     * without it the caller has a subscription and no way to end it, and no amount of
     * discipline at the call site can supply one.
     *
     * The initial value arrives before this function returns, so a SwiftUI view has state on
     * first render rather than a frame of placeholder — but NOT because of an explicit call.
     * StateFlow replays its current value to every new collector, and Main.immediate does not
     * re-dispatch when it is already on the main thread, so `launch` runs the body up to the
     * first suspension inline. An extra `onEach(_state.value)` here was the first version and
     * it delivered the initial state TWICE: once by hand, once by the replay.
     */
    fun watch(onEach: (SearchState) -> Unit): Cancellable {
        val job = scope.launch { state.collect { onEach(it) } }
        return Cancellable { job.cancel() }
    }

    fun close() = scope.cancel()
}

The Swift side, where the handle is owned by the thing whose lifetime should bound it:

// iosApp/Sources/SearchViewModel.swift
@MainActor
final class SearchViewModel: ObservableObject {
    @Published var state: SearchState = .idle

    private let controller: SearchController
    private var subscription: Cancellable?

    init(repo: SearchRepo) {
        self.controller = SearchController(repo: repo)

        // [weak self] cuts the Kotlin→Swift edge. The stored handle cuts the Kotlin→Kotlin
        // one. BOTH are required: with only the weak capture the coroutine runs forever;
        // with only the handle, deinit never fires so cancel() is never called.
        subscription = controller.watch { [weak self] s in
            guard let self else { return }
            self.state = s
        }
    }

    deinit {
        subscription?.cancel()
        controller.close()
    }
}

And the output it produced — the same fourteen-navigation run, read from the memory graph:

$ # navigate into and out of search 14 times, then Debug Memory Graph

SearchViewModel            0 instances
SharedSearchController     0 instances
SharedCancellable          0 instances

$ grep -c 'GET /search?q=kotlin' access.log
1

One request for one typed query, and nothing retained.

How we proved it

That the two fixes are independent and both necessary — the matrix, because applying them one at a time is what made each look useless:

                                   SearchViewModel   SearchController   /search calls
as shipped                              14                 14               14
+ [weak self] only                       1                 14               14
+ handle, cancelled in deinit only      14                 14               14
+ both                                   0                  0                1

Row three is the instructive one: the handle alone changes nothing, because deinit is what calls cancel() and the retain cycle is what prevents deinit. A fix that depends on a callback the bug suppresses will always measure as a no-op.

That the leak was a live subscription and not merely a retained object. A retained object with a dead coroutine is a memory problem; a retained object with a live one is also a correctness and a cost problem, and the server log separates them:

# 14 retained controllers, repository refresh triggered once
$ grep -c 'GET /search' access.log
14

Fourteen outbound requests for one user action is the measurement that made this a priority rather than a cleanup task.

What the two declarations actually export, read from the generated header rather than assumed — because I had believed one of them did not cross, and it does:

# fun interface Cancellable { fun cancel() }
$ grep -A2 'protocol SharedCancellable' shared.h
@protocol SharedCancellable
- (void)cancel;      ← an existential: Swift holds `any SharedCancellable`

# class Cancellable(private val onCancel: () -> Unit)
$ grep -A2 'interface SharedCancellable' shared.h
@interface SharedCancellable : SharedBase
- (void)cancel;      ← a concrete type: Swift holds `Cancellable`

Both are storable, so this is an ergonomics choice and not the load-bearing one. The load-bearing part is that watch returns something; what that something is declared as is a matter of taste.

That cancel() is safe to call twice. The fixture is the real sequence — an explicit stop followed by deinit — which without the atomic would cancel an already-cancelled job and, in the version before this one, re-enter onCancel on a scope that had been cancelled:

$ ./gradlew :shared:jvmTest --tests '*CancelIsIdempotent*'
PASSED   cancel() × 3 → onCancel invoked 1 time

What I cannot tell you is whether the synchronous initial emission is right for every consumer. It removes a frame of placeholder in SwiftUI and it means watch invokes its callback before returning — so a caller that assigns the handle and mutates state in the callback sees the mutation first. That ordering surprised one reviewer. It is documented and it is a trade, not a clean win.

And an honest scope limit. Fourteen screens in one app, one flow type. The mechanism is the export shape rather than anything about search, so I expect it to transfer; the leak count is a function of how many screens subscribe, which is the least interesting number in the post.

Fourteen navigations measured four ways, with the weak capture freeing the view model while fourteen controllers remain live and still issuing fourteen search requests for one typed query, and only the combination reaching zero

What this generalizes to

An abstraction expressed entirely in a language's own features does not cross a boundary that lacks them, and the honest move is to export a different shape rather than a degraded version of the same one. The question to ask of any streaming API at an FFI is not how to export the stream but what the consumer must be able to do — subscribe, receive, and stop — and then to check that the exported signature contains all three. A subscribe that returns Unit is missing the third, and nothing in the type system will mention it.


Keep reading

One post a week on shipping mobile software. No digests, no course pitch. Subscribe.

One post a week, no digest

New writing when it lands. No roundups, no course pitch, no "10 tips".

Follow by RSS