A Kotlin Multiplatform sample application demonstrating how to share code for Android and iOS. The data, domain and presentation logic live in common Kotlin feature modules, the UI is native on each platform.
Ths app uses Gradle as build system. The Kotlin Toolchain equivalent can be found in kmp-showcase-kotlin-toolchain repository.
- 💎 KMP Showcase
A weather app built with KMP that displays weather for current week and each day sourced from the Open-Meteo API. The application demonstrates real-world scenarios including network requests, local caching, navigation, and state management.
Features:
- Weekly Forecast - display weekly weather forecast with daily summary and temperature range; pull to refresh
- Daily Forecast - display detailed daily weather forecast with hourly temperature, precipitation, wind and sunrise / sunset
Built with modern Kotlin Multiplatform tools and libraries, prioritizing code sharing, native UI on each platform, project structure stability and production-readiness.
Core Technologies:
- Kotlin - Modern, expressive programming language
- Kotlin Multiplatform - Share data, domain and presentation logic between Android and iOS
- Coroutines - Asynchronous programming
- Flow - Reactive data streams
- Serialization - JSON parsing
- kotlinx-datetime - Multiplatform date and time
Kotlin-Swift Interop:
- SKIE - Kotlin Native compiler plugin that improves Kotlin-Swift interoperability
(
Flow→AsyncSequence/Observing,sealedclass / interface → exhaustive Swift enum (onEnum(of:)),suspend→async, default arguments, etc.) - KMP-ObservableViewModel - Share Kotlin ViewModels
between Android and iOS. Makes Kotlin state changes observable by SwiftUI and clears
viewModelScopewhen the view goes away
Presentation:
- FlowMVI - MVI framework (state, intents, actions, plugins)
- AndroidX ViewModel - Multiplatform lifecycle-aware ViewModel
Android UI:
- Jetpack Compose - Declarative UI framework
- Material Design 3 - Design system
- Navigation 3 - Back stack based navigation with ViewModels scoped to back stack entries
iOS UI:
- SwiftUI - Declarative UI framework
- NavigationStack - Value-based navigation
Networking:
- Ktor Client - Multiplatform HTTP client (Android engine on
Android, Darwin engine on iOS)
- Ktor Resources - Type-safe HTTP requests. A
@Resourcerequest class (e.g.ForecastRequestModel) is serialized into the URL path and query parameters, so no manualappend(...) - Content Negotiation - JSON (de)serialization with kotlinx.serialization
- Ktor Resources - Type-safe HTTP requests. A
Dependency Injection:
- Koin - Lightweight multiplatform dependency injection framework
Architecture:
- Clean Architecture - Separation of concerns with defined layers
- MVVM + MVI - Shared ViewModels exposing a single UI state
- Shared logic, native UI - Data, domain and presentation in Kotlin; Jetpack Compose and SwiftUI screens
- Modular Design - Feature-based modules for scalability
Testing:
- kotlin-test - Multiplatform test library (
commonTest, Android host tests, iOS simulator tests)
Code Quality:
- Ktlint - Kotlin code formatting and issue detection
- Nlopez Jetpack Compose Rules - Set of custom rules for Jetpack Compose
- Detekt - Static analysis and complexity checks
- Android Lint - Android-specific code analysis
- Spotless - Code formatting enforcement
- SwiftLint - Swift style and conventions
Build & CI:
- Gradle Kotlin DSL - Type-safe build scripts
- Version Catalogs - Centralized dependency management
- Convention Plugins - Shared build logic (see Convention Plugins)
- Swift Package Manager - iOS dependencies
(
KMPObservableViewModelSwiftUI) - Renovate - Automated dependency update PRs (see Dependency Management)
GitHub Actions:
- Check - CI pipeline building both apps and running all linters (see CI Pipeline)
Gradle Plugins:
- Android Application (
com.android.application) - Android app module configuration - Android KMP Library
(
com.android.kotlin.multiplatform.library) - Android target for KMP modules - Kotlin Multiplatform
(
org.jetbrains.kotlin.multiplatform) - Kotlin compilation for Android and iOS - Kotlin Serialization (
org.jetbrains.kotlin.plugin.serialization) - JSON serialization support - Kotlin Compose Compiler
(
org.jetbrains.kotlin.plugin.compose) - Compose compiler plugin - SKIE (
co.touchlab.skie) - Swift-friendly framework API - Detekt (
io.gitlab.arturbosch.detekt) - Static code analysis - Spotless (
com.diffplug.spotless) - Code formatting
flowchart LR
subgraph native["Native UI"]
android["androidApp<br/>(Jetpack Compose)"]
ios["iosApp<br/>(SwiftUI)"]
end
subgraph shared["<div style='text-align:center'>Shared Logic<br/>(Kotlin Multiplatform)</div>"]
presentation["Presentation layer<br/>(ViewModels, state)"]
domain["Domain layer<br/>(use cases, models)"]
data["Data layer<br/>(repositories, networking)"]
end
android --> presentation
ios --> presentation
presentation --> domain
domain <--> data
linkStyle 3 marker-end:none;
classDef presentationLayer fill:#C2E6FC,stroke:#38ADFA,stroke-width:3px,color:#222222;
classDef domainLayer fill:#FFC7C2,stroke:#FF4B23,stroke-width:3px,color:#222222;
classDef dataLayer fill:#CEF4D4,stroke:#60D477,stroke-width:3px,color:#222222;
classDef nativeLayer fill:#DECDFF,stroke:#8C4FFF,stroke-width:3px,color:#222222;
class presentation presentationLayer;
class domain domainLayer;
class data dataLayer;
class android,ios nativeLayer;
style native fill:#F5F7FA,stroke:#B8C2CC,color:#222222;
style shared fill:#F5F7FA,stroke:#B8C2CC,color:#222222;
-
/iosApp contains the iOS application entry point. Feature SwiftUI code lives in the feature modules (see below).
-
/iosBridge builds the single framework the iOS app links (
import iosBridge). It exports every feature module and starts Koin for iOS (see Dependency Injection). -
/feature/forecast is the forecast feature shared between app targets in the project. The most important subfolder is commonMain. androidMain holds the Jetpack Compose UI in the presentation package (
WeeklyForecastScreen,DailyForecastScreen). iosMain/swift holds the SwiftUI UI in the samepresentationlayout (see Feature UI Lives in the Feature Module). -
/feature/base holds code shared by all feature modules, e.g.
StoreViewModel.
Clean Architecture is implemented at the module level - each feature module contains its own set of Clean
Architecture layers. The shared layers live in commonMain; only the view code is platform-specific:
feature/forecast/src/
├── commonMain/kotlin/com/igorwojda/showcase/feature/forecast/
│ ├── presentation/ ViewModels, State, Intent, Action (shared)
│ ├── domain/
│ │ ├── model/ domain models
│ │ ├── repository/ repository interfaces
│ │ └── usecase/ use cases
│ ├── data/
│ │ ├── model/ network request / response models
│ │ └── repository/ repository implementations
│ └── di/ Koin module
├── androidMain/kotlin/…/presentation/ Jetpack Compose screens and components
├── iosMain/kotlin/…/di/ Koin accessors for Swift
└── iosMain/swift/presentation/ SwiftUI screens and components
:feature:basedoesn't follow this structure. It holds code shared by all feature modules (StoreViewModel,configuredStore,HttpClient, Koin start-up).
This layer is closest to what the user sees on the screen.
The presentation layer mixes MVVM and MVI patterns (see
UI State Management via Flow MVI):
MVVM- a shared ViewModel (StoreViewModel) encapsulates acommon UI state. It exposes thestatevia an observable state holder (Kotlin Flow)MVI- anintentmodifies thecommon UI stateand emits a new state to a view viaKotlin Flow
The
common stateis a single source of truth for each view. This solution derives from Unidirectional Data Flow and Redux principles.
The ViewModel and its state are written once in commonMain; each platform only renders the state (see
Consuming Common ViewModels).
Components:
- Screen - a Jetpack Compose (
androidMain) or SwiftUI (iosMain/swift) view. Observes the common state, renders it and passes user interactions to theViewModelas intents. Views are hard to test, so they should be as simple as possible. - ViewModel - shared across platforms. Owns a FlowMVI store, which handles intents and emits state changes and one-off actions to the view.
- State - sealed common state for a single view (e.g.
Loading/Content/Error). - Intent - user interaction sent from the view to the
ViewModel(e.g.Reload). - Action - one-off side effect sent from the
ViewModelto the view (e.g. show a toast).
This is the core layer of the application. Notice that the domain layer is independent of any other layers. This
allows making domain models and business logic independent from other layers. In other words, changes in other layers
will not affect the domain layer eg. changing the API (data layer) or screen UI (presentation layer) ideally will
not result in any code change within the domain layer.
Components:
- UseCase - contains business logic. Exposes a single
operator fun invoke(e.g.GetForecastUseCase). - DomainModel - defines the core structure of the data that will be used within the application. This is the source
of truth for application data (e.g.
ForecastModel). - Repository interface - required to keep the
domainlayer independent from thedata layer(Dependency inversion).
Encapsulates application data. Provides the data to the domain layer eg. retrieves data from the internet and caches
it in memory (see Caching).
Components:
- Repository - exposes data to the
domainlayer. It fetches data from theData Source, keeps it in the cache and maps it intodomainmodels (e.g.ForecastRepositoryImpl). - Mapper - maps
data modeltodomain model(to keepdomainlayer independent from thedatalayer). Mappers are private extension functions next to the repository (e.g.ForecastResponseModel.toForecast()).
This application has one Data Source - Ktor (network access to the Open-Meteo API). It
consists of multiple classes:
- Ktor HttpClient - shared client configured in
:feature:base, with the platform engine (Android / Darwin) - Request Model - a Ktor Resources
@Resourceclass defining the endpoint, path and query parameters (e.g.ForecastRequestModel) - Response Model - definition of the network objects for a given endpoint (e.g.
ForecastResponseModel, with sub-objects such asDailyResponseModel)
Response Models are annotated with @Serializable, so kotlinx.serialization understands how to parse the data into
objects.
Each feature module contains several standard items that provide essential functionality and configuration:
Components:
- Gradle Build Script -
build.gradle.ktsapplying theshowcase.featureconvention plugin plus the module's own dependencies. - Koin DI Module - dependency injection configuration in
commonMain(e.g.featureForecastModule), plus Swift accessors iniosMain(see Dependency Injection). - Tests -
commonTestsource set withkotlin-test, run on the Android host and the iOS simulator (set up by the convention plugin; no tests yet).
Gradle version catalog
(libs.versions.toml) is used for centralized dependency management. Third-party
dependency coordinates (group, artifact, version) are shared across all modules and build-logic.
The version catalog consists of a few major sections:
[versions]- declare versions that can be referenced by all dependencies[libraries]- declare the aliases to library coordinates[plugins]- declare Gradle plugin dependencies (including the project's own convention plugins)
Each module applies a convention plugin, so common dependencies are shared without the need to add them explicitly in each module.
Renovate (renovate.json) opens pull requests for new versions of the
version catalog entries, the Gradle wrapper and GitHub Actions. Minor and patch updates merge automatically once CI
passes; major updates need a manual merge. Kotlin is capped at 2.4.10 until SKIE supports newer
versions; raise the cap in renovate.json together with the skie version.
Convention plugins in build-logic standardize build configuration across modules, so each module's build script only declares what's specific to it.
Type-safe project accessors
are enabled in settings.gradle.kts (enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS")), so
module build scripts reference other modules by generated, compile-checked accessors instead of error-prone string
paths:
// Before
implementation(project(":feature:forecast"))
// After
implementation(projects.feature.forecast)- Used in androidApp/build.gradle.kts (the features the app ships) and
iosBridge/build.gradle.kts (
api(...)of every feature, whichshowcase.iosbridgeexports to Swift). - Accessors are generated only for the main build's scripts, not for
build-logicsources, soFeatureConventionPluginstill usesproject(":feature:base").
All dependency and Gradle plugin versions are defined in the TOML version catalog file
(libs.versions.toml). This includes the Android SDK levels (android-compileSdk,
android-minSdk, android-targetSdk), which the convention plugins read, so every module targets the same SDKs.
The Java/JVM version is defined once, as the java entry in libs.versions.toml. The
convention plugins read it through javaVersion / jvmTarget helpers in
VersionCatalogExt.kt, so Java and Kotlin in every
module (Android app and KMP libraries) always target the same bytecode version:
compileOptions {
sourceCompatibility = libs.javaVersion
targetCompatibility = libs.javaVersion
}
compilerOptions {
jvmTarget.set(libs.jvmTarget)
}This is the bytecode target only. The JDK that runs Gradle is set separately by the Gradle daemon JVM criteria (gradle-daemon-jvm.properties).
build-logic/settings.gradle.kts imports the same catalog
(versionCatalogs { create("libs") { from(files("../gradle/libs.versions.toml")) } }), so build-logic's own build
script uses type-safe libs.* accessors, e.g. for the Android and Kotlin Gradle plugins.
Convention plugin sources can't use the generated accessors, so they look entries up by name through small helpers in VersionCatalogExt.kt:
implementation(libs.lib("androidx-compose-runtime"))
compileSdk = libs.version("android-compileSdk").toInt()
pluginManager.apply(libs.pluginId("composeCompiler"))Screens are driven by FlowMVI stores, owned by shared ViewModels. Each screen
has one immutable State, a set of Intents (user events) and Actions (one-off side effects, e.g. a toast).
- Unidirectional data flow. The UI sends intents and renders state, and only the store changes state. It's easy to follow what happened and why.
- One state per screen. A sealed
State(Loading/Content/Error) can't represent impossible combinations, and SKIE turns it into an exhaustive Swift enum. - Written once, used on both platforms. State, intents and business rules live in
commonMain; Compose and SwiftUI only render. - Plugins instead of boilerplate. Loading (
init), intent handling (reduce) and error handling (recover) are small reusable plugins, so ViewModels contain little more than the feature logic. - Thread-safe state updates.
updateStateis serialized by the store, so parallel coroutines can't overwrite each other's changes. - Built-in tooling. Logging and remote debugging come as plugins, installed once for every store.
ViewModels extend StoreViewModel,
which owns a FlowMVI store (built with configuredStore). Each platform consumes it through a different API:
| Consumer | State | Actions | Intents |
|---|---|---|---|
| Android (Compose) | val state by viewModel.store.subscribe { action -> … } |
same subscribe lambda |
store.intent(…) |
| iOS (SwiftUI) | viewModel.states |
viewModel.actions |
viewModel.onIntent(intent:) |
iOS can't use store directly - Store is a Kotlin interface, exported to Swift as an Objective-C
protocol, and protocols lose their generic types. Swift would see store.states untyped and
store.intent accepting any MVIIntent. StoreViewModel re-exposes the store with concrete types:
states: typedStateFlow; SKIE turns it into anAsyncSequenceforObserving. Collecting it opens a store subscription, which lives as long as the view observes it.actions: a coldFlow, also backed by a store subscription. The subscription lives as long as the Swift.taskthat collects it, soActionShareBehavior.Distributesees the screen arrive and leave.onIntent: accepts only the screen's own intent type.
Both flows read the store through FlowMVI's subscribe, like the Compose subscribe does. The store's own
states property is internal FlowMVI API (@InternalFlowMVIAPI), and reading it doesn't register a subscriber, so
plugins such as whileSubscribed wouldn't see a screen that only renders state.
Android should keep using store, which ties the subscription to the Compose lifecycle.
FlowMVI has no official SwiftUI integration (its docs cover iOS only through Compose Multiplatform), hence this
bridge. Its experimental NativeStore (callbacks, manual close()) isn't used: SKIE flows are cancelled
automatically with the SwiftUI task.
Trade-off: a screen that collects both flows holds two subscriptions. That's fine with the default
ActionShareBehavior.Distribute, but ActionShareBehavior.Restrict (one subscription per store) would throw.
Every store is built with
configuredStore
instead of FlowMVI's store. It launches the store in viewModelScope, sets name and debuggable, and installs
logging and remote debugging. The ViewModel adds only its own plugins:
override val store = configuredStore(initial = WeeklyForecastState.Loading, name = "WeeklyForecast") {
recover { … }
init { … }
reduce { … }
}- One place for cross-cutting setup. A new ViewModel can't forget logging or debugging, and a change (e.g. an extra plugin) happens once instead of in every ViewModel.
- Only non-default config is set. FlowMVI defaults (e.g.
ActionShareBehavior.Distribute) aren't repeated. - Custom builder over a base-class hook. A plain extension on
StoreViewModelwraps FlowMVI'sstoreDSL without changing it, so ViewModels still use the standard plugins (init,reduce,recover).
Known issue: debuggable is hardcoded to true, so logging and remote debugging also run in release builds. The
fix is to take a debug flag from the app and install both only when it's set. enableRemoteDebugging throws on
a non-debuggable store, so both must change together.
A feature's native UI sits next to its shared logic, in the feature module, not in the app modules. The app modules only wire things together: entry point, DI start-up and navigation.
feature/forecast/src/
├── commonMain/kotlin/…/presentation/ ViewModels, state (shared)
├── androidMain/kotlin/…/presentation/ Jetpack Compose screens and components
└── iosMain/swift/presentation/ SwiftUI screens and components
- One package for all view code. Screens, components and UI helpers go under
presentation(presentation/<feature>, shared helpers inpresentation/common), on both platforms. There is nouipackage. - Android is a normal KMP
androidMainsource set. The Compose compiler and Compose dependencies come from theshowcase.featureconvention plugin, so feature build scripts don't repeat them. - iOS Swift can't be compiled by Gradle, so the files are compiled by the Xcode app target through one
synchronized folder per module in
iosApp.xcodeproj(forecast→feature/forecast/src/iosMain/swift). New files there are picked up automatically. All folders compile into one Swift module, so type names must be unique across them. - Feature modules don't apply SKIE. SKIE is applied only to
:iosBridge, the module that builds the framework, and it bundles Swift only from that module. So the features'src/iosMain/swiftfiles stay out of the Kotlin framework, where the Swift packages the screens import (KMPObservableViewModelSwiftUI) aren't available.
Trade-off: the module owns its iOS UI files but not their build. Xcode compiles them, so an iOS-only UI change still needs an Xcode build to verify.
Navigation is native on each platform and stays out of shared code. Shared ViewModels don't know about
routes or screens; a screen reports a user event through a callback (onDayClick), and the platform's
UI layer decides where to go.
| Platform | Library | Back stack | ViewModel lifetime |
|---|---|---|---|
| Android | Navigation 3 | rememberNavBackStack of @Serializable NavKey routes in App |
scoped to the back stack entry (rememberViewModelStoreNavEntryDecorator) |
| iOS | SwiftUI NavigationStack |
NavigationLink(value: day.date) + .navigationDestination(for: LocalDate.self) in WeeklyForecastScreen |
owned by the destination view (@StateViewModel) |
- Routes carry IDs, not data.
DailyForecastRouteholds only theLocalDate.DailyForecastViewModelgets it as a Koin parameter (parametersOf(date); on iOSprovideDailyForecastViewModel(date:)) and reads the day from the repository cache (see Caching). Routes stay small enough to save and restore, and the screen can load its own data on its own, e.g. after process death. - One ViewModel per destination. Each opened day gets its own
DailyForecastViewModel, which is cleared when the screen is popped, on both platforms. WeeklyForecastScreenis the start destination on both platforms.
Koin is used for dependency injection. All definitions live in shared code, one Koin module per Gradle module
(baseModule,
featureForecastModule),
so both platforms resolve the same instances.
Only the composition roots start Koin, because only they know which features the app ships. They pass the features'
Koin modules to initializeKoin
in :feature:base, which adds baseModule and the platform's own config through includes(config)
(Koin KMP setup). Feature modules never start Koin, so
they don't depend on each other.
ForecastRepositoryImpl
keeps the latest forecast in an in-memory cache (guarded by a Mutex). The first request hits the network; DailyForecastViewModel then reads the day from the cache (via GetDailyWeatherUseCase).
Pull-to-refresh (WeeklyForecastIntent.Refresh) bypasses the cache (forceRefresh = true) and replaces the cached
value; if it fails, the current forecast stays on screen and a toast shows the error. The cached forecast expires after 15 minutes (wall clock) and is re-downloaded on the next request; the cache itself
lives as long as the process.
UI types are named by role, consistently on both platforms:
…Screen— a full destination the user navigates to. Owns its root state (a ViewModel), takes no state from a parent, and appears in the routing layer.WeeklyForecastScreenandDailyForecastScreenon both platforms.…Content— the stateless body of a screen, rendered for its loaded state and kept in the screen's file (WeeklyForecastContent,DailyForecastContent, sharedErrorContent).- Everything else — reusable parts and leaf components, named after what they are
(
CurrentWeatherCard,TemperatureRangeBar), one per file. They take values from a parent and own no root state. The same name is used on both platforms.
On the iOS side this deviates from Apple's idiom, where every view type is suffixed
View (ContentView, SettingsView) regardless of scope. The deviation is deliberate:
it keeps vocabulary aligned across the two platforms, and makes "is this navigable?"
answerable from the type name instead of only from ViewModel ownership and folder
placement. Apply it to every destination without exception.
Kotlin linters run once from the root project over the whole repository (all modules and build-logic), so modules
don't configure them. Android Lint runs per Android module, configured in
AndroidLintConventionPlugin:
./gradlew detektApply # Apply Detekt formatting fixes
./gradlew detektCheck # Run Detekt Check
./gradlew spotlessApply # Apply spotless code formatting fixes
./gradlew spotlessCheck # Run spotless Check
./gradlew lintApply # Apply safe Android Lint fixes
./gradlew lintCheck # Run Android Lint Check- ktlint rules: .editorconfig, plus Compose rules.
- Detekt rules: detekt.yml, on top of the Detekt defaults. Reports:
build/reports/detekt/. lintCheck/lintApplyare aliases for AGP'slint/lintFix, so every linter is invoked the same way.- Android Lint rules: lint.xml, on top of the Lint defaults. Warnings are errors, so a check is either
fixed or disabled in
lint.xml. Reports:androidApp/build/reports/. - Android Lint covers the
:androidAppmodule (Kotlin sources, manifest, resources and the Gradle files). The:feature:*modules are not analyzed - AGP's Kotlin Multiplatform library plugin doesn't create Lint tasks for KMP modules yet, socheckDependencieshas nothing to pull in for them. TODO/FIXMEmarkers are not allowed in code: DetektForbiddenCommentflags them in Kotlin comments,NotImplementedDeclarationflags KotlinTODO()calls, and the SwiftLinttodorule (on by default) covers Swift.- Formatting is done by ktlint only. Detekt runs without its
detekt-formatting(ktlint wrapper) plugin, sodetektApplyfixes only Detekt's own auto-correctable rules.
Swift code is linted by SwiftLint, which isn't part of the Gradle build
(install it with brew install swiftlint):
swiftlint --fix # Apply SwiftLint fixes
swiftlint lint --strict # Run SwiftLint Check (warnings fail, same as CI)- SwiftLint rules: .swiftlint.yml, on top of the SwiftLint defaults. It runs from the root and
covers the iOS app plus every feature's
src/iosMain/swiftfolder.
GitHub Actions run on every pull request and push to main:
| Job | Runner | Runs |
|---|---|---|
| Build Android App | Ubuntu | ./gradlew :androidApp:assembleDebug, uploads the debug APK |
| Build iOS App | macOS | xcodebuild simulator build of iosApp (its build phase builds the Kotlin framework) |
| Detekt | Ubuntu | ./gradlew detektCheck, uploads the report |
| Spotless (ktlint) | Ubuntu | ./gradlew spotlessCheck |
| Android Lint | Ubuntu | ./gradlew lintCheck, uploads the report |
| SwiftLint | Ubuntu | swiftlint lint --strict in the SwiftLint container |
FlowMVI provides Remote Debugging.
In this project remote debugging host is set to "127.0.0.1" ip address (enableRemoteDebugging(host = "127.0.0.1") in configuredStore).
To make debugging work on Android physical device run adb reverse tcp:9684 tcp:9684 command.

