Skip to content

About

A repository for kmp-showcase

Resources

Stars

22 stars

Watchers

0 watching

Forks

Repository files navigation

💎 KMP Showcase

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.

Application Scope

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

Tech-Stack

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-Swift Interop:

  • SKIE - Kotlin Native compiler plugin that improves Kotlin-Swift interoperability (Flow → AsyncSequence / Observing, sealed class / 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 viewModelScope when the view goes away

Presentation:

Android UI:

iOS UI:

Networking:

  • Ktor Client - Multiplatform HTTP client (Android engine on Android, Darwin engine on iOS)
    • Ktor Resources - Type-safe HTTP requests. A @Resource request class (e.g. ForecastRequestModel) is serialized into the URL path and query parameters, so no manual append(...)
    • Content Negotiation - JSON (de)serialization with kotlinx.serialization

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:

Build & CI:

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

Architecture

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;
Loading

Feature Module Structure

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:base doesn't follow this structure. It holds code shared by all feature modules (StoreViewModel, configuredStore, HttpClient, Koin start-up).

Presentation Layer

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 a common UI state. It exposes the state via an observable state holder (Kotlin Flow)
  • MVI - an intent modifies the common UI state and emits a new state to a view via Kotlin Flow

The common state is 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 the ViewModel as 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 ViewModel to the view (e.g. show a toast).

Domain Layer

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 domain layer independent from the data layer (Dependency inversion).

Data Layer

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 domain layer. It fetches data from the Data Source, keeps it in the cache and maps it into domain models (e.g. ForecastRepositoryImpl).
  • Mapper - maps data model to domain model (to keep domain layer independent from the data layer). 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 @Resource class 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 as DailyResponseModel)

Response Models are annotated with @Serializable, so kotlinx.serialization understands how to parse the data into objects.

Common Module Components

Each feature module contains several standard items that provide essential functionality and configuration:

Components:

  • Gradle Build Script - build.gradle.kts applying the showcase.feature convention plugin plus the module's own dependencies.
  • Koin DI Module - dependency injection configuration in commonMain (e.g. featureForecastModule), plus Swift accessors in iosMain (see Dependency Injection).
  • Tests - commonTest source set with kotlin-test, run on the Android host and the iOS simulator (set up by the convention plugin; no tests yet).

Gradle Config

Dependency Management

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

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

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, which showcase.iosbridge exports to Swift).
  • Accessors are generated only for the main build's scripts, not for build-logic sources, so FeatureConventionPlugin still uses project(":feature:base").

Unified Version Configuration

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.

Java/JVM Version Configuration

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).

Version Catalog Access in build-logic

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"))

Design Decisions

UI State Management via Flow MVI

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. updateState is 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.

Consuming Common ViewModels

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: typed StateFlow; SKIE turns it into an AsyncSequence for Observing. Collecting it opens a store subscription, which lives as long as the view observes it.
  • actions: a cold Flow, also backed by a store subscription. The subscription lives as long as the Swift .task that collects it, so ActionShareBehavior.Distribute sees 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.

Shared Store Setup

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 StoreViewModel wraps FlowMVI's store DSL 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.

Feature UI Lives in the Feature Module

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 in presentation/common), on both platforms. There is no ui package.
  • Android is a normal KMP androidMain source set. The Compose compiler and Compose dependencies come from the showcase.feature convention 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/swift files 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

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. DailyForecastRoute holds only the LocalDate. DailyForecastViewModel gets it as a Koin parameter (parametersOf(date); on iOS provideDailyForecastViewModel(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.
  • WeeklyForecastScreen is the start destination on both platforms.

Dependency Injection

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.

Caching

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.

Naming Conventions

Screens vs Components

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. WeeklyForecastScreen and DailyForecastScreen on both platforms.
  • …Content — the stateless body of a screen, rendered for its loaded state and kept in the screen's file (WeeklyForecastContent, DailyForecastContent, shared ErrorContent).
  • 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.

Linters

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 / lintApply are aliases for AGP's lint / 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 :androidApp module (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, so checkDependencies has nothing to pull in for them.
  • TODO / FIXME markers are not allowed in code: Detekt ForbiddenComment flags them in Kotlin comments, NotImplementedDeclaration flags Kotlin TODO() calls, and the SwiftLint todo rule (on by default) covers Swift.
  • Formatting is done by ktlint only. Detekt runs without its detekt-formatting (ktlint wrapper) plugin, so detektApply fixes 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/swift folder.

CI Pipeline

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

Debugging FlowMVI

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.

About

A repository for kmp-showcase

Resources

Stars

22 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages