Engineering

Every list you hand to Swift is copied, and the cost is per call

A Kotlin List becomes an NSArray by conversion, not by reference.

MobileByteSensei 10 min read
kmpiosperformance

TL;DR

  • A SwiftUI list bound to viewModel.visibleItems — a Kotlin List<Item> property — dropped to 41 fps on a 2,000-row feed. The Kotlin side was doing no work; the property read was copying 2,000 objects, 60 times a second.
  • A Kotlin collection crossing to Swift is converted, not referenced. List<Item> becomes an NSArray by building a new array and bridging every element.
  • Three changes took it to a steady 60 fps: expose a count and an indexed accessor, convert once per change rather than per read, and keep the element type small.
  • What reading on buys: the per-call cost by collection size, which conversions are O(1) and which are not, and the one measurement that distinguishes this from a Kotlin performance problem.

The case this post is about

A feed view model in shared code, exposing its items the obvious way:

// commonMain/kotlin/feed/FeedViewModel.kt
class FeedViewModel(repo: FeedRepo) {

    private val _state = MutableStateFlow(FeedState())

    /** Everything the list should show, filtered and sorted. */
    val visibleItems: List<Item>
        get() = _state.value.items
            .filter { it.matches(_state.value.query) }
            .sortedWith(_state.value.sort.comparator)
}
// iosApp/Sources/FeedView.swift
struct FeedView: View {
    @ObservedObject var model: FeedModel

    var body: some View {
        List(model.viewModel.visibleItems, id: \.id) { item in
            FeedRow(item: item)
        }
    }
}

That reads correctly in both languages and is the shape every Kotlin consumer uses.

The symptom

Scrolling a 2,000-row feed on an iPhone 12:

# Xcode ▸ Instruments, Time Profiler, 10 s of scrolling
  frame rate                      41 fps (target 60)
  heaviest stack
    43.2%  SharedFeedViewModel.visibleItems
      38.1%  kotlin.collections.List → NSArray conversion
       4.8%  SharedItem allocation
  allocations during the scroll   1,214,000 objects

A million allocations in ten seconds, inside a property getter that does a filter and a sort. The first hypothesis was the filter and the sort — the Kotlin code is doing real work, on every read, which is a genuine problem and is not this one.

Caching the computation did not help much:

// cached: filter and sort only when the inputs change. NOT `by lazy` — lazy caches once and
// forever, and its argument is a lock rather than a dependency.
val visibleItems: List<Item> get() = computed.value

private val computed: StateFlow<List<Item>> = _state
    .map { s -> s.items.filter { it.matches(s.query) }.sortedWith(s.sort.comparator) }
    .stateIn(scope, SharingStarted.Eagerly, emptyList())
  frame rate                      44 fps
  SharedFeedViewModel.visibleItems  39.4%
    38.0%  List → NSArray conversion      ← unchanged
     1.1%  the cached computation

Three frames per second. The filter and sort were 1% and the conversion was 38%, and the conversion happens whether or not the Kotlin value is cached — because the conversion is not part of the Kotlin computation at all.

Ten seconds of scrolling profiled before and after caching the Kotlin computation, showing the filter and sort falling to one percent while the collection conversion stays at thirty-eight and the frame rate moves by three

Why the obvious fix failed

The first attempt was to return an array type that sounded closer to the destination:

val visibleItems: Array<Item> get() = …

Array<Item> exports as NSArray too, with the same per-element bridge, and it costs a Kotlin toTypedArray() on top. 44 fps → 43.

The second attempt was kotlinx.collections.immutable:

val visibleItems: ImmutableList<Item> get() = …

Immutability is a Compose stability concern and has no bearing on the Obj-C boundary — an ImmutableList still exports as NSArray by conversion. Unchanged.

The third attempt was to hold the list on the Swift side and update it on change:

@Published var items: [Item] = []
// … updated from a Kotlin callback

Which does fix the per-read cost and introduced a second copy: the callback hands over a Kotlin list, that gets converted once, and then SwiftUI's diffing copies the [Item] again on each assignment. Better — 52 fps — and still paying a full conversion per change, which on a fast-typing filter was once per keystroke.

The actual mechanism

