Objective-C Interop, Swift Export and SKIE

This content was generated with the assistance of AI and should be verified against the official Kotlin Multiplatform documentation before being relied on in production.

See the section bibliography for the reference material consulted while preparing these pages.

Swift cannot call Kotlin directly. The Kotlin/Native compiler produces a framework whose public API is described by an Objective-C header, and Swift imports that header like any other Objective-C library. Everything you write in commonMain and iosMain reaches your iOS developers through that translation, so its rules decide whether the shared module feels like a native Swift package or like a foreign library full of KotlinInt boxes and missing switch exhaustiveness. This page explains the translation, the annotations that tune it, the cost of exporting more than you need, and the two ways to leave the Objective-C layer behind: SKIE (a third-party Swift-focused compiler plugin) and Swift export (JetBrains' direct Kotlin-to-Swift path).

The page targets the section baseline: Kotlin 2.4.20, Xcode 26.4, SKIE 0.10.15 and KMP-NativeCoroutines 1.0.6. Stability levels matter here. Exporting Kotlin to Objective-C is a long-standing feature, but several of the annotations below are Experimental and need an opt-in. Swift export is Alpha, and importing Swift packages into Kotlin (SwiftPM import) is Alpha as well. Importing Objective-C and C libraries into Kotlin (the opposite direction) is Beta. SKIE and KMP-NativeCoroutines are third-party tools with their own release cadence and Kotlin compatibility ranges. Examples are slices of the running example, TrailMate Everywhere (package com.example.trailmate), introduced in What Kotlin Multiplatform Is and When to Use It.

This page does not re-teach Kotlin or SwiftUI. See Kotlin for Swift Developers for the language mapping, Integrating Shared Code into iOS for Xcode wiring, Consuming Coroutines and Flow from Swift and SwiftUI for suspend and Flow in SwiftUI, and Language Interoperability for the Apple side of the Objective-C bridge.

The Big Picture

The figure shows the same six Kotlin declarations as the Objective-C header describes them, as Swift sees them by default, and as Swift sees them when SKIE or Swift export is in play.

Four-column mapping figure. Column one lists Kotlin declarations: a top-level function in TrailFormatting.kt

Three consequences follow. The header is the contract: whatever Objective-C cannot express (generic functions, default arguments, exhaustive sealed hierarchies, Flow) is degraded, erased or omitted. Every public declaration costs binary and header size. And SKIE sits on top of the header while Swift export replaces it, so they are alternatives, not layers to stack.

How Kotlin Looks Through the Objective-C Header

The following Kotlin slice is used throughout this section. It is deliberately ordinary shared code, not code written for iOS.

// sharedLogic/src/commonMain/kotlin/com/example/trailmate/domain/Trail.kt
package com.example.trailmate.domain

enum class Difficulty { EASY, MODERATE, HARD }

data class Trail(
    val id: String,
    val name: String,
    val lengthKm: Double,
    val difficulty: Difficulty,
    val photoUrl: String?,
)
// sharedLogic/src/commonMain/kotlin/com/example/trailmate/presentation/TrailListUiState.kt
package com.example.trailmate.presentation

import com.example.trailmate.domain.Trail

sealed interface TrailListUiState {
    data object Loading : TrailListUiState
    data class Success(val trails: List<Trail>) : TrailListUiState
    data class Failed(val message: String) : TrailListUiState
}

Names, Prefixes and Name Clashes

Objective-C has no namespaces. Kotlin/Native therefore prefixes every exported class with the framework’s baseName (for example SharedTrail) in the header, and Swift removes the prefix again because the framework is a module: in Swift you write Trail, and import Shared provides it.

Two Kotlin classes called Trail in different packages cannot both be SharedTrail. The compiler disambiguates them with a suffix (an underscore, for example Trail_), which makes the second one awkward to use. Likewise, Kotlin members whose names clash after translation (for example overloads that differ only in a parameter type that erases to id) receive generated names. Treat any generated suffix as a bug in your public API: rename the Kotlin declaration or use @ObjCName (see below).

In Swift the type is simply Trail (import Shared); Double stays Double, Long becomes Int64 and Boolean becomes Bool. See Kotlin docs — Interoperability with Swift/Objective-C.

Packages Are Flattened

The Objective-C header has no package concept, so com.example.trailmate.domain.Trail and com.example.trailmate.data.Trail are both just Trail as far as the import is concerned. There is no domain.Trail in Swift. This is why a tidy Kotlin package structure does not help the Swift reader, and why unique public type names matter. (Swift export keeps the package structure by default and offers flattenPackage; see below.)

Top-Level Declarations and the Kt Classes

Kotlin allows functions and properties outside classes; Objective-C does not. Top-level declarations are wrapped in a class named after the source file plus Kt. A function in TrailFormatting.kt becomes a static member of TrailFormattingKt.

// sharedLogic/src/commonMain/kotlin/com/example/trailmate/domain/TrailFormatting.kt
package com.example.trailmate.domain

fun formatLength(lengthKm: Double): String =
    if (lengthKm < 1.0) "${(lengthKm * 1000).toInt()} m" else "%.1f km".format(lengthKm)
// iosApp/iosApp/Formatting.swift
import Shared

func label(for trail: Trail) -> String {
    // The file name decides the class name: TrailFormatting.kt -> TrailFormattingKt
    TrailFormattingKt.formatLength(lengthKm: trail.lengthKm)
}

Renaming the Kotlin file silently renames the Swift call site, which is a source-breaking change for iOS developers. Group top-level helpers in an object, or give an @ObjCName to the file’s facade, when the name should be stable. See Kotlin docs — Interoperability with Swift/Objective-C.

Generics Have Limits

Objective-C has "lightweight generics" on classes only, with no variance, no generic functions and no generic protocols. The export keeps what fits and erases the rest.

Kotlin Objective-C header What Swift sees

class Page<T : Any>

Lightweight generic class Page<T>

Page<Trail>: usable like a Swift generic class.

class Page<T> (nullable bound)

T is treated as id with nullability stripped

The type argument is AnyObject-like; reads give Any?.

interface Repo<T>

Plain protocol, the generic parameter disappears

Any in every position; cast manually.

Int, Long, Boolean as type argument

Boxed NSNumber subclasses

KotlinInt, KotlinLong, KotlinBoolean.

For TrailMate this means: prefer concrete types in shared APIs that iOS calls (TrailRepository, not a generic Repository<T>), and keep generic abstractions internal to commonMain.

Enums and Sealed Classes

A Kotlin enum class becomes an Objective-C class derived from KotlinEnum, with each entry exposed as a class property. Swift renames ALL_CAPS entries to lower camel case (EASY becomes .easy). It is not a Swift enum, so a switch over it must include a default branch. A sealed class or interface becomes a class (or protocol) plus one subclass per case, named from the outer and inner names (TrailListUiStateSuccess). Swift cannot check the hierarchy for exhaustiveness. A Kotlin object is exposed through a shared accessor.

// iosApp/iosApp/TrailListView.swift
import Shared
import SwiftUI

func color(for difficulty: Difficulty) -> Color {
    switch difficulty {
    case .easy: return .green
    case .moderate: return .orange
    case .hard: return .red
    default: return .gray                  // required: not an exhaustive enum
    }
}

@ViewBuilder
func content(for state: TrailListUiState) -> some View {
    if state is TrailListUiStateLoading {
        ProgressView()
    } else if let success = state as? TrailListUiStateSuccess {
        List(success.trails, id: \.id) { TrailRow(trail: $0) }
    } else if let failed = state as? TrailListUiStateFailed {
        Text(failed.message)
    }
    // Adding a Kotlin subclass later compiles fine and silently shows nothing
}

The silent-failure comment is the real cost. SKIE and Swift export fix exactly this (see below). See Kotlin docs — Interoperability with Swift/Objective-C.

Default Arguments

Objective-C methods have no default parameters, and Kotlin/Native does not generate overloads. The Swift call must pass every argument. Declare explicit overloads in commonMain when the iOS caller should have the short form.

// sharedLogic/src/commonMain/kotlin/com/example/trailmate/domain/TrailSearch.kt
class TrailSearch(private val repository: TrailRepository) {
    suspend fun load(page: Int = 0, region: String? = null): List<Trail> = repository.trails(page, region)
    suspend fun load(): List<Trail> = load(page = 0, region = null)   // explicit short form for Swift
}

Unit, Nothing and Primitive Boxing

Kotlin Swift Remarks

fun refresh(): Unit

Void (no return value)

As a plain return type, Unit disappears.

(Trail) → Unit lambda parameter

(Trail) → KotlinUnit closure

Inside a function type or as a generic argument Unit is the object KotlinUnit; return KotlinUnit.shared.

fun fail(): Nothing

Void return, a function that never returns

As a type argument, Nothing is KotlinNothing, which has no values.

Int?, Double?

KotlinInt?, KotlinDouble?

Nullable primitives are boxed; read with .intValue, .doubleValue.

A typical trap is a Kotlin callback parameter such as onChange: (List<Trail>) → Unit: the Swift closure must end with return KotlinUnit.shared. See Kotlin docs — Interoperability with Swift/Objective-C.

Collections Bridging

Kotlin collections cross the boundary as Foundation collections, and the conversion copies; it is not a view.

Kotlin Objective-C Swift

List<T>, MutableList<T>

NSArray<T>, NSMutableArray<T>

[T] (value copy; mutable lists need the NSMutableArray API)

Set<T>

NSSet<T>

Set<T>

Map<K, V>

NSDictionary<K, V>

[K: V]

List<Int>

NSArray<KotlinInt>

[KotlinInt]

Copying has two implications. Mutations on the Swift side never propagate back to the Kotlin collection, and passing a 50,000-element list on every state emission costs a conversion each time. Keep List<Int> and similar primitive collections out of the iOS-facing API where possible; return a small domain class instead.

Exceptions and @Throws

Kotlin exceptions are unchecked and Objective-C has no equivalent, so by default an uncaught Kotlin exception that reaches Swift terminates the app. Annotate a function with @Throws(SomeException::class) and the export turns it into an NSError out-parameter that Swift imports as throws. Only the listed exception classes (and their subclasses) are converted; any other exception still terminates. For suspend functions, the completion handler receives an NSError only for exceptions listed in @Throws and for CancellationException.

// sharedLogic/src/commonMain/kotlin/com/example/trailmate/data/TrailRepository.kt
package com.example.trailmate.data

import com.example.trailmate.domain.Trail
import kotlin.coroutines.cancellation.CancellationException
import kotlinx.io.IOException

interface TrailRepository {
    @Throws(IOException::class, CancellationException::class)
    suspend fun refresh(region: String?)

    // Not annotated: a thrown exception would crash the app when called from Swift
    fun cached(id: String): Trail?
}

Prefer modelling expected failures as values (a Result or sealed UI state, as in Architecture, Shared ViewModels and State) and reserve @Throws for I/O boundaries. See Kotlin docs — Interoperability with Swift/Objective-C.

Shaping the Header with Annotations

Four annotations let you control what Swift sees without changing the Kotlin code’s meaning. All of them only affect the Objective-C export.

Annotation Stability Effect

@ObjCName

Experimental (@OptIn(ExperimentalObjCName::class))

Gives a class, function, property or parameter another Objective-C and Swift name.

@HiddenFromObjC

Experimental (@OptIn(ExperimentalObjCRefinement::class))

Removes a declaration from the Objective-C header; it stays usable from Kotlin.

@ShouldRefineInSwift

Experimental (ExperimentalObjCRefinement)

Exports the declaration with a __ prefix, as swift_private, so a Swift extension can wrap it.

@HidesFromObjC

Experimental (ExperimentalObjCRefinement)

A meta-annotation: marks your own annotation so that anything it annotates is hidden.

@ObjCName

@ObjCName fixes unsightly or clashing names. swiftName changes what Swift sees, name changes the Objective-C name, and exact = true makes the name literal instead of adding the framework prefix.

// sharedLogic/src/commonMain/kotlin/com/example/trailmate/domain/TrailFormatting.kt
@file:OptIn(ExperimentalObjCName::class)

package com.example.trailmate.domain

import kotlin.experimental.ExperimentalObjCName

@ObjCName(swiftName = "TrailFormat")
object TrailFormatting {
    fun formatLength(@ObjCName("km") lengthKm: Double): String =
        if (lengthKm < 1.0) "${(lengthKm * 1000).toInt()} m" else "%.1f km".format(lengthKm)
}

Swift now calls TrailFormat.shared.formatLength(km: 12.4) instead of TrailFormatting.shared.formatLength(lengthKm:). See Kotlin docs — Interoperability with Swift/Objective-C.

@HiddenFromObjC

Hide declarations that are public for Kotlin consumers but meaningless to Swift: generic helpers, inline functions, Flow operators that Swift cannot call usefully, or members that clash after translation.

// sharedLogic/src/commonMain/kotlin/com/example/trailmate/domain/TrailExtensions.kt
@HiddenFromObjC   // opt in with ExperimentalObjCRefinement; generic helper, useless in Swift
fun <T : Any> Page<T>.firstOrNull(): T? = items.firstOrNull()

@ShouldRefineInSwift

@ShouldRefineInSwift is the "wrap it nicely" option. The declaration is exported with a __ prefix and swift_private, so ordinary Swift code does not see it, and you publish a better Swift wrapper in an extension.

// sharedLogic/src/commonMain/kotlin/com/example/trailmate/domain/TrailStats.kt
@file:OptIn(ExperimentalObjCRefinement::class)

package com.example.trailmate.domain

class TrailStats(val values: List<Double>) {
    // Exported as __average() in Swift
    @ShouldRefineInSwift
    fun average(): Double? = if (values.isEmpty()) null else values.average()
}
// iosApp/iosApp/TrailStats+Swift.swift
import Shared

extension TrailStats {
    // A Swift-flavoured API on top of the refined member
    var average: Double? { __average()?.doubleValue }
}

@HidesFromObjC

@HidesFromObjC is for annotation authors. Annotate your own annotation with it and every declaration carrying your annotation is hidden, so the intent reads at the use site: declare @HidesFromObjC annotation class KotlinOnly and annotate members with @KotlinOnly. KMP-NativeCoroutines is built on this mechanism. See Kotlin docs — Interoperability with Swift/Objective-C.

Controlling the Framework: export() and Transitive Export

By default a framework contains the module that builds it, and only that module’s public API appears in the header. Types from dependencies are used internally but are not visible to Swift, and a public function that returns such a type exposes it as an opaque Any. The export() function in the Gradle DSL makes selected dependencies visible.

// sharedLogic/build.gradle.kts
kotlin {
    listOf(iosArm64(), iosSimulatorArm64()).forEach { target ->
        target.binaries.framework {
            baseName = "Shared"
            isStatic = true
            // Make the shared ViewModel base class and the domain module visible to Swift
            export(libs.androidx.lifecycle.viewmodel)
            export(project(":sharedDomain"))
        }
    }
}

Exported dependencies must also be declared with api (not implementation) in commonMain.dependencies. Exporting the Jetpack ViewModel is what lets a SwiftUI view call viewModel.viewModelScope or subclass it, as described in Architecture, Shared ViewModels and State.

Transitive Export and Its Cost

transitiveExport = true exports a dependency and everything it brings along. It is rarely a good idea: the header grows with every transitive type, compile time and binary size increase, and a library’s internal types become your public Swift API. Enable it with transitiveExport = true inside the framework \{} block, only when you accept that cost. See Kotlin docs — Build final native binaries.

Keeping the Public Surface Small

A shared module with a hundred public types produces a header with hundreds of declarations, plus the Obj-C runtime metadata that goes with them. The binary-size and compile-time effects add up, as does Xcode indexing of a very large generated interface. Practical rules:

  • Make everything internal unless iOS needs it. internal declarations are not in the header.

  • Expose a small facade (a ViewModel, a repository interface, a few DTOs), not the data layer.

  • Prefer implementation over api for dependencies; use api only for what you export.

  • Build static frameworks (isStatic = true) when linking into one app binary; dynamic frameworks carry duplicated runtime when two Kotlin frameworks coexist, which is why one umbrella framework is recommended.

Binary options, static versus dynamic linking and measuring size are covered in Kotlin/Native Memory, Performance, Binary Size and Debugging. The Gradle DSL itself is in Gradle Configuration, the Multiplatform DSL and Compatibility.

SKIE: A Better Swift API on Top of the Header

SKIE (Swift Kotlin Interface Enhancer, by Touchlab) is a Kotlin compiler plugin that generates additional Swift code next to the framework so the Objective-C header feels like idiomatic Swift. It does not replace the header; it wraps it. Its headline features:

  • Exhaustive switch over Kotlin enums and sealed classes or interfaces, using the generated onEnum(of:).

  • suspend functions as Swift async functions with cancellation: cancelling the Swift Task cancels the Kotlin coroutine.

  • Flow, StateFlow and SharedFlow as AsyncSequence, so SwiftUI can consume them with for await.

  • Default arguments (opt-in; the support is documented as experimental).

SKIE tracks the Kotlin compiler closely, so each release supports a defined Kotlin range. Check its compatibility table before upgrading Kotlin; 0.10.15 is the version used in this section.

// sharedLogic/build.gradle.kts
// plugins { alias(libs.plugins.skie) }   // id "co.touchlab.skie", version 0.10.15
skie {
    features {
        // Default-argument interop is opt-in; enable it only when you want it
        group {
            DefaultArgumentInterop.Enabled(true)
        }
    }
}

Exhaustive Switch with onEnum(of:)

SKIE generates an onEnum(of:) function for every sealed hierarchy and enum. The compiler then checks that every case is handled; adding a Kotlin subclass becomes a Swift compile error instead of a silent gap.

// iosApp/iosApp/TrailListView.swift
import Shared
import SwiftUI

struct TrailListContent: View {
    let state: TrailListUiState

    var body: some View {
        switch onEnum(of: state) {
        case .loading:
            ProgressView()
        case .success(let success):
            List(success.trails, id: \.id) { TrailRow(trail: $0) }
        case .failed(let failed):
            Text(failed.message)
        }
    }
}

suspend and Flow

With SKIE, a suspend function is an ordinary Swift async throwing function that honours cancellation, and a Flow is an AsyncSequence. The detailed SwiftUI patterns, and the comparison with KMP-NativeCoroutines 1.0.6, are in Consuming Coroutines and Flow from Swift and SwiftUI.

Costs and Caveats of SKIE

SKIE is a third-party plugin, so a Kotlin upgrade may wait for a SKIE release. It adds generated Swift and compile time, changes the Swift names you call (onEnum(of:)), so migrating to Swift export later means rewriting call sites, and it works on the Objective-C export, so it is not combined with Swift export.

Swift Export (Alpha)

Swift export is JetBrains' direct path from Kotlin to Swift: instead of an Objective-C header it generates a Swift interface (a Swift module that calls into the Kotlin binary through a C bridge). It reached Alpha in Kotlin 2.4.0 and is Alpha in 2.4.20. Alpha means APIs, supported constructs and generated names can change; JetBrains recommends evaluating it, not betting a production app on it.

What it maps:

Kotlin Objective-C header (default) Swift export

suspend fun

Completion handler, or async without cancellation

async Swift function

Flow<T>

Kotlinx_coroutines_coreFlow protocol

AsyncSequence

sealed class or interface

Class hierarchy; non-exhaustive

Swift enum with cases (since 2.4.20)

enum class

KotlinEnum subclass

Swift enum

Package com.example.trailmate

Dropped; one flat namespace

Preserved as Swift namespaces, or flattened with flattenPackage

Top-level function

Static member on a Kt class

Global Swift function

Enabling Swift Export

Enable it with the swiftExport \{} block and tell Gradle which modules to export. Only exported modules are visible to Swift, so a multi-module project exports each module it wants Swift to see.

// sharedLogic/build.gradle.kts
import org.jetbrains.kotlin.gradle.ExperimentalSwiftExportDsl

kotlin {
    iosArm64()
    iosSimulatorArm64()

    @OptIn(ExperimentalSwiftExportDsl::class)
    swiftExport {
        moduleName = "Shared"
        // Drop the package from Swift names: com.example.trailmate.domain.Trail -> Trail
        flattenPackage = "com.example.trailmate"

        // Other modules are exported explicitly, each optionally with its own module name
        export(project(":sharedDomain"))
    }
}

The Xcode run-script phase calls a different Gradle task than the Objective-C framework route. Instead of embedAndSignAppleFrameworkForXcode, Xcode runs embedSwiftExportForXcode; the user-script sandboxing setting has to stay disabled for the same reason as in the direct integration.

The build phase script (exactly as in the direct-integration setup) ends with ./gradlew :sharedLogic:embedSwiftExportForXcode instead of the embedAndSign task.

On the Swift side import Shared now imports the generated Swift module, and for await next in viewModel.trails and try await work without any extra library.

Current Limits

Swift export is Alpha, so verify the supported-constructs list in the documentation for your Kotlin version before committing to it. Expect gaps in less common language features (some generics, certain class hierarchies, extensions and inline classes), dependencies that are not exported cannot be seen from Swift, and the generated Swift API differs from the Objective-C API, so switching later is a source-breaking change in the iOS app. The documentation also describes the limits of the Objective-C and C library import path; see Kotlin docs — Stability of Kotlin/Native library import.

Choosing Between the Objective-C Header, SKIE and Swift Export

Plain Objective-C export SKIE Swift export

Stability

Long-standing export; annotations Experimental

Third-party, 0.10.x

Alpha (JetBrains)

Swift ergonomics

Basic: no exhaustive switch, completion handlers

High: onEnum, async, AsyncSequence

High: native enums, async, AsyncSequence

Cancellation of coroutines

No

Yes

Yes (check current docs)

Extra dependency

None

Gradle plugin; follows Kotlin versions

None; part of Kotlin Gradle plugin

Source-compatible with Obj-C Swift API

Baseline

Mostly; adds new calls

No: names and shapes differ

Good fit

Libraries; stability first

Production apps today

Evaluation; early adopters

A pragmatic route for TrailMate is to start with the plain export plus the annotations above, add SKIE when SwiftUI needs exhaustive switches and cancellation, and watch Swift export.

Calling Swift-Only APIs from Kotlin

Kotlin/Native can call Objective-C APIs directly (platform libraries such as platform.CoreLocation), but not Swift-only APIs: SwiftUI, Swift concurrency types, most modern Apple frameworks that are only exposed in Swift, and your own Swift packages. (Importing Swift packages directly is SwiftPM import, Alpha, covered in Dependencies, klibs.io, cinterop and SwiftPM Import.)

The portable, stable pattern is dependency inversion: define an interface in Kotlin, implement it in Swift, and pass the implementation across the boundary once at app startup. This is exactly the LocationProvider pattern used in expect/actual and Calling Platform APIs, only the implementation lives in Swift instead of iosMain.

// sharedLogic/src/commonMain/kotlin/com/example/trailmate/location/LocationProvider.kt
package com.example.trailmate.location

data class GeoPoint(val latitude: Double, val longitude: Double)

interface LocationProvider {
    // Callback form: Swift can implement it without any Kotlin-coroutine knowledge
    fun requestLocation(onResult: (GeoPoint?) -> Unit)
}
// iosApp/iosApp/IosLocationProvider.swift
import CoreLocation
import Shared

final class IosLocationProvider: NSObject, LocationProvider, CLLocationManagerDelegate {
    private let manager = CLLocationManager()
    private var pending: ((GeoPoint?) -> KotlinUnit)?

    override init() {
        super.init()
        manager.delegate = self
    }

    func requestLocation(onResult: @escaping (GeoPoint?) -> KotlinUnit) {
        pending = onResult
        manager.requestWhenInUseAuthorization()
        manager.requestLocation()
    }

    func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
        let point = locations.last.map { GeoPoint(latitude: $0.coordinate.latitude, longitude: $0.coordinate.longitude) }
        _ = pending?(point)
        pending = nil
    }
}

