Publishing Kotlin Multiplatform (KMP) apps requires navigating platform-specific workflows while maintaining code consistency. This guide provides battle-tested strategies for deploying to Android, iOS, web, and desktop, complete with CI/CD automation and store optimization tips.
android { buildTypes { release { isMinifyEnabled = true proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") // Add custom ProGuard rules for KMP libraries proguardFile("multiplatform-proguard-rules.pro") } } }shrinkResources true in your Android build configuration.bundletool build-apks --bundle=app.aab --output=app.apks bundletool get-size total --apks=app.apkskotlin { js(IR) { browser { webpackTask { output.libraryTarget = "umd" // Enable tree shaking mode = org.jetbrains.kotlin.gradle.targets.js.webpack.KotlinWebpackConfig.Mode.PRODUCTION } } } }| Platform | Action | Implementation Details |
|---|---|---|
| Android | Obfuscate with R8/ProGuard | Add -keepattributes SourceFile,LineNumberTable to preserve crash reporting |
| iOS | Enable app transport security (ATS) | Add NSAllowsArbitraryLoads: false in Info.plist |
| Web | Use HTTPS and CSP headers | Set Content-Security-Policy: default-src 'self' in server config |
| Desktop | Sign binaries with digital certificates | Use platform-specific signing tools (signtool.exe, codesign) |
Additional Security Measures:
// In shared code expect fun setupCertificatePinning(host: String, pins: List<String>) // Android implementation actual fun setupCertificatePinning(host: String, pins: List<String>) { OkHttpClient.Builder() .certificatePinner(CertificatePinner.Builder() .add(host, *pins.toTypedArray()) .build()) .build() }./gradlew allTests Ensure test coverage across all platforms with platform-specific test configurations:kotlin { sourceSets { val commonTest by getting { dependencies { implementation(kotlin("test")) implementation("io.kotest:kotest-assertions-core:5.6.2") } } val androidUnitTest by getting { dependencies { implementation("junit:junit:4.13.2") implementation("androidx.test:runner:1.5.2") } } val iosTest by getting } }class LoginScreenTest { @Test fun invalidLoginShowsError() { composeTestRule.setContent { AppTheme { LoginScreen() } } // Input invalid credentials composeTestRule.onNodeWithTag("email_field").performTextInput("invalid@email") composeTestRule.onNodeWithTag("password_field").performTextInput("short") composeTestRule.onNodeWithTag("login_button").performClick() // Verify error is displayed composeTestRule.onNodeWithText("Invalid credentials").assertIsDisplayed() } }./gradlew assembleDebug gcloud firebase test android run --type instrumentation \ --app app/build/outputs/apk/debug/app-debug.apk \ --test app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk \ --device model=Pixel6,version=33,locale=en,orientation=portraitfastlane pilot distribute --ipa "iosApp.ipa" --groups "Internal Testers"Step 1: Create Keystore
keytool -genkeypair -v -keystore release.jks -keyalg RSA -keysize 4096 -validity 10000 -alias my-app
Important: Store this keystore securely – losing it means you cannot update your app!
Best Practices for Keystore Management:
Step 2: Configure Gradle
signingConfigs {
create("release") {
storeFile = rootProject.file("release.jks")
// Don't hardcode passwords in build files
storePassword = System.getenv("STORE_PASSWORD") ?: properties["STORE_PASSWORD"].toString()
keyAlias = "my-app"
keyPassword = System.getenv("KEY_PASSWORD") ?: properties["KEY_PASSWORD"].toString()
// Enable v1 and v2 signing for maximum compatibility
enableV1Signing = true
enableV2Signing = true
}
}
buildTypes {
release {
signingConfig = signingConfigs["release"]
// Additional release optimizations
isShrinkResources = true
isMinifyEnabled = true
}
}
Step 3: Build AAB
./gradlew :androidApp:bundleRelease
The AAB file will be located at androidApp/build/outputs/bundle/release/androidApp-release.aab
Verify Bundle Contents:
bundletool build-apks --bundle=androidApp-release.aab --output=androidApp.apks
bundletool extract-apks --apks=androidApp.apks --output-dir=extracted --device-spec=device-spec.json
Play Store Optimization Tips:
// In Xcode project settings DEVELOPMENT_TEAM = "YOUR_TEAM_ID"; CODE_SIGN_STYLE = Automatic;// In Xcode project capabilities tab // Or manually in entitlements file <key>com.apple.developer.in-app-payments</key> <array> <string>merchant.com.yourcompany.app</string> </array># Build the Kotlin framework ./gradlew :iosApp:linkReleaseFrameworkIosArm64 # Archive the app (can also be done through Xcode UI) xcodebuild -workspace iosApp.xcworkspace -scheme iosApp -configuration Release -sdk iphoneos -archivePath build/iosApp.xcarchive archive # Export IPA xcodebuild -exportArchive -archivePath build/iosApp.xcarchive -exportOptionsPlist exportOptions.plist -exportPath build/ipa exportOptions.plist example:<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <plist version="1.0"> <dict> <key>method</key> <string>app-store</string> <key>teamID</key> <string>YOUR_TEAM_ID</string> </dict> </plist># Using Fastlane fastlane pilot upload --ipa "build/ipa/iosApp.ipa" --api_key_path "AuthKey.p8" --api_key_id "YOUR_KEY_ID" --issuer_id "YOUR_ISSUER_ID" # Or using Transporter app xcrun altool --upload-app -f build/ipa/iosApp.ipa -t ios -u "YOUR_APPLE_ID" -p "YOUR_APP_SPECIFIC_PASSWORD" TestFlight Distribution: # Generate optimized JS bundle
./gradlew jsBrowserProductionWebpack
Output: Static files in build/distributions including:
Optimization Options:
kotlin {
js(IR) {
browser {
commonWebpackConfig {
cssSupport {
enabled.set(true)
}
// Enable code splitting
outputFileName = "app.[contenthash].js"
}
// Enable progressive web app features
webpackTask {
output.libraryTarget = "umd"
// Add service worker for offline support
output.globalObject = "this"
}
// Optimize bundle size
dceTask {
keep("app.org.example.main")
}
}
binaries.executable()
}
}
| Host | Advantage | Deploy Command | Additional Setup |
|---|---|---|---|
| Vercel | Automatic CDN, SSR support | vercel deploy --prod | Create vercel.json for routing |
| GitHub Pages | Free hosting for OSS projects | git push origin gh-pages | Set up GitHub Actions workflow |
| Firebase | Integrated analytics & auth | firebase deploy --only hosting | Configure firebase.json |
| Netlify | Continuous deployment, forms | netlify deploy --prod | Create netlify.toml |
| AWS S3/CloudFront | Scalable, global CDN | aws s3 sync build/distributions s3://bucket-name | Set up CloudFront distribution |
Performance Tips:
# Nginx configuration brotli on; brotli_comp_level 6; brotli_types text/plain text/css application/javascript application/json;# For static assets with content hash in filename location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|otf|eot)$ { expires 1y; add_header Cache-Control "public, max-age=31536000, immutable"; } # For HTML and other dynamic content location / { expires -1; add_header Cache-Control "no-store, no-cache, must-revalidate"; }Deployment Verification:
# Windows
./gradlew :desktopApp:packageMsi
# macOS
./gradlew :desktopApp:packageDmg
# Linux
./gradlew :desktopApp:packageDeb
Configuration Options:
compose.desktop {
application {
mainClass = "MainKt"
nativeDistributions {
targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
// Windows-specific options
windows {
menuGroup = "Kotlin Multiplatform Apps"
// Add icon and installer customization
iconFile.set(project.file("icon.ico"))
upgradeUuid = "your-app-upgrade-uuid"
perUserInstall = true
}
// macOS-specific options
macOS {
bundleID = "com.example.app"
// Add signing identity for notarization
signing {
sign.set(true)
identity.set("Developer ID Application: Your Name (TEAM_ID)")
}
// Add entitlements for App Sandbox
entitlementsFile.set(project.file("entitlements.plist"))
}
// Linux-specific options
linux {
packageName = "kmp-app"
debMaintainer = "support@example.com"
appCategory = "Development"
menuGroup = "Development"
}
}
}
}
SignTool sign /fd SHA256 /a /f MyCertificate.pfx /p MyPassword MyApp.msix# Step 1: Create App-Specific Password in Apple ID account # Step 2: Submit for notarization xcrun notarytool submit MyApp.dmg --apple-id "your@email.com" --password "app-specific-password" --team-id "TEAM_ID" # Step 3: Check status xcrun notarytool info [REQUEST_UUID] --apple-id "your@email.com" --password "app-specific-password" --team-id "TEAM_ID" # Step 4: Staple ticket to DMG xcrun stapler staple MyApp.dmg Notarization Requirements: # snapcraft.yaml name: my-kmp-app base: core22 version: '1.0' summary: KMP Desktop Application description: | A cross-platform desktop application built with Kotlin Multiplatform and Compose for Desktop. grade: stable confinement: strict apps: my-kmp-app: command: bin/desktop extensions: [gnome] desktop: usr/share/applications/my-kmp-app.desktop parts: my-kmp-app: plugin: dump source: build/compose/binaries/main/deb stage-packages: - libgtk-3-0 - libx11-6 Publishing to Snap Store:# Login to Snap Store snapcraft login # Build snap package snapcraft # Upload to store snapcraft upload --release=stable my-kmp-app_1.0_amd64.snap# my-kmp-app.rb cask "my-kmp-app" do version "1.0.0" sha256 "calculated-sha256-of-your-dmg" url "https://github.com/yourusername/my-kmp-app/releases/download/v#{version}/MyApp-#{version}.dmg" name "My KMP App" desc "Cross-platform desktop application built with Kotlin Multiplatform" homepage "https://example.com/my-kmp-app" app "My KMP App.app" endPlatform Integration:
Update Mechanisms:
// In shared code expect class UpdateManager { fun checkForUpdates() fun downloadUpdate() fun installUpdate() } // Platform-specific implementations actual class UpdateManager { actual fun checkForUpdates() { // Platform-specific update check } // ... }name: KMP Deploy
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
android:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v3
with:
java-version: 17
distribution: 'temurin'
cache: 'gradle'
- name: Build Android App Bundle
run: ./gradlew :androidApp:bundleRelease
- name: Sign Android App Bundle
uses: r0adkll/sign-android-release@v1
with:
releaseDirectory: androidApp/build/outputs/bundle/release
signingKeyBase64: ${{ secrets.SIGNING_KEY }}
alias: ${{ secrets.KEY_ALIAS }}
keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }}
keyPassword: ${{ secrets.KEY_PASSWORD }}
- name: Upload to Google Play
if: github.event_name != 'pull_request'
uses: r0adkll/upload-google-play@v1
with:
serviceAccountJson: ${{ secrets.GCP_SA }}
packageName: com.example.app
releaseFiles: androidApp/build/outputs/bundle/release/androidApp-release.aab
track: internal
status: completed
ios:
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v3
with:
java-version: 17
distribution: 'temurin'
cache: 'gradle'
- name: Build iOS Framework
run: ./gradlew :iosApp:linkReleaseFrameworkIosArm64
- name: Install Apple Certificates
if: github.event_name != 'pull_request'
uses: apple-actions/import-codesign-certs@v1
with:
p12-file-base64: ${{ secrets.IOS_P12 }}
p12-password: ${{ secrets.IOS_P12_PASS }}
- name: Build and Archive iOS App
if: github.event_name != 'pull_request'
run: |
cd iosApp
xcodebuild -workspace iosApp.xcworkspace -scheme iosApp -configuration Release -sdk iphoneos -archivePath build/iosApp.xcarchive archive
- name: Export IPA
if: github.event_name != 'pull_request'
run: |
cd iosApp
xcodebuild -exportArchive -archivePath build/iosApp.xcarchive -exportOptionsPlist exportOptions.plist -exportPath build/ipa
- name: Upload to TestFlight
if: github.event_name != 'pull_request'
uses: apple-actions/upload-testflight-build@v1
with:
app-path: iosApp/build/ipa/iosApp.ipa
api-key-id: ${{ secrets.APPSTORE_API_KEY_ID }}
api-key-issuer-id: ${{ secrets.APPSTORE_API_KEY_ISSUER_ID }}
api-key-content: ${{ secrets.APPSTORE_API_KEY_CONTENT }}
web:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v3
with:
java-version: 17
distribution: 'temurin'
cache: 'gradle'
- name: Build Web App
run: ./gradlew jsBrowserProductionWebpack
- name: Deploy to Firebase
if: github.event_name != 'pull_request'
uses: FirebaseExtended/action-hosting-deploy@v0
with:
repoToken: ${{ secrets.GITHUB_TOKEN }}
firebaseServiceAccount: ${{ secrets.FIREBASE_SERVICE_ACCOUNT }}
channelId: live
projectId: your-firebase-project-id
desktop:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
include:
- os: ubuntu-latest
task: packageDeb
artifact: build/compose/binaries/main/deb/*.deb
- os: windows-latest
task: packageMsi
artifact: build/compose/binaries/main/msi/*.msi
- os: macos-latest
task: packageDmg
artifact: build/compose/binaries/main/dmg/*.dmg
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v3
with:
java-version: 17
distribution: 'temurin'
cache: 'gradle'
- name: Build Desktop Package
run: ./gradlew :desktopApp:${{ matrix.task }}
- name: Upload Artifact
uses: actions/upload-artifact@v3
with:
name: desktop-${{ matrix.os }}
path: ${{ matrix.artifact }}
- name: Create GitHub Release
if: github.event_name != 'pull_request' && startsWith(github.ref, 'refs/tags/')
uses: softprops/action-gh-release@v1
with:
files: ${{ matrix.artifact }}
- name: Cache Gradle packages uses: actions/cache@v3 with: path: | ~/.gradle/caches ~/.gradle/wrapper ~/.konan key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} restore-keys: | ${{ runner.os }}-gradle-jobs: build: runs-on: self-hosted # Job steps... Benefits: ./gradlew --parallelRelease Health Monitoring:
// In build.gradle.kts dependencies { implementation("com.google.firebase:firebase-crashlytics:18.4.0") }# GitHub workflow for crash alerts name: Crash Alert on: schedule: - cron: '0 */6 * * *' # Every 6 hours jobs: check-crashes: runs-on: ubuntu-latest steps: - name: Check Firebase Crashlytics uses: firebase/firebase-admin-node@v1 with: # Custom script to check crash rates script: | const crashes = await firebase.crashlytics().getCrashRate(); if (crashes > THRESHOLD) { // Send alert to Slack/Teams/Email }Semantic Versioning:
Implementation:
// In build.gradle.kts
val versionMajor = 1
val versionMinor = 0
val versionPatch = 0
val versionBuild = 0 // Incremented for each build
android {
defaultConfig {
versionCode = versionMajor * 10000 + versionMinor * 1000 + versionPatch * 100 + versionBuild
versionName = "$versionMajor.$versionMinor.$versionPatch"
}
}
Android Staged Rollout:
iOS Phased Release:
Web Canary Deployments:
# Firebase hosting configuration
hosting:
target: production
releases:
production:
- version: "1.0.0"
percentage: 10
- version: "0.9.0"
percentage: 90
Emergency Rollback Plan:
Automated Rollback Triggers:
name: Auto-Rollback
on:
workflow_dispatch:
schedule:
- cron: '*/15 * * * *' # Check every 15 minutes
jobs:
monitor:
runs-on: ubuntu-latest
steps:
- name: Check Error Rates
Parallel Testing Strategy:
jobs: test: strategy: matrix: module: [shared, androidApp, iosApp, webApp] steps: - run: ./gradlew :${{ matrix.module }}:testCaching Dependencies:
- name: Cache Gradle packages
uses: actions/cache@v3
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
~/.konan
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*') }}
Environment-Specific Configurations:
// build.gradle.kts
android {
buildTypes {
create("staging") {
initWith(getByName("debug"))
buildConfigField("String", "API_URL", "\"https://staging-api.example.com\"")
manifestPlaceholders["appName"] = "MyApp (Staging)"
}
}
}
Cross-Platform Analytics:
// In shared code
expect class AnalyticsTracker {
fun logEvent(name: String, params: Map<String, Any>)
}
// In Android
actual class AnalyticsTracker {
private val firebaseAnalytics = FirebaseAnalytics.getInstance(context)
actual fun logEvent(name: String, params: Map<String, Any>) {
val bundle = Bundle()
params.forEach { (key, value) ->
when (value) {
is String -> bundle.putString(key, value)
is Int -> bundle.putInt(key, value)
// Handle other types
}
}
firebaseAnalytics.logEvent(name, bundle)
}
}
// In iOS
actual class AnalyticsTracker {
actual fun logEvent(name: String, params: Map<String, Any>) {
Analytics.logEvent(name, parameters: params as? [String: NSObject])
}
}
Key Metrics to Track:
Unified Error Handling:
// In shared code
fun reportException(exception: Throwable, metadata: Map<String, String> = emptyMap()) {
try {
platformCrashReporter.reportException(exception, metadata)
} catch (e: Exception) {
// Fallback logging
println("Failed to report exception: $exception")
}
}
// Platform-specific implementations
expect object platformCrashReporter {
fun reportException(exception: Throwable, metadata: Map<String, String>)
}
Automated Alerts:
Metadata Best Practices:
Screenshot Guidelines:
Release Notes Strategy:
iOS-Specific ASO:
Review Prompt Implementation:
// In iOS app
func requestReview() {
if #available(iOS 14.0, *) {
if let scene = UIApplication.shared.connectedScenes.first(where: { $0.activationState == .foregroundActive }) as? UIWindowScene {
SKStoreReviewController.requestReview(in: scene)
}
} else {
SKStoreReviewController.requestReview()
}
}
A/B Testing Store Listings:
Version Scheme:
// In build.gradle.kts
val versionMajor = 1
val versionMinor = 2
val versionPatch = 3
val versionBuild = 45
android {
defaultConfig {
versionCode = versionMajor * 10000 + versionMinor * 1000 + versionPatch * 100 + versionBuild
versionName = "$versionMajor.$versionMinor.$versionPatch"
}
}
Version Tracking:
Android Staged Rollout:
iOS Phased Release:
Web Progressive Deployment:
Emergency Release Process:
Rollback Plan:
Cross-Platform Requirements:
Implementation Example:
// In shared code
@Composable
fun PrivacyPolicyScreen() {
val privacyPolicyUrl = rememberPlatformPrivacyPolicyUrl()
Column(modifier = Modifier.padding(16.dp)) {
Text("Privacy Policy", style = MaterialTheme.typography.h5)
Spacer(Modifier.height(16.dp))
Text("Please review our privacy policy to understand how we collect and use your data.")
Spacer(Modifier.height(16.dp))
Button(onClick = { openUrl(privacyPolicyUrl) }) {
Text("View Privacy Policy")
}
}
}
// Platform-specific implementation
expect fun rememberPlatformPrivacyPolicyUrl(): String
Key Components:
Versioning Terms:
Cross-Platform Accessibility:
// In shared code
@Composable
fun AccessibleButton(
onClick: () -> Unit,
text: String,
contentDescription: String? = null
) {
Button(
onClick = onClick,
modifier = Modifier.semantics {
if (contentDescription != null) {
this.contentDescription = contentDescription
}
}
) {
Text(text)
}
}
Accessibility Testing Checklist:
Deploying Kotlin Multiplatform applications requires careful planning and platform-specific knowledge, but the benefits of code sharing and unified deployment processes make it worthwhile. By following this comprehensive guide, you can streamline your deployment workflow, ensure compliance with platform requirements, and deliver high-quality applications to users across Android, iOS, web, and desktop platforms.
Remember that deployment is not the end of the development cycle but rather an ongoing process of monitoring, optimization, and improvement. Regular updates, responsive customer support, and continuous performance monitoring will help ensure your KMP application’s long-term success.
Additional Resources:
This continuation completes the deployment guide with detailed sections on CI/CD best practices, post-deployment monitoring, app store optimization, versioning strategies, and legal compliance considerations. Each section includes practical code examples, configuration snippets, and actionable advice for Kotlin Multiplatform developers.
Internal Links
External Links
For ongoing maintenance, monitor Kotlin Releases and update dependencies quarterly. 🚀
Introduction: Transform Your Cross-Platform Development with Material Design 3 Are you ready to revolutionize your… Read More
Jetpack Compose 1.8 rolls out handy features like Autofill integration, slick Text enhancements including auto-sizing… Read More
Reified Keyword in Kotlin: Simplify Your Generic Functions Kotlin's reified keyword lets your generic functions know the… Read More
Android Studio Cloud: Ditch the Setup, Code Anywhere (Seriously!) Alright, fellow Android devs, gather 'round… Read More