Kotlin Multiplatform v bankovní aplikaci: jak zmigrovat velký projekt do KMP a nerozbít ho

📺 Záznam přednášky Petra Polívky a Alexandera Grigorova z Ackee Meets: Mobile Development:
Kotlin Multiplatform zní na papíře jako sen: „Šoupneme logiku do KMP, iOS se napojí a paráda.“ Přesně takhle si to na začátku představovali iOSáci – že Androiďáci všechno odmakají, oni budou chillovat a pak jen napojí UI. Projekťáci zase počítali, že se ušetří spousta peněz. Realita? Odmakali to obě platformy a projekťáci chvílemi prožívali krušné chvíle. Tady je upřímný příběh o tom, jak se do Kotlin Multiplatform migruje velká, roky běžící bankovní a investiční aplikace – se vším, co bolelo, i s tím, co se nakonec vyplatilo.
📋 TL;DR
- Kotlin Multiplatform (KMP) není jen „napsat jednou a napojit“. U velkého existujícího projektu je to hlavně migrace, architektura a domluva dvou platforem nad jedním kódem.
- Sdíleli jsme ViewModely kompletně v KMP, UI zůstalo nativní. iOS bere sdílený kód jako XCFramework generovaný přes SKIE, závislosti řeší Koin.
- Nešli jsme do monorepa – u rozběhlého projektu jsme Android naimportovali jako submodul do iOS repa.
- Migrovali jsme postupně (nejmenší obrazovka → velká feature → migrace Android kódu) a zavedli pravidla i merge request proces, kde sdílený kód schvaluje i člověk z druhé platformy.
- Největší pasti: Swift concurrency, error handling (Kotlin neřekne, co vyhazuje → vlastní
OperationResult) a iPad bez protějšku na Androidu. - Po roce v produkci: 60 % logiky v KMP, 29 sdílených ViewModelů, 43 use caseů, nula velkých průšvihů – a nové features píšeme rychleji.

Tohle je pokračování příběhu o sdílení kódu mezi platformami. Pokud teprve tápete, co KMP vlastně je a kdy dává smysl, začněte u Kotlin Multiplatform v praxi. Tenhle článek jde o úroveň dál – do velkého produkčního projektu.
🏦 Výchozí situace: velká appka, dvě nativní platformy
Řeč je o mobilní aplikaci J&T Banky na správu investic a privátní bankovnictví – zhruba 170 obrazovek, nativně na obou platformách. Historicky byla zaměřená spíš na iOS, takže většina návrhů vznikala nejdřív pro iOS a teprve pak se připravovala pro Android. Design se navíc na obou platformách často lišil a vyvíjel se rozdílně.
Specifikum? V aplikaci je docela komplikovaná logika – spousta API requestů, ověření, výpočtů s částkami. A přesně tyhle věci jsou ideální kandidát na sdílení. Právě proto začalo dávat smysl přemýšlet nad KMP i na projektu, který už dávno jel.
🤔 Proč jsme po letech sáhli po Kotlin Multiplatform

Motivace byla čtyřvrstvá:
- Zrychlení vývoje – část logiky píšeme jen jednou místo dvakrát.
- Sjednocení chování – i když jsme obě appky vyvíjeli proti stejné specifikaci, logika ve výsledku občas fungovala trošku jinak a vznikaly bugy jen na jedné platformě. Sdílením logiky sjednotíme i chování.
- Snadnější budoucí změny – v bance se legislativa a požadavky mění pořád. Když se mění na jednom místě, ušetří to čas.
- Oprava bugů na jednom místě – místo dvou samostatných honů na tutéž chybu.
🧱 Architektura KMP: XCFramework, SKIE a sdílené ViewModely
Zvolili jsme XCFramework generovaný přes SKIE – iOS ho integruje jako knihovnu a používá sdílený kód. Klíčové rozhodnutí bylo sdílet ViewModely u obrazovek: celé žijí v KMP, data se namapují na UI objekty a v UI už ideálně nezůstane žádná logika.