There is no shared representation between a Kotlin collection and an NSArray, so the boundary builds one.

List<Item> is a Kotlin object with Kotlin iteration. NSArray is an Obj-C class with its own storage and its own protocol. Neither can be reinterpreted as the other, so the exporter emits a conversion: allocate an NSArray, walk the Kotlin list, bridge each element, insert it.

Every element is bridged individually. Item is a Kotlin class, so each one is reached through a SharedItem — an Obj-C object holding a reference to the Kotlin instance. The runtime associates a Kotlin object with its wrapper, so the first read of a 2,000-item list allocates 2,000 wrappers and later reads of the same instances look the existing ones up. What is never reused is the NSArray: it is allocated and filled element by element on every single read, and the per-element retain, lookup and insert is the cost that repeats.

And the conversion happens per property read, because a property read is a method call across the boundary. Swift has no way to know the value is the same as last time; visibleItems is [SharedFeedViewModel visibleItems], and the exporter's implementation converts on each invocation.

Which is why SwiftUI makes it expensive. body is re-evaluated whenever SwiftUI decides to — during a scroll, that is every frame. Each evaluation reads model.viewModel.visibleItems once, which is one full conversion of 2,000 elements at 60 Hz.

So the cost is not Kotlin's and not Swift's: it is elements × reads, and a declarative UI makes reads large. That also explains why caching the Kotlin computation barely moved it — caching reduces the work behind the property and not the number of conversions.

What is and is not O(1) across the boundary turned out to be the useful thing to know:

  an Int, a String, a Boolean           bridged per value, cheap, no allocation for Int
  a Kotlin class instance               one wrapper allocation, then cached per instance
  List<T> / Set<T> / Map<K,V>           FULL conversion, every call, O(n)
  a count (Int property)                O(1)
  an indexed get(i)                     O(1) plus one element bridge

A count and an indexed accessor are both O(1), which is exactly what a list view needs.

Which values are constant-time across the boundary and which are linear, with a count and an indexed accessor flat across collection sizes while the collection property itself scales with the number of elements

The fix

Expose the shape a list view actually consumes — a count and an indexed accessor — and convert at most once per change rather than once per read.

// commonMain/kotlin/feed/FeedViewModel.kt
class FeedViewModel(private val repo: FeedRepo) {

    private val _state = MutableStateFlow(FeedState())

    /** For Kotlin consumers: the real collection, unchanged. */
    val visibleItems: List<Item> get() = computed.value

    // Computed once per input change, not per read. The Kotlin side was never the cost, and
    // this is here so the indexed accessor below is O(1) rather than O(n) per element.
    private val computed: StateFlow<List<Item>> = _state
        .map { s -> s.items.filter { it.matches(s.query) }.sortedWith(s.sort.comparator) }
        .stateIn(scope, SharingStarted.Eagerly, emptyList())

    // ── the exported surface for a list view ──────────────────────────────────────────
    //
    // A count and an indexed get. Both O(1) across the boundary, where the collection
    // property is O(n) PER READ — and a declarative UI reads it once per frame.

    val visibleCount: Int get() = computed.value.size

    /**
     * Returns null rather than throwing on an out-of-range index. SwiftUI can ask for an
     * index from a frame it rendered before the list shrank, and a crash there is a race
     * nobody can fix from the Swift side.
     */
    fun visibleItemAt(index: Int): Item? = computed.value.getOrNull(index)

    /** A monotonic token Swift can compare to decide whether to rebuild its own array. */
    val visibleRevision: Long get() = revision.value
    private val revision = MutableStateFlow(0L)
        .also { r -> scope.launch { computed.collect { _ -> r.update { it + 1 } } } }
}
// iosApp/Sources/FeedView.swift
struct FeedView: View {
    @ObservedObject var model: FeedModel

    var body: some View {
        // Driven by a COUNT, so body's evaluation costs one Int read instead of one
        // 2,000-element conversion. Each row then bridges exactly the one item it draws.
        List(0..<Int(model.viewModel.visibleCount), id: \.self) { i in
            if let item = model.viewModel.visibleItemAt(index: Int32(i)) {
                FeedRow(item: item)
            }
        }
    }
}

