I shipped an iOS app rendered with Skia instead of UIKit. Here's what it actually cost

The app is Kotlin Multiplatform with Compose for the UI, which means the iOS screens are drawn by Skia into a single view rather than composed from UIKit. It's on the App Store. I want to write down what that decision costs on the iOS side specifically, including one thing I shipped broken because it can't be fixed from app code.

Text selection in CJK, which I couldn't fix

Long-press to select a word in Chinese and you get one character.

SkiaParagraph.getWordBoundary hands off to Skia, and SkUnicode ships no CJK segmentation dictionary, so you get plain UAX#29. An ideograph is Word_Break=Other and only matches WB999, the "break everywhere else" rule, so every character is its own word.

I spent most of a day trying to fix it from app code before giving up. To fix it you'd need characters merged, not split, and the rules that join across a character (MidLetter, Numeric, ExtendNumLet, ZWJ) all require ALetter/Numeric/Katakana/Hebrew_Letter on both sides. WB4 discards zero-width Format characters before any of that runs, so you can't insert your way out either. I think it needs a change in skiko.

Compose on Android has the reverse bug in the same feature, where a long press swallows the whole sentence, and there you can plant zero-width breaks with ICU and move on. Only one of the two was fixable from where I was sitting.

If anyone has a way around the Skia side I'd like to hear it, this is the part of the app I'm least happy about.

Overlays stop at the safe area

A full-window scrim behind a modal left the status bar and home indicator bands undimmed, because the popup respects platform insets. Looks like a rendering glitch on iOS since a native presentation dims edge to edge.

actual fun fullBleedPopupProperties(): PopupProperties = PopupProperties(usePlatformInsets = false)

Small fix, but you only go looking for it if you already know what the native version looks like.

Material sheets don't move like iOS sheets

This took more time than anything else on the list relative to how small it looks. Material3's ModalBottomSheet settles with a spring and an iOS sheet slides on a decelerating curve, and side by side you can tell immediately even if you can't say why.

Material3 doesn't expose an animation spec for sheets. It reads the show and drag-settle spec from motionScheme.defaultSpatialSpec and the dismiss spec from motionScheme.fastEffectsSpec, and it re-reads both from the ambient theme inside the sheet's own composition, so you have to override through a scoped theme rather than passing anything in:

private val SheetMotionScheme = object : MotionScheme by MotionScheme.standard() {
    override fun <T> defaultSpatialSpec(): FiniteAnimationSpec<T> = tween(400, easing = IosSheetEasing)
    override fun <T> fastEffectsSpec(): FiniteAnimationSpec<T> = tween(400, easing = IosSheetEasing)
}

val IosSheetEasing = CubicBezierEasing(a = 0.32f, b = 0.72f, c = 0f, d = 1f)

There's a fair amount of this kind of work. It doesn't come with the framework and it doesn't show up in any estimate, but users notice when it's missing.

Scroll behaviour you end up rebuilding

UIScrollView hands you a set of behaviours that people read as "this app is put together properly". None of them come with Compose. We wrote all three of these into our own UI library.

alwaysBounceVertical. On iOS a scroll view rubber-bands even when the content fits on screen. Compose won't bounce if there's nothing to scroll, so short pages feel inert next to a native app. Ours is a modifier doing a graphicsLayer translation, which means the list also needs clipToBounds() or the bounce draws the top row over whatever is pinned above it:

LazyColumn(
    modifier
        .fillMaxWidth()
        .clipToBounds()
        .alwaysBounceVertical(listState),
)

scrollsToTop. Tapping the status bar scrolls to top automatically on UIScrollView. In Compose you catch the tap and route it to the right scroll state yourself, and it gets fiddly on pages with a pinned top bar because the thing covering the status bar isn't the thing that scrolls. We ended up with a ScrollBox wrapping the whole scaffold rather than the top bar slot, and the bar opts into the gesture with a modifier.

Swipe to reveal row actions. Nothing built in, so the swipe, the action buttons and the thresholds are all yours. The part that's easy to miss is that opening one row has to close whichever row was open before, or you get two rows showing actions at once, which no iOS list does. Ours is a coordinator passed down through a composition local so the rows can see each other.

None of these were hard to write. They're just things you get on iOS rather than things you build, so nobody thinks to schedule them.

One where iOS was the stricter platform

We stream SSE from the backend. The first version used flow { ... emit(event) } inside Ktor's execute {} block, which passed everything on Android and died on iOS with the backend logging context canceled.