public abstract class WalletPaymentViewModel : ViewModel() {
public abstract val screenStateStream: StateFlow<WalletPaymentScreenState>
public abstract val logEventStream: Flow<WalletPaymentLogEvent>
public abstract val navigationEventStream: Flow<WalletPaymentNavigationEvent>
}Pro obrazovky, které ještě nejsou zmigrované (nebo nedává smysl je migrovat celé), vytváříme use casy, které obalí logiku a obě platformy je jen využívají. Takový use case vrací náš vlastní OperationResult a přes anotaci @KoinKmmExport se ze Swiftu pohodlně získává instance z Koinu:

@KoinKmmExport
public interface GetEnvironmentUseCase {
public operator fun invoke(): OperationResult<AppEnvironment>
}
internal class GetEnvironmentUseCaseImpl(
private val environmentProvider: EnvironmentProvider,
) : GetEnvironmentUseCase {
override fun invoke(): OperationResult<AppEnvironment> =
tryOperation { environmentProvider.current() }
}Modulově to vypadá takhle: logic modul (datové a doménové modely, dotazování na API), na něj napojený prezentační modul (přemapuje data na UI objekty a přes fasádu je distribuuje na obě platformy) a testing modul (fakes, factories, testy). Kvůli postupné migraci zatím Android modul sahá i přímo na logic modul – až bude appka celá v KMP, tenhle vztah zmizí.
Do Koinu navíc ze Swiftu posíláme důležité věci, které chceme používat ve sdíleném kódu: formátovače (banka má vysoké nároky na práci s částkami, ať to vypadá stejně a je to na jednom místě) a nativní logger, který se hodí i pro testing.
Z předchozích projektů jsme měli zkušenost s monorepem a chtěli ho nasadit i tady. Nakonec jsme to neudělali: projekt už běžel, byla v něm hromada kódu, vysoké nároky na verzování a časté konflikty. Šli jsme proto standardní cestou a Android projekt naimportovali jako submodul do iOS kódu (submodulovou závislost jsme tam už měli na GraphQL, takže nic nového). Nen í to nutně ideální řešení navždy – ale zatím stačí.
🔀 Migrace do KMP krok za krokem

Nešli jsme do toho po hlavě. Postup byl opatrný a v podstatě ve třech vlnách:
- Nejmenší obrazovka v projektu – jak popisuje Petr Polívka, chtěli jsme si ověřit, že dokážeme něco fetchnout a že umíme observovat změny z ViewModelu. Znělo to jednoduše, jenže i tady jsme narazili – bankovní network service je fakt složitá a museli jsme rozběhat dvě.
- Velká feature bez migrace Android kódu – tady jsme si otestovali víc: navigaci, bottom sheety, alerty. Cíl byl neušetřit si práci migrací, ale osahat terén.
- Refaktoring a nové features, které už vyžadovaly migraci Android kódu – poslední a nejbolestivější krok.
Aby to bolelo míň, zavedli jsme si pravidla migrace:
- Ke KMP se chováme jako ke knihovně. Co může být
internal, zůstáváinternal– ať toho ven vystrkujeme co nejmíň. - Migrujeme postupně. To, že máme KMP, neznamená naházet do něj celý Android projekt a nechat iOS, ať se s tím popere.
- Merge request proces. Když někdo sahá do sdíleného kódu, nejdřív to probere s někým z iOS. Do review sdíleného kódu pak musí přidat i člověk zodpovědný za stejnou feature na iOS.
„Když jsme měli všechny kroky za sebou, byla otázka, jak to bylo. No, bolelo to. A aby to bolelo trošku míň, udělali jsme si pravidla migrace.“
Ten iOS reviewer má jednoduchý úkol: ukázat, že „tohle vypadá hezky v Kotlinu, ale ze Swiftu se to používá blbě – pojďme to změnit“. Klidně se stane, že na Androidu začneme psát o kousek horší kód výměnou za to, že na iOSu to bude o dost lepší. Typický příklad: místo sealed interface používáme v Kotlinu sealed class, protože se s tím ve Swiftu pracuje líp. Když sdílený kód dostane approve, reviewer ho rovnou implementuje na iOS a merguje se současně do obou projektů – ať nevznikne stav, kdy Android má něco, co Swift ne (nebo naopak).
🕳️ Technické pasti KMP: Swift concurrency, error handling a iPad