Three details matter. Keep the interface small and expressed in Kotlin types, closures returning KotlinUnit (or Swift export’s equivalent). Implementing a Kotlin suspend member from Swift is not the stable path, so prefer callbacks or return values. Finally, create the Swift object once at app startup (for example in the App initializer, passing it to a Kotlin fun initTrailMate(locationProvider: LocationProvider) in iosMain, called as IosEntryPointKt.initTrailMate(locationProvider: IosLocationProvider())) and let the Kotlin graph hold it. See Kotlin docs — Interoperability with Swift/Objective-C and Kotlin docs — Use platform-specific APIs.

Shipping a Privacy Manifest for an SDK

Apple requires a privacy manifest (PrivacyInfo.xcprivacy) for apps and for third-party SDKs that use "required reason" APIs or collect data. If you distribute your shared module as an SDK (an XCFramework consumed by other apps, as the com.example:trail-geo library in Publishing Multiplatform Libraries), the manifest must travel inside the framework so the consuming app’s Xcode privacy report can aggregate it. A regular application declares its manifest in its own target; see Security and Privacy for what the App Store expects.

A manifest is a property list. This one declares no tracking and no collected data, and gives a reason for reading UserDefaults, which a settings feature built on multiplatform-settings would do.

<!-- sharedLogic/src/iosMain/resources/PrivacyInfo.xcprivacy -->
<?xml version="1.0" encoding="UTF-8"?>
<plist version="1.0">
<dict>
    <key>NSPrivacyTracking</key>
    <false/>
    <key>NSPrivacyAccessedAPITypes</key>
    <array>
        <dict>
            <key>NSPrivacyAccessedAPIType</key>
            <string>NSPrivacyAccessedAPICategoryUserDefaults</string>
            <key>NSPrivacyAccessedAPITypeReasons</key>
            <array>
                <string>CA92.1</string>
            </array>
        </dict>
    </array>