flow enforces context preservation and Ktor's response scope isn't guaranteed to run on the collector's dispatcher. On JVM/OkHttp it happens to, so the check never trips. Kotlin/Native throws, the coroutine fails, the connection drops. channelFlow + send fixes it.

So the iOS build caught a real concurrency bug that the Android build had no way of surfacing. That one went in our favour.

StoreKit 2 detail worth checking in your own code

Sharing the billing logic forced me to be precise about something I'd previously been sloppy with:

/** null when there is no active subscription; throws when the store can't be reached. */
suspend fun subscriptionAutoRenewing(): Boolean?

RenewalInfo.willAutoRenew gives you the real answer, but if "no active subscription" and "StoreKit didn't respond" both collapse into false, a paying user sees a "your subscription has been canceled" banner any time their connection is flaky. Nothing to do with cross-platform, I just found it while writing the shared interface.

One thing that was easier than native

Live language switching. NSLocalizedString resolves against NSBundle, which caches the launch language, so changing language in-app normally means swizzling or a restart. CMP resolves resources per composition against NSLocale.preferredLanguages, which reads AppleLanguages out of NSUserDefaults live.

NSUserDefaults.standardUserDefaults.setObject(listOf(tag), "AppleLanguages")

That plus a key(tag) re-render and all 12 locales swap with no restart. Read preferredLanguages at startup first so you can restore "follow system" later.

Overall

Layout, state and business logic shared fine. What didn't come free is the stuff above: text selection is worse and in one case I couldn't fix it, and insets, scroll behaviour and motion all need deliberate work or the app reads as Android with different colours. A lot of that work is rebuilding things UIKit gives you by default, which is easy to underestimate because you've never had to think about them. You need someone who knows what iOS is supposed to feel like, because nothing in the toolchain will tell you.

Whether that's a good trade depends on how much of your app is the shared part. For us it was worth it. I wouldn't assume that generalises.

reddit.com
u/ikrisliu — 7 days ago

Compose's WordIterator makes a long press select the entire sentence in Chinese

Upfront disclosure: I found this while shipping a Compose Multiplatform app, but this bug is pure Android — it's in androidx ui-text and it reproduces in any Compose app with a SelectionContainer or a selectable Text. If your app has Chinese, Japanese or Korean users, you probably have it right now and haven't noticed.

The symptom

Long-press a word in Chinese prose to select it. Instead of the word, you get the entire clause, stopping only at punctuation. Latin text in the same app behaves perfectly.

That's why this survives review: if you and your QA read English, the selection handles look flawless.

The cause

WordIterator.nextBoundary / prevBoundary skip any boundary whose two sides are both letters or digits. That rule was added for a good reason — a letter↔emoji seam shouldn't split a word — but the check is on character class, and every ideograph is a letter.

