Engineering

We shared 60% of the code and one platform used all of it

Measured per target rather than per line: the iOS app reached a third of the shared surface because the API was shaped around Android's lifecycle.

MobileByteSensei 11 min read
kmparchitecturepractices

TL;DR

  • "60% shared" was true and measured the wrong thing. It counted lines that compile for every target. Measured by what each target actually calls, iOS reached 34% of the shared public surface and Android reached 96%.
  • The gap was not missing iOS features. It was API shape: 41 of 58 shared public entry points assumed an Android-ish lifecycle — a Context-like handle, an owner that outlives a screen, a "resume" that iOS does not signal — so the iOS app had its own Swift implementations beside them.
  • The fix was not to share more. It was to find the 11 duplicate implementations, see which shared API each one was routing around, and reshape those.
  • What reading on buys: the reach measurement (a grep and a linker map), the four shapes that blocked iOS, and what 34% → 70% actually changed.

The case this post is about

The number everyone quoted, from a line count:

$ tokei --exclude '*Test*' shared/src | grep -E 'commonMain|androidMain|iosMain'
  commonMain    18,420 lines
  androidMain    6,110
  iosMain        2,840
  desktopMain    1,290
  wasmJsMain     1,460
# 18,420 / 30,120 = 61.2%

Sixty-one percent of the Kotlin lives in commonMain. That is a true statement about the repository and it says nothing about whether anyone calls it.

And the shape that produced the problem, repeated across the shared surface:

// core/sync/src/commonMain/kotlin/SyncManager.kt
class SyncManager(
    private val repo: SyncRepo,
    private val scope: CoroutineScope,
) {
    /** Call from the host's onResume. */
    fun onResume() { scope.launch { repo.pull() } }

    /** Call from the host's onPause. */
    fun onPause() { pending?.cancel() }

    /** Register once per Activity. */
    fun observeConnectivity(owner: LifecycleOwner, onChange: (Boolean) -> Unit) { … }
}

Three methods, all shaped around a host that has onResume, onPause and a LifecycleOwner.

The symptom

The iOS app growing Swift code that duplicated Kotlin we had already written:

# Swift files implementing something the shared module also implements
  iosApp/Sync/SwiftSyncCoordinator.swift          312 lines   ← duplicates SyncManager
  iosApp/Network/ReachabilityWatcher.swift         88          ← duplicates observeConnectivity
  iosApp/Storage/KeychainStore.swift              140          ← duplicates SecureStore
  …
  11 files, 1,840 lines

And the measurement nobody had taken, once we took it:

# shared PUBLIC entry points (classes, top-level functions, public members): 58
  target     entry points called    reach
  android            56              96%
  desktop            29              50%
  web                24              41%
  ios                20              34%

Three of four targets used half or less of the shared surface. The 61% figure and the 34% figure are both correct and they answer different questions — and only one of them is about whether sharing worked.

The cost was not abstract:

# bugs fixed in Kotlin that did NOT reach iOS, because iOS used its own implementation
  12 months          7 bugs
  of which, re-fixed in Swift later     5
  of which, never fixed on iOS          2    (found during this audit)

Sixty-one percent of lines shared against call-site reach measured per target, with one platform reaching ninety-seven percent and another thirty-four, and seven Kotlin bug fixes that never reached iOS

Why the obvious fix failed

The first response was to set a target: "get iOS to 80% reach". Which produced a quarter of work shaped by a number rather than by a problem — two of the APIs iOS was not calling, it genuinely should not call (an Android-only notification channel helper, a WorkManager scheduler), and "reach" counted them against us.

The second response was to delete the Swift duplicates and force the shared API:

// SwiftSyncCoordinator deleted; call SyncManager directly
syncManager.onResume()      // ← from where?

From where. SwiftUI has no onResume. We wired it to scenePhase == .active, which fires on every return from the app switcher and on every alert dismissal on some iOS versions — so pull() ran 3.4× more often than on Android, and the duplicate existed partly because someone had already discovered that.

The third response was an expect/actual lifecycle shim so onResume would have an iOS meaning. Which makes the shared API callable by giving a platform a vocabulary it does not have, and the shim then needs its own per-platform semantics — the problem moved one file down.

The actual mechanism

