A target describes one product to build (an app, a framework, a test bundle), a build configuration is a named set of build settings such as Debug or Release, and a scheme says which targets each action (Run, Test, Profile, Archive) builds and with which configuration.
Configuration: one column of settings shared by all targets. A Staging flavour is a new configuration plus an xcconfig, almost never a duplicated target that you then have to keep in sync.
Scheme: owns no build settings; it stores launch arguments, environment variables and test plans, which apply only when Xcode launches the app.
**.xcodeproj**: one project: targets, file references and settings in project.pbxproj.
**.xcworkspace**: a container that opens several projects and packages together so their targets can depend on each other. CocoaPods generates one around your project.
The classic trap: schemes are per user by default, under xcuserdata/. Until someone ticks Shared, which moves the file to xcshareddata/xcschemes/, CI fails with "scheme not found". Commit xcshareddata, gitignore xcuserdata.
iOSSenior
Xcode Build & Modularization
Targets, schemes, xcconfig, SPM, static vs dynamic linking, XCFramework, build times, thinning
Questions closed
0 / 22
- 0Review
- 1Asked often
Questions
Pick the oldest iOS version that still carries a meaningful share of your users (usually the current major release minus one or two), and reach newer APIs behind if #available checks and @available annotations.
Use your own analytics, not only Apple's adoption chart: the share of your sessions on each iOS version is exactly what you give up by raising the target.
Going low costs engineering time (an availability check and a fallback per new API, plus testing the old OS); going high costs users, who stay on the last compatible version without updates.
Packages set a floor: a dependency whose platforms: is higher than your target fails with "The package product 'X' requires minimum platform version 17.0 for the iOS platform, but this target supports 16.0", so the slowest-moving module decides.
#available is a runtime check; @available is a compile-time contract that forces callers to check.
The certificate proves who signed the app, entitlements list the capabilities the app claims (push, App Groups, Keychain sharing), and the provisioning profile is Apple's signed permission that ties an App ID, certificates, entitlements and (for development and Ad Hoc) specific devices together.
Certificate: a key pair: the private key lives in your Keychain, Apple signs the public half. Development certificates sign builds for your own devices; distribution certificates sign TestFlight, App Store and Ad Hoc builds. Without the private key the certificate is useless.
App ID: the registration of your bundle ID (com.example.app) in the developer portal, prefixed with the team ID; it carries the enabled capabilities.
Entitlements: a plist signed into the binary. Every entitlement must be allowed by the profile, or the install or upload is rejected.
Provisioning profile: embedded in the app as embedded.mobileprovision; the types are development, Ad Hoc, App Store and Enterprise.
At install time iOS checks the signature, that the certificate and device are in the profile, and that the entitlements fit it.
CocoaPods generates a workspace and builds pods from a central spec repo, Carthage only builds frameworks that you link yourself, and SPM is Apple's own dependency manager built into Swift and Xcode: no extra tool and no generated workspace.
CocoaPods: a Ruby gem with a Podfile and Podfile.lock. pod install rewrites your project and adds its own xcconfigs. Huge catalogue, but a second build system to keep working. Its maintainers have announced that the trunk spec repo becomes read-only in December 2026, so no new pod versions will be published after that.
Carthage: builds XCFrameworks and leaves linking to you; manual and barely maintained.
SPM: Package.swift plus Package.resolved, integrated into Xcode. Supports resources, binary targets (XCFrameworks), build plugins and macros, and macros can only be distributed as packages.
Why teams moved: one tool fewer on every Mac and CI runner, first-party support in Xcode, local packages double as a modularisation tool, and SDK vendors now ship SPM first. CocoaPods mostly survives in legacy apps and React Native projects.
In Debug the assert fails and the app traps with "Assertion failed: id is required"; in Release assert is compiled out and the #if DEBUG block does not exist, so the call silently prints item--1.
What differs between the two configurations
Optimisation: SWIFT_OPTIMIZATION_LEVEL is -Onone in Debug and -O in Release. assert and assertionFailure are only checked in -Onone; precondition survives -O (it is removed only by -Ounchecked); fatalError traps in every mode except -Ounchecked, where the optimizer may assume it is never reached.
**#if DEBUG**: works because new projects put DEBUG into SWIFT_ACTIVE_COMPILATION_CONDITIONS for Debug only. It is compile time: the Release binary does not contain that code at all.
Compilation mode: Debug compiles incrementally; Release uses wholemodule, optimising across files. That is why Release builds slower and runs faster.
Debug info: DEBUG_INFORMATION_FORMAT is dwarf in Debug and dwarf-with-dsym in Release, which produces the dSYM crash reports are symbolicated with.
Testability: ENABLE_TESTABILITY = YES in Debug is what lets @testable import see internal symbols.
Never validate real input with assert: it disappears in the build users run. Use precondition or proper error handling, and test a Release build before shipping; optimiser-only bugs and missing debug code only show up there.
An .xcconfig is a plain-text file of build settings assigned to a configuration, so settings live in reviewable, mergeable lines instead of inside project.pbxproj, which Xcode rewrites as a machine-generated blob and two branches will conflict on.
Resolution order
Lowest to highest, later wins: Xcode defaults → the project's xcconfig → project build settings → the target's xcconfig → target build settings → xcodebuild SETTING=value on the command line.
What to know
**$(inherited)** splices in the value from the level below. For list settings (OTHER_LDFLAGS, HEADER_SEARCH_PATHS, SWIFT_ACTIVE_COMPILATION_CONDITIONS), forgetting it silently drops flags set lower down (CocoaPods' linker flags, for example), and the build fails at link time with undefined symbols, far from the line you edited.
**#include** pulls in a shared base file; #include? tolerates a missing file, which suits gitignored per-developer overrides.
Conditional settings such as OTHER_SWIFT_FLAGS[sdk=iphonesimulator*] replace a forked target.
**// starts a comment, so a URL value gets cut off; store the host and build the URL in code.
The usual trap**: a value left in the target's Build Settings tab (shown in bold) outranks the xcconfig. "My xcconfig is ignored" is almost always that. Check with xcodebuild -showBuildSettings, not by rereading the file.
Typical layout: one Base.xcconfig, one file per configuration that includes it, and an empty Build Settings tab.
Xcode's build system turns the targets into a dependency graph of tasks, then for each target compiles the sources, processes resources, links one binary, assembles the bundle, runs script phases and code-signs the result, in parallel wherever the graph allows.
The steps
Plan: resolve packages, compute build settings, order targets by their dependencies and build the task graph. Tasks whose inputs did not change are skipped; that is what makes incremental builds possible.
Compile: swift-frontend type-checks and compiles Swift files in batches and emits a .swiftmodule, the module's interface that dependent modules compile against; clang compiles C and Objective-C. The output is one .o file per source file.
Resources: asset catalogs become Assets.car (actool), storyboards and XIBs become nibs (ibtool), String Catalogs become .strings, and Info.plist gets build settings substituted.
Link: ld merges object files and static libraries into one Mach-O executable and records which dynamic libraries it needs.
Assemble and sign: copy the binary and resources into the .app, embed frameworks and extensions, run Run Script phases at their position, then codesign with the entitlements. Release builds (and so Archive) also produce dSYMs.
Where the time goes
Usually Swift compilation, and linking on big apps. Speed comes from the graph more than from flags: modules that can compile in parallel, script phases with declared inputs and outputs, and incremental builds instead of habitual clean builds.
A target is one module, a product is what the package exports to other packages, a package dependency is another package you consume; your target is invisible outside unless a library product lists it, however much of it is public.
Target: one module (Swift or C-family). dependencies drive build order and what the target may link, but SwiftPM does not reliably reject an import you forgot to declare; a transitively built module often still resolves, which breaks later when the graph changes. swift build --explicit-target-dependency-import-check=error turns the check on; xcodebuild has no equivalent.
Product: what the package exports. .library(name:targets:) bundles one or more targets under a name consumers depend on; targets left out stay internal implementation detail.
Package dependency: .package(path:) for a local package, .package(url:from:) for a remote one. In .product(name:package:) the package: label is the package identity (the folder or repository name), not the library name. That is the most common manifest mistake.
Access control is the other half: keep internal by default, use package (Swift 5.9) for helpers shared between this package's targets, and public only for real API.
Resources need .process(...) or .copy(...) in the target and are read through Bundle.module, otherwise they are missing at runtime.
If the product list is as long as the target list, no boundary has been drawn.
SwiftPM collects the version requirements of every package in the graph and picks one version per package (the newest that satisfies all of them), then records the exact versions and revisions in Package.resolved. Committing that file is what makes every machine build the same graph.
Requirements are semver ranges: from: "1.2.0" means 1.2.0..<2.0.0, .upToNextMinor(from:) lets only patch releases move, exact: pins one version. branch: and revision: are unversioned, and a package that depends on a branch cannot itself be consumed as a versioned dependency.
One version per package: unlike npm, two dependents cannot get different copies. Incompatible ranges fail resolution with an error naming both requirements.
Where the file lives: next to Package.swift, or for an Xcode project under .xcodeproj/project.xcworkspace/xcshareddata/swiftpm/. It gets gitignored by mistake because it looks generated. Only the root package's file counts, so a library's Package.resolved pins nothing for its consumers.
Resolve vs update: swift package resolve and xcodebuild -resolvePackageDependencies honour the file; swift package update and Xcode's Update to Latest Package Versions rewrite it. On CI, -disableAutomaticPackageResolution makes the build fail instead of silently re-resolving.
Supply chain: macros and build plugins run code during the build, so bump them deliberately, one dependency per pull request.
An .xcframework is a bundle that holds one build of a library per platform and variant (device, simulator, Mac Catalyst) side by side, with an Info.plist Xcode uses to pick the right one. A fat framework merged everything into one binary with lipo, which broke once simulators became arm64.
**Why lipo fails**: a Mach-O holds one slice per architecture, and on Apple silicon the simulator and the device are both arm64, so they cannot share a binary. A fat binary that still carries simulator slices is rejected on upload (ITMS-90087, unsupported architectures), and the old "strip architectures" script also broke the vendor's signature.
Layout: one directory per slice, such as ios-arm64 and ios-arm64_x86_64-simulator, each holding a .framework or a static library with headers, optionally with dSYMs.
Building one: archive once per destination with SKIP_INSTALL=NO and BUILD_LIBRARY_FOR_DISTRIBUTION=YES, then run xcodebuild -create-xcframework.
Distributing: a SwiftPM .binaryTarget with a zip URL and checksum, or a local path. XCFrameworks can be signed (Xcode 15+), and SDKs on Apple's list of commonly used SDKs must be signed and carry a privacy manifest.
The cost of any binary dependency: no stepping into it without dSYMs, no patching on your schedule, and waiting for the vendor whenever Xcode or Swift moves.
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 a file the compiler reads, everything downstream recompiles too.
Making a phase skippable
Declare Input Files and Output Files, or .xcfilelist files when the list is long or generated. The build system compares them and skips the phase when nothing changed.
Every file the script reads must be an input and every file it writes an output. A phase with no outputs is dirty by definition; unticking "Based on dependency analysis" only makes that explicit.
ENABLE_USER_SCRIPT_SANDBOXING (on for new projects since Xcode 15) fails the build when a script touches an undeclared path: annoying, and exactly what keeps the declarations honest.
Other ways incremental builds die
A generator that rewrites its output on every run (a timestamp, a build number) changes the bytes even when the meaning did not. Write only when the content differs.
A formatter that edits files in the source tree: new modification dates mean recompilation.
Linting reads the whole tree and writes nothing the compiler needs, so it belongs in CI or a pre-commit hook, not in every local build.
A build phase must be able to say what it read and what it wrote; if it cannot, it is not a build step.
App thinning means the App Store builds a variant per device from your upload: slicing keeps one architecture, one asset scale and matching GPU resources, so the user downloads a compressed variant far smaller than the universal .ipa and installs a larger uncompressed one.
Slicing only reaches images and data inside an asset catalog. A PNG loose in the bundle or inside a framework's resources ships to every device.
On-demand resources keep tagged content out of the initial download and fetch it on first use: tutorials, level packs, rarely used media. Apple's replacement is Background Assets; its managed Apple-hosted packs require iOS 26 as the deployment target, so ODR stays for apps supporting older versions.
Bitcode is history: deprecated in Xcode 14, and the App Store no longer accepts it.
Download vs install size: download is the compressed size and triggers the cellular-download warning; install is uncompressed and is what fails on a full device. The encrypted executable barely compresses, so code counts almost one to one while assets shrink a lot.
Measuring before upload: export a non-App Store build with thinning set to <thin-for-all-variants>; Xcode writes App Thinning Size Report.txt with both sizes per device variant.
Compare the oldest and newest supported devices separately; the variants differ more than people expect.
PrivacyInfo.xcprivacy is a plist bundled with the app or an SDK that declares what data it collects, whether it tracks, which domains it tracks through and why it calls "required reason" APIs. App Store Connect flags a build that uses those APIs without a declared reason, and you cannot ship it until the reason is declared.
Required reason APIs: UserDefaults, file timestamps, system boot time, free disk space and active keyboards. Each category needs an approved reason code, such as CA92.1 for reading the app's own UserDefaults. Without it App Store Connect flags the build with ITMS-91053, "Missing API declaration", and you cannot ship it until the reason is declared.
Tracking: NSPrivacyTracking and NSPrivacyTrackingDomains. When the user has not allowed tracking, iOS blocks connections to the listed domains.
Collected data: NSPrivacyCollectedDataTypes. Xcode merges the manifests of the app and every SDK into a privacy report (Organizer ▸ Generate Privacy Report), which helps fill in the App Store privacy label.
Third-party SDKs ship their own manifest; the app's manifest does not cover them. SDKs on Apple's list of commonly used SDKs must also be signed.
**Not the same as Info.plist**: usage strings like NSCameraUsageDescription are the text of the permission prompt, and accessing the camera without one crashes the app.
You archive a Release build, sign it for distribution and upload it to App Store Connect, where it is processed and can go to TestFlight testers; then you attach it to an app version, submit it for App Review and release it manually, automatically or as a phased rollout.
The path
Archive and upload: Product ▸ Archive, then the Organizer, Transporter, xcodebuild -exportArchive with destination set to upload, or fastlane. The build number must be unique within the version; processing takes minutes.
TestFlight: internal testers (up to 100 team members) get a build as soon as it is processed. External testers (up to 10,000, by email or public link) need Beta App Review for the first build of a version. Builds expire after 90 days.
App Review: needs metadata, screenshots, the privacy label, the export-compliance answer (ITSAppUsesNonExemptEncryption in Info.plist answers it for every build) and a demo account if the app requires login. Typical rejections: crashes, placeholder content, privacy issues, digital goods sold outside the rules.
Release: manual, automatic after approval, or a phased release over 7 days that only affects automatic updates and can be paused. Expedited review exists for critical fixes.
Requirements that move every year
Each spring Apple raises the minimum SDK for uploads: Xcode 16 with the iOS 18 SDK from April 2025, Xcode 26 with the iOS 26 SDK from April 2026.
Privacy manifests, required-reason declarations and signed third-party SDKs are checked at upload, not at review.
CI runs the same xcodebuild (usually through fastlane or Xcode Cloud) on a macOS runner, and the hard part is signing: the runner needs a distribution certificate's private key and a matching profile without anyone's Mac or personal Apple ID being involved.
The pipeline
On every pull request: resolve packages from the committed Package.resolved, build, run the test plan on a simulator, lint.
On main or a tag: set the build number, archive, upload to TestFlight, keep the dSYMs for the crash reporter.
Reproducibility: pin the Xcode version (xcode-select or DEVELOPER_DIR), use shared schemes, cache SwiftPM checkouts with -clonedSourcePackagesDirPath.
Signing options
**fastlane match**: certificates and profiles stored encrypted in a private git repo or bucket; CI runs it with readonly: true so it never creates or revokes anything; the project must then use manual signing with the matched profile (or call update_code_signing_settings).
Manual secrets: the .p12 and profile stored as encrypted CI secrets and imported into a temporary keychain (setup_ci or security create-keychain).
Cloud-managed: Xcode Cloud, or xcodebuild -allowProvisioningUpdates with an API key, lets Apple manage certificates.
Authenticate with an App Store Connect API key (.p8, key ID, issuer ID), not a person's Apple ID: no two-factor prompts, and it can be revoked on its own.
A static library is copied into the app binary at link time and costs nothing extra at launch, while every embedded dynamic framework is a separate Mach-O image that dyld must find, map, fix up and initialise before main: twelve frameworks, twelve times that work.
Static
A .a, or a SwiftPM library product with no explicit type, which is usually linked statically; set type: .static or .dynamic when it matters.
The linker can strip what nothing references.
The price: link the same static library into two images and you get two copies of its code and of its globals.
Dynamic
One image in Frameworks/, embedded and signed. Needed when an app extension and the app must share one copy of the code, and typical for binary SDKs.
Per image dyld pays a path search, mapping, code-signature checks, rebase and bind fixups, Objective-C class registration and initialisers.
Apple's own frameworks are nearly free because they are prelinked in the dyld shared cache. Yours are not.
Mergeable libraries (Xcode 15+)
Build the frameworks with MERGEABLE_LIBRARY = YES and set MERGED_BINARY_TYPE on the app target: Release merges them into the app binary as if linked statically, Debug keeps them separate so incremental links stay fast.
The trap is measuring a warm launch: dyld caches launch information after the first run, so measure a first launch after install or reboot with the App Launch template in Instruments. Static by default, dynamic only when the code must be shared at runtime, mergeable when you want many modules and one image.
Analytics is declared type: .static, so its code, including AnalyticsTracker.shared, is copied into both Feed and Profile. At runtime there are two independent singletons, and for Objective-C classes the console warns "Class X is implemented in both … One of the two will be used. Which one is undefined."
What it looks like: two caches, two feature-flag stores or two event queues that disagree, +load and global initialisers running twice, the same code paid for twice in size. Swift types produce no warning at all, so the bug is usually silent.
Xcode sometimes catches it: when Xcode targets link the same package product statically it reports that the product "is linked as a static library by" both and "this will result in duplication of library code".
Finding it
otool -L on the app binary and every embedded framework: the link graph as built.
nm -gU on each binary, grepping for a symbol from the suspect library: the same defined symbol in two images is the bug.
LD_GENERATE_MAP_FILE = YES and the link map show which object files went where; image list in lldb shows loaded images at runtime.
Fixing it
Exactly one image may contain the code. Either make everything static (type: .static on every product, as in the example) so only the app binary contains Analytics (or use mergeable libraries), or, if an extension must share it at runtime, give Analytics a single dynamic owner that the others link against. Stripping the duplicate symbol is not a fix: the two copies of state are still there.
Prove what is rebuilding before blaming the compiler: a one-line edit that costs minutes is far more often a dependency-graph problem (something forcing many files or modules to rebuild) than one slow file.
Measure
Product ▸ Perform Action ▸ Build With Timing Summary, or -showBuildTimingSummary, then the Build Timeline in the report navigator: every task, its duration, and whether the build ran in parallel or as one long serial chain.
Touch one file and count the SwiftCompile tasks. Hundreds for a one-line edit means the graph is wrong.
Only then look at source, with -warn-long-function-bodies and -warn-long-expression-type-checking.
Usual culprits
A Run Script phase without declared outputs, or a generator that rewrites a file on every build.
An edit to a public or @inlinable declaration in a module everyone imports, or a giant Core that every feature depends on: all dependants recompile. Interface modules shield callers from implementation edits.
A bridging header or a generated header imported everywhere.
A tall chain of modules each waiting for the previous one; a flatter graph compiles in parallel.
Release only: wholemodule re-optimises the whole module on any change (see the Debug and Release question); do not switch it off to save build time.
A few expressions the type checker struggles with (long + chains, heterogeneous literals). Annotate types or split them.
Measure honestly
Incremental, not clean; take the median of three runs and change one thing at a time: indexing and cold caches move the numbers.
public is a promise, not 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, which is the coupling the module was supposed to prevent.
The access levels, including the one people miss
internal (the default) covers the whole module; the right answer for almost everything.
package (Swift 5.9) is visible to every target in the same package but not to its clients. This is what used to force people into public: a helper two targets of one package share.
public is importable by clients; classes still cannot be subclassed outside the module.
open is public plus subclassable and overridable. Widening public to open later is safe; narrowing back breaks clients.
What a feature module should vend
One entry point (a View or a coordinator) plus the value types in its signature and the protocols it needs injected. Everything else stays internal.
Memberwise and default initialisers are internal, so a public struct cannot be constructed from outside until you write a public init.
Tests need no widening: @testable import already sees internal.
@_exported import Networking looks like a fix and is a trap: clients compile without importing Networking, until two modules export the same name or you try to drop the dependency and find every client leaning on it. It is an underscored, unsupported attribute.
Neither feature imports the other: move the contract they need into an interface module that both depend on, and let the app's composition root bind the implementations.
Why SwiftPM is right to refuse
The target graph must be acyclic, so a cycle is a hard error. Inside one big target the same coupling compiled happily, which is how it grew; the module boundary is doing its job by making it visible.
Three ways out, in order of preference
Interface split: ProfileInterface holds protocols, route types and plain values and depends on nothing. Profile implements it, Checkout depends only on ProfileInterface, and the app links both. Bonus: editing an implementation no longer recompiles its callers.
A routing registry in Core: features register handlers for typed routes at launch. Uniform and cheap, but wiring errors move from compile time to runtime, so test the real composition root.
Push the shared thing down: if both need Address, it is a domain model, not a feature: it belongs in Core and the cycle disappears.
Fake fixes
Injecting a closure whose parameter type still comes from the other module: the import is still there.
An interface module that grows view models and UI is the implementation under another name; keep it to protocols, identifiers and plain values.
If two features must talk, what they talk about belongs to neither: below them in Core, or above them in the app.
ABI stability lets a compiled binary run against the OS's Swift runtime; module stability lets a binary framework be imported by a newer compiler. BUILD_LIBRARY_FOR_DISTRIBUTION buys the second through library evolution, and turns every public declaration into a promise.
Two stabilities, often confused
ABI stability: shipped with Swift 5 on Apple platforms. You get it for free.
Module stability: without it, a binary .swiftmodule is readable only by the exact compiler that produced it, and the next Xcode fails with "module compiled with Swift X cannot be imported by the Swift Y compiler". The setting makes the compiler emit a textual .swiftinterface instead.
What resilience costs
Clients no longer bake your types' layout into their code: public structs and enums are reached through accessors, so field access is a call the optimiser cannot see through.
Your non-frozen public enum may gain cases, so clients get a warning unless they add @unknown default.
@frozen hands the layout back and makes an enum exhaustive, forever. @inlinable copies a function body into clients, so old apps keep running the old logic after you ship a fix.
Every module in your public signatures must be built for distribution too.
Turning it on is easy; turning it off after clients shipped is a breaking change. Source dependencies leave it off; anything shipped as a binary turns it on from day one.
Generators replace the conflict-prone project.pbxproj with a short, reviewable manifest. Migrate in one mechanical, provable step (generate the same project, prove it builds an identical app, delete the .xcodeproj from git) and restructure targets only afterwards.
Why teams adopt them
No more pbxproj merge conflicts: the project is a build artifact, regenerated on checkout.
The module graph is code: targets, dependencies and settings follow shared conventions instead of 200 hand-clicked variations.
Tuist adds graph-aware extras such as selective testing and binary caching of modules.
The order that keeps it reversible
Move build settings into .xcconfig files first; the manifest stays thin.
Describe the existing targets one for one: no renames, no new modules.
Add a CI job that regenerates from scratch and fails if a .xcodeproj is still tracked, then gitignore it.
Only now split, merge or move targets, one pull request at a time.
Proving it worked
xcodebuild -showBuildSettings per target and configuration, diffed before and after.
The built .app compared entry by entry, plus otool -L to catch a framework that quietly stopped linking.
A tag on the last commit with the checked-in project: that is the rollback.
Pure SwiftPM is not the whole answer (it has no iOS app or extension products), so the shell stays a (generated) Xcode project while features become packages. The real risk is social: someone edits settings in Xcode's UI and the next generate drops them; the CI check is what makes the new habit stick.