</dict>
</plist>

The Kotlin documentation describes how to include the manifest in the frameworks Kotlin/Native builds; follow it for the exact Gradle wiring, because the right hook depends on whether you ship a framework, an XCFramework or a SwiftPM binary target. Two rules hold in every case. A dynamic framework is a bundle that can carry the file, while a static framework is linked into the app, so its manifest content has to be merged into the app’s own manifest. And the reasons you declare must be accurate: they describe what your code, and the Kotlin/Native runtime and the dependencies it links, actually do.

Common Mistakes

Mistake Symptom Fix

Exposing many public types

Large header, slow Xcode indexing, larger binary

Make declarations internal, expose a facade, use @HiddenFromObjC.

transitiveExport = true as a first reflex

Header contains third-party types; builds slow

Export only the modules iOS actually needs.

Exceptions without @Throws

App terminates on a Kotlin exception

Annotate I/O boundaries, or model failures as values.

Switching over a sealed hierarchy with as?

A new Kotlin case silently renders nothing

Use SKIE onEnum(of:) or Swift export.

Enabling Swift export in production

API churn between Kotlin releases

Treat it as Alpha; pilot it in a branch.

Shipping an SDK without a privacy manifest

Xcode privacy report gaps; App Store review feedback

Add and bundle PrivacyInfo.xcprivacy.