I was building a Kotlin Multiplatform SDK that needed to support four analytics providers—Google Analytics, Facebook, AppsFlyer, and Amplitude. Each customer typically only uses one or two, never all four.
The Challenge: How to let developers pick and choose which analytics integrations to include, without forcing them to bundle everything?
The solution seemed obvious: build separate implementations for each analytics provider and let developers include only what they need.
On Android, this is straightforward—create separate Gradle modules, and developers pick which ones to add. Done in 30 seconds.
dependencies {
implementation("com.example:shared:1.2.3")
implementation("com.example:analytics-google:1.2.3") // ✓ Just this
}
But on iOS? I had no idea where to start.
So I had two terrible options (or maybe more, but I could only think of these two):
Option 1: Bundle everything into one fat XCFramework. Every iOS developer downloads all four analytics SDKs, even if they only use one. App size bloat. Unused dependencies sitting in production apps. iOS developers hate this.
Option 2: Split into five separate XCFramework packages. Now I’m publishing five independent Swift packages, coordinating the five releases every time I change the shared interface. Version hell. I tried this. Wouldn’t recommend it.
Being an Android Dev, I spent the next few days figuring out how SPM worked and going through its documentation.
The Breakthrough: SPM Products
The breakthrough came from understanding how Swift Package Manager structures packages1. SPM has three key concepts:
- Package: One repository with one
Package.swiftfile - Products: The libraries developers can import and use
- Targets: Your actual source code and resources
The key insight: One SPM package can expose multiple products2 that developers consume independently.
For Android developers: it’s like depending on androidx.room:room-runtime without pulling in androidx.room:room-testing. Same multi-module concept, just structured differently in SPM.
How SPM Products Work
In Gradle, you create separate modules (subprojects) and each publishes its own artifact. In SPM, you define everything in a single Package.swift manifest:
let package = Package(
name: "YourSDK",
products: [
.library(name: "Core", targets: ["Core"]),
.library(name: "Analytics_Google", targets: ["Analytics_Google"]),
.library(name: "Analytics_Facebook", targets: ["Analytics_Facebook"]),
],
targets: [
.binaryTarget(name: "Core", ...),
.target(name: "Analytics_Google", dependencies: ["Core"]),
.target(name: "Analytics_Facebook", dependencies: ["Core"]),
]
)
Each .library() declaration creates a separate consumable unit. iOS developers can depend on individual products from the same package.
Here’s how an iOS app would consume it in their Package.swift:
dependencies: [
.package(url: "https://github.com/you/sdk", from: "1.2.3"),
],
targets: [
.target(
name: "YourApp",
dependencies: [
.product(name: "Core", package: "sdk"),
.product(name: "Analytics_Google", package: "sdk"), // ✓ Just this
]
),
]
Notice: one package dependency, multiple product selections. This is the iOS equivalent of Gradle’s multi-module publishing.
The Architecture
Before diving into Package.swift, let’s see how the analytics implementations actually work—from shared Kotlin interface to platform-specific code.
1. Shared Interface (Kotlin Common)
// In your KMP shared module
// shared/src/commonMain/kotlin/analytics/AnalyticsWriter.kt
abstract class AnalyticsWriter {
abstract suspend fun logEvent(
event: Analytics.Event,
properties: Map<Analytics.Parameter, Any>
)
}
This is the contract both platforms implement. Nothing iOS or Android-specific here—just the interface.
2. Android Implementation (Kotlin Android)
// analytics-google/src/androidMain/kotlin/GoogleAnalyticsWriter.kt
class GoogleAnalyticsWriter : AnalyticsWriter() {
private val firebaseAnalytics by lazy { Firebase.analytics }
override suspend fun logEvent(
event: Analytics.Event,
properties: Map<Analytics.Parameter, Any>
) {
firebaseAnalytics.logEvent(event.name) {
properties.forEach { (key, value) ->
val truncatedValue = value.toString().take(100)
param(key.name, truncatedValue)
}
}
}
}
Standard Kotlin implementation using Firebase Analytics KTX extensions. Lives in a separate Gradle module (analytics-google).
3. iOS Implementation (Swift)
// Sources/Analytics_Google/GoogleAnalyticsWriter.swift
import Foundation
import SharedSDK // The KMP XCFramework
import FirebaseAnalytics
public class GoogleAnalyticsWriter : AnalyticsWriter {
public override func logEvent(
event: Analytics.Event,
properties: [Analytics.Parameter : Any]
) async throws {
var convertedProperties = [String: Any]()
for (key, value) in properties {
let stringValue = String(describing: value)
let truncatedValue = stringValue.prefix(100)
convertedProperties[key.name] = truncatedValue
}
Analytics.logEvent(event.name, parameters: convertedProperties)
}
}
Notice:
- Inherits from
AnalyticsWriter(exposed from the KMP XCFramework) - Imports the shared SDK and FirebaseAnalytics
- Implements the same interface, but calls native iOS Firebase APIs
- Lives in
Sources/Analytics_Google/directory
Why Swift for iOS implementations?
Each analytics provider (Firebase, Facebook, AppsFlyer, etc.) has a native iOS SDK with Swift APIs. The Swift wrapper inherits from the shared AnalyticsWriter interface (exposed from the KMP XCFramework) and forwards calls to the appropriate native SDK. This keeps the integration simple, uses each SDK’s official iOS implementation, and makes it easy for iOS developers to customize or extend the wrappers if needed.
SPM Package Structure
Now that we’ve seen the implementations, here’s how Package.swift exposes them:
// swift-tools-version:5.3
import PackageDescription
let package = Package(
name: "SharedSDK",
platforms: [.iOS(.v13)],
products: [
.library(name: "SharedSDK", targets: ["SharedSDK"]),
.library(name: "Analytics_Google", targets: ["Analytics_Google"]),
.library(name: "Analytics_Facebook", targets: ["Analytics_Facebook"]),
// ... other analytics providers
],
dependencies: [
.package(name: "Firebase", url: "https://github.com/firebase/firebase-ios-sdk.git", from: "10.24.0"),
.package(name: "Facebook", url: "https://github.com/facebook/facebook-ios-sdk.git", from: "17.0.0"),
// ... other SDKs
],
targets: [
.binaryTarget(
name: "SharedSDK",
url: "https://example.com/shared-1.2.3.zip", // These will be generated by KMMBridge
checksum: "abc123..." // These will be generated by KMMBridge
),
.target(
name: "Analytics_Google",
dependencies: [
.product(name: "FirebaseAnalytics", package: "Firebase"),
.target(name: "SharedSDK"),
]
),
.target(
name: "Analytics_Facebook",
dependencies: [
.product(name: "FacebookCore", package: "Facebook"),
.target(name: "SharedSDK")
]
),
// ... other analytics targets
]
)
Breaking it down:
-
Binary Target: The KMP XCFramework3 (
SharedSDK) which contains the shared Kotlin code andAnalyticsWriterinterface - Swift Wrapper Targets: Each analytics provider is a
.target()that:- Contains the Swift implementation (e.g.,
Sources/Analytics_Google/GoogleAnalyticsWriter.swift) - Depends on the KMP binary target (to inherit
AnalyticsWriter) - Depends on the native iOS SDK (Firebase, Facebook, etc.)
- Contains the Swift implementation (e.g.,
-
Products: Each target is exposed as a separate
.library()product that iOS developers can import - Dependencies: All potential analytics SDKs are declared upfront, but only pulled in when the corresponding product is selected
Publishing the XCFramework
You might wonder: how does the XCFramework get built and published to that URL in the Package.swift?
This is where KMMBridge4 comes in. KMMBridge automates the entire iOS publishing workflow:
- Builds the XCFramework from your Kotlin Multiplatform code
- Uploads it to your chosen hosting (GitHub Releases, Maven, S3, etc.)
- Generates the checksum for SPM verification
- Updates Package.swift with the new URL and checksum
In your shared module’s build.gradle.kts:
plugins {
id("co.touchlab.kmmbridge") version "0.5.1"
}
kmmbridge {
githubReleaseArtifacts()
spm()
}
Now when you run ./gradlew kmmBridgePublish, it handles everything. The XCFramework gets published, Package.swift gets updated, and iOS developers can immediately pull the new version via SPM.
Without KMMBridge, you’d manually build the XCFramework, upload it, calculate checksums, and update Package.swift—error-prone and tedious. KMMBridge makes iOS publishing as simple as Android’s ./gradlew publish.
Platform Parity: The Developer Experience
Both Android and iOS developers now have the same granular control:
Android:
dependencies {
implementation("com.example:shared:1.2.3")
implementation("com.example:analytics-google:1.2.3")
implementation("com.example:analytics-appsflyer:1.2.3")
}
iOS:
dependencies: [
.product(name: "SharedSDK", package: "sdk"),
.product(name: "Analytics_Google", package: "sdk"),
.product(name: "Analytics_AppsFlyer", package: "sdk"),
]
In Xcode, when adding the package, iOS developers see all available products and can select only what they need:

SPM’s dependency resolution ensures:
- Only the selected analytics SDK (Google Analytics/Firebase) is downloaded
- Its native iOS dependencies are pulled in automatically
- Unused providers (Facebook, AppsFlyer, Amplitude) are completely excluded
- The core KMP framework is shared (downloaded once)
The key difference: Android uses multiple Gradle modules that publish separately, while iOS uses one SPM package with multiple products. Different mechanics, same outcome—developers pick exactly what they need.
Dependency Resolution Flow
Android Dev selects Google Analytics, iOS Dev selects Facebook Analytics. Each platform downloads only their selection, the shared module, and required external SDKs—AppsFlyer and Amplitude remain unused. This is what Kotlin Multiplatform should feel like—shared logic, platform-native distribution, granular control.
Conclusion
Building modular KMP SDKs doesn’t mean compromising on iOS developer experience. SPM products give iOS developers the same dependency granularity that Android developers expect from Gradle modules—just with different mechanics.
If you’re building a KMP SDK with optional components—analytics, payment providers, whatever—this pattern works. Your iOS developers will thank you.
-
Swift Package Manager Documentation - Official Swift.org guide to SPM concepts including packages, products, and targets. ↩
-
Creating Swift Packages with Multiple Products - Apple’s PackageDescription API reference showing how to define multiple library products. ↩
-
XCFramework is Apple’s standard format for distributing binary frameworks that support multiple platforms and architectures (iOS device, iOS simulator, etc.) ↩
-
KMMBridge is an open-source tool by Touchlab that automates XCFramework publishing for Kotlin Multiplatform projects. ↩