Xcode Build & Modularization
Irbisa · cheatsheetSeptember 13, 2026

Xcode Build & Modularization

Targets, schemes, xcconfig, SPM, static vs dynamic linking, XCFramework, build times, thinning

Senior Developer16 itemscompressed for a skim
  1. 01

    You pull a teammate's branch and their new Staging scheme is nowhere in Xcode — where does a scheme live, and what belongs in git?

    Medium

    A scheme is stored per user by default, under xcuserdata/, and only becomes a file the team shares when you tick Shared and Xcode moves it into xcshareddata/xcschemes/.

    <!-- App.xcodeproj/xcshareddata/xcschemes/Staging.xcscheme  -> shared, in git.
         Unshared, the identical file sits under
         App.xcodeproj/xcuserdata/<you>.xcuserdatad/xcschemes/ and CI never sees it. -->
    <Scheme LastUpgradeVersion = "1620" version = "1.7">
       <BuildAction parallelizeBuildables = "YES" buildImplicitDependencies = "YES">
          <BuildActionEntries>
    …
  2. 02

    Two pull requests touched the project settings and the pbxproj conflict is unreadable — how do xcconfig files change that, and what breaks without $(inherited)?

    Medium

    Move build settings out of project.pbxproj into .xcconfig text files assigned per configuration: settings become plain lines that review and merge like any other source file, instead of one machine-generated blob two branches both rewrote.

    # Settings become text: a diff a reviewer can read, and a conflict git can merge.
    cat > Config/Base.xcconfig <<'EOF'
    // Shared by every configuration. The target's Build Settings tab stays empty.
    SWIFT_VERSION = 6.0
    IPHONEOS_DEPLOYMENT_TARGET = 17.0
    OTHER_LDFLAGS = $(inherited) -ObjC
    …
  3. 03

    Another package cannot import Networking even though that target builds fine and the type is public — what did you leave out of Package.swift?

    Medium

    A products entry: only targets exposed through a library product are visible outside their own package, no matter how much of the code is public.

    // Modules/Networking/Package.swift
    import PackageDescription
    
    let package = Package(
      name: "Networking",
      platforms: [.iOS(.v17)],
    …
  4. 04

    CI resolved a newer version of a dependency than your laptop had — what is Package.resolved for, and who on the team should be committing it?

    Medium

    Package.resolved records the exact version or revision SwiftPM chose for every dependency, and in an app repository it must be committed — it is the only thing that makes two machines resolve the same graph.

    // App/Package.swift — the same requirements Xcode's package UI writes for you
    import PackageDescription
    
    let package = Package(
      name: "App",
      dependencies: [
    …
  5. 05

    Twelve modules were converted from static libraries to embedded dynamic frameworks and cold launch got noticeably slower — what changed at load time?

    Medium

    Every embedded dynamic framework is a separate Mach-O image that dyld has to find, map, fix up and initialise before main runs, while a static library costs nothing at launch because its code was copied into the app binary at link time.

    // Modules/DesignSystem/Package.swift
    import PackageDescription
    
    let package = Package(
      name: "DesignSystem",
      platforms: [.iOS(.v17)],
    …
  6. 06

    The console says a class is implemented in both the app and a framework and that one of the two will be used — how did that happen and how do you find it?

    Medium

    Two loaded images both contain that class, which means one static library got linked into more than one binary — classically into a framework and into the app that embeds it.

    // WRONG: Analytics is static (no explicit type), and two dynamic frameworks each
    // link it. Both images end up containing AnalyticsTracker and its globals.
    let wrong = Package(
      name: "App",
      products: [
        .library(name: "Analytics", targets: ["Analytics"]),
    …
  7. 07

    A vendor sent a .framework that runs in the simulator, and App Store Connect rejects the upload for unsupported architectures — what should they have shipped instead?

    Medium

    An .xcframework, which stores one build per platform and variant side by side instead of trying to lipo device and simulator into a single fat binary.

    set -euo pipefail
    
    # One archive per platform AND variant. lipo cannot help: device and simulator are
    # both arm64 on Apple silicon, which is exactly why .xcframework exists.
    for DEST in "generic/platform=iOS" "generic/platform=iOS Simulator"; do
      xcodebuild archive \
    …
  8. 08

    Changing one line in a feature module still costs twelve minutes — how do you find out what is actually being rebuilt and which file is genuinely expensive?

    Hard

    Prove what is rebuilding before you blame the compiler: a one-line edit that costs twelve minutes is a dependency-graph problem far more often than a slow file.

    set -euo pipefail
    
    # 1. What is the build actually doing? Every task with its duration; the Build
    #    Timeline in Xcode's log navigator shows what ran in parallel and what did not.
    xcodebuild -workspace App.xcworkspace -scheme App -configuration Debug \
      -destination 'generic/platform=iOS Simulator' \
    …
  9. 09

    A Debug build takes 40 seconds and Release takes 25 minutes, with one file dominating the log — what is the compiler doing differently?

    Hard

    Release defaults to whole-module optimisation, so the module is type-checked and optimised as a single unit — one expensive expression is no longer isolated to its own file the way it is under Debug's incremental mode.

    // Debug.xcconfig — compile fast
    // SWIFT_COMPILATION_MODE  = singlefile
    // SWIFT_OPTIMIZATION_LEVEL = -Onone
    //
    // Release.xcconfig — run fast
    // SWIFT_COMPILATION_MODE  = wholemodule
    …
  10. 10

    Every type in your feature package ended up public because that was the only way to make the app compile — what did that cost, and what should the module expose?

    Hard

    public is a promise rather than a convenience: each public symbol is API you have to keep working, and a wide surface lets any file in the app reach into the module's internals — exactly the coupling the module was supposed to prevent.

    // Package.swift — one product, two targets that share internals
    import PackageDescription
    
    let package = Package(
      name: "Checkout",
      products: [.library(name: "Checkout", targets: ["Checkout"])],
    …
  11. 11

    Checkout must open Profile's address editor and Profile must open Checkout's payment sheet, and SwiftPM rejects the circular dependency — how do you break it?

    Hard

    Neither feature imports the other: extract the contract they need into an interface module both depend on, and let the app's composition root bind the implementations.

    // Package.swift — the interface target is what breaks the cycle
    import PackageDescription
    
    let package = Package(
      name: "Features",
      products: [
    …
  12. 12

    You ship your SDK as a pre-built XCFramework — what does turning on BUILD_LIBRARY_FOR_DISTRIBUTION actually commit you to?

    Hard

    It turns on library evolution: the compiler emits a textual .swiftinterface so clients built with a different Swift version can import you, and it stops those clients from baking your types' memory layout into their code — the price is that every public declaration becomes a promise you cannot take back.

    // Framework target built with BUILD_LIBRARY_FOR_DISTRIBUTION = YES
    
    // Not frozen: you may add a case in 2.0, so clients must handle the unknown one
    public enum PaymentState: Sendable {
      case pending
      case settled(Receipt)
    …
  13. 13

    You need to call a C library from a Swift package and the bridging header that worked in the app target is not available — what replaces it?

    Hard

    A Swift package has no bridging header — that mechanism only exists for a target Xcode compiles alongside Objective-C — so the C code becomes a module of its own that Swift imports, either as a C target inside the package or as a .systemLibrary target with a hand-written module.modulemap.

    // Package.swift
    import PackageDescription
    
    let package = Package(
      name: "Crypto",
      products: [.library(name: "Crypto", targets: ["Crypto"])],
    …
  14. 14

    Your .ipa is 60 MB, App Store Connect reports 140 MB and a tester's phone downloads 45 MB — which number is real and where does the difference come from?

    Hard

    The phone's number is the real one: the archive carries every architecture, asset scale and localisation, and the store slices it into a variant per device before anyone downloads it, so the only figures worth quoting are the per-device download and install sizes in the App Store Connect size report.

    #!/bin/bash
    set -euo pipefail
    
    # 1. Archive once — the .xcarchive still contains every variant
    xcodebuild archive \
      -workspace App.xcworkspace -scheme App \
    …
  15. 15

    SwiftLint, the code generator and the licence script run on every single build even when nothing changed — what is Xcode missing here?

    Hard

    A Run Script phase with no declared input and output files cannot be proven up to date, so the build system runs it every time — and if it writes anywhere the compiler reads, it invalidates everything downstream as well.

    # ── Run Script phase, WRONG ────────────────────────────────────────────────
    # No inputs, no outputs: runs on every build. Worse, it rewrites a file the
    # compiler reads, so everything depending on Strings.swift recompiles too.
    swiftlint --fix --path "$SRCROOT/Sources"
    "$SRCROOT/Tools/gen-strings" "$SRCROOT/Resources/Localizable.xcstrings" \
      > "$SRCROOT/Sources/Generated/Strings.swift"
    …
  16. 16

    A 200-target .xcodeproj conflicts on nearly every branch — how do you move to a generated project without freezing feature work?

    Hard

    Do the migration as one mechanical, provable step — generate the same project from a manifest, show it builds an identical app, then delete the .xcodeproj from git — and restructure targets only afterwards, when a rollback is no longer a rescue operation.

    import ProjectDescription
    
    // Project.swift — the target graph as reviewable source, no pbxproj in git
    let project = Project(
      name: "App",
      settings: .settings(configurations: [
    …