The shared percentage measures compilation, and the thing you care about is call-site reach. They diverge in exactly one way: shared code that compiles for a target the target cannot sensibly call. And when we read the 38 unreached entry points, they clustered into four shapes:

  shape                                            entry points   iOS can call it?
  takes a lifecycle owner / Context-like handle          14        no — no equivalent
  requires the caller to signal resume/pause             11        no — no such callback
  returns a type only Android consumes (LiveData,
    WorkRequest, Intent-shaped payload)                   9        no — unusable in Swift
  genuinely Android-only by design                        4        correctly not called
                                                     ──────
                                                         38

Fourteen take a handle iOS does not have. A LifecycleOwner parameter makes a function uncallable from Swift, and the expect/actual version of that parameter is a type with no meaningful iOS implementation.

Eleven ask the caller to signal a lifecycle iOS does not emit. onResume/onPause is not a vocabulary SwiftUI has; the nearest thing has different firing semantics, which is a behavioural difference rather than a naming one.

Nine return an Android type. A LiveData or a WorkRequest crossing into Swift is either unusable or wrapped, and the wrapper is where the duplicate starts.

And four are correctly Android-only — which matters, because it means 100% reach is the wrong goal and a reach target is the wrong instrument.

So the real finding is the inverse of "share more". Every one of the 11 Swift duplicates was routing around a shape, not a missing feature. The shared code was written first on Android, by people thinking in Android, and it compiled for iOS the whole time.

Thirty-eight unreached shared entry points categorised by shape, showing thirty-four blocked by an API shape and only four legitimately platform-specific

The fix

Start from the duplicates, not from the percentage. For each one, find the shared API it routes around and reshape that — which is a bounded piece of work with a definite end, unlike a reach target.

// core/sync/src/commonMain/kotlin/SyncManager.kt
/**
 * Reshaped from three lifecycle callbacks to one scope-owned loop.
 *
 * The old shape asked the HOST to call onResume/onPause and to hand over a LifecycleOwner.
 * Android has all three; SwiftUI has none of them, which is why iosApp carried a 312-line
 * SwiftSyncCoordinator doing the same work beside this class. Forcing the old API onto
 * scenePhase made pull() run 3.4× more often than on Android — the duplicate existed partly
 * because someone had already found that.
 *
 * Now: the caller supplies a scope and a Flow of "the app is foreground". Both exist on all
 * four targets; neither is an Android concept wearing a multiplatform name.
 */
class SyncManager(private val repo: SyncRepo) {

    fun syncWhileForeground(
        scope: CoroutineScope,
        isForeground: Flow<Boolean>,
    ): Job = scope.launch {
        isForeground
            // The de-dup the Swift coordinator had and this class did not: scenePhase fires on
            // alert dismissal on some iOS versions, so a raw edge is not a foreground event.
            .distinctUntilChanged()
            .collectLatest { foreground -> if (foreground) repo.pull() }
    }

    /**
     * Connectivity as a Flow rather than (owner, callback). The owner parameter was the single
     * most common reason an API was uncallable from Swift — 14 of 58 entry points took one.
     */
    fun connectivity(): Flow<Boolean> = repo.connectivityChanges().distinctUntilChanged()
}
// iosApp — the 312-line coordinator becomes a wiring call
@main struct MyApp: App {
    @Environment(\.scenePhase) private var phase
    private let sync = SyncManager(repo: …)

    var body: some Scene {
        WindowGroup { ContentView() }
            .onChange(of: phase) { foregroundSubject.send($0 == .active) }
    }
}

And the measurement, in CI, because the number that mattered was one nobody was computing:

# tools/shared-reach.sh — run per target, reported per PR
set -euo pipefail

# The shared PUBLIC surface: what a consumer could call.
./gradlew :core:printPublicApi > /tmp/surface.txt        # one fully-qualified name per line
total=$(wc -l < /tmp/surface.txt)

# What a target ACTUALLY calls. Not a line count: the question is call-site reach, and the
# 61%-shared figure that satisfied us for a year was measuring compilation instead.
for target in android ios desktop web; do
  called=0
  while read -r sym; do
    short=${sym##*.}
    # Android/desktop/web: grep the target's own sources. iOS: the Swift sources plus the
    # linker map, since Swift can reach a symbol our grep patterns miss.
    case $target in
      ios) hit=$( { grep -rl "\b$short\b" iosApp --include='*.swift' || true
                    grep -l "$sym" build/ios/App.linkmap 2>/dev/null || true; } | head -1 ) ;;
      *)   hit=$(grep -rl "\b$short\b" "src/${target}Main" --include='*.kt' 2>/dev/null | head -1) ;;
    esac
    [ -n "$hit" ] && called=$((called+1))
  done < /tmp/surface.txt
  echo "$target: $called/$total = $((called*100/total))%"
