Techbase Academy
Android · Lesson 69

Integrate with iOS: Direct Integration and Swift Package Manager

11 phasesConnect FeeTracker to iOS (or Prepare To)Android Studio
Phase 1 of 11
Lesson Introduction

Your shared module compiles for iOS, but an iPhone app is built in Xcode, with Swift code and Apple's tools. Something has to connect the two: Xcode must build your Kotlin code into a framework and link it into the iOS app, and Swift must be able to call it. This lesson covers the ways to do that, and how to find and add multiplatform libraries.

It covers Integrate iOS into your project with direct integration, Add dependencies on multiplatform libraries, Search for multiplatform dependencies at klibs.io and Integrate iOS with remote Swift Package Manager from the Kotlin Multiplatform programme (https://developer.android.com/courses/pathways/kotlin-multiplatform), using the JetBrains pages for direct integration (https://www.jetbrains.com/help/kotlin-multiplatform-dev/multiplatform-direct-integration.html), adding dependencies (https://www.jetbrains.com/help/kotlin-multiplatform-dev/multiplatform-add-dependencies.html) and Swift package export (https://www.jetbrains.com/help/kotlin-multiplatform-dev/multiplatform-spm-export.html), and the library index at https://klibs.io.

By the end you will be able to:

  • Explain the three ways an iOS app can consume shared Kotlin code.
  • Set up direct integration with a Run Script build phase.
  • Call Kotlin from Swift, and know the main interop rules.
  • Add multiplatform dependencies and use klibs.io to check target support.
  • Describe how an XCFramework and Swift Package Manager (SPM) distribution work.
  • Follow an Android-only path if you have no Mac.

Big idea: iOS integration is a build-system problem, not a language problem. Xcode asks Gradle to build the framework; Swift imports it like any other.

Honesty note: you need a Mac with Xcode to build or run the iOS app. This lesson was written without access to one, so the Xcode steps follow the official documentation rather than my own run. Xcode menus, Kotlin plugin tasks and the Swift Package Manager integration are updated often, and the SPM support in particular has been evolving. Treat the exact setting names as a guide and check the pages linked above. If you have no Mac, read this lesson for understanding and follow the Android-only path at the end.

✏️ Your Task
Practise what you just learned about Lesson Introduction. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 2 of 11
Prerequisite Concepts
  • Shared module structure and the iOS framework block (lessons 67 and 68).
  • expect/actual and interfaces (lessons 66 and 68).
  • Basic Swift syntax: constants, functions, classes (read-only familiarity is enough).
  • Coroutines and Flow (lesson 33).
✏️ Your Task
Practise what you just learned about Prerequisite Concepts. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 3 of 11
Conceptual Understanding: Three Ways to Connect
Method Idea Good for
Direct integration Xcode runs a Gradle task during its build and links the produced framework A monorepo where Android and iOS share one repository (the usual starting point)
Local Swift Package Kotlin exports a Swift package on your machine; Xcode adds it as a local package Teams who prefer SPM in Xcode but keep one repository
Remote Swift Package You publish a built XCFramework and a Package.swift; iOS developers add the package by URL iOS developers who should not need a Kotlin toolchain, or sharing with other teams

(CocoaPods integration also exists, but it is being phased out in favour of the above; you may meet it in older projects.)

Whatever you choose, the pipeline is the same:

text
:shared (Kotlin) ──Gradle──► SharedKit.framework / XCFramework ──Xcode──► iosApp
                                                                          │
                                         Swift: import SharedKit ◄────────┘
✏️ Your Task
Practise what you just learned about Conceptual Understanding: Three Ways to Connect. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 4 of 11
Step-by-Step Build 1: Prepare the Shared Module

In shared/build.gradle.kts each iOS target must declare a framework (lesson 67). The framework name is what Swift imports.

kotlin
kotlin {
 val xcfName = "SharedKit"
 iosArm64 {
 binaries.framework {
 baseName = xcfName
 isStatic = true // common choice; check the current docs for your setup
 }
 }
 iosSimulatorArm64 {
 binaries.framework {
 baseName = xcfName
 isStatic = true
 }
 }
}

The documentation notes that the Gradle task used for direct integration only exists if binaries.framework is declared for the iOS target. If the task is missing, this is the first place to look.

A static framework is linked into the app at build time; a dynamic one is a separate file loaded at run time. Static frameworks are the usual default for app code, and the page on iOS integration discusses when to choose otherwise. (I could not re-check the current guidance for isStatic while writing; follow the official page for your Kotlin version.)

Write Kotlin that is friendly to Swift

Swift sees your Kotlin API through an Objective-C-style header. A few habits make the API pleasant:

kotlin
// commonMain: a facade class for iOS (also fine for Android)
class FeeApi(
 private val repository: PaymentsRepository,
 private val calculate: CalculateFeeSummary = CalculateFeeSummary()
) {
 suspend fun loadSummary(): FeeSummary {
 val payments = repository.payments()
 return calculate(payments.map { it.amountNaira })
 }
 fun observeSummary(): Flow<FeeSummary> =
 repository.observePayments().map { calculate(it.map { p -> p.amountNaira }) }
}
Kotlin feature How Swift sees it Advice
suspend fun An async function (recent versions) or a completion-handler function Fine; errors arrive as NSError unless annotated
Flow<T> A Kotlin class, not a Swift AsyncSequence Wrap it, or use a helper library, or expose a callback
sealed class Class hierarchy, no exhaustive switch Add helpers or consider Swift export (below)
Generics Often erased to Any Keep public APIs concrete
Default arguments Not visible Provide overloads or factory functions
Long, Int Int64, Int32 Be explicit
Top-level functions Methods on a generated ...Kt class (for example Platform_iosKt.platform()) Prefer classes

The JetBrains documentation shows exactly this convention: a file Platform.ios.kt is called from Swift as Platform_iosKt.platform().

Kotlin exceptions: unless you declare them, Kotlin exceptions crossing into Swift terminate the app. Use @Throws(SomeException::class) on functions that may throw, so Swift receives a catchable error, or return a Result-like sealed type.

kotlin
@Throws(IOException::class, CancellationException::class)
suspend fun refresh() { /* ... */ }

Wrapping a Flow for Swift

A simple callback-based wrapper works without extra libraries:

kotlin
// commonMain
class FlowObserver<T>(private val flow: Flow<T>) {
 private var job: Job? = null
 fun start(scope: CoroutineScope, onEach: (T) -> Unit) {
 job = scope.launch { flow.collect { onEach(it) } }
 }
 fun stop() { job?.cancel() }
}
class SummaryObserver(private val api: FeeApi) {
 private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
 private val observer = FlowObserver(api.observeSummary())
 fun start(onEach: (FeeSummary) -> Unit) = observer.start(scope, onEach)
 fun stop() { observer.stop(); scope.cancel() }
}

Third-party libraries (for example SKIE or KMP-NativeCoroutines) can generate friendlier Swift wrappers; they are optional and change, so evaluate them at the time.

✏️ Your Task
Practise what you just learned about Step-by-Step Build 1: Prepare the Shared Module. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 5 of 11
Step-by-Step Build 2: Direct Integration (Mac Only)

The JetBrains documentation describes direct integration as building the framework from an Xcode Run Script phase that calls a Gradle task named embedAndSignAppleFrameworkForXcode.

  1. Open the iOS project in Xcode. From Android Studio you can right-click the .xcodeproj and choose Open In → Open in Associated Application, or open it from Xcode directly.
  2. In Xcode choose your app target → Build Phases → + → New Run Script Phase.
  3. Paste a script of this form (from the documentation, adjusted to your paths):
sh
if [ "YES" = "$OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED" ]; then
  echo "Skipping Gradle build task invocation due to OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED environment variable set to \"YES\""
  exit 0
fi
cd "$SRCROOT/.."
./gradlew :shared:embedAndSignAppleFrameworkForXcode
  • cd must point to the root of the multiplatform project (here, one level above the iOS folder).
  • :shared is your shared module's name.
  • The if guard stops Xcode from building the framework a second time when Android Studio's run configuration has already built it.
  1. Disable "Based on dependency analysis" for this phase, so that it runs on every build.
  2. Drag the phase above "Compile Sources", so the framework exists before Swift is compiled.
  3. In Build Settings, under Build Options, set User Script Sandboxing to No. A sandboxed script cannot run Gradle. If you built before changing it, stop the sandboxed Gradle daemon with ./gradlew --stop.
  4. If you use build configurations other than Debug and Release, add a User-Defined setting KOTLIN_FRAMEWORK_BUILD_TYPE set to Debug or Release.
  5. Make sure Xcode can find and link the framework. The documentation points to the iOS integration overview for the framework search path and linker flags used by the task's output; the project template usually sets these for you, which is why starting from the official wizard is easier than wiring them by hand.
  6. Build (Cmd+B).

Use it from Swift

swift
import SwiftUI
import SharedKit
struct ContentView: View {
 @State private var message = "Loading…"
 var body: some View {
 Text(message)
 .task {
 let summary = CalculateFeeSummary(termFee: 450_000)
 .invoke(payments: [100_000, 50_000])
 message = "Balance: \(summary.balance)"
 }
 }
}

(Kotlin's Long appears in Swift as Int64, and a Kotlin List<Long> as [KotlinLong], so the call above may need KotlinLong(value:) wrapping in a real project. Xcode's autocomplete shows the exact generated signatures; trust it over a tutorial.)

If you previously used CocoaPods, the documentation gives a migration: clean the build folder, run pod deintegrate, remove the cocoapods { } block from Gradle, and delete the podspec and Podfile.

Run on a simulator

In Xcode choose an iPhone simulator and Run. Android Studio can also launch the iOS run configuration on a Mac when the Kotlin Multiplatform tooling is installed. Because the Gradle task builds Kotlin each time, the first build is slow; later builds are incremental.

✏️ Your Task
Practise what you just learned about Step-by-Step Build 2: Direct Integration (Mac Only). Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 6 of 11
Step-by-Step Build 3: Multiplatform Dependencies

Shared code can only use libraries that publish a variant for each of your targets. Check before you depend on one.

Common examples

kotlin
sourceSets {
 commonMain.dependencies {
 implementation(libs.kotlinx.coroutines.core) // multiplatform
 implementation(libs.kotlinx.serialization.json) // multiplatform
 implementation(libs.kotlinx.datetime) // multiplatform
 implementation(libs.ktor.client.core) // multiplatform client; engines per platform
 }
 androidMain.dependencies {
 implementation(libs.ktor.client.okhttp)
 }
 iosMain.dependencies {
 implementation(libs.ktor.client.darwin)
 }
}

When a multiplatform library publishes to Maven, Gradle metadata lets you declare it once in commonMain and Gradle selects the right artifact for each target. If a library is JVM-only, declaring it in commonMain fails on iOS, which is the error you saw in lesson 67.

Search with klibs.io

klibs.io (https://klibs.io) is a search index for Kotlin Multiplatform libraries, launched by JetBrains, that lets you search and filter by target platform, so you can see at a glance whether a library supports Android and iOS. The index aims to make multiplatform libraries easier to discover and compare. Recent JetBrains posts describe it as indexing thousands of KMP projects, but its exact size and features change, so treat its numbers as a snapshot.

A good routine before adopting any library:

  1. Search klibs.io for the capability (for example "datetime", "image loading", "settings").
  2. Filter by the targets you use (Android, iOS).
  3. Check last release date, stars/activity, and licence.
  4. Read the library's own README for setup: some need extra Gradle plugins or platform-specific configuration.
  5. Try it in a small spike before building on it.

Popular choices (check currency before use)

Need Multiplatform option
Async and streams kotlinx.coroutines
JSON kotlinx.serialization
HTTP Ktor client
Date and time kotlinx-datetime (and the growing stdlib time APIs)
Local SQL database Room KMP (lesson 70), SQLDelight
Key-value storage DataStore (multiplatform)
Dependency injection Koin, kotlin-inject
Image loading Coil 3 and others (verify targets)
✏️ Your Task
Practise what you just learned about Step-by-Step Build 3: Multiplatform Dependencies. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 7 of 11
Step-by-Step Build 4: Distributing with Swift Package Manager

Swift Package Manager (SPM) is Apple's dependency tool. Sharing your Kotlin module as a Swift package means iOS developers add it in Xcode like any Swift dependency, without needing a Kotlin or Gradle setup in their project.

The building blocks

  1. An XCFramework: a bundle that contains your framework for several Apple targets (devices and simulators). The Kotlin Gradle plugin can assemble one from your iOS targets:
kotlin
import org.jetbrains.kotlin.gradle.plugin.mpp.apple.XCFramework
kotlin {
 val xcf = XCFramework("SharedKit")
 listOf(iosArm64(), iosSimulatorArm64()).forEach { target ->
 target.binaries.framework {
 baseName = "SharedKit"
 xcf.add(this)
 }
 }
}

Running the generated task (named like assembleSharedKitReleaseXCFramework; list tasks with ./gradlew :shared:tasks to see the exact name) produces SharedKit.xcframework under the module's build/XCFrameworks folder.

  1. A Package.swift file that tells SPM where the binary is:
swift
// swift-tools-version:5.9
import PackageDescription
let package = Package(
 name: "SharedKit",
 platforms: [.iOS(.v15)],
 products: [
 .library(name: "SharedKit", targets: ["SharedKit"])
 ],
 targets: [
 .binaryTarget(
 name: "SharedKit",
 url: "https://github.com/your-org/shared-kit/releases/download/1.0.0/SharedKit.xcframework.zip",
 checksum: "<result of swift package compute-checksum>"
 )
 ]
)
  1. A place to host the zipped XCFramework: for example a GitHub Release or cloud storage, plus a Git repository containing Package.swift with a version tag.

The release routine

text
1. Build the XCFramework with Gradle (release configuration).
2. Zip it:            zip -r SharedKit.xcframework.zip SharedKit.xcframework
3. Compute checksum:  swift package compute-checksum SharedKit.xcframework.zip
4. Upload the zip to a release (the URL goes in Package.swift).
5. Put the checksum and URL into Package.swift, commit, tag it 1.0.0.
6. In Xcode: File > Add Package Dependencies... > paste the repository URL.

The remote approach has a trade-off: iOS developers no longer need Kotlin, but every change to shared code needs a new release, which slows the feedback loop compared with direct integration. Many teams use direct integration while developing and SPM for releases or for other teams.

Newer tooling

The JetBrains documentation for Swift package export describes an integrated route in which the Kotlin Gradle plugin helps produce and wire Swift packages, and there is also Swift export, an effort to generate more idiomatic Swift APIs than the Objective-C bridge (better support for enums, sealed types and generics). Both have been changing from release to release and some parts are labelled experimental. Read the current page before adopting them; the manual XCFramework route above works because it relies only on stable Apple and Gradle features.

Local packages

For one repository, a local Swift package (a Package.swift with a path-based binary target or a package produced by the Gradle task) lets Xcode treat your module as a package without publishing. See the local integration page on kotlinlang.org for the current steps.

✏️ Your Task
Practise what you just learned about Step-by-Step Build 4: Distributing with Swift Package Manager. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 8 of 11
Step-by-Step Build 5: The Android-Only Path (No Mac)

If you have no Mac you can still do almost everything in this programme:

  1. Build and test the shared module for Android (androidHostTest, commonTest on the JVM).
  2. Keep iosArm64() and iosSimulatorArm64() declared, so the structure matches a real project. Gradle will skip iOS compilation tasks that require Apple's toolchain on non-Mac machines; the exact behaviour depends on your Kotlin version and kotlin.native.ignoreDisabledTargets settings, so read any warning Gradle prints.
  3. Write the iosMain code carefully using the Apple API names you learn from the documentation; you will not be able to compile it, but a reviewer on a Mac can.
  4. Use a CI service with macOS runners (GitHub Actions offers them) to compile and test iOS targets on every push:
yaml
# .github/workflows/ios.yml
name: iOS build
on: [push]
jobs:
 ios:
 runs-on: macos-latest
 steps:
 - uses: actions/checkout@v4
 - uses: actions/setup-java@v4
 with: { distribution: temurin, java-version: 17 }
 - name: Build iOS framework and run simulator tests
 run: ./gradlew :shared:iosSimulatorArm64Test :shared:linkDebugFrameworkIosSimulatorArm64

(Task names depend on your plugin versions; check ./gradlew :shared:tasks.) This is a cost-effective way to keep the iOS side honest without owning a Mac.

✏️ Your Task
Practise what you just learned about Step-by-Step Build 5: The Android-Only Path (No Mac). Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 9 of 11
Guided Practice Exercises
🎯 Your Challenge

Exercise 1: Why is the build failing?

Xcode reports "Task 'embedAndSignAppleFrameworkForXcode' not found in project ':shared'". List two likely causes.

(1) The iOS target in shared/build.gradle.kts has no binaries.framework { } declaration, and the task only exists when it does. (2) The module name in the script (:shared) does not match the real module name, or the cd path does not point to the Gradle root, so the wrong project is used.

🎯 Your Challenge

Exercise 2: Make the API Swift-friendly

Improve this Kotlin API for iOS callers: fun loadAll(): Flow<List<Payment>> and fun parse(raw: String): Payment that throws on bad input.

Wrap the flow in an observer class with start/stop and callbacks (or use a Swift-interop library). Annotate the throwing function with @Throws(IllegalArgumentException::class) so Swift gets a catchable error instead of a crash, or return a sealed result type. Keep types concrete, since generics are erased at the boundary.

🎯 Your Challenge

Exercise 3: Pick a distribution method

Choose direct integration, local SPM or remote SPM for: (a) a small team in one repository developing both apps; (b) a separate iOS team in another company that must not install Gradle; (c) a CI release of version 2.1 of the shared SDK.

(a) Direct integration, for the fastest feedback. (b) Remote SPM with a published XCFramework, since they can add it by URL. (c) Remote SPM (an XCFramework attached to a tagged release), because releases need a versioned, reproducible artifact.

✏️ Your Task
Practise what you just learned about Guided Practice Exercises. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 10 of 11
Common Mistakes (and How to Fix Them)

Mistake #1: Forgetting binaries.framework. Fix: declare it on each iOS target.

Mistake #2: Leaving User Script Sandboxing on. Fix: set it to No for the Gradle run script.

Mistake #3: Putting the Run Script phase after Compile Sources. Fix: drag it above.

Mistake #4: Wrong project root in the script. Fix: cd to the folder with gradlew.

Mistake #5: Exposing Flow and generics directly to Swift. Fix: wrap them.

Mistake #6: Letting Kotlin exceptions cross unannotated. Fix: use @Throws or return result types.

Mistake #7: Adding a JVM-only library to commonMain. Fix: check klibs.io for targets first.

Mistake #8: Publishing a Swift package without a checksum or tag. Fix: compute and commit both.

Mistake #9: Assuming iOS compiles because Android does. Fix: build iOS in CI on a macOS runner.

Mistake #10: Following old CocoaPods tutorials blindly. Fix: use the current integration pages.

✏️ Your Task
Practise what you just learned about Common Mistakes (and How to Fix Them). Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 11 of 11
Knowledge Check

Question 1. Which Gradle task does direct integration call from Xcode?

embedAndSignAppleFrameworkForXcode (run as :yourModule:embedAndSignAppleFrameworkForXcode).

Question 2. Why must the Run Script phase come before Compile Sources?

The framework must be built before Swift code that imports it is compiled.

Question 3. What does klibs.io help you do?

Search Kotlin Multiplatform libraries and filter by supported targets, so you can check Android and iOS support before adopting one.

Question 4. What is an XCFramework?

A bundle containing a framework built for several Apple targets (devices and simulators), which SPM can distribute as a binary target.

Question 5. What are two options if you have no Mac?

Build and test the Android side locally while keeping iOS targets declared, and use a macOS CI runner to compile and test the iOS targets.

Go deeper: official resources

✏️ Your Task
Practise what you just learned about Knowledge Check. Open Android Studio, type the examples by hand, change a value, and observe what happens.
🏗️ Build It — Mini Project
Connect FeeTracker to iOS (or Prepare To)

Project goal: Produce everything needed for an iOS app to consume the shared FeeTracker module, to the extent your hardware allows.

Requirements:

  1. Declare binaries.framework for both iOS targets, naming the framework SharedKit.
  2. Write a Swift-friendly facade (FeeApi) with @Throws where needed, and a SummaryObserver that wraps the flow.
  3. With a Mac: create the Xcode project, add the Run Script phase exactly as in Build 2, disable script sandboxing, and show a screen in SwiftUI that displays the balance.
  4. Without a Mac: write the Swift file you would use, and add the GitHub Actions workflow that builds the iOS framework on macos-latest.
  5. Add the XCFramework configuration and describe the release routine for a remote Swift package in a RELEASING.md.
  6. Search klibs.io for three libraries FeeTracker needs (for example datetime, settings, image loading) and record their supported targets and last release.
  7. Write a short comparison of direct integration versus remote SPM for your team.

Stretch goal: evaluate one Swift interop helper library (for example for Flow-to-AsyncSequence conversion) and write down its pros and cons.

A RELEASING.md (example):

text
# Releasing SharedKit for iOS

1. ./gradlew :shared:assembleSharedKitReleaseXCFramework
2. cd shared/build/XCFrameworks/release
3. zip -r SharedKit.xcframework.zip SharedKit.xcframework
4. swift package compute-checksum SharedKit.xcframework.zip     -> paste into Package.swift
5. Create a GitHub release "1.2.0", upload SharedKit.xcframework.zip
6. Update url + checksum in Package.swift, commit, tag 1.2.0, push
7. iOS app: File > Add Package Dependencies > repository URL, version rule "Up to next major 1.2.0"

A klibs.io research table (example; verify at the time):

text
Need          Library          Android  iOS  Last release   Note
Date/time     kotlinx-datetime   yes    yes  recent         first-party
Settings      multiplatform-settings or DataStore  yes yes  recent
Images        Coil 3             yes    yes  recent         check Compose Multiplatform requirement

A SwiftUI view using the observer wrapper:

swift
final class SummaryViewModel: ObservableObject {
 @Published var balanceText = "…"
 private let observer: SummaryObserver
 init(api: FeeApi) {
 observer = SummaryObserver(api: api)
 observer.start { [weak self] summary in
 DispatchQueue.main.async { self?.balanceText = "Balance: \(summary.balance)" }
 }
 }
 deinit { observer.stop() }
}

The Android-only path is complete when CI shows a green iOS build, even though no one on the team ran the app locally.

1

Open your finished project in Android Studio. Check that the .gitignore file the wizard created is in the project root. It keeps local.properties, build folders and keystores out of Git. Never commit passwords or signing keys.

2

Choose Git > Create Git Repository… (or VCS > Enable Version Control Integration in older versions), select the project folder, then open Git > Commit, tick every file, and write the message “Add Android lesson 69 project”.

3

On github.com click New repository, name it android-lesson-69-integrate-with-ios-direct-integration-and, set it to Public, leave Add a README unticked, and click Create repository.

4

Back in Android Studio choose Git > Manage Remotes…, add the repository URL as origin, then choose Git > Push… and push your main branch. Prefer the terminal? Run git remote add origin <your-url> then git push -u origin main.

5

Add a README.md with a screenshot of your app running and a short description. Employers and clients read READMEs first. Your project is now part of your public portfolio. 🎉

Lesson 69 complete! 🎉

You covered:

  • Lesson Introduction
  • Prerequisite Concepts
  • Conceptual Understanding: Three Ways to Connect
  • Step-by-Step Build 1: Prepare the Shared Module
  • Step-by-Step Build 2: Direct Integration (Mac Only)
  • Step-by-Step Build 3: Multiplatform Dependencies
  • Step-by-Step Build 4: Distributing with Swift Package Manager
  • Step-by-Step Build 5: The Android-Only Path (No Mac)
  • Guided Practice Exercises
  • Common Mistakes (and How to Fix Them)
  • Knowledge Check
All 72 lessons in Google Android Studio ·Techbase Academy ·© 2025–2026 Techbase Consultant Services, Ibadan
Esc
↑ ↓ to moveEnter to openEsc to close