So in Chinese, every boundary between two characters qualifies as "letter on both sides", every boundary gets skipped, and the expansion runs until it hits punctuation or the end of the paragraph. English stops at its spaces (spaces aren't letters), which is why the bug is invisible in Latin scripts.

In my testing this is present from 1.8.0 through at least 1.12.0-beta02, and there's no public API to opt out of the behavior.

The workaround

Since you can't change the iterator, you change the text: plant zero-width breaks at Han–Han seams so the iterator has boundaries it won't skip. ICU already knows where the words are — it segments Han by dictionary off the script, not the locale, so the default locale is fine and the boundaries come out identical under zh and en:

fun cjkWordBoundaries(text: String): List<Int> {
    // Latin-only prose already selects correctly and is the common case — skip the scan entirely.
    if (text.codePoints().noneMatch(::isHan)) return emptyList()
    val iterator = BreakIterator.getWordInstance()
    iterator.setText(text)
    val offsets = mutableListOf<Int>()
    var offset = iterator.first()
    while (offset != BreakIterator.DONE) {
        if (text.isHanSeam(offset)) offsets += offset
        offset = iterator.next()
    }
    return offsets
}

/** Interior offsets only, and only where BOTH sides are Han. */
private fun String.isHanSeam(offset: Int): Boolean {
    if (offset <= 0 || offset >= length) return false
    return isHan(codePointAt(offset)) && isHan(Character.codePointBefore(this, offset))
}

private fun isHan(cp: Int): Boolean = Character.UnicodeScript.of(cp) == Character.UnicodeScript.HAN

Two things that matter in the details:

  • Only report Han↔Han seams. A Han↔Latin seam already stops the runaway on its own, so planting a break there would only pollute the text for no gain.
  • Keep the Latin fast path. Most strings in most apps have no Han at all, and you don't want an ICU pass on every selectable Text.

The more general point

The reason I'm posting this rather than just filing it: it belongs to a category I got burned by repeatedly, which is Android quietly being the forgiving platform.

Another one from the same codebase, this time in coroutines. This is fine on Android:

fun stream(): Flow<Event> = flow {
    client.prepareGet(url).execute { response -> /* parse */ emit(event) }
}

flow {}'s emit enforces context preservation — you may not emit from a coroutine context other than the collector's. Ktor's execute {} block gives you a scope that may run on a different dispatcher. On OkHttp it happens to run in-context, so the contract is never violated and everything passes, forever.

It's still a contract violation. It's just latent instead of active, held in place by an implementation detail of the engine you happen to use. Swap the engine, change a dispatcher, and it becomes real. (channelFlow {} + send is the fix — send is safe across contexts.)

Same shape as the WordIterator bug: the platform's forgiving behavior in the common case is exactly what stops you from finding the problem.

What I'd check in your own app

  1. Long-press a Chinese/Japanese sentence in any selectable Text. Takes 10 seconds.
  2. Grep for flow { wrapping a third-party callback or execute/use scope.

Happy to go into detail on either. If someone knows of a ui-text issue already tracking the first one, or a cleaner workaround than planting breaks, I'd genuinely like to hear it — the zero-width approach works but it means the string you select from isn't byte-identical to the string you rendered, and I'm not thrilled about that.

reddit.com
u/ikrisliu — 7 days ago

94% shared code — and 100% of my hard bugs were in the other 6%

I shipped a Kotlin Multiplatform app to the App Store and Google Play. The boring stat first, because everyone asks: ~94% of the code is in commonMain — 51.7k lines shared, against 1.5k in androidMain, 1.4k in iosMain, 130 lines of Kotlin in the Android shell and 423 lines of Swift in the iOS shell. 356 tests, all in commonTest, all running on both targets.

That number is not the interesting part. The interesting part is the 6%, and specifically the bugs that compiled fine, passed on Android, and were broken on iOS — or the reverse. Those are the ones that cost me real days, so here they are.

Stack, for context: Kotlin 2.4.10, Compose Multiplatform 1.11.1, Ktor 3.5.1 + Ktorfit 2.7.5, Koin 4.2.2 with annotation processing, coil3, kotlinx-serialization, detekt. No SQLDelight — everything is Flow off the network with in-memory repo caching.


1. flow {} vs channelFlow {} for SSE

We stream analysis results over SSE. First version was basically this:

fun stream(reportId: String): Flow<AnalysisEvent> =
    flow {
        client.prepareGet("$baseUrl/v1/analysis/$reportId/stream") {
            accept(ContentType.Text.EventStream)
        }.execute { response -> /* parse frames */ emit(event) }
    }

Fine on Android. On iOS the stream would die partway through and the backend logged the request as context canceled.

flow {} enforces context preservation, so you can't emit from a coroutine context other than the collector's. Ktor's execute {} block isn't guaranteed to run on the collector's dispatcher. With OkHttp it happens to, so the check never trips. On Kotlin/Native it doesn't, you get Flow invariant is violated, the coroutine fails and the request aborts.

channelFlow's send doesn't have that restriction:

fun stream(reportId: String): Flow<AnalysisEvent> =
    channelFlow {
        client.prepareGet(...) { ... }.streamInto { send(it) }
    }

This one cost me a day and a half, mostly because the Android tests were all green and I kept looking at the backend.

2. Long-press word selection in Chinese

We have 12 locales including zh/ja/ko/ar/he. Users can long-press a phrase in a report to get it explained. Selection was broken in Chinese on both platforms, in opposite directions.

On Android a long press selected the whole clause, up to the nearest punctuation. WordIterator.nextBoundary/prevBoundary skip any boundary where both sides are letters or digits. That was added so a letter/emoji seam doesn't split a word, but every ideograph counts as a letter, so in Chinese every boundary gets skipped and the selection just keeps expanding. Latin text has spaces so it's unaffected, which is why you don't notice unless someone tests in CJK. I saw this from 1.8.0 through 1.12.0-beta02 and couldn't find a public API to turn it off.

Workaround is to plant zero-width breaks at Han-Han seams, using ICU to find them:

actual fun cjkWordBoundaries(text: String): List<Int> {
    if (text.codePoints().noneMatch(::isHan)) return emptyList()
    val iterator = BreakIterator.getWordInstance()
    iterator.setText(text)
    val offsets = mutableListOf<Int>()
    var offset = iterator.first()
    while (offset != BreakIterator.DONE) {
        if (text.isHanSeam(offset)) offsets += offset
        offset = iterator.next()
    }
    return offsets
}

Only Han-Han seams, since a Han/Latin seam already stops the runaway by itself.

iOS has the opposite problem: a long press selects one character. SkiaParagraph.getWordBoundary goes to Skia, and SkUnicode has no CJK segmentation dictionary, so you get plain UAX#29 where an ideograph is Word_Break=Other and only matches WB999.

I spent a while trying to fix that one from app code before concluding you can't. It needs characters merged rather than split, and the joining rules (MidLetter, Numeric, ExtendNumLet, ZWJ) all require ALetter/Numeric/Katakana/Hebrew_Letter on both sides, plus WB4 throws away zero-width Format characters first. So the iOS actual is emptyList() with a long comment above it explaining why, so I don't try again in six months.

3. Switching language in-app without a restart

I assumed this needed a restart on iOS because NSBundle caches the launch language. That's true for NSLocalizedString but not for CMP, which resolves resources per composition against NSLocale.preferredLanguages, and that reads AppleLanguages out of NSUserDefaults live.

actual object LocalAppLocale {
    private val systemDefault: List<*> = NSLocale.preferredLanguages
    private val local = staticCompositionLocalOf { NSLocale.currentLocale.localeIdentifier }

    @Composable
    actual infix fun provides(value: String?): ProvidedValue<*> {
        val defaults = NSUserDefaults.standardUserDefaults
        defaults.setObject(value?.let { listOf(it) } ?: systemDefault, "AppleLanguages")
        return local provides (value ?: (systemDefault.firstOrNull() as? String) ?: "en")
    }
}

Wrap the app in CompositionLocalProvider(LocalAppLocale provides tag) with a key(tag) around it and strings swap live. Grab preferredLanguages at startup so a null tag can go back to following the OS.

4. Bare %s in strings.xml renders literally

CMP's formatter only matches indexed placeholders, %(\d+)\$[ds]. A bare %s isn't a compile error or a crash, it just prints %s in the UI.

<string name="credits">You have %s credits left</string>    <!-- prints "%s" -->
<string name="credits">You have %1$s credits left</string>  <!-- correct -->

With 924 strings across 12 locales I can't eyeball this, so it's a CI grep now.

5. Auth DI cycle, and the bearer token following a redirect

AuthRepo needs an HttpClient to hit the token endpoint, and the main client's bearer plugin needs AuthRepo to get a token. Koin sees a cycle. Fix is a second client with no bearer plugin that only serves /v1/auth/token and /v1/auth/refresh. Not clever, just what it takes.

The part that took longer: our avatar endpoint 302s to a signed URL on a storage host. Ktor follows the redirect and re-attaches headers, storage sees a bearer it doesn't want and rejects it, and avatars just don't load with nothing useful in the logs. So bearer injection is scoped to a host set:

private val API_HOSTS = setOf(Url(BASE_URL).host, Url(BASE_URL_AI).host)

Presigned upload PUTs go through a third, completely bare client. Four HttpClients in one Koin module looks like a lot in review.

Bonus: IAP return type

Purchases sit behind one IapClient interface with StoreKit 2 and Play Billing implementations. Abstraction was easy, but I want to flag one signature:

/** null when there is no active subscription; throws when the store can't be reached. */
suspend fun subscriptionAutoRenewing(): Boolean?

Obvious version returns Boolean. Then "no subscription" and "store didn't answer" both become false and a paying user gets a "subscription canceled" banner whenever the network is flaky. Both stores expose the distinction (isAutoRenewing / RenewalInfo.willAutoRenew), so the shared interface has to keep it.

Anyway

Architecture shared without much trouble: MVVM + repos + Flow throughout, Ktorfit interfaces, Koin annotations, and the tests are all in commonTest and run on both. The platform slice is small and mostly boring bridges (biometrics, IAP, share sheet, file picker, push, image encoding). It's just that all five of the above live in that slice, and in three of them Android was perfectly happy while iOS wasn't.

Happy to answer questions on any of these. Still interested if anyone has a way around the Skia CJK thing.

The app is Prismo Health (AI that explains blood tests and medical reports), on the App Store and Google Play — but the post is the point, ask me anything about the KMP side. (https://prismo.health)

u/ikrisliu — 7 days ago