Swift concurrency
Swift si čím dál víc věcí tahá na hlavní vlákno – třeba default actor isolation (ve Swiftu 6.2 a novějším) znamená, že Swift kód běží ve výchozím stavu na hlavním vlákně. Zapnuli jsme to a appka začala padat pokaždé, když se Kotlin snažil sáhnout na Swift kód. Dává to smysl: Swift běží asynchronně, ale Kotlin o tom neví a snaží se to spustit synchronně → pád. Podobně SwiftUI běží na hlavním vlákně, ale ViewModely z KMP běží někdy jinde – nejčastěji nás to potrápilo u bottom sheetů, kde se změna stavu nestíhala propagovat a UI několikrát poskočilo.
Error handling
Ve Swiftu je radost: u funkce vždycky víte, jestli může hodit výjimku, a kompilátor vás donutí ji ošetřit – dokonce už i s typy. A pak přijde Kotlin, kde každá funkce může hodit cokoliv, a když to špatně označíte, appka spadne. Řešení: každý use case, který používáme ze Swiftu, vrací náš vlastní OperationResult – buď success, nebo error s definovanou strukturou, kterou umíme prohnat dál, případně cancellation.
Tohle je asi nejzákeřnější rozdíl oproti Swiftu. Ve sdíleném kódu se vyplatí zavést jednotný obal výsledku (u nás OperationResult se success / error / cancellation), aby chyba dorazila na UI jako přemapovaný objekt, a ne jako nečekaný pád za běhu.
iPad (a další rozdíly platforem)

Aplikace podporuje iPad, ale ne Android tablet. Kvůli tomu mají někdy ViewModely na iOSu víc funkcí než na Androidu. Protože do sdíleného modulu nechceme dávat to, co Android nevyužije, u těchto obrazovek nesdílíme ViewModely, ale use casy.
Dalších drobných pastí bylo víc:
- Knihovny bez podpory KMP – třeba Retrofit nebo JUnit. Kde šlo, zmigrovali jsme na KMP alternativy (Ktor, Kotest); kde ne, nechali jsme nativní řešení a obalili ho abstrakcí.
- Kaskádování při přesunu – přesunout jednu obrazovku neznamená přesunout jen ji. Když migrujete platbu, táhnete s sebou i běžné účty, peněženky a další. Museli jsme hledat hranici, co ještě migrovat a co ne – a klidně rozdělit repozitáře a migrovat jen aktuálně potřebné funkce.
- Konflikty v pojmenování – třída
Currencybyla na obou platformách a po exportu na iOS to házelo konflikty. Ve SKIE se to naštěstí vyřeší anotací a přejmenováním při exportu (zavedli jsme prefixShared). - Resources – sázíme na Compose Resources, protože v nich vidíme větší budoucnost.
- UI rozdíly: bottom sheety, snackbary i taby se na platformách chovají jinak. Řešili jsme je generickými, abstraktními řešeními, aby se daly znovu použít i na dalších zmigrovaných obrazovkách.
Konkrétně u UI komponent to vypadalo takhle:
| UI prvek | Android | iOS | Naše řešení v KMP |
|---|---|---|---|
| Bottom sheet | svázaný s konkrétní obrazovkou | otevíratelný z jakékoliv obrazovky | logika ve ViewModelu, jeden generický model pro obě platformy |
| Notifikace akce | snackbar | dialog | do UI posíláme data i akci, každá platforma si je vykreslí po svém |
| Taby | slidování mezi taby (drží data o všech tabech) | změna celého obsahu obrazovky | abstrakce s daty o všech tabech, iOS k nim přistupuje jako ke stavu jedné obrazovky |
🧪 Co se naopak krásně povedlo: snapshot testy
Díky tomu, že máme sdílené ViewModely i s UI objekty, můžeme sdílet generování dat pro previews a snapshot testy. Takže i když Android a iOS obrazovka vypadají vizuálně jinak a mají jiný design, obsah dat je stejný – a my dokážeme sledovat a porovnávat stavy téže obrazovky na obou platformách naráz. Menší dárek za veškerou tu bolest.
🤖 AI na migraci do KMP
Claude používáme při běžných implementacích už teď – třeba na psaní use casů podle zavedených vzorů a na testy k migrovanému kódu. Do budoucna zvažujeme i specializovanější nástroje přímo na migraci zbytku Android kódu do KMP. (Jestli vás zajímá, jak tenhle přístup stavíme obecně, mrkněte na agentic development.)
📈 Výsledky KMP po roce v produkci