And the element type, which was the third of the three and is worth its own note:

// commonMain/kotlin/feed/Item.kt
//
// The exported row type carries ONLY what a row draws. The full Item had 19 properties
// including a nested author object and a tag list — and a List<Item> conversion bridges
// the nested collection for every element, so the per-element cost was itself O(n).
data class ItemRow(
    val id: String,
    val title: String,
    val subtitle: String,
    val timestampLabel: String,     // pre-formatted: formatting on the Swift side is a second bridge
    val isUnread: Boolean,
)

And the output it produced, same device, same 2,000-row feed, same ten seconds of scrolling:

$ # Instruments, Time Profiler
                                      before    cached    final
  frame rate                          41 fps    44 fps    60 fps
  visibleItems / visibleCount share    43.2%     39.4%      1.8%
  allocations during the scroll     1,214,000   1,190,000  18,400
  per-keystroke filter latency          84 ms     31 ms      4 ms

How we proved it

That the conversion and not the Kotlin computation was the cost — the measurement that redirected the whole investigation:

                                    visibleItems share   of which conversion
  as shipped (filter + sort per read)       43.2%              38.1%
  with the computation cached               39.4%              38.0%

Caching removed the filter and the sort and left 38%. The remaining cost is not in any Kotlin function.

That it is per-read and not per-change. A counter on the exported getter, during a scroll with no data changing at all:

  data changes in 10 s of scrolling                     0
  visibleItems getter invocations                     614
  elements converted                            1,228,000

Six hundred reads of an unchanged value. Nothing was recomputed and everything was reconverted.

That a count and an indexed get are O(1) across the boundary, measured rather than assumed:

  collection size      visibleItems (ms)   visibleCount (ms)   visibleItemAt (ms)
         100                 0.9                 0.004              0.006
       1,000                 8.7                 0.004              0.006
       2,000                17.4                 0.004              0.006
      10,000                86.1                 0.005              0.006

The collection property is linear and the two accessors are flat. That table is the whole fix.

That the element type's own collections multiply the cost. The nested-list discovery, which I had not expected:

  element type                       conversion of 2,000 elements
  Item (19 props, nested author, List<Tag>)        17.4 ms
  Item without the List<Tag>                        9.1 ms
  ItemRow (5 flat props)                            2.2 ms

A List<Item> where Item contains a List<Tag> bridges the inner list per element, so the per-element cost is itself O(n). Flattening the row type was worth very nearly 8× before any of the other changes.

That the nullable accessor is necessary and not defensive padding. A fixture that shrinks the list between a frame's layout and its row rendering:

$ ./gradlew :feature:feed:iosSimulatorArm64Test --tests '*IndexFromAStaleFrame*'
  with visibleItemAt(i): Item         → IndexOutOfBoundsException, crash
  with visibleItemAt(i): Item?        → row skipped, next frame correct

SwiftUI asked for index 1,842 of a list that had just become 1,100 items. That is not a bug in either language; it is two render loops with different clocks.

What I cannot tell you is where the crossover is. At 100 elements the collection property costs 0.9 ms and nobody would restructure a view model for that — the indexed accessor is worth it somewhere between 100 and 1,000, and exactly where depends on how often the UI reads. Our threshold was "a scrollable list"; a fixed five-item menu still returns a List.

And an honest cost. The exported surface is now three members where it was one, and visibleRevision is a token Swift has to remember to use. Two of the three Swift call sites that needed it got it right and one did not — it rebuilt its array every frame anyway, which cost a conversion we had just removed. A revision token is a protocol, and a protocol can be ignored.

Three measurements: six hundred getter invocations during a scroll in which no data changed, the per-size cost table that motivated the fix, and the nested collection inside the element type making the per-element cost itself linear

What this generalizes to

A type that exists in both languages is not therefore shared, and the boundary's job is to produce one from the other — which for an aggregate means touching every element. The question to ask of any cross-boundary property is how often it is read and how much it costs per read, because a declarative UI turns a cheap-looking property access into a per-frame operation. Where the answer is O(n) per frame, the fix is usually to export the two O(1) questions a consumer actually asks rather than the aggregate it would prefer to receive.


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