done

# And the finding that actually drives work: shared APIs with a same-named duplicate in a
# platform source set. This is the list that is bounded and finishable.
echo "--- duplicates ---"
while read -r sym; do
  short=${sym##*.}
  grep -rl "class *${short#*.}\|struct ${short#*.}" iosApp --include='*.swift' 2>/dev/null \
    | sed "s|^|  $short duplicated by |"
done < /tmp/surface.txt

And the output it produced:

$ bash tools/shared-reach.sh
android: 56/58 = 96%
ios:     41/58 = 70%
desktop: 38/58 = 65%
web:     31/58 = 53%
--- duplicates ---
  SecureStore duplicated by iosApp/Storage/KeychainStore.swift

iOS went from 34% to 70%. One duplicate remains — SecureStore, which is a genuine platform boundary we chose to leave as Swift and is now a recorded choice rather than an unnoticed one.

$ # the four shapes, after
  shape                                            entry points   change
  takes a lifecycle owner / Context-like handle          2         was 14
  requires the caller to signal resume/pause             0         was 11
  returns a type only Android consumes                   3         was 9
  genuinely Android-only by design                       4         unchanged — correctly so
$ ./tools/bug-reach.sh --days 180
  bugs fixed in Kotlin that did not reach iOS
    before (prior 180 days)   4
    after                     0

How we proved it

That the two numbers measure different things. The measurement that reframed the project, and the cheapest one here:

  line-share (tokei, commonMain / total)                61%
  call-site reach, averaged across the four targets     55%
  call-site reach, iOS alone                            34%

The first number cannot go down when a target stops calling something. The second can.

That the unreached APIs cluster by shape, not by feature. The reading that turned a reach number into a work list:

  38 unreached entry points, categorised by hand
    lifecycle-owner parameter        14
    resume/pause callback contract   11
    Android return type               9
    correctly Android-only            4

Thirty-four of thirty-eight are shape. Four are legitimate.

That every Swift duplicate routed around one of those shapes. Checked one by one, since this is the claim the fix rests on:

  Swift file                      routes around                       shape
  SwiftSyncCoordinator.swift      SyncManager.onResume/onPause         resume/pause
  ReachabilityWatcher.swift       observeConnectivity(owner, cb)       lifecycle owner
  KeychainStore.swift             SecureStore                          genuine boundary
  … 8 more                        …                                    8 shapes

Ten of eleven were shape. One was a real platform boundary.

That forcing the old API onto scenePhase was worse. Measured before deciding to reshape rather than to wire, because wiring is the cheaper-looking option:

  pull() invocations per foreground hour
    android, onResume                    4.1
    ios, scenePhase == .active          13.9     ← 3.4×
    ios, distinctUntilChanged Flow        4.3

The duplicate's de-dup logic was load-bearing, and the shared class did not have it.

That a reach target is the wrong instrument. Established by what the first quarter's work produced:

  work driven by "get iOS to 80%"
    APIs made iOS-callable that iOS should not call    2
    net reach gained from them                        +3 points
    bugs prevented                                     0

That the duplicate list is the finishable one. The contrast that justifies the script's second half:

  instrument            size      has an end?
  "raise reach to 80%"   open      no — includes APIs that should not be called
  "11 duplicates"        11        yes — and each one names its own cause

What I cannot tell you is whether 70% is where this should stop. The remaining 17 unreached entry points are 4 legitimately Android-only, 3 Android return types and 2 lifecycle-owner parameters we have not reshaped, and 8 that iOS could call and does not need yet — and I cannot distinguish "does not need" from "has not noticed" without a product conversation per API.

And an honest limitation of the measurement. The reach script greps for short names, so it over-counts on common words (Config, State) and the iOS pass needs the linker map because Swift can reach a Kotlin symbol our patterns miss. Both error in the direction of reporting more reach than exists, which means the 34% was, if anything, generous.

Each Swift duplicate traced to the shared API shape it routes around, the forced wiring running the sync three times too often, and the contrast between an open reach target and a finishable duplicate list

What this generalizes to

A sharing percentage computed from source counts the code that compiles for every target, which is a property of the build rather than of the architecture. The number that tells you whether sharing worked is how much of the shared surface each target actually calls — and when that number is low, the cause is usually not missing features but API shape: a parameter one platform cannot supply, a callback one platform does not emit, a return type one platform cannot use. The tractable version of the problem is not the percentage at all. It is the list of duplicate implementations, because each one names the shape it was written to avoid.


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