You run flutter build ipa. Minutes later, you have an archive ready for App Store submission. Between your Dart source code and the app running on a user's iPhone, the code undergoes a series of transformations — compiled, linked, signed, archived, uploaded, processed, and delivered.
This post traces the complete pipeline, from the build command to the moment the app runs on the user's device. Every step maps to concepts from this series — the kernel, the launch process, the sandbox, and the code signing that ties it all together.
Phase 1: Dart AOT compilation
The first stage is identical in purpose to the Android build (Android series, Post 10): the Dart AOT compiler transforms your Dart source into native machine code.
flutter build ipa --releaseThe Dart AOT compiler (gen_snapshot) runs on your development machine and produces:
- Native ARM64 code — your Dart functions compiled to Apple Silicon instructions. The instruction set is the same ARM64 as Android, but the binary format and calling conventions differ (iOS uses the Apple ARM64 ABI, Android uses the Linux ARM64 ABI).
- A Dart heap snapshot — compile-time constants and pre-allocated objects, serialised for efficient loading.
- Metadata — stack trace information, type metadata, GC metadata.
The output is packaged as App.framework — an iOS framework bundle containing the compiled Dart code. This is equivalent to Android's libapp.so, but packaged in Apple's framework format rather than a bare shared library.
On iOS, there's no libapp.so. The compiled Dart code is in:
App.framework/
├── App (the Mach-O binary with your Dart code)
├── Info.plist (framework metadata)
└── _CodeSignature/ (code signature)The binary format is Mach-O (Mach Object), the native executable format for Apple platforms. Where Android uses ELF (Executable and Linkable Format), iOS uses Mach-O. Different headers, different segment structures, same purpose: a container for compiled machine code and data.
Phase 2: Xcode build
After Dart compilation, Flutter invokes Xcode's build system (xcodebuild) to handle the iOS-specific build steps. This is where iOS and Android builds diverge significantly — Android uses Gradle, iOS uses Xcode.
Swift/Objective-C compilation
Your AppDelegate.swift, any plugin Swift/Objective-C code, and the Flutter embedding code are compiled by Xcode's Swift and Clang compilers into Mach-O binaries.
For a typical Flutter app, the native code is thin:
// AppDelegate.swift
import UIKit
import Flutter
@main
class AppDelegate: FlutterAppDelegate {
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
GeneratedPluginRegistrant.register(with: self)
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
}But plugins can contribute substantial native code. A camera plugin includes Swift code for AVFoundation camera interaction. A maps plugin includes the Google Maps SDK framework. The total native code can be significant.
Framework embedding
The Flutter engine (Flutter.framework), your Dart code (App.framework), and plugin frameworks are embedded in the app bundle. Xcode copies them to the Frameworks/ directory inside the .app bundle and ensures they're signed.
YourApp.app/
├── YourApp (main executable — thin, loads Flutter)
├── Info.plist (app configuration)
├── Assets.car (compiled asset catalog — icons, launch images)
├── Base.lproj/
│ └── LaunchScreen.storyboardc (compiled launch storyboard)
├── Frameworks/
│ ├── Flutter.framework/ (Flutter engine, ~15MB)
│ ├── App.framework/ (your Dart code, ~5-15MB)
│ ├── camera_avfoundation.framework/ (plugin)
│ ├── firebase_core.framework/ (plugin)
│ └── ...
├── flutter_assets/ (images, fonts, Dart assets)
├── _CodeSignature/ (code signature)
└── embedded.mobileprovision (provisioning profile)Asset compilation
Xcode compiles the asset catalog (.xcassets) into a binary format (Assets.car) that's optimised for the target device. App icons at multiple resolutions, launch images, and any native image assets are included.
Flutter assets (from your pubspec.yaml assets: section) are copied to flutter_assets/ by the Flutter build tool, separate from Xcode's asset catalog. The Flutter engine loads these at runtime using its own asset loading mechanism, not UIKit's.
Phase 3: Code signing
Every component in the app bundle must be signed (Post 8):
- Plugin frameworks are signed individually with your team's code signing identity.
- Flutter.framework and App.framework are signed.
- The main executable is signed.
- The app bundle as a whole is signed, including the embedded provisioning profile.
The signing process:
- Xcode selects the signing certificate and provisioning profile (automatic signing uses the first matching profile; manual signing uses the profile you specify).
- Each framework and executable is signed with
codesign, which computes per-page hashes (code directory hashes) and embeds them with the certificate. - The entitlements file is embedded in the signature of the main executable.
- The provisioning profile is copied into the bundle as
embedded.mobileprovision.
If automatic signing is enabled (the default for development), Xcode manages certificates and profiles through your Apple Developer account. For production builds, you typically use a distribution certificate and an App Store provisioning profile.
Phase 4: Archive and export
flutter build ipa creates an Xcode archive — a .xcarchive bundle containing the signed app, debug symbols, and build metadata. The archive is then exported as an IPA (iOS App Archive) — a ZIP file with a specific structure:
YourApp.ipa
└── Payload/
└── YourApp.app/
└── (everything from Phase 2)The IPA is what you upload to App Store Connect (for distribution) or install directly on a device (for ad-hoc testing).
Debug symbols (dSYMs)
Alongside the archive, Xcode produces dSYM files — debug symbol bundles that map memory addresses to source code locations. These are essential for crash report symbolication.
For Flutter, there are multiple dSYM files:
- YourApp.app.dSYM — symbols for the main executable and native code
- App.framework.dSYM — this won't contain Dart symbols (Dart uses its own symbol format)
Dart crash symbolication uses the --split-debug-info output — a separate symbol map that flutter symbolize uses to convert Dart stack traces from memory addresses to function names and line numbers. Upload these symbols to your crash reporting service (Firebase Crashlytics, Sentry) for readable Dart crash reports.
Phase 5: App Store processing
When you upload the IPA to App Store Connect (via xcrun altool, Transporter, or Xcode's Organizer), Apple's systems process it:
App thinning
Apple generates device-specific variants of your app. The universal IPA you uploaded contains assets for all devices, but each user downloads only what their device needs:
- Slicing — removes assets for other device classes. iPhone users don't download iPad-specific assets. 2x display users don't download 3x assets.
- On-demand resources — if configured, large resources are hosted by Apple and downloaded when needed, not at install time.
The Flutter-specific content (Flutter.framework, App.framework, flutter_assets/) is ARM64 only (32-bit iOS devices haven't been supported since iOS 11), so there's no architecture slicing for these.
Bitcode (historical)
Apple previously required Bitcode — an intermediate representation that allowed Apple to recompile your app for new architectures. As of Xcode 14, Bitcode is deprecated and no longer accepted. Flutter never supported Bitcode for its native libraries, which was a recurring friction point when Bitcode was required. This is no longer relevant.
Automated review checks
App Store Connect runs automated checks before the app reaches human review:
- Crash detection — the app is launched on automated test devices to check for immediate crashes.
- Privacy manifest validation — starting in 2024, apps must declare which APIs they use that Apple considers privacy-sensitive (file timestamps, user defaults, disk space).
- Entitlement validation — the claimed entitlements are checked against the provisioning profile and the developer account's capabilities.
- Binary scanning — the binary is scanned for use of private APIs (undocumented Apple APIs that could break in future OS versions).
Flutter apps occasionally fail binary scanning when a plugin uses a private API. This results in a rejection email from App Store Connect listing the private API symbols found. The fix is to update the plugin or contact the plugin author.
Phase 6: Installation on the user's device
When the user downloads your app from the App Store:
- The device downloads the thinned IPA — only the variant for their specific device.
- The system verifies the code signature — Apple re-signed the app during processing, so the device trusts the signature.
- The app is installed to
/private/var/containers/Bundle/Application/<UUID>/. - A data container is created at
/private/var/mobile/Containers/Data/Application/<UUID>/. - Metal shader compilation — on first install, the system may pre-compile Metal shaders for the specific GPU. Impeller's precompiled shaders are in Metal IR format; the system converts them to the device's GPU-specific machine code.
Phase 7: First launch
The launch path from Post 2 executes:
[0ms] User taps app icon
└─ SpringBoard sends launch request to launchd
[~5ms] launchd: posix_spawn() creates new process
└─ Kernel allocates Mach task, BSD process, address space
[~10ms] dyld starts
└─ Maps dyld shared cache (system frameworks — instant, already mapped)
└─ Loads Flutter.framework (~80-150ms)
└─ Loads App.framework (~50-100ms)
└─ Loads plugin frameworks
└─ Runs initialisers
[~200ms] main() → UIApplicationMain → AppDelegate
└─ Launch storyboard is visible to user
[~250ms] FlutterAppDelegate.didFinishLaunchingWithOptions:
└─ FlutterEngine created
└─ Dart VM starts
└─ App.framework loaded into Dart VM
[~350ms] Dart main() executes
└─ runApp(MyApp())
└─ Build → Layout → Paint
[~500ms] First Flutter frame rendered
└─ Impeller → Metal → GPU → CAMetalLayer
└─ Core Animation composites → display
└─ Launch storyboard transitions outiOS vs Android build pipeline
The Dart compilation phase is nearly identical — the same gen_snapshot compiler, the same AOT process, just targeting different binary formats. The divergence is in the platform-specific wrapping: Gradle vs Xcode, APK signing vs Apple code signing, Play Store vs App Store.
Common build issues on iOS
"No valid code signing identity." Your development or distribution certificate is expired, missing, or not in Keychain Access. Refresh via Xcode → Settings → Accounts, or regenerate in the Apple Developer portal.
"Provisioning profile doesn't include capability." You added a capability (push notifications, app groups) in your entitlements but the provisioning profile wasn't regenerated to include it. In Xcode → Signing & Capabilities, toggle the capability off and on, or use automatic signing to let Xcode manage it.
"Module 'plugin_name' not found." A CocoaPods or Swift Package Manager dependency wasn't properly installed. Run cd ios && pod install --repo-update.
"Bitcode not supported." An older plugin includes Bitcode-enabled binaries. Update the plugin or add ENABLE_BITCODE = NO to your Xcode build settings (this is the default for new Flutter projects).
Large IPA size. Check flutter build ipa --analyze-size. Common culprits: uncompressed assets in flutter_assets/, large plugin native libraries, or debug symbols accidentally included in the release build. The Flutter engine alone is ~15MB; a minimal Flutter app's IPA is ~25-30MB.
App Store rejection: "Non-public API usage." A plugin uses an undocumented Apple API. The rejection email lists the offending symbols. Update the plugin, or if you maintain it, replace the private API usage with a public alternative.
The complete chain
From your Dart source to pixels on a user's iPhone:
Dart source (.dart files)
→ gen_snapshot (AOT compiler) → App.framework (Mach-O, ARM64)
Swift plugin code (.swift files)
→ Xcode Swift compiler → plugin.framework (Mach-O)
Flutter engine (prebuilt by Google)
→ Flutter.framework (Mach-O, ARM64)
Assets (images, fonts)
→ Copied to flutter_assets/
All of the above → .app bundle (signed)
→ .xcarchive → .ipa → uploaded to App Store Connect
→ App thinning (per-device optimisation)
→ Review (automated + human)
→ Published to App Store
User downloads and installs
→ Code signature verified
→ App bundle placed in container
→ Data container created
→ Metal shaders finalised
User taps icon
→ launchd → posix_spawn → dyld
→ Loads shared cache (system frameworks)
→ Loads Flutter.framework, App.framework
→ main() → FlutterAppDelegate
→ FlutterEngine → Dart VM
→ main() → runApp() → build → layout → paint
→ Impeller → Metal → GPU → Core Animation → display
→ Photons leave the OLED panelTen posts, from the hybrid kernel that runs beneath everything, through the launch process, lifecycle, memory management, threading, IPC, rendering, security, networking, and now the build pipeline. Each layer exists to solve a specific problem, and each was built by different teams at different times, solving different constraints. Your Flutter app sits on all of them — using their guarantees, respecting their limits, and ultimately producing pixels on a display from a Widget build() method that knows nothing about any of it.
This is Post 10 of the iOS Under the Surface series. Previous: Networking: From Dart to the Wire. First post: XNU: The Hybrid Kernel.*