Integrating Shared Code into iOS: Direct, SwiftPM and CocoaPods
|
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. |
A Kotlin Multiplatform module never runs inside an iOS app as Kotlin source. It is compiled by Kotlin/Native into an Apple framework — a binary plus an Objective-C header and a module map — and Xcode links and embeds that framework like any other. Everything on this page is about one question: who builds that framework, when, and how does Xcode find it? There are three answers for a local checkout (direct integration, a local Swift package, a local CocoaPods podspec) and two for a remote one (an XCFramework behind SwiftPM, or behind CocoaPods).
The baseline is the one used across this section: Kotlin 2.4.20, Compose Multiplatform 1.12.1,
AGP 9.4, Xcode 26.4. Every example is a slice of TrailMate Everywhere, the multiplatform version of the
Android section’s TrailMate app (package com.example.trailmate): the Gradle module
sharedLogic produces a framework named Shared that the Xcode project iosApp imports. Stability levels used
below: direct integration, the CocoaPods plugin and XCFramework export are Stable; SwiftPM import (the
swiftPMDependencies \{} block, which also drives the CocoaPods migration) and Swift export are Alpha.
Project layout is covered on
Project Structure, Targets, Source Sets and
the Hierarchy Template; what appears in Swift once the framework is imported on
Objective-C Interop, Swift Export and SKIE.
Xcode, signing and Swift packages themselves are not re-taught — see
Xcode Projects and Previews and
Swift Packages and Modularization.
The Options at a Glance
Two axes decide everything: local or remote (does the iOS app build the shared code from source, or consume a prebuilt binary?) and which dependency manager the iOS side uses.
| Option | Where the framework is built | Needs | Choose it when |
|---|---|---|---|
Direct integration |
By Gradle, from an Xcode Run Script build phase |
A monorepo; no Pod dependencies in the Kotlin code |
Default for new projects. The Kotlin Multiplatform IDE plugin and the web wizard generate it. |
Local Swift package |
By Gradle, from an Xcode scheme pre-action |
A monorepo whose iOS code is split into local Swift packages |
The iOS app is modularized with SwiftPM and a package, not the app target, imports |
Local CocoaPods podspec |
By Gradle, from script phases written into the generated podspec |
A monorepo with a Podfile; Kotlin code that imports Pod libraries |
The shared module must call Pods (for example an Obj-C-only SDK) through cinterop. |
Remote XCFramework + SwiftPM |
In CI, once per release; consumers download a ZIP |
A |
iOS developers must not need a JDK or Gradle, or the shared code has its own release cycle. |
Remote XCFramework + CocoaPods |
In CI, once per release |
A published podspec |
The consuming team is still CocoaPods-only. |
Local options give instant feedback and one commit for a cross-cutting change. Remote options draw a hard line between the code bases: the iOS app sees the shared code as an ordinary third-party dependency.
Anatomy of the Framework
All options start from the same Gradle declaration: an Apple target with a framework binary. Without a framework
binary, none of the integration tasks below is registered. This is the shared module’s configuration in TrailMate
Everywhere, using the Android-KMP plugin that AGP 9 requires (never com.android.library, which can no longer be
combined with the Kotlin Multiplatform plugin).
// sharedLogic/build.gradle.kts
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.androidKotlinMultiplatformLibrary) // com.android.kotlin.multiplatform.library
}
kotlin {
android {
namespace = "com.example.trailmate.shared"
compileSdk = 37
minSdk = 26
}
listOf(
iosArm64(), // physical devices
iosSimulatorArm64(), // Apple-silicon simulators
).forEach { target ->
target.binaries.framework {
baseName = "Shared" // the name Swift will `import`
isStatic = true // see "Static or Dynamic?" below
binaryOption("bundleId", "com.example.trailmate.shared")
}
}
}
baseName becomes the Swift module name, so it is part of your public API: renaming it breaks every
import Shared. The bundleId binary option sets CFBundleIdentifier so that the framework is uniquely
identified once embedded. The targets are the Tier 1 iOS targets; iosX64 (Intel simulators) is Tier 3 and is
omitted unless a team still needs it.
Static or Dynamic?
isStatic = true |
isStatic = false (default) |
|
|---|---|---|
Result |
The Kotlin code is linked into the app executable. |
A separate |
Embed and sign |
Nothing to embed; the task still runs and is harmless. |
Must be embedded and code-signed — the job of |
Whichever linking you choose, ship one Kotlin framework per app. Several independent Kotlin frameworks each
carry their own copy of the runtime and cannot exchange Kotlin types; combine modules into an umbrella module
instead: a module that declares the others with api(…) and export(…) in its framework block, as the official guide shows for XCFrameworks.
Direct Integration
Direct integration is the default and the simplest: an Xcode Run Script phase calls Gradle’s
embedAndSignAppleFrameworkForXcode task, which builds the framework for the platform and architecture Xcode is
building for, places it in Xcode’s derived-data build directory, and signs it. Xcode already exports the
variables the task needs (CONFIGURATION, SDK_NAME, ARCHS, BUILT_PRODUCTS_DIR, FRAMEWORKS_FOLDER_PATH,
EXPANDED_CODE_SIGN_IDENTITY), so the same script works for Debug and Release, device and simulator.
or an IDE iOS run configuration"] --> B{"OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED
= YES ?"} B -->|"yes: the IDE already ran Gradle"| F B -->|"no"| C["Run Script phase
./gradlew :sharedLogic:embedAndSignAppleFrameworkForXcode"] C --> D["Gradle compiles commonMain + iosMain
with Kotlin/Native
for CONFIGURATION, SDK_NAME, ARCHS"] D --> E["Shared.framework written to
derived data (BUILT_PRODUCTS_DIR)"] E --> F["Compile Sources
Swift sees Shared via framework search paths"] F --> G["Link Binary
static: Kotlin merged into the executable"] G --> H["Embed and sign
dynamic: copied into Frameworks and code-signed"] H --> I["iosApp.app"]
The diagram is the whole story of an Xcode build with direct integration. The Run Script phase must run before Compile Sources, because Swift compilation needs the framework’s header and module map.
Setting It Up in Xcode
Setting up the phase by hand takes five steps in the iosApp target’s settings; a project generated by the
Kotlin Multiplatform IDE plugin already contains them.
-
Add the phase. Build Phases,
+, New Run Script Phase. Paste the script below. -
Disable "Based on dependency analysis" on that phase, so Xcode runs it on every build and does not warn about missing declared outputs.
-
Move the phase above Compile Sources.
-
Disable User Script Sandboxing (Build Settings, Build Options). A sandboxed script cannot launch the Gradle daemon or write the framework outside its allow-list, and the symptoms are cryptic (
rsyncerrors,Operation not permitted). -
Stop a daemon that was started sandboxed with
./gradlew --stop, then build again.
# iosApp/iosApp.xcodeproj -- target iosApp, Build Phases, "Compile Kotlin Framework" (Run Script)
if [ "YES" = "$OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED" ]; then
echo "Skipping Gradle build task invocation due to OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED set to YES"
exit 0
fi
cd "$SRCROOT/.."
./gradlew :sharedLogic:embedAndSignAppleFrameworkForXcode
The guard at the top is the contract between Xcode and the IDEs: when you start an iOS run configuration in
IntelliJ IDEA or Android Studio, the IDE builds the Kotlin framework first (with full IDE support, such as
breakpoints and incremental feedback) and sets OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED=YES, so Xcode does not build
the same framework a second time. Run from Xcode alone, the variable is absent and the script builds it.
$SRCROOT is the directory of iosApp.xcodeproj; TrailMate keeps iosApp one level below the Gradle root, hence
$SRCROOT/...
Framework Search Paths and Build Type
The task writes the framework to $(BUILT_PRODUCTS_DIR), which Xcode already searches, so a hand-written
project needs no extra FRAMEWORK_SEARCH_PATHS for the normal case. The project generated by the IDE plugin
still sets the search path in an .xcconfig, which is also the right place for the two settings below.
// iosApp/Configuration/Config.xcconfig
// Where Xcode finds Shared.framework produced by the Run Script phase
FRAMEWORK_SEARCH_PATHS = $(inherited) $(SRCROOT)/../sharedLogic/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)
// Custom configurations ("Staging") must map to a Kotlin build type: Debug or Release
KOTLIN_FRAMEWORK_BUILD_TYPE = Debug
// The Kotlin framework is Objective-C based: keep module imports on
OTHER_LDFLAGS = $(inherited) -framework Shared
The Kotlin side only knows two build types. If you add Xcode configurations other than the default Debug and
Release, define KOTLIN_FRAMEWORK_BUILD_TYPE as a user-defined build setting with the value Debug or
Release; without it the task cannot decide which framework to build and fails. The -framework Shared linker
flag matters for a static framework whose consumers do not import it through a module map; with the framework in
the target’s Link Binary phase it is redundant.
Once the phase builds, shared Kotlin is an ordinary Swift module: import Shared, then call for example PlatformKt.platformName(), the Objective-C name of the top-level function from expect/actual and Calling Platform APIs. How Kotlin declarations are renamed is on Objective-C Interop, Swift Export and SKIE.
When Direct Integration Is Not Enough
Direct integration has one structural limit: the Kotlin module cannot depend on CocoaPods libraries, because
the framework is built outside any Pod. If shared code must use an Obj-C-only SDK, either use a SwiftPM
dependency through SwiftPM import (Alpha, below), a cinterop definition for a bundled .framework, or the
CocoaPods approach.
Local SwiftPM Package
Modern iOS codebases are split into local Swift packages: a TrailMateKit package holds feature code, and the
app target is a thin shell. A package cannot contain a Run Script phase, and SwiftPM’s own build
(swift build) is not supported for the Kotlin framework, so the Kotlin build is moved to the scheme: a
Build pre-action that runs the very same Gradle task before Xcode starts building. Available since
Kotlin 2.0.0. The tutorial assumes the project already uses direct integration; if the framework currently comes
from a binary target or a Pod, migrate first.
Set it up in Xcode with Product, Scheme, Edit Scheme, Build, Pre-actions, +, New Run Script Action, and
select the app target under "Provide build settings from" so the script sees SDK_NAME and ARCHS.
# iosApp scheme "iosApp" -- Build, Pre-actions, New Run Script Action
cd "$SRCROOT/.."
./gradlew :sharedLogic:embedAndSignAppleFrameworkForXcode
// iosApp/Packages/TrailMateKit/Package.swift
// swift-tools-version:6.0
import PackageDescription
let package = Package(
name: "TrailMateKit",
platforms: [.iOS(.v16)],
products: [
.library(name: "TrailMateKit", targets: ["TrailMateKit"]),
],
targets: [
// No binaryTarget and no dependency on Shared: the framework comes from the
// scheme pre-action and is found through the app target's build settings.
.target(name: "TrailMateKit"),
.testTarget(name: "TrailMateKitTests", dependencies: ["TrailMateKit"]),
]
)
// iosApp/Packages/TrailMateKit/Sources/TrailMateKit/SharedBridge.swift
import Shared
/// The only file in the package that touches Kotlin types; features call this wrapper.
public func trailMateGreeting() -> String {
"TrailMate on \(PlatformKt.platformName())"
}
The trade-off against the Run Script phase is rebuild granularity: with the pre-action, a change in common code
only needs the Kotlin side rebuilt to be reflected in Xcode, while the regular phase requires rebuilding
both. Keeping every import Shared in a thin wrapper package, as above, also means that when a Swift export
migration arrives (see
Objective-C Interop, Swift Export and SKIE),
only that package changes. For the Swift side of modularization, see
Swift Packages and Modularization.
Local CocoaPods
The Kotlin CocoaPods plugin (org.jetbrains.kotlin.native.cocoapods, Stable) turns the Gradle module into a
Pod: it generates a .podspec whose script phases invoke Gradle, and the iOS Podfile points at it with
:path. Choose it only when the Kotlin code must import Pod libraries (pod("SDWebImage") produces cinterop
bindings), or when the iOS project is already a CocoaPods one and must stay that way.
// sharedLogic/build.gradle.kts
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.androidKotlinMultiplatformLibrary)
alias(libs.plugins.kotlinCocoapods) // org.jetbrains.kotlin.native.cocoapods
}
kotlin {
iosArm64()
iosSimulatorArm64()
cocoapods {
// Required
version = "1.0.0"
summary = "TrailMate shared logic"
homepage = "https://example.com/trailmate"
// Optional
name = "SharedLogic"
ios.deploymentTarget = "16.0"
podfile = project.file("../iosApp/Podfile") // lets Gradle run `pod install`
framework {
baseName = "Shared" // required
isStatic = true // dynamic by default
// export(project(":sharedUI")); transitiveExport = false (default)
}
// Custom Xcode configurations must map to a Kotlin build type
xcodeConfigurationToNativeBuildType["Staging"] = NativeBuildType.DEBUG
// A Pod used from Kotlin: cinterop bindings are generated
pod("SDWebImage") {
version = "5.20.0"
}
}
}
# iosApp/Podfile
target 'iosApp' do
use_frameworks!
platform :ios, '16.0'
# The Kotlin module as a Pod: the folder holds the Gradle-generated podspec
pod 'SharedLogic', :path => '../sharedLogic'
end
See Kotlin docs — CocoaPods overview and setup and Kotlin docs — Use a Kotlin project as a CocoaPods dependency.
The workflow differs from the other options in four ways. The Gradle-generated podspec is produced by the
podspec task. Open the .xcworkspace, never the .xcodeproj, once pod install has created it. The Podfile
must contain use_frameworks! or use_modular_headers!, otherwise Pod dependencies cannot be imported into
Kotlin/Native. And for a CI or fresh clone, run ./gradlew podInstall rather than a bare pod install: resource
directories that Gradle creates at build time (for example Compose Multiplatform’s resources) do not exist yet, so
a plain pod install configures Xcode without them and the app launches with missing fonts and images.
The cocoapods \{} block is the DSL entry point: version, summary and homepage are required podspec fields, framework \{} configures the produced binary (baseName is required), pod() declares a Pod used from Kotlin (linkOnly = true links a dynamic-framework Pod without cinterop bindings), podfile lets Gradle run pod install, specRepos \{} adds private spec repositories, and noPodspec() skips podspec generation.
Why CocoaPods and embedAndSign Cannot Be Combined
The documentation is explicit: the CocoaPods approach cannot be used together with the
embedAndSignAppleFrameworkForXcode mechanism. The reason is that both own the same two jobs and produce
conflicting results.
| Concern | Direct integration | CocoaPods plugin |
|---|---|---|
Who builds the framework |
A Run Script phase in the app target |
Script phases inside the Pod, run from |
Where it lands |
Derived data, found through the app’s framework search paths |
The Pod’s own output directory, linked through the generated |
Who embeds and signs |
|
CocoaPods' |
Source of truth for settings |
The |
The Podfile and the podspec |
Doing both would build the framework twice, embed two copies of the Kotlin runtime and fight over the same
output files, so a project must pick one. Switching from CocoaPods to direct integration is therefore a
migration, not a toggle: clean the build folder, run pod deintegrate in the Podfile’s directory, delete the
cocoapods \{} block, the .podspec and the Podfile, then add the Run Script phase.
Remote Integration: an XCFramework Behind SwiftPM
When the iOS team should not need Gradle at all, build the framework once in CI and publish it as a binary.
SwiftPM consumes such binaries as an XCFramework — a bundle holding one framework per platform slice, here
device and simulator — described by a Package.swift with a binaryTarget. Fat or universal frameworks and
bitcode are legacy: bitcode has been unsupported since Kotlin 2.0.20.
Building the XCFramework
// sharedLogic/build.gradle.kts
import org.jetbrains.kotlin.gradle.plugin.mpp.apple.XCFramework
kotlin {
// Name of the module consumers import
val xcframeworkName = "Shared"
val xcf = XCFramework(xcframeworkName)
listOf(
iosArm64(),
iosSimulatorArm64(),
).forEach { target ->
target.binaries.framework {
baseName = xcframeworkName
// Uniquely identifies the framework (CFBundleIdentifier)
binaryOption("bundleId", "com.example.trailmate.${xcframeworkName}")
xcf.add(this)
isStatic = true
}
}
}
# CI step (macOS runner), repository root of TrailMate Everywhere
./gradlew :sharedLogic:assembleSharedXCFramework
# Output: sharedLogic/build/XCFrameworks/release/Shared.xcframework
cd sharedLogic/build/XCFrameworks/release
zip -r Shared.xcframework.zip Shared.xcframework
swift package compute-checksum Shared.xcframework.zip
The task name is assemble<Name>XCFramework, built from the XCFramework(name) argument, so Shared gives
assembleSharedXCFramework; add a build type for the debug variant (assembleSharedDebugXCFramework). The
checksum is a SHA-256 of the ZIP that SwiftPM verifies on download: if the ZIP is re-uploaded under the same
URL with different bytes, every consumer’s resolution fails, so never replace a published ZIP — publish a new
version. Only the exported module’s declarations are visible to Swift; if the framework must expose several
Gradle modules, assemble them into one umbrella module, as described next.
Hosting on GitHub Releases and Writing Package.swift
The ZIP needs a URL that downloads directly. A GitHub release is the common choice: create a release on a tag,
attach the ZIP as an asset and copy its download link. Verify the link with curl before using it.
// trailmate-shared-spm/Package.swift (a separate Git repository holding only the manifest)
// swift-tools-version:5.3
import PackageDescription
let package = Package(
name: "Shared",
platforms: [
.iOS(.v16),
],
products: [
.library(name: "Shared", targets: ["Shared"]),
],
targets: [
.binaryTarget(
name: "Shared",
url: "https://github.com/example/trailmate-everywhere/releases/download/shared-1.2.0/Shared.xcframework.zip",
checksum: "<output of swift package compute-checksum>"
),
]
)
Starting with Kotlin 2.4.20 (the docs name 2.4.20-RC3), when the project consumes SwiftPM dependencies the
assemble…XCFramework task generates a Package.swift and related SwiftPM files next to the XCFramework, so
you can distribute those instead of writing the manifest by hand. For a project without SwiftPM dependencies the
manifest above is still written manually. Validate either with swift package reset && swift package
show-dependencies --format json, then push the manifest, create and push a semantic-version Git tag (SwiftPM
versions packages by tags) and add it in Xcode with File, Add Package Dependencies.
Where to Keep Package.swift
| Layout | Advantage | Drawback |
|---|---|---|
Separate repository for the manifest (recommended) |
The manifest is versioned independently of the Kotlin code; scales best. |
Another repository, and CI must push the manifest and tag there. |
Next to the Kotlin code |
Simplest setup. |
SwiftPM versions are Git tags and can collide with the project’s own release tags. |
Inside the consumer app’s repository |
No tag conflicts. |
In a multi-package project only one consumer package can depend on the external module; CI must update the consumer repository on every release. |
KMMBridge
KMMBridge from Touchlab automates exactly this pipeline: a Gradle plugin that assembles the XCFramework,
uploads it (to GitHub Releases or other storage), computes the checksum, and updates the Package.swift or
podspec in a consuming repository on each release, from a CI workflow. It is a third-party tool and is worth
evaluating before hand-scripting the steps above. How the same release flow is wired into CI is on
CI/CD for Kotlin Multiplatform with GitHub Actions.
Migrating from CocoaPods to SwiftPM
The documentation steers new projects toward SwiftPM, although CocoaPods is Stable and not deprecated.
SwiftPM import (Kotlin code consuming Swift packages through swiftPMDependencies \{}) is Alpha, and the
migration relies on it, so treat the result as a moving target and pin the Kotlin version. The migration has
three phases, and the Kotlin Gradle plugin must be on the 2.4.20 line (the docs name its
RC3 build).
Phase 1: Declare the SwiftPM Dependencies Next to the Pods
// sharedLogic/build.gradle.kts
kotlin {
listOf(iosArm64(), iosSimulatorArm64()).forEach { target ->
// 1c. cocoapods { framework { } } becomes binaries.framework { }
target.binaries.framework {
baseName = "Shared"
isStatic = true
}
}
// 1a. Declare the Swift package -- Alpha
swiftPMDependencies {
swiftPackage(
url = url("https://github.com/firebase/firebase-ios-sdk.git"),
version = from("12.5.0"),
products = listOf(product("FirebaseAnalytics")),
)
}
// The old declaration stays until the Xcode project is reconfigured
cocoapods {
pod("FirebaseAnalytics") {
version = "12.5.0"
}
}
}
Sync Gradle to import the APIs of the Swift package. If the Pod and the package expose identical APIs, usually
only the import lines in Kotlin change: import cocoapods.FirebaseAnalytics.FIRAnalytics becomes an import from the swiftPMImport package. The
Firebase library is only an illustration of a Pod with a Swift-package twin; TrailMate itself has no Pod
dependencies and would skip straight to direct integration.
Phase 2: Reconfigure the Xcode Project
The CocoaPods plugin owned the framework build, so the Xcode project must first adopt direct integration. The
tooling generates the command for you: build the project in Xcode (the build fails on purpose) and read the
error, or build from the terminal, then run the command it prints. It edits the .xcodeproj so that the build
triggers the embed task, and it runs two Gradle tasks that give the page its two migration names.
# Run from iosApp/ -- the paths are printed in the build error and differ per machine
XCODEPROJ_PATH='/path/to/trailmate/iosApp/iosApp.xcodeproj' \
GRADLE_PROJECT_PATH=':sharedLogic' \
'/path/to/trailmate/gradlew' -p '/path/to/trailmate' \
':sharedLogic:integrateEmbedAndSign' ':sharedLogic:integrateLinkagePackage'
| Task | What it does |
|---|---|
|
Modifies the |
|
Adds the generated local Swift package that links the Kotlin framework and the SwiftPM dependencies declared in
|
After the command, resolve the SwiftPM dependencies in the IDE (Tools, Swift Package Manager, Resolve Dependencies, in IntelliJ IDEA) and build again.
Phase 3: Remove the CocoaPods Integration
# Run from iosApp/
pod deintegrate
If some Pods have no Swift package, keep them: edit the Podfile to delete only the line that mentions the Kotlin
module (pod 'SharedLogic', :path ⇒ '../sharedLogic'), run pod install, and keep the dependency-free parts of
the cocoapods \{} block. If everything moved to SwiftPM, delete the whole cocoapods \{} block, and the
CocoaPods plugin alias from both the root and the module plugins \{} blocks. The documentation also offers a prepared AI-agent skill for this migration; review its diff.
For the Kotlin side of the new dependencies, and the klibs.io and cinterop alternatives, see Dependencies, klibs.io, cinterop and SwiftPM Import.
Monorepo or Separate iOS Repository?
The local-versus-remote decision is really a repository and team decision. The iOS app can live in the same repository as the shared module or consume it as a versioned binary.
| Criterion | Monorepo (local integration) | Separate iOS repository (remote integration) |
|---|---|---|
Change that spans shared code and UI |
One commit, one pull request, instant in Xcode |
Two pull requests and a release in between |
iOS developers need a JDK and Gradle |
Yes (the wrapper downloads Gradle; a JDK is required) |
No: they download a ZIP |
Release cadence |
One cadence for all platforms |
Shared code releases independently, with semantic versions |
Mechanism |
Direct integration, local package or local podspec |
XCFramework with a |
Debugging shared code |
Step from Swift into Kotlin in one project |
Only with the Kotlin sources checked out; binary frameworks lose that |
CI cost |
One pipeline, builds Kotlin on every iOS build |
Extra publishing pipeline; iOS builds are fast |
Typical fit |
One product team, TrailMate Everywhere itself |
Platform teams, SDKs, or an iOS app adopted incrementally |
For an existing iOS app adopting Kotlin, start with a local package in a branch (fast feedback), and move to a published XCFramework only when team boundaries require it; the adoption path is on Adopting Kotlin Multiplatform in Existing Apps.
Common Mistakes
| Mistake | Fix |
|---|---|
The Run Script phase sits below Compile Sources |
Move it above; Swift needs the framework header to compile. |
Build fails with |
Disable User Script Sandboxing for the app target and run |
|
The target has no |
Using both the |
Pick one: deintegrate the pods or remove the plugin. |
Re-uploading a changed ZIP under the same release URL |
Checksum failures for all consumers; publish a new version and tag. |
Two Kotlin frameworks in one app |
Duplicate runtimes and unshareable types; merge them into one umbrella module. |