Čísla, jak to vypadá dnes:
- 60 % celé logiky z Androidu je zmigrované do KMP.
- 29 sdílených ViewModelů používáme napříč platformami.
- 43 use caseů voláme přímo ze Swift kódu.
- 260 souborů ve Swiftu tak či onak používá sdílený kód.
- Rok a měsíc v produkci – a zatím žádné velké problémy.
Konečně tak můžeme říct, že se to KMP vyplatí a reálně z něj těžíme. Kdykoliv teď dostaneme novou feature, myslíme na sdílený kód a píšeme ji o něco rychleji.

Po roce v produkci jednoznačně ano – ale s hvězdičkou. Návratnost nepřišla hned, zpočátku to bylo pomalejší a bolavější. Vyplatí se to u velkých, dlouhodobých projektů s komplexní logikou, kde má smysl investovat do sjednotného chování a rychlejšího vývoje nových features.
❓ FAQ
Kdy se vyplatí migrovat existující aplikaci do Kotlin Multiplatform?
Až je technologie zralá a projekt má stabilní architekturu. Aplikace vznikala v době, kdy byla KMP prakticky v betě, a nasazovat tak velkou technologii natvrdo do produkčního bankovnictví nedávalo smysl. U bankovní aplikace je navíc cena chyby vyšší než jinde. Konzervativnější start a migrace do KMP až v momentě, kdy technologie dozrála, se ukázaly jako bezpečnější cesta.
Monorepo, nebo submodul pro sdílený kód?
U nového projektu klidně monorepo. U velkého rozběhlého projektu s hodně kódem a náročným verzováním jsme zvolili import Android projektu jako submodulu do iOS repa – méně konfliktů, žádná velká přestavba.
Co je OperationResult?
Náš vlastní obal výsledku use casu se stavy success / error / cancellation. Řeší to, že Kotlin (na rozdíl od Swiftu) neřekne, jakou výjimku funkce vyhazuje, takže by neošetřená chyba spadla až za běhu.
Jak řešit pády kvůli Swift concurrency?
Hlídat, na jakém vlákně běží kód z KMP. Swift si tahá věci na hlavní vlákno (např. default actor isolation ve Swiftu 6.2 a novějším), zatímco ViewModely z KMP můžou běžet jinde – je potřeba propagaci stavu explicitně ošetřit, hlavně u komponent jako bottom sheety.
Sdílet ViewModely, nebo use casy?
Když je logika stejná pro obě platformy, sdílejte ViewModely. Když má jedna platforma navíc (třeba iPad na iOSu, který Android nemá), je čistší sdílet jen use casy a ViewModel nechat nativní.
Kolik logiky reálně zmigrujete?
U nás 60 % logiky z Androidu za rok a měsíc v produkci. Migrace je postupná a nikdy nemusí být (a nemusí dávat smysl) stoprocentní.
🎯 Co si odnést
Migrace bankovní aplikace J&T Banky do Kotlin Multiplatform není o tom „napsat jednou a napojit“. Je to postupná práce na architektuře, pravidlech a komunikaci dvou platforem nad jedním kódem. Klíč je jít po malých krocích, brát KMP jako knihovnu, zavést merge request proces se zapojením obou platforem a počítat s tím, že Swift concurrency, error handling a rozdíly platforem si vyberou svou daň. Odměnou je sjednocené chování, sdílené testy a znatelně rychlejší vývoj nových features – i když u prvních migrací pořádně zabolí.
Celý příběh i s dotazy z publika najdete v záznamu přednášky. Termíny dalších Ackee Meets sledujte na Eventbritu a přihlaste se k newsletteru – příští takový obsah vám přijde přímo do schránky.




