skip to content

A teammate ships a single iosArm64 .framework and the app fails to run on the Apple-silicon simulator. Explain the architecture problem and how XCFramework plus the right targets fix it.

level: seniorimportance: should knowfreq 38%

answer

  1. iosArm64 = device; iosSimulatorArm64 = Apple-silicon simulator
  2. device arm64 and simulator arm64 are different platforms
  3. lipo can't merge same-arch different-platform slices
  4. XCFramework = bundle of platform/arch slices, Xcode auto-picks
  5. assemble<Name>XCFramework task

basics

~20 s

iosArm64 is for real devices only. The simulator on an Apple-silicon Mac needs iosSimulatorArm64. A plain .framework holds one slice, so the simulator can't link it. Build both targets and bundle them in an XCFramework, which carries multiple slices.

solid answer

~40 s

iosArm64() produces an ARM64 device binary; the iOS Simulator on Apple-silicon Macs is also ARM64 but a different platform variant (simulator vs device), so the device framework won't link there. You need iosSimulatorArm64() (and iosX64() for Intel-Mac simulators). A single .framework only contains one platform/arch slice. The fix is an XCFramework, an Apple bundle that holds multiple framework slices keyed by platform+arch; Xcode auto-picks the matching slice at build time. In KMP, use the XCFramework("Name") helper, add each target's framework binary to it, and run the assemble<Name>XCFramework task. You can't lipo device+simulator arm64 together (same CPU, different platform); XCFramework is the supported solution. Ship it via SwiftPM binaryTarget, CocoaPods, or manual Xcode link.

code

kotlin · 12 lines
kotlin
import org.jetbrains.kotlin.gradle.plugin.mpp.apple.XCFramework

kotlin {
    val xcf = XCFramework("Shared")
    listOf(iosArm64(), iosSimulatorArm64(), iosX64()).forEach { target ->
        target.binaries.framework {
            baseName = "Shared"
            xcf.add(this)
        }
    }
}
// ./gradlew assembleSharedXCFramework -> Shared.xcframework (device + simulator slices)

go deeper

for a junior

Recognizes the simulator needs a different target but may not know the exact name.

for a middle

Names iosSimulatorArm64() and knows XCFramework bundles slices.

for a senior

Explains the device-vs-simulator platform distinction, why lipo fails, and wires the XCFramework helper and assemble task.

for a principal

Defines the org's target matrix and distribution pipeline (which simulator archs, CI agent types, release vs debug XCFramework, checksums).

## The root cause: device arm64 != simulator arm64 Apple-silicon simulators run **ARM64**, the same CPU family as iPhones. But a binary is tagged with a **platform**: `ios` (device) vs `ios-simulator`. A framework built only for `iosArm64()` is the **device** slice. When Xcode tries to build/run on the simulator it looks for an `ios-simulator` slice and finds none, so linking or running fails with a 'building for simulator but linking ... built for iOS' style error. So you must declare the simulator target: ```kotlin kotlin { iosArm64() // physical devices (ios, arm64) iosSimulatorArm64() // Apple-silicon simulator (ios-simulator, arm64) // iosX64() // Intel-Mac simulator (ios-simulator, x86_64) } ``` ## Why a plain .framework can't carry both A classic `.framework` Mach-O binary can hold multiple **CPU architectures** in one fat binary (`lipo`), but **not two slices that share the same CPU arch but differ in platform**. Device-arm64 and simulator-arm64 collide under `lipo`. So fat-merging is not a valid fix for device + Apple-silicon-simulator. ## XCFramework is the solution An **XCFramework** (`.xcframework`) is an Apple bundle that stores **multiple separate framework slices** indexed by `platform + arch + variant`. Xcode selects the correct slice automatically per build target. It cleanly supports device, simulator, macOS, etc., side by side. In KMP: ```kotlin import org.jetbrains.kotlin.gradle.plugin.mpp.apple.XCFramework kotlin { val xcf = XCFramework("Shared") listOf(iosArm64(), iosSimulatorArm64(), iosX64()).forEach { t -> t.binaries.framework { baseName = "Shared" xcf.add(this) } } } ``` Run `./gradlew assembleSharedXCFramework` (or the debug/release variant tasks). The output `Shared.xcframework` contains a device slice and one or more simulator slices. ## Consuming the XCFramework - **SwiftPM:** `.binaryTarget(name: "Shared", path: "Shared.xcframework")` (or remote `url:checksum:`). - **CocoaPods:** reference via the generated podspec. - **Manual:** drag into Xcode 'Frameworks, Libraries, and Embedded Content'. ## Practical checklist - Always declare both `iosArm64()` and `iosSimulatorArm64()` (add `iosX64()` if Intel-Mac CI agents exist). - Package as XCFramework, never a single per-target framework for distribution. - Keep `baseName` identical across targets so Swift imports one module name.

  • Why can't you lipo-merge the device and Apple-silicon simulator frameworks?
    Both are arm64, so lipo sees a duplicate architecture; they differ only by platform variant (ios vs ios-simulator), which lipo can't disambiguate. XCFramework keys on platform+arch.
  • When do you still need iosX64()?
    When you must run on the iOS Simulator on Intel Macs (e.g., older CI agents), since that simulator is x86_64.

A plain framework is a single-key hotel room; an XCFramework is a key-card that opens whichever room (device or simulator) you arrive at.

saying these in an interview costs you the question

  • Tries to fix it by lipo-merging device + simulator arm64
  • Thinks the simulator can use the device framework because both are arm64
  • Doesn't mention iosSimulatorArm64() at all
  • Ships a single .framework for distribution instead of an XCFramework

context