文章 代码库 城市生活记忆 Claude Skill AI分享 问龙虾
返回 Claude Skill

iPhone 应用开发

iOS 应用开发全栈指南,SwiftUI、UIKit 和应用架构

开发 社区公开 by Community

<essential_principles>

How We Work

The user is the product owner. Claude is the developer.

The user does not write code. The user does not read code. The user describes what they want and judges whether the result is acceptable. Claude implements, verifies, and reports outcomes.

1. Prove, Don’t Promise

Never say “this should work.” Prove it:

xcodebuild -destination 'platform=iOS Simulator,name=iPhone 16' build 2>&1 | xcsift
xcodebuild test -destination 'platform=iOS Simulator,name=iPhone 16'
xcrun simctl boot "iPhone 16" && xcrun simctl launch booted com.app.bundle

If you didn’t run it, you don’t know it works.

2. Tests for Correctness, Eyes for Quality

QuestionHow to Answer
Does the logic work?Write test, see it pass
Does it look right?Launch in simulator, user looks at it
Does it feel right?User uses it
Does it crash?Test + launch
Is it fast enough?Profiler

Tests verify correctness. The user verifies desirability.

3. Report Outcomes, Not Code

Bad: “I refactored DataService to use async/await with weak self capture” Good: “Fixed the memory leak. leaks now shows 0 leaks. App tested stable for 5 minutes.”

The user doesn’t care what you changed. The user cares what’s different.

4. Small Steps, Always Verified

Change → Verify → Report → Next change

Never batch up work. Never say “I made several changes.” Each change is verified before the next. If something breaks, you know exactly what caused it.

5. Ask Before, Not After

Unclear requirement? Ask now. Multiple valid approaches? Ask which. Scope creep? Ask if wanted. Big refactor needed? Ask permission.

Wrong: Build for 30 minutes, then “is this what you wanted?” Right: “Before I start, does X mean Y or Z?“

6. Always Leave It Working

Every stopping point = working state. Tests pass, app launches, changes committed. The user can walk away anytime and come back to something that works. </essential_principles>

**Ask the user:**

What would you like to do?

  1. Build a new app
  2. Debug an existing app
  3. Add a feature
  4. Write/run tests
  5. Optimize performance
  6. Ship/release
  7. Something else

Then read the matching workflow from workflows/ and follow it.

| Response | Workflow | |----------|----------| | 1, "new", "create", "build", "start" | `workflows/build-new-app.md` | | 2, "broken", "fix", "debug", "crash", "bug" | `workflows/debug-app.md` | | 3, "add", "feature", "implement", "change" | `workflows/add-feature.md` | | 4, "test", "tests", "TDD", "coverage" | `workflows/write-tests.md` | | 5, "slow", "optimize", "performance", "fast" | `workflows/optimize-performance.md` | | 6, "ship", "release", "TestFlight", "App Store" | `workflows/ship-app.md` | | 7, other | Clarify, then select workflow or references |

<verification_loop>

After Every Change

# 1. Does it build?
xcodebuild -scheme AppName -destination 'platform=iOS Simulator,name=iPhone 16' build 2>&1 | xcsift

# 2. Do tests pass?
xcodebuild -scheme AppName -destination 'platform=iOS Simulator,name=iPhone 16' test

# 3. Does it launch? (if UI changed)
xcrun simctl boot "iPhone 16" 2>/dev/null || true
xcrun simctl install booted ./build/Build/Products/Debug-iphonesimulator/AppName.app
xcrun simctl launch booted com.company.AppName

Report to the user:

  • “Build: ✓”
  • “Tests: 12 pass, 0 fail”
  • “App launches in simulator, ready for you to check [specific thing]” </verification_loop>

<when_to_test>

Testing Decision

Write a test when:

  • Logic that must be correct (calculations, transformations, rules)
  • State changes (add, delete, update operations)
  • Edge cases that could break (nil, empty, boundaries)
  • Bug fix (test reproduces bug, then proves it’s fixed)
  • Refactoring (tests prove behavior unchanged)

Skip tests when:

  • Pure UI exploration (“make it blue and see if I like it”)
  • Rapid prototyping (“just get something on screen”)
  • Subjective quality (“does this feel right?”)
  • One-off verification (launch and check manually)

The principle: Tests let the user verify correctness without reading code. If the user needs to verify it works, and it’s not purely visual, write a test. </when_to_test>

<reference_index>

Domain Knowledge

All in references/:

Architecture: app-architecture, swiftui-patterns, navigation-patterns Data: data-persistence, networking Platform Features: push-notifications, storekit, background-tasks Quality: polish-and-ux, accessibility, performance Assets & Security: app-icons, security, app-store Development: project-scaffolding, cli-workflow, cli-observability, testing, ci-cd </reference_index>

<workflows_index>

Workflows

All in workflows/:

FilePurpose
build-new-app.mdCreate new iOS app from scratch
debug-app.mdFind and fix bugs
add-feature.mdAdd to existing app
write-tests.mdWrite and run tests
optimize-performance.mdProfile and speed up
ship-app.mdTestFlight, App Store submission
</workflows_index>

Reference: Accessibility

Accessibility

VoiceOver, Dynamic Type, and inclusive design for iOS apps.

VoiceOver Support

Basic Labels

struct ItemRow: View {
    let item: Item

    var body: some View {
        HStack {
            Image(systemName: item.icon)
                .accessibilityHidden(true)  // Icon is decorative

            VStack(alignment: .leading) {
                Text(item.name)
                Text(item.date, style: .date)
                    .font(.caption)
                    .foregroundStyle(.secondary)
            }

            Spacer()

            if item.isCompleted {
                Image(systemName: "checkmark")
                    .accessibilityHidden(true)
            }
        }
        .accessibilityElement(children: .combine)
        .accessibilityLabel("\(item.name), \(item.isCompleted ? "completed" : "incomplete")")
        .accessibilityHint("Double tap to view details")
    }
}

Custom Actions

struct ItemRow: View {
    let item: Item
    let onDelete: () -> Void
    let onToggle: () -> Void

    var body: some View {
        HStack {
            Text(item.name)
        }
        .accessibilityElement(children: .combine)
        .accessibilityLabel(item.name)
        .accessibilityAction(named: "Toggle completion") {
            onToggle()
        }
        .accessibilityAction(named: "Delete") {
            onDelete()
        }
    }
}

Traits

Text("Important Notice")
    .accessibilityAddTraits(.isHeader)

Button("Submit") { }
    .accessibilityAddTraits(.startsMediaSession)

Image("photo")
    .accessibilityAddTraits(.isImage)

Link("Learn more", destination: url)
    .accessibilityAddTraits(.isLink)

Toggle("Enable", isOn: $isEnabled)
    .accessibilityAddTraits(isEnabled ? .isSelected : [])

Announcements

// Announce changes
func saveCompleted() {
    AccessibilityNotification.Announcement("Item saved successfully").post()
}

// Screen change
func showNewScreen() {
    AccessibilityNotification.ScreenChanged(nil).post()
}

// Layout change
func expandSection() {
    isExpanded = true
    AccessibilityNotification.LayoutChanged(nil).post()
}

Rotor Actions

struct ArticleView: View {
    @State private var fontSize: CGFloat = 16

    var body: some View {
        Text(article.content)
            .font(.system(size: fontSize))
            .accessibilityAdjustableAction { direction in
                switch direction {
                case .increment:
                    fontSize = min(fontSize + 2, 32)
                case .decrement:
                    fontSize = max(fontSize - 2, 12)
                @unknown default:
                    break
                }
            }
    }
}

Dynamic Type

Scaled Fonts

// System fonts scale automatically
Text("Title")
    .font(.title)

Text("Body")
    .font(.body)

// Custom fonts with scaling
Text("Custom")
    .font(.custom("Helvetica", size: 17, relativeTo: .body))

// Fixed size (use sparingly)
Text("Fixed")
    .font(.system(size: 12).fixed())

Scaled Metrics

struct IconButton: View {
    @ScaledMetric var iconSize: CGFloat = 24
    @ScaledMetric(relativeTo: .body) var spacing: CGFloat = 8

    var body: some View {
        HStack(spacing: spacing) {
            Image(systemName: "star")
                .font(.system(size: iconSize))
            Text("Favorite")
        }
    }
}

Line Limits with Accessibility

Text(item.description)
    .lineLimit(3)
    .truncationMode(.tail)
    // But allow more for accessibility sizes
    .dynamicTypeSize(...DynamicTypeSize.accessibility1)

Testing Dynamic Type

#Preview("Default") {
    ContentView()
}

#Preview("Large") {
    ContentView()
        .environment(\.sizeCategory, .accessibilityLarge)
}

#Preview("Extra Extra Large") {
    ContentView()
        .environment(\.sizeCategory, .accessibilityExtraExtraLarge)
}

Reduce Motion

struct AnimatedView: View {
    @Environment(\.accessibilityReduceMotion) private var reduceMotion
    @State private var isExpanded = false

    var body: some View {
        VStack {
            // Content
        }
        .animation(reduceMotion ? .none : .spring(), value: isExpanded)
    }
}

// Alternative animations
struct TransitionView: View {
    @Environment(\.accessibilityReduceMotion) private var reduceMotion
    @State private var showDetail = false

    var body: some View {
        VStack {
            if showDetail {
                DetailView()
                    .transition(reduceMotion ? .opacity : .slide)
            }
        }
        .animation(.default, value: showDetail)
    }
}

Color and Contrast

Semantic Colors

// Use semantic colors that adapt
Text("Primary")
    .foregroundStyle(.primary)

Text("Secondary")
    .foregroundStyle(.secondary)

Text("Tertiary")
    .foregroundStyle(.tertiary)

// Error state
Text("Error")
    .foregroundStyle(.red)  // Use semantic red, not custom

Increase Contrast

struct ContrastAwareView: View {
    @Environment(\.accessibilityDifferentiateWithoutColor) private var differentiateWithoutColor
    @Environment(\.accessibilityIncreaseContrast) private var increaseContrast

    var body: some View {
        HStack {
            Circle()
                .fill(increaseContrast ? .primary : .secondary)

            if differentiateWithoutColor {
                // Add non-color indicator
                Image(systemName: "checkmark")
            }
        }
    }
}

Color Blind Support

struct StatusIndicator: View {
    let status: Status
    @Environment(\.accessibilityDifferentiateWithoutColor) private var differentiateWithoutColor

    var body: some View {
        HStack {
            Circle()
                .fill(status.color)
                .frame(width: 10, height: 10)

            if differentiateWithoutColor {
                Image(systemName: status.icon)
            }

            Text(status.label)
        }
    }
}

enum Status {
    case success, warning, error

    var color: Color {
        switch self {
        case .success: return .green
        case .warning: return .orange
        case .error: return .red
        }
    }

    var icon: String {
        switch self {
        case .success: return "checkmark.circle"
        case .warning: return "exclamationmark.triangle"
        case .error: return "xmark.circle"
        }
    }

    var label: String {
        switch self {
        case .success: return "Success"
        case .warning: return "Warning"
        case .error: return "Error"
        }
    }
}

Focus Management

Focus State

struct LoginView: View {
    @State private var username = ""
    @State private var password = ""
    @FocusState private var focusedField: Field?

    enum Field {
        case username, password
    }

    var body: some View {
        Form {
            TextField("Username", text: $username)
                .focused($focusedField, equals: .username)
                .submitLabel(.next)
                .onSubmit {
                    focusedField = .password
                }

            SecureField("Password", text: $password)
                .focused($focusedField, equals: .password)
                .submitLabel(.done)
                .onSubmit {
                    login()
                }
        }
        .onAppear {
            focusedField = .username
        }
    }
}

Accessibility Focus

struct AlertView: View {
    @AccessibilityFocusState private var isAlertFocused: Bool

    var body: some View {
        VStack {
            Text("Important Alert")
                .accessibilityFocused($isAlertFocused)
        }
        .onAppear {
            isAlertFocused = true
        }
    }
}

Button Shapes

struct AccessibleButton: View {
    @Environment(\.accessibilityShowButtonShapes) private var showButtonShapes

    var body: some View {
        Button("Action") { }
            .padding()
            .background(showButtonShapes ? Color.accentColor.opacity(0.1) : Color.clear)
            .clipShape(RoundedRectangle(cornerRadius: 8))
    }
}

Smart Invert Colors

Image("photo")
    .accessibilityIgnoresInvertColors()  // Photos shouldn't invert

Audit Checklist

VoiceOver

  • All interactive elements have labels
  • Decorative elements are hidden
  • Custom actions for swipe gestures
  • Headings marked correctly
  • Announcements for dynamic changes

Dynamic Type

  • All text uses dynamic fonts
  • Layout adapts to large sizes
  • No text truncation at accessibility sizes
  • Touch targets remain accessible (44pt minimum)

Color and Contrast

  • 4.5:1 contrast ratio for text
  • Information not conveyed by color alone
  • Works with Increase Contrast
  • Works with Smart Invert

Motion

  • Animations respect Reduce Motion
  • No auto-playing animations
  • Alternative interactions for gesture-only features

General

  • All functionality available via VoiceOver
  • Logical focus order
  • Error messages are accessible
  • Time limits are adjustable

Testing Tools

Accessibility Inspector

  1. Open Xcode > Open Developer Tool > Accessibility Inspector
  2. Point at elements to inspect labels, traits, hints
  3. Run audit for common issues

VoiceOver Practice

  1. Settings > Accessibility > VoiceOver
  2. Use with your app
  3. Navigate by swiping, double-tap to activate

Voice Control

  1. Settings > Accessibility > Voice Control
  2. Test all interactions with voice commands

Xcode Previews

#Preview {
    ContentView()
        .environment(\.sizeCategory, .accessibilityExtraExtraExtraLarge)
        .environment(\.accessibilityReduceMotion, true)
        .environment(\.accessibilityDifferentiateWithoutColor, true)
}

Reference: App Architecture

App Architecture

State management, dependency injection, and architectural patterns for iOS apps.

State Management

@Observable (iOS 17+)

The modern approach for shared state:

@Observable
class AppState {
    var items: [Item] = []
    var selectedItemID: UUID?
    var isLoading = false
    var error: AppError?

    // Computed properties work naturally
    var selectedItem: Item? {
        items.first { $0.id == selectedItemID }
    }

    var hasItems: Bool { !items.isEmpty }
}

// In views - only re-renders when used properties change
struct ContentView: View {
    @Environment(AppState.self) private var appState

    var body: some View {
        if appState.isLoading {
            ProgressView()
        } else {
            ItemList(items: appState.items)
        }
    }
}

Two-Way Bindings

For binding to @Observable properties:

struct SettingsView: View {
    @Environment(AppState.self) private var appState

    var body: some View {
        @Bindable var appState = appState

        Form {
            TextField("Username", text: $appState.username)
            Toggle("Notifications", isOn: $appState.notificationsEnabled)
        }
    }
}

State Decision Tree

@State - View-local UI state

  • Toggle expanded/collapsed
  • Text field content
  • Sheet presentation
struct ItemRow: View {
    @State private var isExpanded = false

    var body: some View {
        VStack {
            // ...
        }
    }
}

@Observable in Environment - Shared app state

  • User session
  • Navigation state
  • Feature flags
@main
struct MyApp: App {
    @State private var appState = AppState()

    var body: some Scene {
        WindowGroup {
            ContentView()
                .environment(appState)
        }
    }
}

@Query - SwiftData persistence

  • Database entities
  • Filtered/sorted queries
struct ItemList: View {
    @Query(sort: \Item.createdAt, order: .reverse)
    private var items: [Item]

    var body: some View {
        List(items) { item in
            ItemRow(item: item)
        }
    }
}

Dependency Injection

Environment Keys

Define environment keys for testable dependencies:

// Protocol for testability
protocol NetworkServiceProtocol {
    func fetch<T: Decodable>(_ endpoint: Endpoint) async throws -> T
}

// Live implementation
class LiveNetworkService: NetworkServiceProtocol {
    func fetch<T: Decodable>(_ endpoint: Endpoint) async throws -> T {
        // Real implementation
    }
}

// Mock for testing
class MockNetworkService: NetworkServiceProtocol {
    var mockResult: Any?
    var mockError: Error?

    func fetch<T: Decodable>(_ endpoint: Endpoint) async throws -> T {
        if let error = mockError { throw error }
        return mockResult as! T
    }
}

// Environment key
struct NetworkServiceKey: EnvironmentKey {
    static let defaultValue: NetworkServiceProtocol = LiveNetworkService()
}

extension EnvironmentValues {
    var networkService: NetworkServiceProtocol {
        get { self[NetworkServiceKey.self] }
        set { self[NetworkServiceKey.self] = newValue }
    }
}

// Inject at app level
@main
struct MyApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
                .environment(\.networkService, LiveNetworkService())
        }
    }
}

// Use in views
struct ItemList: View {
    @Environment(\.networkService) private var networkService

    var body: some View {
        // ...
    }

    func loadItems() async {
        let items: [Item] = try await networkService.fetch(.items)
    }
}

Dependency Container

For complex apps with many dependencies:

@Observable
class AppDependencies {
    let network: NetworkServiceProtocol
    let storage: StorageServiceProtocol
    let purchases: PurchaseServiceProtocol
    let analytics: AnalyticsServiceProtocol

    init(
        network: NetworkServiceProtocol = LiveNetworkService(),
        storage: StorageServiceProtocol = LiveStorageService(),
        purchases: PurchaseServiceProtocol = LivePurchaseService(),
        analytics: AnalyticsServiceProtocol = LiveAnalyticsService()
    ) {
        self.network = network
        self.storage = storage
        self.purchases = purchases
        self.analytics = analytics
    }

    // Convenience for testing
    static func mock() -> AppDependencies {
        AppDependencies(
            network: MockNetworkService(),
            storage: MockStorageService(),
            purchases: MockPurchaseService(),
            analytics: MockAnalyticsService()
        )
    }
}

// Inject as single environment object
@main
struct MyApp: App {
    @State private var dependencies = AppDependencies()

    var body: some Scene {
        WindowGroup {
            ContentView()
                .environment(dependencies)
        }
    }
}

View Models (When Needed)

For views with significant logic, use a view-local model:

struct ItemDetailScreen: View {
    let itemID: UUID
    @State private var viewModel: ItemDetailViewModel

    init(itemID: UUID) {
        self.itemID = itemID
        self._viewModel = State(initialValue: ItemDetailViewModel(itemID: itemID))
    }

    var body: some View {
        Form {
            if viewModel.isLoading {
                ProgressView()
            } else if let item = viewModel.item {
                ItemContent(item: item)
            }
        }
        .task {
            await viewModel.load()
        }
    }
}

@Observable
class ItemDetailViewModel {
    let itemID: UUID
    var item: Item?
    var isLoading = false
    var error: Error?

    init(itemID: UUID) {
        self.itemID = itemID
    }

    func load() async {
        isLoading = true
        defer { isLoading = false }

        do {
            item = try await fetchItem(id: itemID)
        } catch {
            self.error = error
        }
    }

    func save() async {
        // Save logic
    }
}

Coordinator Pattern

For complex navigation flows:

@Observable
class OnboardingCoordinator {
    var currentStep: OnboardingStep = .welcome
    var isComplete = false

    enum OnboardingStep {
        case welcome
        case permissions
        case personalInfo
        case complete
    }

    func next() {
        switch currentStep {
        case .welcome:
            currentStep = .permissions
        case .permissions:
            currentStep = .personalInfo
        case .personalInfo:
            currentStep = .complete
            isComplete = true
        case .complete:
            break
        }
    }

    func back() {
        switch currentStep {
        case .welcome:
            break
        case .permissions:
            currentStep = .welcome
        case .personalInfo:
            currentStep = .permissions
        case .complete:
            currentStep = .personalInfo
        }
    }
}

struct OnboardingFlow: View {
    @State private var coordinator = OnboardingCoordinator()

    var body: some View {
        Group {
            switch coordinator.currentStep {
            case .welcome:
                WelcomeView(onContinue: coordinator.next)
            case .permissions:
                PermissionsView(onContinue: coordinator.next, onBack: coordinator.back)
            case .personalInfo:
                PersonalInfoView(onContinue: coordinator.next, onBack: coordinator.back)
            case .complete:
                CompletionView()
            }
        }
        .animation(.default, value: coordinator.currentStep)
    }
}

Error Handling

Structured Error Types

enum AppError: LocalizedError {
    case networkError(NetworkError)
    case storageError(StorageError)
    case validationError(String)
    case unauthorized
    case unknown(Error)

    var errorDescription: String? {
        switch self {
        case .networkError(let error):
            return error.localizedDescription
        case .storageError(let error):
            return error.localizedDescription
        case .validationError(let message):
            return message
        case .unauthorized:
            return "Please sign in to continue"
        case .unknown(let error):
            return error.localizedDescription
        }
    }

    var recoverySuggestion: String? {
        switch self {
        case .networkError:
            return "Check your internet connection and try again"
        case .unauthorized:
            return "Tap to sign in"
        default:
            return nil
        }
    }
}

enum NetworkError: LocalizedError {
    case noConnection
    case timeout
    case serverError(Int)
    case decodingError

    var errorDescription: String? {
        switch self {
        case .noConnection:
            return "No internet connection"
        case .timeout:
            return "Request timed out"
        case .serverError(let code):
            return "Server error (\(code))"
        case .decodingError:
            return "Invalid response from server"
        }
    }
}

Error Presentation

struct ContentView: View {
    @Environment(AppState.self) private var appState

    var body: some View {
        NavigationStack {
            // Content
        }
        .alert(
            "Error",
            isPresented: Binding(
                get: { appState.error != nil },
                set: { if !$0 { appState.error = nil } }
            ),
            presenting: appState.error
        ) { error in
            Button("OK") { }
            if error.recoverySuggestion != nil {
                Button("Retry") {
                    Task { await retry() }
                }
            }
        } message: { error in
            VStack {
                Text(error.localizedDescription)
                if let suggestion = error.recoverySuggestion {
                    Text(suggestion)
                        .font(.caption)
                }
            }
        }
    }
}

Testing Architecture

Unit Testing with Mocks

@Test
func testLoadItems() async throws {
    // Arrange
    let mockNetwork = MockNetworkService()
    mockNetwork.mockResult = [Item(name: "Test")]

    let viewModel = ItemListViewModel(networkService: mockNetwork)

    // Act
    await viewModel.load()

    // Assert
    #expect(viewModel.items.count == 1)
    #expect(viewModel.items[0].name == "Test")
    #expect(viewModel.isLoading == false)
}

@Test
func testLoadItemsError() async throws {
    // Arrange
    let mockNetwork = MockNetworkService()
    mockNetwork.mockError = NetworkError.noConnection

    let viewModel = ItemListViewModel(networkService: mockNetwork)

    // Act
    await viewModel.load()

    // Assert
    #expect(viewModel.items.isEmpty)
    #expect(viewModel.error != nil)
}

Preview with Dependencies

#Preview {
    ContentView()
        .environment(AppDependencies.mock())
        .environment(AppState())
}

Reference: App Icons

App Icons

Complete guide for generating, configuring, and managing iOS app icons from the CLI.

Quick Start (Xcode 14+)

The simplest approach—provide a single 1024×1024 PNG and let Xcode auto-generate all sizes:

  1. Create Assets.xcassets/AppIcon.appiconset/
  2. Add your 1024×1024 PNG
  3. Create Contents.json with single-size configuration
{
  "images": [
    {
      "filename": "icon-1024.png",
      "idiom": "universal",
      "platform": "ios",
      "size": "1024x1024"
    }
  ],
  "info": {
    "author": "xcode",
    "version": 1
  }
}

The system auto-generates all required device sizes from this single image.

CLI Icon Generation

Using sips (Built into macOS)

Generate all required sizes from a 1024×1024 source:

#!/bin/bash
# generate-app-icons.sh
# Usage: ./generate-app-icons.sh source.png output-dir

SOURCE="$1"
OUTPUT="${2:-AppIcon.appiconset}"

mkdir -p "$OUTPUT"

# Generate all required sizes
sips -z 1024 1024 "$SOURCE" --out "$OUTPUT/icon-1024.png"
sips -z 180 180 "$SOURCE" --out "$OUTPUT/icon-180.png"
sips -z 167 167 "$SOURCE" --out "$OUTPUT/icon-167.png"
sips -z 152 152 "$SOURCE" --out "$OUTPUT/icon-152.png"
sips -z 120 120 "$SOURCE" --out "$OUTPUT/icon-120.png"
sips -z 87 87 "$SOURCE" --out "$OUTPUT/icon-87.png"
sips -z 80 80 "$SOURCE" --out "$OUTPUT/icon-80.png"
sips -z 76 76 "$SOURCE" --out "$OUTPUT/icon-76.png"
sips -z 60 60 "$SOURCE" --out "$OUTPUT/icon-60.png"
sips -z 58 58 "$SOURCE" --out "$OUTPUT/icon-58.png"
sips -z 40 40 "$SOURCE" --out "$OUTPUT/icon-40.png"
sips -z 29 29 "$SOURCE" --out "$OUTPUT/icon-29.png"
sips -z 20 20 "$SOURCE" --out "$OUTPUT/icon-20.png"

echo "Generated icons in $OUTPUT"

Using ImageMagick

#!/bin/bash
# Requires: brew install imagemagick

SOURCE="$1"
OUTPUT="${2:-AppIcon.appiconset}"

mkdir -p "$OUTPUT"

for size in 1024 180 167 152 120 87 80 76 60 58 40 29 20; do
  convert "$SOURCE" -resize "${size}x${size}!" "$OUTPUT/icon-$size.png"
done

Complete Contents.json (All Sizes)

For manual size control or when not using single-size mode:

{
  "images": [
    {
      "filename": "icon-1024.png",
      "idiom": "ios-marketing",
      "scale": "1x",
      "size": "1024x1024"
    },
    {
      "filename": "icon-180.png",
      "idiom": "iphone",
      "scale": "3x",
      "size": "60x60"
    },
    {
      "filename": "icon-120.png",
      "idiom": "iphone",
      "scale": "2x",
      "size": "60x60"
    },
    {
      "filename": "icon-87.png",
      "idiom": "iphone",
      "scale": "3x",
      "size": "29x29"
    },
    {
      "filename": "icon-58.png",
      "idiom": "iphone",
      "scale": "2x",
      "size": "29x29"
    },
    {
      "filename": "icon-120.png",
      "idiom": "iphone",
      "scale": "3x",
      "size": "40x40"
    },
    {
      "filename": "icon-80.png",
      "idiom": "iphone",
      "scale": "2x",
      "size": "40x40"
    },
    {
      "filename": "icon-60.png",
      "idiom": "iphone",
      "scale": "3x",
      "size": "20x20"
    },
    {
      "filename": "icon-40.png",
      "idiom": "iphone",
      "scale": "2x",
      "size": "20x20"
    },
    {
      "filename": "icon-167.png",
      "idiom": "ipad",
      "scale": "2x",
      "size": "83.5x83.5"
    },
    {
      "filename": "icon-152.png",
      "idiom": "ipad",
      "scale": "2x",
      "size": "76x76"
    },
    {
      "filename": "icon-76.png",
      "idiom": "ipad",
      "scale": "1x",
      "size": "76x76"
    },
    {
      "filename": "icon-80.png",
      "idiom": "ipad",
      "scale": "2x",
      "size": "40x40"
    },
    {
      "filename": "icon-40.png",
      "idiom": "ipad",
      "scale": "1x",
      "size": "40x40"
    },
    {
      "filename": "icon-58.png",
      "idiom": "ipad",
      "scale": "2x",
      "size": "29x29"
    },
    {
      "filename": "icon-29.png",
      "idiom": "ipad",
      "scale": "1x",
      "size": "29x29"
    },
    {
      "filename": "icon-40.png",
      "idiom": "ipad",
      "scale": "2x",
      "size": "20x20"
    },
    {
      "filename": "icon-20.png",
      "idiom": "ipad",
      "scale": "1x",
      "size": "20x20"
    }
  ],
  "info": {
    "author": "xcode",
    "version": 1
  }
}

Required Sizes Reference

PurposeSize (pt)ScalePixelsDevice
App Store1024×10241x1024Marketing
Home Screen60×603x180iPhone
Home Screen60×602x120iPhone
Home Screen83.5×83.52x167iPad Pro
Home Screen76×762x152iPad
Spotlight40×403x120iPhone
Spotlight40×402x80iPhone/iPad
Settings29×293x87iPhone
Settings29×292x58iPhone/iPad
Notification20×203x60iPhone
Notification20×202x40iPhone/iPad

iOS 18 Dark Mode & Tinted Icons

iOS 18 adds appearance variants: Any (default), Dark, and Tinted.

Asset Structure

Create three versions of each icon:

  • icon-1024.png - Standard (Any appearance)
  • icon-1024-dark.png - Dark mode variant
  • icon-1024-tinted.png - Tinted variant

Dark Mode Design

  • Use transparent background (system provides dark fill)
  • Keep foreground elements recognizable
  • Lighten foreground colors for contrast against dark background
  • Or provide full icon with dark-tinted background

Tinted Design

  • Must be grayscale, fully opaque
  • System applies user’s tint color over the grayscale
  • Use gradient background: #313131 (top) to #141414 (bottom)

Contents.json with Appearances

{
  "images": [
    {
      "filename": "icon-1024.png",
      "idiom": "universal",
      "platform": "ios",
      "size": "1024x1024"
    },
    {
      "appearances": [
        {
          "appearance": "luminosity",
          "value": "dark"
        }
      ],
      "filename": "icon-1024-dark.png",
      "idiom": "universal",
      "platform": "ios",
      "size": "1024x1024"
    },
    {
      "appearances": [
        {
          "appearance": "luminosity",
          "value": "tinted"
        }
      ],
      "filename": "icon-1024-tinted.png",
      "idiom": "universal",
      "platform": "ios",
      "size": "1024x1024"
    }
  ],
  "info": {
    "author": "xcode",
    "version": 1
  }
}

Alternate App Icons

Allow users to choose between different app icons.

Setup

  1. Add alternate icon sets to asset catalog
  2. Configure build setting in project.pbxproj:
ASSETCATALOG_COMPILER_ALTERNATE_APPICON_NAMES = "DarkIcon ColorfulIcon";

Or add icons loose in project with @2x/@3x naming and configure Info.plist:

<key>CFBundleIcons</key>
<dict>
    <key>CFBundleAlternateIcons</key>
    <dict>
        <key>DarkIcon</key>
        <dict>
            <key>CFBundleIconFiles</key>
            <array>
                <string>DarkIcon</string>
            </array>
        </dict>
        <key>ColorfulIcon</key>
        <dict>
            <key>CFBundleIconFiles</key>
            <array>
                <string>ColorfulIcon</string>
            </array>
        </dict>
    </dict>
    <key>CFBundlePrimaryIcon</key>
    <dict>
        <key>CFBundleIconFiles</key>
        <array>
            <string>AppIcon</string>
        </array>
    </dict>
</dict>

SwiftUI Implementation

import SwiftUI

enum AppIcon: String, CaseIterable, Identifiable {
    case primary = "AppIcon"
    case dark = "DarkIcon"
    case colorful = "ColorfulIcon"

    var id: String { rawValue }

    var displayName: String {
        switch self {
        case .primary: return "Default"
        case .dark: return "Dark"
        case .colorful: return "Colorful"
        }
    }

    var iconName: String? {
        self == .primary ? nil : rawValue
    }
}

@Observable
class IconManager {
    var currentIcon: AppIcon = .primary

    init() {
        if let iconName = UIApplication.shared.alternateIconName,
           let icon = AppIcon(rawValue: iconName) {
            currentIcon = icon
        }
    }

    func setIcon(_ icon: AppIcon) async throws {
        guard UIApplication.shared.supportsAlternateIcons else {
            throw IconError.notSupported
        }

        try await UIApplication.shared.setAlternateIconName(icon.iconName)
        currentIcon = icon
    }

    enum IconError: LocalizedError {
        case notSupported

        var errorDescription: String? {
            "This device doesn't support alternate icons"
        }
    }
}

struct IconPickerView: View {
    @Environment(IconManager.self) private var iconManager
    @State private var error: Error?

    var body: some View {
        List(AppIcon.allCases) { icon in
            Button {
                Task {
                    do {
                        try await iconManager.setIcon(icon)
                    } catch {
                        self.error = error
                    }
                }
            } label: {
                HStack {
                    // Preview image (add to asset catalog)
                    Image("\(icon.rawValue)-preview")
                        .resizable()
                        .frame(width: 60, height: 60)
                        .clipShape(RoundedRectangle(cornerRadius: 12))

                    Text(icon.displayName)

                    Spacer()

                    if iconManager.currentIcon == icon {
                        Image(systemName: "checkmark")
                            .foregroundStyle(.blue)
                    }
                }
            }
            .buttonStyle(.plain)
        }
        .navigationTitle("App Icon")
        .alert("Error", isPresented: .constant(error != nil)) {
            Button("OK") { error = nil }
        } message: {
            if let error {
                Text(error.localizedDescription)
            }
        }
    }
}

Design Guidelines

Technical Requirements

  • Format: PNG, non-interlaced
  • Transparency: Not allowed (fully opaque)
  • Shape: Square with 90° corners
  • Color Space: sRGB or Display P3
  • Minimum: 1024×1024 for App Store

Design Constraints

  1. No rounded corners - System applies mask automatically
  2. No text unless essential to brand identity
  3. No photos or screenshots - Too detailed at small sizes
  4. No drop shadows or gloss - System may add effects
  5. No Apple hardware - Copyright protected
  6. No SF Symbols - Prohibited in icons/logos

Safe Zone

The system mask cuts corners using a superellipse shape. Keep critical elements away from edges.

Corner radius formula: 10/57 × icon_size

  • 57px icon = 10px radius
  • 1024px icon ≈ 180px radius

Test at Small Sizes

Your icon must be recognizable at 29×29 pixels (Settings icon size). If details are lost, simplify the design.

Troubleshooting

”Missing Marketing Icon” Error

Ensure you have a 1024×1024 icon with idiom ios-marketing in Contents.json.

Icon Has Transparency

App Store rejects icons with alpha channels. Check with:

sips -g hasAlpha icon-1024.png

Remove alpha channel:

sips -s format png -s formatOptions 0 icon-1024.png --out icon-1024-opaque.png

Or with ImageMagick:

convert icon-1024.png -background white -alpha remove -alpha off icon-1024-opaque.png

Interlaced PNG Error

Convert to non-interlaced:

convert icon-1024.png -interlace none icon-1024.png

Rounded Corners Look Wrong

Never pre-round your icon. Provide square corners and let iOS apply the mask. Pre-rounding causes visual artifacts where the mask doesn’t align.

Complete Generation Script

One-command generation for a new project:

#!/bin/bash
# setup-app-icon.sh
# Usage: ./setup-app-icon.sh source.png project-path

SOURCE="$1"
PROJECT="${2:-.}"
ICONSET="$PROJECT/Assets.xcassets/AppIcon.appiconset"

mkdir -p "$ICONSET"

# Generate 1024x1024 (single-size mode)
sips -z 1024 1024 "$SOURCE" --out "$ICONSET/icon-1024.png"

# Remove alpha channel if present
sips -s format png -s formatOptions 0 "$ICONSET/icon-1024.png" --out "$ICONSET/icon-1024.png"

# Generate Contents.json for single-size mode
cat > "$ICONSET/Contents.json" << 'EOF'
{
  "images": [
    {
      "filename": "icon-1024.png",
      "idiom": "universal",
      "platform": "ios",
      "size": "1024x1024"
    }
  ],
  "info": {
    "author": "xcode",
    "version": 1
  }
}
EOF

echo "App icon configured at $ICONSET"

Reference: App Store

App Store Submission

App Review guidelines, privacy requirements, and submission checklist.

Pre-Submission Checklist

App Completion

  • All features working
  • No crashes or major bugs
  • Performance optimized
  • Memory leaks resolved

Content Requirements

  • App icon (1024x1024)
  • Screenshots for all device sizes
  • App preview videos (optional)
  • Description and keywords
  • Privacy policy URL
  • Support URL

Technical Requirements

  • Minimum iOS version set correctly
  • Privacy manifest (PrivacyInfo.xcprivacy)
  • All permissions have usage descriptions
  • Export compliance answered
  • Content rights declared

Screenshots

Required Sizes

iPhone 6.9" (iPhone 16 Pro Max): 1320 x 2868
iPhone 6.7" (iPhone 15 Plus): 1290 x 2796
iPhone 6.5" (iPhone 11 Pro Max): 1284 x 2778
iPhone 5.5" (iPhone 8 Plus): 1242 x 2208

iPad Pro 13" (6th gen): 2064 x 2752
iPad Pro 12.9" (2nd gen): 2048 x 2732

Automating Screenshots

With fastlane:

# Fastfile
lane :screenshots do
  capture_screenshots(
    scheme: "MyAppUITests",
    devices: [
      "iPhone 16 Pro Max",
      "iPhone 8 Plus",
      "iPad Pro (12.9-inch) (6th generation)"
    ],
    languages: ["en-US", "es-ES"],
    output_directory: "./screenshots"
  )
end

Snapfile:

devices([
  "iPhone 16 Pro Max",
  "iPhone 8 Plus",
  "iPad Pro (12.9-inch) (6th generation)"
])

languages(["en-US"])
scheme("MyAppUITests")
output_directory("./screenshots")
clear_previous_screenshots(true)

UI Test for screenshots:

import XCTest

class ScreenshotTests: XCTestCase {
    override func setUpWithError() throws {
        continueAfterFailure = false
        let app = XCUIApplication()
        setupSnapshot(app)
        app.launch()
    }

    func testScreenshots() {
        snapshot("01-HomeScreen")

        // Navigate to feature
        app.buttons["Feature"].tap()
        snapshot("02-FeatureScreen")

        // Show detail
        app.cells.firstMatch.tap()
        snapshot("03-DetailScreen")
    }
}

Privacy Policy

Required Elements

  1. What data is collected
  2. How it’s used
  3. Who it’s shared with
  4. How long it’s retained
  5. User rights (access, deletion)
  6. Contact information

Template Structure

# Privacy Policy for [App Name]

Last updated: [Date]

## Information We Collect
- Account information (email, name)
- Usage data (features used, session duration)

## How We Use Information
- Provide app functionality
- Improve user experience
- Send notifications (with permission)

## Data Sharing
We do not sell your data. We share with:
- Analytics providers (anonymized)
- Cloud storage providers

## Data Retention
We retain data while your account is active.
Request deletion at [email].

## Your Rights
- Access your data
- Request deletion
- Export your data

## Contact
[email]

App Review Guidelines

Common Rejections

1. Incomplete Information

  • Missing demo account credentials
  • Unclear functionality

2. Bugs and Crashes

  • App crashes on launch
  • Features don’t work

3. Placeholder Content

  • Lorem ipsum text
  • Incomplete UI

4. Privacy Issues

  • Missing usage descriptions
  • Accessing data without permission

5. Misleading Metadata

  • Screenshots don’t match app
  • Description claims unavailable features

Demo Account

In App Store Connect notes:

Demo Account:
Username: [email protected]
Password: Demo123!

Notes:
- Subscription features are enabled
- Push notifications require real device

Review Notes

Notes for Review:

1. This app requires camera access for QR scanning (Settings tab > Scan QR).

2. Push notifications are used for:
   - Order status updates
   - New message alerts

3. Background location is used for:
   - Delivery tracking only when order is active

4. Demo account has pre-populated data for testing.

5. In-app purchases can be tested with sandbox account.

Export Compliance

Quick Check

Answer YES to export compliance if your app:

  • Only uses HTTPS for network requests
  • Only uses Apple’s standard encryption APIs
  • Only uses encryption for authentication/DRM

Most apps using HTTPS only can answer YES and select that encryption is exempt.

Full Compliance

If using custom encryption, you need:

  • Encryption Registration Number (ERN) from BIS
  • Or exemption documentation

App Privacy Labels

In App Store Connect, declare:

Data Types

  • Contact Info (name, email, phone)
  • Health & Fitness
  • Financial Info
  • Location
  • Browsing History
  • Search History
  • Identifiers (user ID, device ID)
  • Usage Data
  • Diagnostics

Data Use

For each data type:

  • Linked to User: Can identify the user
  • Used for Tracking: Cross-app/web advertising

Example Declaration

Contact Info - Email Address:
- Used for: App Functionality (account creation)
- Linked to User: Yes
- Used for Tracking: No

Usage Data:
- Used for: Analytics
- Linked to User: No
- Used for Tracking: No

In-App Purchases

Configuration

  1. App Store Connect > Features > In-App Purchases
  2. Create products with:
    • Reference name
    • Product ID (com.app.product)
    • Price
    • Localized display name/description

Review Screenshots

Provide screenshots showing:

  • Purchase screen
  • Content being purchased
  • Restore purchases option

Subscription Guidelines

  • Clear pricing shown before purchase
  • Easy cancellation instructions
  • Terms of service link
  • Restore purchases available

TestFlight

Internal Testing

  • Up to 100 internal testers
  • No review required
  • Immediate availability

External Testing

  • Up to 10,000 testers
  • Beta App Review required
  • Public link option

Test Notes

What to Test:
- New feature: Cloud sync
- Bug fix: Login issues on iOS 18
- Performance improvements

Known Issues:
- Widget may not update immediately
- Dark mode icon pending

Submission Process

1. Archive

xcodebuild archive \
    -project MyApp.xcodeproj \
    -scheme MyApp \
    -archivePath build/MyApp.xcarchive

2. Export

xcodebuild -exportArchive \
    -archivePath build/MyApp.xcarchive \
    -exportOptionsPlist ExportOptions.plist \
    -exportPath build/

3. Upload

xcrun altool --upload-app \
    --type ios \
    --file build/MyApp.ipa \
    --apiKey YOUR_KEY_ID \
    --apiIssuer YOUR_ISSUER_ID

4. Submit

  1. App Store Connect > Select build
  2. Complete all metadata
  3. Submit for Review

Post-Submission

Review Timeline

  • Average: 24-48 hours
  • First submission: May take longer
  • Complex apps: May need more review

Responding to Rejection

  1. Read rejection carefully
  2. Address ALL issues
  3. Reply in Resolution Center
  4. Resubmit

Expedited Review

Request for:

  • Critical bug fixes
  • Time-sensitive events
  • Security issues

Submit request at: https://developer.apple.com/contact/app-store/?topic=expedite

Phased Release

After approval, choose:

  • Immediate: Available to everyone
  • Phased: 7 days gradual rollout
    • Day 1: 1%
    • Day 2: 2%
    • Day 3: 5%
    • Day 4: 10%
    • Day 5: 20%
    • Day 6: 50%
    • Day 7: 100%

Can pause or accelerate at any time.

Version Updates

What’s New

Version 2.1

New:
• Cloud sync across devices
• Dark mode support
• Widget for home screen

Improved:
• Faster app launch
• Better search results

Fixed:
• Login issues on iOS 18
• Notification sound not playing

Maintaining Multiple Versions

  • Keep previous version available during review
  • Test backward compatibility
  • Consider forced updates for critical fixes

Reference: Background Tasks

Background Tasks

BGTaskScheduler, background fetch, and silent push for background processing.

BGTaskScheduler

Setup

  1. Add capability: Background Modes
  2. Enable: Background fetch, Background processing
  3. Register identifiers in Info.plist:
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
    <string>com.app.refresh</string>
    <string>com.app.processing</string>
</array>

Registration

import BackgroundTasks

@main
struct MyApp: App {
    init() {
        registerBackgroundTasks()
    }

    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }

    private func registerBackgroundTasks() {
        // App Refresh - for frequent, short updates
        BGTaskScheduler.shared.register(
            forTaskWithIdentifier: "com.app.refresh",
            using: nil
        ) { task in
            guard let task = task as? BGAppRefreshTask else { return }
            handleAppRefresh(task: task)
        }

        // Processing - for longer, deferrable work
        BGTaskScheduler.shared.register(
            forTaskWithIdentifier: "com.app.processing",
            using: nil
        ) { task in
            guard let task = task as? BGProcessingTask else { return }
            handleProcessing(task: task)
        }
    }
}

App Refresh Task

Short tasks that need to run frequently:

func handleAppRefresh(task: BGAppRefreshTask) {
    // Schedule next refresh
    scheduleAppRefresh()

    // Create task
    let refreshTask = Task {
        do {
            try await syncLatestData()
            task.setTaskCompleted(success: true)
        } catch {
            task.setTaskCompleted(success: false)
        }
    }

    // Handle expiration
    task.expirationHandler = {
        refreshTask.cancel()
    }
}

func scheduleAppRefresh() {
    let request = BGAppRefreshTaskRequest(identifier: "com.app.refresh")
    request.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60)  // 15 minutes

    do {
        try BGTaskScheduler.shared.submit(request)
    } catch {
        print("Could not schedule app refresh: \(error)")
    }
}

private func syncLatestData() async throws {
    // Fetch new data from server
    // Update local database
    // Badge update if needed
}

Processing Task

Longer tasks that can be deferred:

func handleProcessing(task: BGProcessingTask) {
    // Schedule next
    scheduleProcessing()

    let processingTask = Task {
        do {
            try await performHeavyWork()
            task.setTaskCompleted(success: true)
        } catch {
            task.setTaskCompleted(success: false)
        }
    }

    task.expirationHandler = {
        processingTask.cancel()
    }
}

func scheduleProcessing() {
    let request = BGProcessingTaskRequest(identifier: "com.app.processing")
    request.earliestBeginDate = Date(timeIntervalSinceNow: 60 * 60)  // 1 hour
    request.requiresNetworkConnectivity = true
    request.requiresExternalPower = false

    do {
        try BGTaskScheduler.shared.submit(request)
    } catch {
        print("Could not schedule processing: \(error)")
    }
}

private func performHeavyWork() async throws {
    // Database maintenance
    // Large file uploads
    // ML model training
    // Cache cleanup
}

Background URLSession

For large uploads/downloads that continue when app is suspended:

class BackgroundDownloadService: NSObject {
    static let shared = BackgroundDownloadService()

    private lazy var session: URLSession = {
        let config = URLSessionConfiguration.background(
            withIdentifier: "com.app.background.download"
        )
        config.isDiscretionary = true  // System chooses best time
        config.sessionSendsLaunchEvents = true  // Wake app on completion

        return URLSession(
            configuration: config,
            delegate: self,
            delegateQueue: nil
        )
    }()

    private var completionHandler: (() -> Void)?

    func download(from url: URL) {
        let task = session.downloadTask(with: url)
        task.resume()
    }

    func handleEventsForBackgroundURLSession(
        identifier: String,
        completionHandler: @escaping () -> Void
    ) {
        self.completionHandler = completionHandler
    }
}

extension BackgroundDownloadService: URLSessionDownloadDelegate {
    func urlSession(
        _ session: URLSession,
        downloadTask: URLSessionDownloadTask,
        didFinishDownloadingTo location: URL
    ) {
        // Move file to permanent location
        let documentsURL = FileManager.default.urls(
            for: .documentDirectory,
            in: .userDomainMask
        ).first!
        let destinationURL = documentsURL.appendingPathComponent("downloaded.file")

        try? FileManager.default.moveItem(at: location, to: destinationURL)
    }

    func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) {
        DispatchQueue.main.async {
            self.completionHandler?()
            self.completionHandler = nil
        }
    }
}

// In AppDelegate
func application(
    _ application: UIApplication,
    handleEventsForBackgroundURLSession identifier: String,
    completionHandler: @escaping () -> Void
) {
    BackgroundDownloadService.shared.handleEventsForBackgroundURLSession(
        identifier: identifier,
        completionHandler: completionHandler
    )
}

Silent Push Notifications

Trigger background work from server:

Configuration

Entitlements:

<key>UIBackgroundModes</key>
<array>
    <string>remote-notification</string>
</array>

Handling

// In AppDelegate
func application(
    _ application: UIApplication,
    didReceiveRemoteNotification userInfo: [AnyHashable: Any]
) async -> UIBackgroundFetchResult {
    guard let action = userInfo["action"] as? String else {
        return .noData
    }

    do {
        switch action {
        case "sync":
            try await syncData()
            return .newData
        case "refresh":
            try await refreshContent()
            return .newData
        default:
            return .noData
        }
    } catch {
        return .failed
    }
}

Payload

{
    "aps": {
        "content-available": 1
    },
    "action": "sync",
    "data": {
        "lastUpdate": "2025-01-01T00:00:00Z"
    }
}

Location Updates

Background location monitoring:

import CoreLocation

class LocationService: NSObject, CLLocationManagerDelegate {
    private let manager = CLLocationManager()

    override init() {
        super.init()
        manager.delegate = self
        manager.allowsBackgroundLocationUpdates = true
        manager.pausesLocationUpdatesAutomatically = true
    }

    // Significant location changes (battery efficient)
    func startMonitoringSignificantChanges() {
        manager.startMonitoringSignificantLocationChanges()
    }

    // Region monitoring
    func monitorRegion(_ region: CLCircularRegion) {
        manager.startMonitoring(for: region)
    }

    // Continuous updates (high battery usage)
    func startContinuousUpdates() {
        manager.desiredAccuracy = kCLLocationAccuracyBest
        manager.startUpdatingLocation()
    }

    func locationManager(
        _ manager: CLLocationManager,
        didUpdateLocations locations: [CLLocation]
    ) {
        guard let location = locations.last else { return }

        // Process location update
        Task {
            try? await uploadLocation(location)
        }
    }

    func locationManager(
        _ manager: CLLocationManager,
        didEnterRegion region: CLRegion
    ) {
        // Handle region entry
    }
}

Background Audio

For audio playback while app is in background:

import AVFoundation

class AudioService {
    private var player: AVAudioPlayer?

    func configureAudioSession() throws {
        let session = AVAudioSession.sharedInstance()
        try session.setCategory(.playback, mode: .default)
        try session.setActive(true)
    }

    func play(url: URL) throws {
        player = try AVAudioPlayer(contentsOf: url)
        player?.play()
    }
}

Testing Background Tasks

Simulate in Debugger

// Pause in debugger, then:
e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"com.app.refresh"]

Force Early Execution

#if DEBUG
func debugScheduleRefresh() {
    let request = BGAppRefreshTaskRequest(identifier: "com.app.refresh")
    request.earliestBeginDate = Date(timeIntervalSinceNow: 1)  // 1 second for testing

    try? BGTaskScheduler.shared.submit(request)
}
#endif

Best Practices

Battery Efficiency

// Use discretionary for non-urgent work
let config = URLSessionConfiguration.background(withIdentifier: "com.app.upload")
config.isDiscretionary = true  // Wait for good network/power conditions

// Require power for heavy work
let request = BGProcessingTaskRequest(identifier: "com.app.process")
request.requiresExternalPower = true

Respect User Settings

func scheduleRefreshIfAllowed() {
    // Check if user has Low Power Mode
    if ProcessInfo.processInfo.isLowPowerModeEnabled {
        // Reduce frequency or skip
        return
    }

    // Check background refresh status
    switch UIApplication.shared.backgroundRefreshStatus {
    case .available:
        scheduleAppRefresh()
    case .denied, .restricted:
        // Inform user if needed
        break
    @unknown default:
        break
    }
}

Handle Expiration

Always handle task expiration:

func handleTask(_ task: BGTask) {
    let operation = Task {
        // Long running work
    }

    // CRITICAL: Always set expiration handler
    task.expirationHandler = {
        operation.cancel()
        // Clean up
        // Save progress
    }
}

Progress Persistence

Save progress so you can resume:

func performIncrementalSync(task: BGTask) async {
    // Load progress
    let lastSyncDate = UserDefaults.standard.object(forKey: "lastSyncDate") as? Date ?? .distantPast

    do {
        // Sync from last position
        let newDate = try await syncSince(lastSyncDate)

        // Save progress
        UserDefaults.standard.set(newDate, forKey: "lastSyncDate")

        task.setTaskCompleted(success: true)
    } catch {
        task.setTaskCompleted(success: false)
    }
}

Debugging

Check Scheduled Tasks

BGTaskScheduler.shared.getPendingTaskRequests { requests in
    for request in requests {
        print("Pending: \(request.identifier)")
        print("Earliest: \(request.earliestBeginDate ?? Date())")
    }
}

Cancel Tasks

// Cancel specific
BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: "com.app.refresh")

// Cancel all
BGTaskScheduler.shared.cancelAllTaskRequests()

Console Logs

# View background task logs
log stream --predicate 'subsystem == "com.apple.BackgroundTasks"' --level debug

Reference: Ci Cd

CI/CD

Xcode Cloud, fastlane, and automated testing and deployment.

Xcode Cloud

Setup

  1. Enable in Xcode: Product > Xcode Cloud > Create Workflow
  2. Configure in App Store Connect

Basic Workflow

# Configured in Xcode Cloud UI
Workflow: Build and Test
Start Conditions:
  - Push to main
  - Pull Request to main

Actions:
  - Build
  - Test (iOS Simulator)

Post-Actions:
  - Notify (Slack)

Custom Build Scripts

.ci_scripts/ci_post_clone.sh:

#!/bin/bash
set -e

# Install dependencies
brew install swiftlint

# Generate files
cd $CI_PRIMARY_REPOSITORY_PATH
./scripts/generate-assets.sh

.ci_scripts/ci_pre_xcodebuild.sh:

#!/bin/bash
set -e

# Run SwiftLint
swiftlint lint --strict --reporter json > swiftlint-report.json || true

# Check for errors
if grep -q '"severity": "error"' swiftlint-report.json; then
    echo "SwiftLint errors found"
    exit 1
fi

Environment Variables

Set in Xcode Cloud:

  • API_BASE_URL
  • SENTRY_DSN
  • Secrets (automatically masked)

Access in build:

let apiURL = Bundle.main.infoDictionary?["API_BASE_URL"] as? String

Fastlane

Installation

# Install
brew install fastlane

# Or via bundler
bundle init
echo 'gem "fastlane"' >> Gemfile
bundle install

Fastfile

fastlane/Fastfile:

default_platform(:ios)

platform :ios do
  desc "Run tests"
  lane :test do
    run_tests(
      scheme: "MyApp",
      device: "iPhone 16",
      code_coverage: true
    )
  end

  desc "Build and upload to TestFlight"
  lane :beta do
    # Increment build number
    increment_build_number(
      build_number: latest_testflight_build_number + 1
    )

    # Build
    build_app(
      scheme: "MyApp",
      export_method: "app-store"
    )

    # Upload
    upload_to_testflight(
      skip_waiting_for_build_processing: true
    )

    # Notify
    slack(
      message: "New build uploaded to TestFlight!",
      slack_url: ENV["SLACK_URL"]
    )
  end

  desc "Deploy to App Store"
  lane :release do
    # Ensure clean git
    ensure_git_status_clean

    # Build
    build_app(
      scheme: "MyApp",
      export_method: "app-store"
    )

    # Upload
    upload_to_app_store(
      submit_for_review: true,
      automatic_release: true,
      force: true,
      precheck_include_in_app_purchases: false
    )

    # Tag
    add_git_tag(
      tag: "v#{get_version_number}"
    )
    push_git_tags
  end

  desc "Sync certificates and profiles"
  lane :sync_signing do
    match(
      type: "appstore",
      readonly: true
    )
    match(
      type: "development",
      readonly: true
    )
  end

  desc "Take screenshots"
  lane :screenshots do
    capture_screenshots(
      scheme: "MyAppUITests"
    )
    frame_screenshots(
      white: true
    )
  end
end

Match (Code Signing)

fastlane/Matchfile:

git_url("https://github.com/yourcompany/certificates")
storage_mode("git")
type("appstore")
app_identifier(["com.yourcompany.app"])
username("[email protected]")

Setup:

# Initialize
fastlane match init

# Generate certificates
fastlane match appstore
fastlane match development

Appfile

fastlane/Appfile:

app_identifier("com.yourcompany.app")
apple_id("[email protected]")
itc_team_id("123456")
team_id("ABCDEF1234")

GitHub Actions

Basic Workflow

.github/workflows/ci.yml:

name: CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: macos-14

    steps:
    - uses: actions/checkout@v4

    - name: Select Xcode
      run: sudo xcode-select -s /Applications/Xcode_15.4.app

    - name: Cache SPM
      uses: actions/cache@v3
      with:
        path: |
          ~/Library/Caches/org.swift.swiftpm
          .build
        key: ${{ runner.os }}-spm-${{ hashFiles('**/Package.resolved') }}

    - name: Build
      run: |
        xcodebuild build \
          -project MyApp.xcodeproj \
          -scheme MyApp \
          -destination 'platform=iOS Simulator,name=iPhone 16' \
          CODE_SIGNING_REQUIRED=NO

    - name: Test
      run: |
        xcodebuild test \
          -project MyApp.xcodeproj \
          -scheme MyApp \
          -destination 'platform=iOS Simulator,name=iPhone 16' \
          -resultBundlePath TestResults.xcresult \
          CODE_SIGNING_REQUIRED=NO

    - name: Upload Results
      if: always()
      uses: actions/upload-artifact@v3
      with:
        name: test-results
        path: TestResults.xcresult

  deploy:
    needs: test
    runs-on: macos-14
    if: github.ref == 'refs/heads/main'

    steps:
    - uses: actions/checkout@v4

    - name: Install Fastlane
      run: brew install fastlane

    - name: Deploy to TestFlight
      env:
        APP_STORE_CONNECT_API_KEY_KEY_ID: ${{ secrets.ASC_KEY_ID }}
        APP_STORE_CONNECT_API_KEY_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
        APP_STORE_CONNECT_API_KEY_KEY: ${{ secrets.ASC_KEY }}
        MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
        MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GIT_AUTH }}
      run: fastlane beta

Code Signing in CI

- name: Import Certificate
  env:
    CERTIFICATE_BASE64: ${{ secrets.CERTIFICATE_BASE64 }}
    CERTIFICATE_PASSWORD: ${{ secrets.CERTIFICATE_PASSWORD }}
    KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
  run: |
    # Create keychain
    security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
    security default-keychain -s build.keychain
    security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain

    # Import certificate
    echo "$CERTIFICATE_BASE64" | base64 --decode > certificate.p12
    security import certificate.p12 \
      -k build.keychain \
      -P "$CERTIFICATE_PASSWORD" \
      -T /usr/bin/codesign

    # Allow codesign access
    security set-key-partition-list \
      -S apple-tool:,apple:,codesign: \
      -s -k "$KEYCHAIN_PASSWORD" build.keychain

- name: Install Provisioning Profile
  env:
    PROVISIONING_PROFILE_BASE64: ${{ secrets.PROVISIONING_PROFILE_BASE64 }}
  run: |
    mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
    echo "$PROVISIONING_PROFILE_BASE64" | base64 --decode > profile.mobileprovision
    cp profile.mobileprovision ~/Library/MobileDevice/Provisioning\ Profiles/

Version Management

Automatic Versioning

# In Fastfile
lane :bump_version do |options|
  # Get version from tag or parameter
  version = options[:version] || git_tag_last_match(pattern: "v*").gsub("v", "")

  increment_version_number(
    version_number: version
  )

  increment_build_number(
    build_number: number_of_commits
  )
end

Semantic Versioning Script

#!/bin/bash
# scripts/bump-version.sh

TYPE=$1  # major, minor, patch
CURRENT=$(agvtool what-marketing-version -terse1)

IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT"

case $TYPE in
  major)
    MAJOR=$((MAJOR + 1))
    MINOR=0
    PATCH=0
    ;;
  minor)
    MINOR=$((MINOR + 1))
    PATCH=0
    ;;
  patch)
    PATCH=$((PATCH + 1))
    ;;
esac

NEW_VERSION="$MAJOR.$MINOR.$PATCH"
agvtool new-marketing-version $NEW_VERSION
echo "Version bumped to $NEW_VERSION"

Test Reporting

JUnit Format

xcodebuild test \
    -project MyApp.xcodeproj \
    -scheme MyApp \
    -destination 'platform=iOS Simulator,name=iPhone 16' \
    -resultBundlePath TestResults.xcresult

# Convert to JUnit
xcrun xcresulttool get --format json --path TestResults.xcresult > results.json
# Use xcresult-to-junit or similar tool

Code Coverage

# Generate coverage
xcodebuild test \
    -enableCodeCoverage YES \
    -resultBundlePath TestResults.xcresult

# Export coverage report
xcrun xccov view --report --json TestResults.xcresult > coverage.json

Slack Notifications

# In Fastfile
after_all do |lane|
  slack(
    message: "Successfully deployed to TestFlight",
    success: true,
    default_payloads: [:git_branch, :git_author]
  )
end

error do |lane, exception|
  slack(
    message: "Build failed: #{exception.message}",
    success: false
  )
end

App Store Connect API

Key Setup

  1. App Store Connect > Users and Access > Keys
  2. Generate Key with App Manager role
  3. Download .p8 file

Fastlane Configuration

fastlane/Appfile:

# Use API Key instead of password
app_store_connect_api_key(
  key_id: ENV["ASC_KEY_ID"],
  issuer_id: ENV["ASC_ISSUER_ID"],
  key_filepath: "./AuthKey.p8",
  in_house: false
)

Upload with altool

xcrun altool --upload-app \
    --type ios \
    --file build/MyApp.ipa \
    --apiKey $KEY_ID \
    --apiIssuer $ISSUER_ID

Best Practices

Secrets Management

  • Never commit secrets to git
  • Use environment variables or secret managers
  • Rotate keys regularly
  • Use match for certificate management

Build Caching

# Cache derived data
- uses: actions/cache@v3
  with:
    path: |
      ~/Library/Developer/Xcode/DerivedData
      ~/Library/Caches/org.swift.swiftpm
    key: ${{ runner.os }}-build-${{ hashFiles('**/*.swift') }}

Parallel Testing

run_tests(
  devices: ["iPhone 16", "iPad Pro (12.9-inch)"],
  parallel_testing: true,
  concurrent_workers: 4
)

Conditional Deploys

# Only deploy on version tags
on:
  push:
    tags:
      - 'v*'

Reference: Cli Observability

CLI Observability

Complete debugging and monitoring without opening Xcode. Claude has full visibility into build errors, runtime logs, crashes, memory issues, and network traffic.

```bash # Install observability tools (one-time) brew tap ldomaradzki/xcsift && brew install xcsift brew install mitmproxy xcbeautify ```

<build_output>

Build Error Parsing

xcsift converts verbose xcodebuild output to token-efficient JSON for AI agents:

xcodebuild -project MyApp.xcodeproj -scheme MyApp \
  -destination 'platform=iOS Simulator,name=iPhone 16' \
  build 2>&1 | xcsift

Output includes structured errors with file paths and line numbers:

{
  "status": "failed",
  "errors": [
    {"file": "/path/File.swift", "line": 42, "message": "Type mismatch..."}
  ]
}

Alternative (human-readable):

xcodebuild build 2>&1 | xcbeautify

</build_output>

<runtime_logging>

Runtime Logs

In-App Logging Pattern

Add to all apps:

import os

extension Logger {
    static let app = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "App")
    static let network = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "Network")
    static let data = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "Data")
}

// Usage
Logger.network.debug("Request: \(url)")
Logger.data.error("Save failed: \(error)")

Stream Logs from Simulator

# All logs from your app
xcrun simctl spawn booted log stream --level debug \
  --predicate 'subsystem == "com.yourcompany.MyApp"'

# Filter by category
xcrun simctl spawn booted log stream --level debug \
  --predicate 'subsystem == "com.yourcompany.MyApp" AND category == "Network"'

# Errors only
xcrun simctl spawn booted log stream \
  --predicate 'subsystem == "com.yourcompany.MyApp" AND messageType == error'

# JSON output for parsing
xcrun simctl spawn booted log stream --level debug --style json \
  --predicate 'subsystem == "com.yourcompany.MyApp"'

Search Historical Logs

# Collect logs from simulator
xcrun simctl spawn booted log collect --output sim_logs.logarchive

# Search collected logs
log show sim_logs.logarchive --predicate 'subsystem == "com.yourcompany.MyApp"'

</runtime_logging>

<crash_analysis>

Crash Logs

Find Crashes (Simulator)

# Simulator crash logs
ls ~/Library/Logs/DiagnosticReports/ | grep MyApp

# View latest crash
cat ~/Library/Logs/DiagnosticReports/MyApp_*.ips | head -200

Symbolicate with atos

# Get load address from "Binary Images:" section of crash report
xcrun atos -arch arm64 \
  -o MyApp.app.dSYM/Contents/Resources/DWARF/MyApp \
  -l 0x104600000 \
  0x104605ca4

# Verify dSYM matches
xcrun dwarfdump --uuid MyApp.app.dSYM

Symbolicate with LLDB

xcrun lldb
(lldb) command script import lldb.macosx.crashlog
(lldb) crashlog /path/to/crash.ips

</crash_analysis>

## LLDB Debugging

Launch with Console Output

# Launch and see stdout/stderr
xcrun simctl launch --console booted com.yourcompany.MyApp

Attach to Running App

# By name
lldb -n MyApp

# By PID
lldb -p $(pgrep MyApp)

# Wait for app to launch
lldb -n MyApp --wait-for

Essential Commands

# Breakpoints
(lldb) breakpoint set --file ContentView.swift --line 42
(lldb) breakpoint set --name "AppState.addItem"
(lldb) breakpoint set --name saveItem --condition 'item.name == "Test"'

# Watchpoints (break when value changes)
(lldb) watchpoint set variable self.items.count

# Execution
(lldb) continue    # or 'c'
(lldb) next        # step over
(lldb) step        # step into
(lldb) finish      # step out

# Inspection
(lldb) p variable
(lldb) po object
(lldb) frame variable   # all local vars
(lldb) bt               # backtrace
(lldb) bt all           # all threads

# Evaluate expressions
(lldb) expr self.items.count
(lldb) expr self.items.append(newItem)

<memory_debugging>

Memory Debugging

Leak Detection (Simulator)

# Check running process for leaks
leaks MyApp

Profiling with xctrace

# List templates
xcrun xctrace list templates

# Time Profiler
xcrun xctrace record \
  --template 'Time Profiler' \
  --time-limit 30s \
  --output profile.trace \
  --device booted \
  --launch -- com.yourcompany.MyApp

# Leaks
xcrun xctrace record \
  --template 'Leaks' \
  --time-limit 5m \
  --device booted \
  --attach MyApp \
  --output leaks.trace

# Export data
xcrun xctrace export --input profile.trace --toc

</memory_debugging>

## Sanitizers

Enable via xcodebuild flags:

# Address Sanitizer (memory errors, buffer overflows)
xcodebuild test \
  -project MyApp.xcodeproj \
  -scheme MyApp \
  -destination 'platform=iOS Simulator,name=iPhone 16' \
  -enableAddressSanitizer YES

# Thread Sanitizer (race conditions)
xcodebuild test \
  -project MyApp.xcodeproj \
  -scheme MyApp \
  -destination 'platform=iOS Simulator,name=iPhone 16' \
  -enableThreadSanitizer YES

# Undefined Behavior Sanitizer
xcodebuild test \
  -project MyApp.xcodeproj \
  -scheme MyApp \
  -destination 'platform=iOS Simulator,name=iPhone 16' \
  -enableUndefinedBehaviorSanitizer YES

Note: ASAN and TSAN cannot run simultaneously.

<network_inspection>

Network Traffic Inspection

mitmproxy Setup

# Run proxy (defaults to localhost:8080)
mitmproxy   # TUI
mitmdump    # CLI output only

Configure macOS Proxy (Simulator uses host network)

# Enable
networksetup -setwebproxy "Wi-Fi" 127.0.0.1 8080
networksetup -setsecurewebproxy "Wi-Fi" 127.0.0.1 8080

# Disable when done
networksetup -setwebproxystate "Wi-Fi" off
networksetup -setsecurewebproxystate "Wi-Fi" off

Install Certificate on Simulator

xcrun simctl keychain booted add-root-cert ~/.mitmproxy/mitmproxy-ca-cert.pem

Important: Restart simulator after proxy/cert changes.

Log Traffic

# Log all requests
mitmdump -w traffic.log

# Filter by domain
mitmdump --filter "~d api.example.com"

# Verbose (show bodies)
mitmdump -v

</network_inspection>

<test_results>

Test Result Parsing

# Run tests with result bundle
xcodebuild test \
  -project MyApp.xcodeproj \
  -scheme MyApp \
  -destination 'platform=iOS Simulator,name=iPhone 16' \
  -resultBundlePath TestResults.xcresult

# Get summary
xcrun xcresulttool get test-results summary --path TestResults.xcresult

# Export as JSON
xcrun xcresulttool get --path TestResults.xcresult --format json > results.json

# Coverage report
xcrun xccov view --report TestResults.xcresult

# Coverage as JSON
xcrun xccov view --report --json TestResults.xcresult > coverage.json

Accessibility Audits (Xcode 15+)

Add to UI tests:

func testAccessibility() throws {
    let app = XCUIApplication()
    app.launch()
    try app.performAccessibilityAudit()
}

Run via CLI:

xcodebuild test \
  -project MyApp.xcodeproj \
  -scheme MyAppUITests \
  -destination 'platform=iOS Simulator,name=iPhone 16' \
  -only-testing:MyAppUITests/AccessibilityTests

</test_results>

<swiftui_debugging>

SwiftUI Debugging

Track View Re-evaluation

var body: some View {
    let _ = Self._printChanges()  // Logs what caused re-render
    VStack {
        // ...
    }
}

Dump Objects

let _ = dump(someObject)  // Full object hierarchy to console

Note: No CLI equivalent for Xcode’s visual view hierarchy inspector. Use logging extensively. </swiftui_debugging>

<simulator_management>

Simulator Management

# List simulators
xcrun simctl list devices

# Boot simulator
xcrun simctl boot "iPhone 16"
open -a Simulator

# Install app
xcrun simctl install booted ./build/Build/Products/Debug-iphonesimulator/MyApp.app

# Launch app
xcrun simctl launch booted com.yourcompany.MyApp

# Launch with console output
xcrun simctl launch --console booted com.yourcompany.MyApp

# Screenshot
xcrun simctl io booted screenshot ~/Desktop/screenshot.png

# Video recording
xcrun simctl io booted recordVideo ~/Desktop/recording.mov

# Set location
xcrun simctl location booted set 37.7749,-122.4194

# Send push notification
xcrun simctl push booted com.yourcompany.MyApp notification.apns

# Reset simulator
xcrun simctl erase booted

</simulator_management>

<device_debugging>

Device Debugging (iOS 17+)

# List devices
xcrun devicectl list devices

# Install app
xcrun devicectl device install app --device <udid> MyApp.app

# Launch app
xcrun devicectl device process launch --device <udid> com.yourcompany.MyApp

</device_debugging>

<standard_debug_workflow>

Standard Debug Workflow

# 1. Build with error parsing
xcodebuild -project MyApp.xcodeproj -scheme MyApp \
  -destination 'platform=iOS Simulator,name=iPhone 16' \
  build 2>&1 | xcsift

# 2. Boot simulator and start log streaming (background terminal)
xcrun simctl boot "iPhone 16"
open -a Simulator
xcrun simctl spawn booted log stream --level debug \
  --predicate 'subsystem == "com.yourcompany.MyApp"' &

# 3. Install and launch
xcrun simctl install booted ./build/Build/Products/Debug-iphonesimulator/MyApp.app
xcrun simctl launch booted com.yourcompany.MyApp

# 4. If crash occurs
cat ~/Library/Logs/DiagnosticReports/MyApp_*.ips | head -100

# 5. Memory check
leaks MyApp

# 6. Deep debugging
lldb -n MyApp

</standard_debug_workflow>

<cli_vs_xcode>

What CLI Can and Cannot Do

TaskCLITool
Build errorsxcsift
Runtime logssimctl log stream
Crash symbolicationatos, lldb
Breakpoints/debugginglldb
Memory leaksleaks, xctrace
CPU profilingxctrace
Network inspectionmitmproxy
Test resultsxcresulttool
Accessibility auditUI tests
Sanitizersxcodebuild flags
View hierarchy⚠️_printChanges() only
GPU debuggingRequires Xcode
</cli_vs_xcode>

Reference: Cli Workflow

CLI Workflow

Build, run, test, and deploy iOS apps entirely from the terminal.

Prerequisites

# Ensure Xcode is installed and selected
xcode-select -p
# Should show: /Applications/Xcode.app/Contents/Developer

# If not, run:
sudo xcode-select -s /Applications/Xcode.app/Contents/Developer

# Install XcodeGen for project creation
brew install xcodegen

# Optional: prettier build output
brew install xcbeautify

# Optional: device deployment
brew install ios-deploy

Create Project (XcodeGen)

Create a new iOS project entirely from CLI:

# Create directory structure
mkdir MyApp && cd MyApp
mkdir -p MyApp/{App,Models,Views,Services,Resources} MyAppTests MyAppUITests

# Create project.yml (Claude generates this - see project-scaffolding.md for full template)
cat > project.yml << 'EOF'
name: MyApp
options:
  bundleIdPrefix: com.yourcompany
  deploymentTarget:
    iOS: "18.0"
targets:
  MyApp:
    type: application
    platform: iOS
    sources: [MyApp]
    settings:
      PRODUCT_BUNDLE_IDENTIFIER: com.yourcompany.myapp
      DEVELOPMENT_TEAM: YOURTEAMID
EOF

# Create app entry point
cat > MyApp/App/MyApp.swift << 'EOF'
import SwiftUI

@main
struct MyApp: App {
    var body: some Scene {
        WindowGroup {
            Text("Hello, World!")
        }
    }
}
EOF

# Generate .xcodeproj
xcodegen generate

# Verify
xcodebuild -list -project MyApp.xcodeproj

# Build
xcodebuild -project MyApp.xcodeproj -scheme MyApp -destination 'platform=iOS Simulator,name=iPhone 16' build

See project-scaffolding.md for complete project.yml templates.

Building

Basic Build

# Build for simulator
xcodebuild -project MyApp.xcodeproj \
    -scheme MyApp \
    -destination 'platform=iOS Simulator,name=iPhone 16' \
    build

# Build for device
xcodebuild -project MyApp.xcodeproj \
    -scheme MyApp \
    -destination 'generic/platform=iOS' \
    build

Clean Build

xcodebuild -project MyApp.xcodeproj \
    -scheme MyApp \
    clean build

Build with Specific SDK

# List available SDKs
xcodebuild -showsdks

# Build with specific SDK
xcodebuild -project MyApp.xcodeproj \
    -scheme MyApp \
    -sdk iphonesimulator17.0 \
    build

Running on Simulator

Boot and Launch

# List available simulators
xcrun simctl list devices

# Boot simulator
xcrun simctl boot "iPhone 16"

# Open Simulator app
open -a Simulator

# Install app
xcrun simctl install booted ~/Library/Developer/Xcode/DerivedData/MyApp-xxx/Build/Products/Debug-iphonesimulator/MyApp.app

# Launch app
xcrun simctl launch booted com.yourcompany.MyApp

# Or install and launch in one step
xcrun simctl install booted MyApp.app && xcrun simctl launch booted com.yourcompany.MyApp

Simulator Management

# Create simulator
xcrun simctl create "My iPhone 16" "iPhone 16" iOS17.0

# Delete simulator
xcrun simctl delete "My iPhone 16"

# Reset simulator
xcrun simctl erase booted

# Screenshot
xcrun simctl io booted screenshot ~/Desktop/screenshot.png

# Record video
xcrun simctl io booted recordVideo ~/Desktop/recording.mov

Simulate Conditions

# Set location
xcrun simctl location booted set 37.7749,-122.4194

# Send push notification
xcrun simctl push booted com.yourcompany.MyApp notification.apns

# Set status bar (time, battery, etc.)
xcrun simctl status_bar booted override --time "9:41" --batteryLevel 100

Running on Device

List Connected Devices

# List devices
xcrun xctrace list devices

# Or using ios-deploy
ios-deploy --detect

Deploy to Device

# Install ios-deploy
brew install ios-deploy

# Deploy and run
ios-deploy --bundle MyApp.app --debug

# Just install without launching
ios-deploy --bundle MyApp.app --no-wifi

# Deploy with app data
ios-deploy --bundle MyApp.app --bundle_id com.yourcompany.MyApp

Wireless Debugging

  1. Connect device via USB once
  2. In Xcode: Window > Devices and Simulators > Connect via network
  3. Deploy wirelessly:
ios-deploy --bundle MyApp.app --wifi

Testing

Run Unit Tests

xcodebuild test \
    -project MyApp.xcodeproj \
    -scheme MyApp \
    -destination 'platform=iOS Simulator,name=iPhone 16' \
    -resultBundlePath TestResults.xcresult

Run UI Tests

xcodebuild test \
    -project MyApp.xcodeproj \
    -scheme MyAppUITests \
    -destination 'platform=iOS Simulator,name=iPhone 16' \
    -resultBundlePath UITestResults.xcresult

Run Specific Tests

# Single test
xcodebuild test \
    -project MyApp.xcodeproj \
    -scheme MyApp \
    -destination 'platform=iOS Simulator,name=iPhone 16' \
    -only-testing:MyAppTests/NetworkServiceTests/testFetchItems

# Test class
xcodebuild test \
    ... \
    -only-testing:MyAppTests/NetworkServiceTests

View Test Results

# Open results in Xcode
open TestResults.xcresult

# Export to JSON (for CI)
xcrun xcresulttool get --path TestResults.xcresult --format json

Debugging

Console Logs

# Stream logs from simulator
xcrun simctl spawn booted log stream --predicate 'subsystem == "com.yourcompany.MyApp"'

# Stream logs from device
idevicesyslog | grep MyApp

LLDB

# Attach to running process
lldb -n MyApp

# Debug app on launch
ios-deploy --bundle MyApp.app --debug

Crash Logs

# Simulator crash logs
ls ~/Library/Logs/DiagnosticReports/

# Device crash logs (via Xcode)
# Window > Devices and Simulators > View Device Logs

Archiving and Export

Create Archive

xcodebuild archive \
    -project MyApp.xcodeproj \
    -scheme MyApp \
    -archivePath build/MyApp.xcarchive \
    -destination 'generic/platform=iOS'

Export IPA

Create ExportOptions.plist:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>method</key>
    <string>app-store</string>
    <key>teamID</key>
    <string>YOUR_TEAM_ID</string>
    <key>uploadSymbols</key>
    <true/>
    <key>uploadBitcode</key>
    <false/>
</dict>
</plist>

Export:

xcodebuild -exportArchive \
    -archivePath build/MyApp.xcarchive \
    -exportOptionsPlist ExportOptions.plist \
    -exportPath build/

App Store Connect

Upload to TestFlight

xcrun altool --upload-app \
    --type ios \
    --file build/MyApp.ipa \
    --apiKey YOUR_KEY_ID \
    --apiIssuer YOUR_ISSUER_ID

Or use xcrun notarytool for newer workflows:

xcrun notarytool submit build/MyApp.ipa \
    --key ~/.appstoreconnect/AuthKey_XXXXX.p8 \
    --key-id YOUR_KEY_ID \
    --issuer YOUR_ISSUER_ID \
    --wait

App Store Connect API Key

  1. App Store Connect > Users and Access > Keys
  2. Generate API Key
  3. Download and store securely

Useful Aliases

Add to .zshrc:

# iOS development
alias ios-build="xcodebuild -project *.xcodeproj -scheme \$(basename *.xcodeproj .xcodeproj) -destination 'platform=iOS Simulator,name=iPhone 16' build"
alias ios-test="xcodebuild test -project *.xcodeproj -scheme \$(basename *.xcodeproj .xcodeproj) -destination 'platform=iOS Simulator,name=iPhone 16'"
alias ios-run="xcrun simctl launch booted"
alias ios-log="xcrun simctl spawn booted log stream --level debug"
alias sim-boot="xcrun simctl boot 'iPhone 16' && open -a Simulator"
alias sim-screenshot="xcrun simctl io booted screenshot ~/Desktop/sim-\$(date +%Y%m%d-%H%M%S).png"

Troubleshooting

Build Failures

# Clear derived data
rm -rf ~/Library/Developer/Xcode/DerivedData

# Reset package caches
rm -rf ~/Library/Caches/org.swift.swiftpm

# Resolve packages
xcodebuild -resolvePackageDependencies

Simulator Issues

# Kill all simulators
killall Simulator

# Reset all simulators
xcrun simctl shutdown all && xcrun simctl erase all

Code Signing

# List identities
security find-identity -v -p codesigning

# Check provisioning profiles
ls ~/Library/MobileDevice/Provisioning\ Profiles/

Reference: Data Persistence

Data Persistence

SwiftData, Core Data, and file-based storage for iOS apps.

SwiftData (iOS 17+)

Model Definition

import SwiftData

@Model
class Item {
    var name: String
    var createdAt: Date
    var isCompleted: Bool
    var priority: Int

    @Relationship(deleteRule: .cascade)
    var tasks: [Task]

    @Relationship(inverse: \Category.items)
    var category: Category?

    init(name: String, priority: Int = 0) {
        self.name = name
        self.createdAt = Date()
        self.isCompleted = false
        self.priority = priority
        self.tasks = []
    }
}

@Model
class Task {
    var title: String
    var isCompleted: Bool

    init(title: String) {
        self.title = title
        self.isCompleted = false
    }
}

@Model
class Category {
    var name: String
    var items: [Item]

    init(name: String) {
        self.name = name
        self.items = []
    }
}

Container Setup

@main
struct MyApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .modelContainer(for: [Item.self, Category.self])
    }
}

Querying Data

struct ItemList: View {
    // Basic query
    @Query private var items: [Item]

    // Sorted query
    @Query(sort: \Item.createdAt, order: .reverse)
    private var sortedItems: [Item]

    // Filtered query
    @Query(filter: #Predicate<Item> { $0.isCompleted == false })
    private var incompleteItems: [Item]

    // Complex query
    @Query(
        filter: #Predicate<Item> { !$0.isCompleted && $0.priority > 5 },
        sort: [
            SortDescriptor(\Item.priority, order: .reverse),
            SortDescriptor(\Item.createdAt)
        ]
    )
    private var highPriorityItems: [Item]

    var body: some View {
        List(items) { item in
            ItemRow(item: item)
        }
    }
}

CRUD Operations

struct ItemList: View {
    @Query private var items: [Item]
    @Environment(\.modelContext) private var context

    var body: some View {
        List {
            ForEach(items) { item in
                ItemRow(item: item)
            }
            .onDelete(perform: delete)
        }
        .toolbar {
            Button("Add", action: addItem)
        }
    }

    private func addItem() {
        let item = Item(name: "New Item")
        context.insert(item)
        // Auto-saves
    }

    private func delete(at offsets: IndexSet) {
        for index in offsets {
            context.delete(items[index])
        }
    }
}

Custom Container Configuration

@main
struct MyApp: App {
    let container: ModelContainer

    init() {
        let schema = Schema([Item.self, Category.self])

        let config = ModelConfiguration(
            schema: schema,
            isStoredInMemoryOnly: false,
            allowsSave: true,
            groupContainer: .identifier("group.com.yourcompany.app")
        )

        do {
            container = try ModelContainer(for: schema, configurations: config)
        } catch {
            fatalError("Failed to configure SwiftData container: \(error)")
        }
    }

    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .modelContainer(container)
    }
}

iCloud Sync

SwiftData syncs automatically with iCloud when:

  1. App has iCloud capability
  2. User is signed into iCloud
  3. Container uses CloudKit
let config = ModelConfiguration(
    cloudKitDatabase: .automatic
)

Core Data (All iOS Versions)

Stack Setup

class CoreDataStack {
    static let shared = CoreDataStack()

    lazy var persistentContainer: NSPersistentContainer = {
        let container = NSPersistentContainer(name: "MyApp")

        // Enable cloud sync
        guard let description = container.persistentStoreDescriptions.first else {
            fatalError("No persistent store description")
        }
        description.cloudKitContainerOptions = NSPersistentCloudKitContainerOptions(
            containerIdentifier: "iCloud.com.yourcompany.app"
        )

        container.loadPersistentStores { description, error in
            if let error = error {
                fatalError("Core Data failed to load: \(error)")
            }
        }

        container.viewContext.automaticallyMergesChangesFromParent = true
        container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy

        return container
    }()

    var viewContext: NSManagedObjectContext {
        persistentContainer.viewContext
    }

    func saveContext() {
        let context = viewContext
        if context.hasChanges {
            do {
                try context.save()
            } catch {
                print("Failed to save context: \(error)")
            }
        }
    }
}

With SwiftUI

@main
struct MyApp: App {
    let coreDataStack = CoreDataStack.shared

    var body: some Scene {
        WindowGroup {
            ContentView()
                .environment(\.managedObjectContext, coreDataStack.viewContext)
        }
    }
}

struct ItemList: View {
    @FetchRequest(
        sortDescriptors: [NSSortDescriptor(keyPath: \Item.createdAt, ascending: false)],
        predicate: NSPredicate(format: "isCompleted == NO")
    )
    private var items: FetchedResults<Item>

    @Environment(\.managedObjectContext) private var context

    var body: some View {
        List(items) { item in
            ItemRow(item: item)
        }
    }
}

File-Based Storage

Codable Models

struct UserSettings: Codable {
    var theme: Theme
    var fontSize: Int
    var notificationsEnabled: Bool

    enum Theme: String, Codable {
        case light, dark, system
    }
}

class SettingsStore {
    private let fileURL: URL

    init() {
        let documentsDirectory = FileManager.default.urls(
            for: .documentDirectory,
            in: .userDomainMask
        ).first!
        fileURL = documentsDirectory.appendingPathComponent("settings.json")
    }

    func load() -> UserSettings {
        guard let data = try? Data(contentsOf: fileURL),
              let settings = try? JSONDecoder().decode(UserSettings.self, from: data) else {
            return UserSettings(theme: .system, fontSize: 16, notificationsEnabled: true)
        }
        return settings
    }

    func save(_ settings: UserSettings) throws {
        let data = try JSONEncoder().encode(settings)
        try data.write(to: fileURL)
    }
}

Document Directory Paths

extension FileManager {
    var documentsDirectory: URL {
        urls(for: .documentDirectory, in: .userDomainMask).first!
    }

    var cachesDirectory: URL {
        urls(for: .cachesDirectory, in: .userDomainMask).first!
    }

    var applicationSupportDirectory: URL {
        let url = urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
        try? createDirectory(at: url, withIntermediateDirectories: true)
        return url
    }
}

UserDefaults

Basic Usage

// Save
UserDefaults.standard.set("value", forKey: "key")
UserDefaults.standard.set(true, forKey: "hasCompletedOnboarding")

// Load
let value = UserDefaults.standard.string(forKey: "key")
let hasCompletedOnboarding = UserDefaults.standard.bool(forKey: "hasCompletedOnboarding")

@AppStorage

struct SettingsView: View {
    @AppStorage("fontSize") private var fontSize = 16
    @AppStorage("isDarkMode") private var isDarkMode = false
    @AppStorage("username") private var username = ""

    var body: some View {
        Form {
            Stepper("Font Size: \(fontSize)", value: $fontSize, in: 12...24)
            Toggle("Dark Mode", isOn: $isDarkMode)
            TextField("Username", text: $username)
        }
    }
}

Custom Codable Storage

extension UserDefaults {
    func set<T: Codable>(_ value: T, forKey key: String) {
        if let data = try? JSONEncoder().encode(value) {
            set(data, forKey: key)
        }
    }

    func get<T: Codable>(_ type: T.Type, forKey key: String) -> T? {
        guard let data = data(forKey: key) else { return nil }
        return try? JSONDecoder().decode(type, from: data)
    }
}

// Usage
UserDefaults.standard.set(userProfile, forKey: "userProfile")
let profile = UserDefaults.standard.get(UserProfile.self, forKey: "userProfile")

Keychain (Sensitive Data)

Simple Wrapper

import Security

class KeychainService {
    enum KeychainError: Error {
        case saveFailed(OSStatus)
        case loadFailed(OSStatus)
        case deleteFailed(OSStatus)
        case dataConversionError
    }

    func save(_ data: Data, for key: String) throws {
        // Delete existing
        try? delete(key)

        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrAccount as String: key,
            kSecValueData as String: data,
            kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlocked
        ]

        let status = SecItemAdd(query as CFDictionary, nil)
        guard status == errSecSuccess else {
            throw KeychainError.saveFailed(status)
        }
    }

    func load(_ key: String) throws -> Data {
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrAccount as String: key,
            kSecReturnData as String: true
        ]

        var result: AnyObject?
        let status = SecItemCopyMatching(query as CFDictionary, &result)

        guard status == errSecSuccess else {
            throw KeychainError.loadFailed(status)
        }

        guard let data = result as? Data else {
            throw KeychainError.dataConversionError
        }

        return data
    }

    func delete(_ key: String) throws {
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrAccount as String: key
        ]

        let status = SecItemDelete(query as CFDictionary)
        guard status == errSecSuccess || status == errSecItemNotFound else {
            throw KeychainError.deleteFailed(status)
        }
    }
}

// String convenience
extension KeychainService {
    func saveString(_ value: String, for key: String) throws {
        guard let data = value.data(using: .utf8) else {
            throw KeychainError.dataConversionError
        }
        try save(data, for: key)
    }

    func loadString(_ key: String) throws -> String {
        let data = try load(key)
        guard let string = String(data: data, encoding: .utf8) else {
            throw KeychainError.dataConversionError
        }
        return string
    }
}

Usage

let keychain = KeychainService()

// Save API token
try keychain.saveString(token, for: "apiToken")

// Load API token
let token = try keychain.loadString("apiToken")

// Delete on logout
try keychain.delete("apiToken")

Migration Strategies

SwiftData Migrations

enum SchemaV1: VersionedSchema {
    static var versionIdentifier = Schema.Version(1, 0, 0)
    static var models: [any PersistentModel.Type] {
        [Item.self]
    }

    @Model
    class Item {
        var name: String
        init(name: String) { self.name = name }
    }
}

enum SchemaV2: VersionedSchema {
    static var versionIdentifier = Schema.Version(2, 0, 0)
    static var models: [any PersistentModel.Type] {
        [Item.self]
    }

    @Model
    class Item {
        var name: String
        var createdAt: Date  // New field

        init(name: String) {
            self.name = name
            self.createdAt = Date()
        }
    }
}

enum MigrationPlan: SchemaMigrationPlan {
    static var schemas: [any VersionedSchema.Type] {
        [SchemaV1.self, SchemaV2.self]
    }

    static var stages: [MigrationStage] {
        [migrateV1toV2]
    }

    static let migrateV1toV2 = MigrationStage.lightweight(
        fromVersion: SchemaV1.self,
        toVersion: SchemaV2.self
    )
}

Reference: Navigation Patterns

Navigation Patterns

NavigationStack, deep linking, and programmatic navigation for iOS apps.

Value-Based Navigation

struct ContentView: View {
    @State private var path = NavigationPath()

    var body: some View {
        NavigationStack(path: $path) {
            List(items) { item in
                NavigationLink(value: item) {
                    ItemRow(item: item)
                }
            }
            .navigationTitle("Items")
            .navigationDestination(for: Item.self) { item in
                ItemDetail(item: item, path: $path)
            }
            .navigationDestination(for: Category.self) { category in
                CategoryView(category: category)
            }
        }
    }
}

Programmatic Navigation

struct ContentView: View {
    @State private var path = NavigationPath()

    var body: some View {
        NavigationStack(path: $path) {
            VStack {
                Button("Go to Settings") {
                    path.append(Route.settings)
                }

                Button("Go to Item") {
                    path.append(items[0])
                }

                Button("Deep Link") {
                    // Push multiple screens
                    path.append(Route.settings)
                    path.append(SettingsSection.account)
                }
            }
            .navigationDestination(for: Route.self) { route in
                switch route {
                case .settings:
                    SettingsView(path: $path)
                case .profile:
                    ProfileView()
                }
            }
            .navigationDestination(for: Item.self) { item in
                ItemDetail(item: item)
            }
            .navigationDestination(for: SettingsSection.self) { section in
                SettingsSectionView(section: section)
            }
        }
    }

    func popToRoot() {
        path.removeLast(path.count)
    }

    func popOne() {
        if !path.isEmpty {
            path.removeLast()
        }
    }
}

enum Route: Hashable {
    case settings
    case profile
}

enum SettingsSection: Hashable {
    case account
    case notifications
    case privacy
}

Tab-Based Navigation

TabView with NavigationStack per Tab

struct MainTabView: View {
    @State private var selectedTab = Tab.home
    @State private var homePath = NavigationPath()
    @State private var searchPath = NavigationPath()
    @State private var profilePath = NavigationPath()

    var body: some View {
        TabView(selection: $selectedTab) {
            NavigationStack(path: $homePath) {
                HomeView()
            }
            .tabItem {
                Label("Home", systemImage: "house")
            }
            .tag(Tab.home)

            NavigationStack(path: $searchPath) {
                SearchView()
            }
            .tabItem {
                Label("Search", systemImage: "magnifyingglass")
            }
            .tag(Tab.search)

            NavigationStack(path: $profilePath) {
                ProfileView()
            }
            .tabItem {
                Label("Profile", systemImage: "person")
            }
            .tag(Tab.profile)
        }
        .onChange(of: selectedTab) { oldTab, newTab in
            // Pop to root when re-tapping current tab
            if oldTab == newTab {
                switch newTab {
                case .home: homePath.removeLast(homePath.count)
                case .search: searchPath.removeLast(searchPath.count)
                case .profile: profilePath.removeLast(profilePath.count)
                }
            }
        }
    }

    enum Tab {
        case home, search, profile
    }
}

Deep Linking

URL Scheme Handling

Configure in Info.plist:

<key>CFBundleURLTypes</key>
<array>
    <dict>
        <key>CFBundleURLSchemes</key>
        <array>
            <string>myapp</string>
        </array>
    </dict>
</array>

Handle in App:

@main
struct MyApp: App {
    @State private var appState = AppState()

    var body: some Scene {
        WindowGroup {
            ContentView()
                .environment(appState)
                .onOpenURL { url in
                    handleDeepLink(url)
                }
        }
    }

    private func handleDeepLink(_ url: URL) {
        // myapp://item/123
        // myapp://settings/account
        guard let components = URLComponents(url: url, resolvingAgainstBaseURL: true) else { return }

        let pathComponents = components.path.split(separator: "/").map(String.init)

        switch pathComponents.first {
        case "item":
            if let id = pathComponents.dropFirst().first {
                appState.navigateToItem(id: id)
            }
        case "settings":
            let section = pathComponents.dropFirst().first
            appState.navigateToSettings(section: section)
        default:
            break
        }
    }
}

@Observable
class AppState {
    var selectedTab: Tab = .home
    var homePath = NavigationPath()

    func navigateToItem(id: String) {
        selectedTab = .home
        homePath.removeLast(homePath.count)
        if let item = findItem(id: id) {
            homePath.append(item)
        }
    }

    func navigateToSettings(section: String?) {
        selectedTab = .profile
        // Navigate to settings
    }
}

Configure in apple-app-site-association on your server:

{
    "applinks": {
        "apps": [],
        "details": [
            {
                "appID": "TEAMID.com.yourcompany.app",
                "paths": ["/item/*", "/user/*"]
            }
        ]
    }
}

Add Associated Domains entitlement:

<key>com.apple.developer.associated-domains</key>
<array>
    <string>applinks:example.com</string>
</array>

Handle same as URL schemes with onOpenURL.

Sheet Navigation

struct ContentView: View {
    @State private var selectedItem: Item?
    @State private var showingNewItem = false

    var body: some View {
        NavigationStack {
            List(items) { item in
                Button(item.name) {
                    selectedItem = item
                }
            }
            .toolbar {
                Button {
                    showingNewItem = true
                } label: {
                    Image(systemName: "plus")
                }
            }
        }
        // Item-based presentation
        .sheet(item: $selectedItem) { item in
            NavigationStack {
                ItemDetail(item: item)
                    .toolbar {
                        ToolbarItem(placement: .cancellationAction) {
                            Button("Done") {
                                selectedItem = nil
                            }
                        }
                    }
            }
        }
        // Boolean-based presentation
        .sheet(isPresented: $showingNewItem) {
            NavigationStack {
                NewItemView()
                    .toolbar {
                        ToolbarItem(placement: .cancellationAction) {
                            Button("Cancel") {
                                showingNewItem = false
                            }
                        }
                    }
            }
        }
    }
}

Full Screen Cover

.fullScreenCover(isPresented: $showingOnboarding) {
    OnboardingFlow()
}

Detents (Sheet Sizes)

.sheet(isPresented: $showingOptions) {
    OptionsView()
        .presentationDetents([.medium, .large])
        .presentationDragIndicator(.visible)
}

Codable Navigation Path

struct ContentView: View {
    @State private var path: [Route] = []

    var body: some View {
        NavigationStack(path: $path) {
            // Content
        }
        .onAppear {
            loadNavigationState()
        }
        .onChange(of: path) { _, newPath in
            saveNavigationState(newPath)
        }
    }

    private func saveNavigationState(_ path: [Route]) {
        if let data = try? JSONEncoder().encode(path) {
            UserDefaults.standard.set(data, forKey: "navigationPath")
        }
    }

    private func loadNavigationState() {
        guard let data = UserDefaults.standard.data(forKey: "navigationPath"),
              let savedPath = try? JSONDecoder().decode([Route].self, from: data) else {
            return
        }
        path = savedPath
    }
}

enum Route: Codable, Hashable {
    case item(id: UUID)
    case settings
    case profile
}

For complex apps, centralize navigation logic:

@Observable
class NavigationCoordinator {
    var homePath = NavigationPath()
    var searchPath = NavigationPath()
    var selectedTab: Tab = .home

    enum Tab {
        case home, search, profile
    }

    func showItem(_ item: Item) {
        selectedTab = .home
        homePath.append(item)
    }

    func showSearch(query: String) {
        selectedTab = .search
        searchPath.append(SearchQuery(text: query))
    }

    func popToRoot() {
        switch selectedTab {
        case .home:
            homePath.removeLast(homePath.count)
        case .search:
            searchPath.removeLast(searchPath.count)
        case .profile:
            break
        }
    }

    func handleDeepLink(_ url: URL) {
        // Parse and navigate
    }
}

// Inject via environment
@main
struct MyApp: App {
    @State private var coordinator = NavigationCoordinator()

    var body: some Scene {
        WindowGroup {
            ContentView()
                .environment(coordinator)
                .onOpenURL { url in
                    coordinator.handleDeepLink(url)
                }
        }
    }
}

Search Integration

Searchable Modifier

struct ItemList: View {
    @State private var searchText = ""
    @State private var searchScope = SearchScope.all

    var filteredItems: [Item] {
        items.filter { item in
            searchText.isEmpty || item.name.localizedCaseInsensitiveContains(searchText)
        }
    }

    var body: some View {
        NavigationStack {
            List(filteredItems) { item in
                NavigationLink(value: item) {
                    ItemRow(item: item)
                }
            }
            .navigationTitle("Items")
            .searchable(text: $searchText, prompt: "Search items")
            .searchScopes($searchScope) {
                Text("All").tag(SearchScope.all)
                Text("Recent").tag(SearchScope.recent)
                Text("Favorites").tag(SearchScope.favorites)
            }
            .navigationDestination(for: Item.self) { item in
                ItemDetail(item: item)
            }
        }
    }

    enum SearchScope {
        case all, recent, favorites
    }
}

Search Suggestions

.searchable(text: $searchText) {
    ForEach(suggestions) { suggestion in
        Text(suggestion.text)
            .searchCompletion(suggestion.text)
    }
}

Reference: Networking

Networking

URLSession patterns, caching, authentication, and offline support.

Basic Networking Service

actor NetworkService {
    private let session: URLSession
    private let decoder: JSONDecoder
    private let encoder: JSONEncoder

    init(session: URLSession = .shared) {
        self.session = session

        self.decoder = JSONDecoder()
        decoder.dateDecodingStrategy = .iso8601
        decoder.keyDecodingStrategy = .convertFromSnakeCase

        self.encoder = JSONEncoder()
        encoder.dateEncodingStrategy = .iso8601
        encoder.keyEncodingStrategy = .convertToSnakeCase
    }

    func fetch<T: Decodable>(_ endpoint: Endpoint) async throws -> T {
        let request = try endpoint.urlRequest()
        let (data, response) = try await session.data(for: request)

        guard let httpResponse = response as? HTTPURLResponse else {
            throw NetworkError.invalidResponse
        }

        guard 200..<300 ~= httpResponse.statusCode else {
            throw NetworkError.httpError(httpResponse.statusCode, data)
        }

        do {
            return try decoder.decode(T.self, from: data)
        } catch {
            throw NetworkError.decodingError(error)
        }
    }

    func send<T: Encodable, R: Decodable>(_ body: T, to endpoint: Endpoint) async throws -> R {
        var request = try endpoint.urlRequest()
        request.httpBody = try encoder.encode(body)
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")

        let (data, response) = try await session.data(for: request)

        guard let httpResponse = response as? HTTPURLResponse else {
            throw NetworkError.invalidResponse
        }

        guard 200..<300 ~= httpResponse.statusCode else {
            throw NetworkError.httpError(httpResponse.statusCode, data)
        }

        return try decoder.decode(R.self, from: data)
    }
}

enum NetworkError: LocalizedError {
    case invalidURL
    case invalidResponse
    case httpError(Int, Data)
    case decodingError(Error)
    case noConnection
    case timeout

    var errorDescription: String? {
        switch self {
        case .invalidURL:
            return "Invalid URL"
        case .invalidResponse:
            return "Invalid server response"
        case .httpError(let code, _):
            return "Server error (\(code))"
        case .decodingError:
            return "Failed to parse response"
        case .noConnection:
            return "No internet connection"
        case .timeout:
            return "Request timed out"
        }
    }
}

Endpoint Pattern

enum Endpoint {
    case items
    case item(id: String)
    case createItem
    case updateItem(id: String)
    case deleteItem(id: String)
    case search(query: String, page: Int)

    var baseURL: URL {
        URL(string: "https://api.example.com/v1")!
    }

    var path: String {
        switch self {
        case .items, .createItem:
            return "/items"
        case .item(let id), .updateItem(let id), .deleteItem(let id):
            return "/items/\(id)"
        case .search:
            return "/search"
        }
    }

    var method: String {
        switch self {
        case .items, .item, .search:
            return "GET"
        case .createItem:
            return "POST"
        case .updateItem:
            return "PUT"
        case .deleteItem:
            return "DELETE"
        }
    }

    var queryItems: [URLQueryItem]? {
        switch self {
        case .search(let query, let page):
            return [
                URLQueryItem(name: "q", value: query),
                URLQueryItem(name: "page", value: String(page))
            ]
        default:
            return nil
        }
    }

    func urlRequest() throws -> URLRequest {
        var components = URLComponents(url: baseURL.appendingPathComponent(path), resolvingAgainstBaseURL: true)
        components?.queryItems = queryItems

        guard let url = components?.url else {
            throw NetworkError.invalidURL
        }

        var request = URLRequest(url: url)
        request.httpMethod = method
        request.setValue("application/json", forHTTPHeaderField: "Accept")

        return request
    }
}

Authentication

Bearer Token

actor AuthenticatedNetworkService {
    private let session: URLSession
    private let tokenProvider: TokenProvider

    init(tokenProvider: TokenProvider) {
        self.session = .shared
        self.tokenProvider = tokenProvider
    }

    func fetch<T: Decodable>(_ endpoint: Endpoint) async throws -> T {
        var request = try endpoint.urlRequest()

        // Add auth header
        let token = try await tokenProvider.validToken()
        request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")

        let (data, response) = try await session.data(for: request)

        guard let httpResponse = response as? HTTPURLResponse else {
            throw NetworkError.invalidResponse
        }

        // Handle 401 - token expired
        if httpResponse.statusCode == 401 {
            // Refresh token and retry
            let newToken = try await tokenProvider.refreshToken()
            request.setValue("Bearer \(newToken)", forHTTPHeaderField: "Authorization")
            let (retryData, retryResponse) = try await session.data(for: request)

            guard let retryHttpResponse = retryResponse as? HTTPURLResponse,
                  200..<300 ~= retryHttpResponse.statusCode else {
                throw NetworkError.unauthorized
            }

            return try JSONDecoder().decode(T.self, from: retryData)
        }

        guard 200..<300 ~= httpResponse.statusCode else {
            throw NetworkError.httpError(httpResponse.statusCode, data)
        }

        return try JSONDecoder().decode(T.self, from: data)
    }
}

protocol TokenProvider {
    func validToken() async throws -> String
    func refreshToken() async throws -> String
}

OAuth 2.0 Flow

import AuthenticationServices

class OAuthService: NSObject {
    func signIn() async throws -> String {
        let authURL = URL(string: "https://auth.example.com/authorize?client_id=xxx&redirect_uri=myapp://callback&response_type=code")!

        return try await withCheckedThrowingContinuation { continuation in
            let session = ASWebAuthenticationSession(
                url: authURL,
                callbackURLScheme: "myapp"
            ) { callbackURL, error in
                if let error = error {
                    continuation.resume(throwing: error)
                    return
                }

                guard let callbackURL = callbackURL,
                      let code = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false)?
                        .queryItems?.first(where: { $0.name == "code" })?.value else {
                    continuation.resume(throwing: OAuthError.invalidCallback)
                    return
                }

                continuation.resume(returning: code)
            }

            session.presentationContextProvider = self
            session.prefersEphemeralWebBrowserSession = true
            session.start()
        }
    }
}

extension OAuthService: ASWebAuthenticationPresentationContextProviding {
    func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
        UIApplication.shared.connectedScenes
            .compactMap { $0 as? UIWindowScene }
            .flatMap { $0.windows }
            .first { $0.isKeyWindow }!
    }
}

Caching

URLCache Configuration

class CachedNetworkService {
    private let session: URLSession

    init() {
        let cache = URLCache(
            memoryCapacity: 50 * 1024 * 1024,  // 50 MB memory
            diskCapacity: 200 * 1024 * 1024     // 200 MB disk
        )

        let config = URLSessionConfiguration.default
        config.urlCache = cache
        config.requestCachePolicy = .returnCacheDataElseLoad

        self.session = URLSession(configuration: config)
    }

    func fetch<T: Decodable>(_ endpoint: Endpoint, cachePolicy: URLRequest.CachePolicy = .useProtocolCachePolicy) async throws -> T {
        var request = try endpoint.urlRequest()
        request.cachePolicy = cachePolicy

        let (data, _) = try await session.data(for: request)
        return try JSONDecoder().decode(T.self, from: data)
    }

    func fetchFresh<T: Decodable>(_ endpoint: Endpoint) async throws -> T {
        try await fetch(endpoint, cachePolicy: .reloadIgnoringLocalCacheData)
    }
}

Custom Caching

actor DataCache {
    private var cache: [String: CachedItem] = [:]
    private let maxAge: TimeInterval

    struct CachedItem {
        let data: Data
        let timestamp: Date
    }

    init(maxAge: TimeInterval = 300) {
        self.maxAge = maxAge
    }

    func get(_ key: String) -> Data? {
        guard let item = cache[key] else { return nil }
        guard Date().timeIntervalSince(item.timestamp) < maxAge else {
            cache.removeValue(forKey: key)
            return nil
        }
        return item.data
    }

    func set(_ data: Data, for key: String) {
        cache[key] = CachedItem(data: data, timestamp: Date())
    }

    func invalidate(_ key: String) {
        cache.removeValue(forKey: key)
    }

    func clearAll() {
        cache.removeAll()
    }
}

Offline Support

Network Monitor

import Network

@Observable
class NetworkMonitor {
    var isConnected = true
    var connectionType: ConnectionType = .wifi

    private let monitor = NWPathMonitor()
    private let queue = DispatchQueue(label: "NetworkMonitor")

    enum ConnectionType {
        case wifi, cellular, unknown
    }

    init() {
        monitor.pathUpdateHandler = { [weak self] path in
            DispatchQueue.main.async {
                self?.isConnected = path.status == .satisfied
                self?.connectionType = self?.getConnectionType(path) ?? .unknown
            }
        }
        monitor.start(queue: queue)
    }

    private func getConnectionType(_ path: NWPath) -> ConnectionType {
        if path.usesInterfaceType(.wifi) {
            return .wifi
        } else if path.usesInterfaceType(.cellular) {
            return .cellular
        }
        return .unknown
    }

    deinit {
        monitor.cancel()
    }
}

Offline-First Pattern

actor OfflineFirstService {
    private let network: NetworkService
    private let storage: StorageService
    private let cache: DataCache

    func fetchItems() async throws -> [Item] {
        // Try cache first
        if let cached = await cache.get("items"),
           let items = try? JSONDecoder().decode([Item].self, from: cached) {
            // Return cached, fetch fresh in background
            Task {
                try? await fetchAndCacheFresh()
            }
            return items
        }

        // Try network
        do {
            let items: [Item] = try await network.fetch(.items)
            await cache.set(try JSONEncoder().encode(items), for: "items")
            return items
        } catch {
            // Fall back to storage
            return try await storage.loadItems()
        }
    }

    private func fetchAndCacheFresh() async throws {
        let items: [Item] = try await network.fetch(.items)
        await cache.set(try JSONEncoder().encode(items), for: "items")
        try await storage.saveItems(items)
    }
}

Pending Operations Queue

actor PendingOperationsQueue {
    private var operations: [PendingOperation] = []
    private let storage: StorageService

    struct PendingOperation: Codable {
        let id: UUID
        let endpoint: String
        let method: String
        let body: Data?
        let createdAt: Date
    }

    func add(_ operation: PendingOperation) async {
        operations.append(operation)
        try? await persist()
    }

    func processAll() async {
        for operation in operations {
            do {
                try await execute(operation)
                operations.removeAll { $0.id == operation.id }
            } catch {
                // Keep in queue for retry
                continue
            }
        }
        try? await persist()
    }

    private func execute(_ operation: PendingOperation) async throws {
        // Execute network request
    }

    private func persist() async throws {
        try await storage.savePendingOperations(operations)
    }
}

Multipart Upload

extension NetworkService {
    func upload(_ fileData: Data, filename: String, mimeType: String, to endpoint: Endpoint) async throws -> UploadResponse {
        let boundary = UUID().uuidString
        var request = try endpoint.urlRequest()
        request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")

        var body = Data()
        body.append("--\(boundary)\r\n".data(using: .utf8)!)
        body.append("Content-Disposition: form-data; name=\"file\"; filename=\"\(filename)\"\r\n".data(using: .utf8)!)
        body.append("Content-Type: \(mimeType)\r\n\r\n".data(using: .utf8)!)
        body.append(fileData)
        body.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!)

        request.httpBody = body

        let (data, response) = try await session.data(for: request)

        guard let httpResponse = response as? HTTPURLResponse,
              200..<300 ~= httpResponse.statusCode else {
            throw NetworkError.httpError((response as? HTTPURLResponse)?.statusCode ?? 0, data)
        }

        return try JSONDecoder().decode(UploadResponse.self, from: data)
    }
}

Download with Progress

class DownloadService: NSObject, URLSessionDownloadDelegate {
    private lazy var session: URLSession = {
        URLSession(configuration: .default, delegate: self, delegateQueue: nil)
    }()

    private var progressHandler: ((Double) -> Void)?
    private var completionHandler: ((Result<URL, Error>) -> Void)?

    func download(from url: URL, progress: @escaping (Double) -> Void) async throws -> URL {
        try await withCheckedThrowingContinuation { continuation in
            self.progressHandler = progress
            self.completionHandler = { result in
                continuation.resume(with: result)
            }
            session.downloadTask(with: url).resume()
        }
    }

    func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
        completionHandler?(.success(location))
    }

    func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) {
        let progress = Double(totalBytesWritten) / Double(totalBytesExpectedToWrite)
        DispatchQueue.main.async {
            self.progressHandler?(progress)
        }
    }

    func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
        if let error = error {
            completionHandler?(.failure(error))
        }
    }
}

Reference: Performance

Performance

Instruments, memory management, launch optimization, and battery efficiency.

Instruments Profiling

Time Profiler

Find CPU-intensive code:

# Profile from CLI
xcrun xctrace record \
    --template 'Time Profiler' \
    --device-name 'iPhone 16' \
    --launch MyApp.app \
    --output profile.trace

Common issues:

  • Main thread work during UI updates
  • Expensive computations in body
  • Synchronous I/O

Allocations

Track memory usage:

xcrun xctrace record \
    --template 'Allocations' \
    --device-name 'iPhone 16' \
    --launch MyApp.app \
    --output allocations.trace

Look for:

  • Memory growth over time
  • Abandoned memory
  • High transient allocations

Leaks

Find retain cycles:

xcrun xctrace record \
    --template 'Leaks' \
    --device-name 'iPhone 16' \
    --launch MyApp.app \
    --output leaks.trace

Common causes:

  • Strong reference cycles in closures
  • Delegate patterns without weak references
  • Timer retain cycles

Memory Management

Weak References in Closures

// Bad - creates retain cycle
class ViewModel {
    var timer: Timer?

    func startTimer() {
        timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in
            self.update()  // Strong capture
        }
    }
}

// Good - weak capture
class ViewModel {
    var timer: Timer?

    func startTimer() {
        timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
            self?.update()
        }
    }

    deinit {
        timer?.invalidate()
    }
}

Async Task Cancellation

class ViewModel {
    private var loadTask: Task<Void, Never>?

    func load() {
        loadTask?.cancel()
        loadTask = Task { [weak self] in
            guard let self else { return }

            let items = try? await fetchItems()

            // Check cancellation before updating
            guard !Task.isCancelled else { return }

            await MainActor.run {
                self.items = items ?? []
            }
        }
    }

    deinit {
        loadTask?.cancel()
    }
}

Large Data Handling

// Bad - loads all into memory
let allPhotos = try await fetchAllPhotos()
for photo in allPhotos {
    process(photo)
}

// Good - stream processing
for await photo in fetchPhotosStream() {
    process(photo)

    // Allow UI updates
    if shouldYield {
        await Task.yield()
    }
}

SwiftUI Performance

Avoid Expensive Body Computations

// Bad - recomputes on every body call
struct ItemList: View {
    let items: [Item]

    var body: some View {
        let sortedItems = items.sorted { $0.date > $1.date }  // Every render!
        List(sortedItems) { item in
            ItemRow(item: item)
        }
    }
}

// Good - compute once
struct ItemList: View {
    let items: [Item]

    var sortedItems: [Item] {
        items.sorted { $0.date > $1.date }
    }

    var body: some View {
        List(sortedItems) { item in
            ItemRow(item: item)
        }
    }
}

// Better - use @State or computed in view model
struct ItemList: View {
    @State private var sortedItems: [Item] = []
    let items: [Item]

    var body: some View {
        List(sortedItems) { item in
            ItemRow(item: item)
        }
        .onChange(of: items) { _, newItems in
            sortedItems = newItems.sorted { $0.date > $1.date }
        }
    }
}

Optimize List Performance

// Use stable identifiers
struct Item: Identifiable {
    let id: UUID  // Stable identifier
    var name: String
}

// Explicit id for efficiency
List(items, id: \.id) { item in
    ItemRow(item: item)
}

// Lazy loading for large lists
LazyVStack {
    ForEach(items) { item in
        ItemRow(item: item)
    }
}

Equatable Conformance

// Prevent unnecessary re-renders
struct ItemRow: View, Equatable {
    let item: Item

    static func == (lhs: ItemRow, rhs: ItemRow) -> Bool {
        lhs.item.id == rhs.item.id &&
        lhs.item.name == rhs.item.name
    }

    var body: some View {
        Text(item.name)
    }
}

// Use in ForEach
ForEach(items) { item in
    ItemRow(item: item)
        .equatable()
}

Task Modifier Optimization

// Bad - recreates task on any state change
struct ContentView: View {
    @State private var items: [Item] = []
    @State private var searchText = ""

    var body: some View {
        List(filteredItems) { item in
            ItemRow(item: item)
        }
        .task {
            items = await fetchItems()  // Reruns when searchText changes!
        }
    }
}

// Good - use task(id:)
struct ContentView: View {
    @State private var items: [Item] = []
    @State private var searchText = ""
    @State private var needsLoad = true

    var body: some View {
        List(filteredItems) { item in
            ItemRow(item: item)
        }
        .task(id: needsLoad) {
            if needsLoad {
                items = await fetchItems()
                needsLoad = false
            }
        }
    }
}

Launch Time Optimization

Measure Launch Time

# Cold launch measurement
xcrun simctl spawn booted log stream --predicate 'subsystem == "com.apple.os.signpost" && category == "PointsOfInterest"'

In Instruments: App Launch template

Defer Non-Critical Work

@main
struct MyApp: App {
    init() {
        // Critical only
        setupErrorReporting()
    }

    var body: some Scene {
        WindowGroup {
            ContentView()
                .task {
                    // Defer non-critical
                    await setupAnalytics()
                    await preloadData()
                }
        }
    }
}

Avoid Synchronous Work

// Bad - blocks launch
@main
struct MyApp: App {
    let database = Database.load()  // Synchronous I/O

    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

// Good - async initialization
@main
struct MyApp: App {
    @State private var database: Database?

    var body: some Scene {
        WindowGroup {
            if let database {
                ContentView()
                    .environment(database)
            } else {
                LaunchScreen()
            }
        }
        .task {
            database = await Database.load()
        }
    }
}

Reduce Dylib Loading

  • Minimize third-party dependencies
  • Use static linking where possible
  • Merge frameworks

Network Performance

Request Batching

// Bad - many small requests
for id in itemIDs {
    let item = try await fetchItem(id)
    items.append(item)
}

// Good - batch request
let items = try await fetchItems(ids: itemIDs)

Image Loading

// Use AsyncImage with caching
AsyncImage(url: imageURL) { phase in
    switch phase {
    case .empty:
        ProgressView()
    case .success(let image):
        image.resizable().scaledToFit()
    case .failure:
        Image(systemName: "photo")
    @unknown default:
        EmptyView()
    }
}

// For better control, use custom caching
actor ImageCache {
    private var cache: [URL: UIImage] = [:]

    func image(for url: URL) async throws -> UIImage {
        if let cached = cache[url] {
            return cached
        }

        let (data, _) = try await URLSession.shared.data(from: url)
        let image = UIImage(data: data)!
        cache[url] = image
        return image
    }
}

Prefetching

struct ItemList: View {
    let items: [Item]
    let prefetcher = ImagePrefetcher()

    var body: some View {
        List(items) { item in
            ItemRow(item: item)
                .onAppear {
                    // Prefetch next items
                    let index = items.firstIndex(of: item) ?? 0
                    let nextItems = items.dropFirst(index + 1).prefix(5)
                    prefetcher.prefetch(urls: nextItems.compactMap(\.imageURL))
                }
        }
    }
}

Battery Optimization

Location Updates

import CoreLocation

class LocationService: NSObject, CLLocationManagerDelegate {
    private let manager = CLLocationManager()

    func startUpdates() {
        // Use appropriate accuracy
        manager.desiredAccuracy = kCLLocationAccuracyHundredMeters  // Not kCLLocationAccuracyBest

        // Allow deferred updates
        manager.allowsBackgroundLocationUpdates = false
        manager.pausesLocationUpdatesAutomatically = true

        // Use significant change for background
        manager.startMonitoringSignificantLocationChanges()
    }
}

Background Tasks

import BackgroundTasks

func scheduleAppRefresh() {
    let request = BGAppRefreshTaskRequest(identifier: "com.app.refresh")
    request.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60)  // 15 minutes

    do {
        try BGTaskScheduler.shared.submit(request)
    } catch {
        print("Could not schedule app refresh: \(error)")
    }
}

func handleAppRefresh(task: BGAppRefreshTask) {
    // Schedule next refresh
    scheduleAppRefresh()

    let refreshTask = Task {
        do {
            try await syncData()
            task.setTaskCompleted(success: true)
        } catch {
            task.setTaskCompleted(success: false)
        }
    }

    task.expirationHandler = {
        refreshTask.cancel()
    }
}

Network Efficiency

// Use background URL session for large transfers
let config = URLSessionConfiguration.background(withIdentifier: "com.app.background")
config.isDiscretionary = true  // System chooses optimal time
config.allowsCellularAccess = false  // WiFi only for large downloads

let session = URLSession(configuration: config, delegate: self, delegateQueue: nil)

Debugging Performance

Signposts

import os

let signposter = OSSignposter()

func processItems() async {
    let signpostID = signposter.makeSignpostID()
    let state = signposter.beginInterval("Process Items", id: signpostID)

    for item in items {
        signposter.emitEvent("Processing", id: signpostID, "\(item.name)")
        await process(item)
    }

    signposter.endInterval("Process Items", state)
}

MetricKit

import MetricKit

class MetricsManager: NSObject, MXMetricManagerSubscriber {
    override init() {
        super.init()
        MXMetricManager.shared.add(self)
    }

    func didReceive(_ payloads: [MXMetricPayload]) {
        for payload in payloads {
            // Process CPU, memory, launch time metrics
            if let cpuMetrics = payload.cpuMetrics {
                print("CPU time: \(cpuMetrics.cumulativeCPUTime)")
            }
        }
    }

    func didReceive(_ payloads: [MXDiagnosticPayload]) {
        for payload in payloads {
            // Process crash and hang diagnostics
        }
    }
}

Performance Checklist

Launch

  • < 400ms to first frame
  • No synchronous I/O in init
  • Deferred non-critical setup

Memory

  • No leaks
  • Stable memory usage
  • No abandoned memory

UI

  • 60 fps scrolling
  • No main thread blocking
  • Efficient list rendering

Network

  • Request batching
  • Image caching
  • Proper timeout handling

Battery

  • Minimal background activity
  • Efficient location usage
  • Discretionary transfers

Reference: Polish And Ux

Polish and UX

Haptics, animations, gestures, and micro-interactions for premium iOS apps.

Haptics

Impact Feedback

import UIKit

struct HapticEngine {
    // Impact - use for UI element hits
    static func impact(_ style: UIImpactFeedbackGenerator.FeedbackStyle) {
        let generator = UIImpactFeedbackGenerator(style: style)
        generator.impactOccurred()
    }

    // Notification - use for outcomes
    static func notification(_ type: UINotificationFeedbackGenerator.FeedbackType) {
        let generator = UINotificationFeedbackGenerator()
        generator.notificationOccurred(type)
    }

    // Selection - use for picker/selection changes
    static func selection() {
        let generator = UISelectionFeedbackGenerator()
        generator.selectionChanged()
    }
}

// Convenience methods
extension HapticEngine {
    static func light() { impact(.light) }
    static func medium() { impact(.medium) }
    static func heavy() { impact(.heavy) }
    static func rigid() { impact(.rigid) }
    static func soft() { impact(.soft) }

    static func success() { notification(.success) }
    static func warning() { notification(.warning) }
    static func error() { notification(.error) }
}

Usage Guidelines

// Button tap
Button("Add Item") {
    HapticEngine.light()
    addItem()
}

// Successful action
func save() async {
    do {
        try await saveToDisk()
        HapticEngine.success()
    } catch {
        HapticEngine.error()
    }
}

// Toggle
Toggle("Enable", isOn: $isEnabled)
    .onChange(of: isEnabled) { _, _ in
        HapticEngine.selection()
    }

// Destructive action
Button("Delete", role: .destructive) {
    HapticEngine.warning()
    delete()
}

// Picker change
Picker("Size", selection: $size) {
    ForEach(sizes, id: \.self) { size in
        Text(size).tag(size)
    }
}
.onChange(of: size) { _, _ in
    HapticEngine.selection()
}

Animations

Spring Animations

// Standard spring (most natural)
withAnimation(.spring(duration: 0.3)) {
    isExpanded.toggle()
}

// Bouncy spring
withAnimation(.spring(duration: 0.5, bounce: 0.3)) {
    showCard = true
}

// Snappy spring
withAnimation(.spring(duration: 0.2, bounce: 0.0)) {
    offset = .zero
}

// Custom response and damping
withAnimation(.spring(response: 0.4, dampingFraction: 0.8)) {
    scale = 1.0
}

Transitions

struct ContentView: View {
    @State private var showDetail = false

    var body: some View {
        VStack {
            if showDetail {
                DetailView()
                    .transition(.asymmetric(
                        insertion: .move(edge: .trailing).combined(with: .opacity),
                        removal: .move(edge: .leading).combined(with: .opacity)
                    ))
            }
        }
        .animation(.spring(duration: 0.3), value: showDetail)
    }
}

// Custom transition
extension AnyTransition {
    static var slideAndFade: AnyTransition {
        .asymmetric(
            insertion: .move(edge: .bottom).combined(with: .opacity),
            removal: .opacity
        )
    }
}

Phase Animations

struct PulsingView: View {
    @State private var isAnimating = false

    var body: some View {
        Circle()
            .fill(.blue)
            .scaleEffect(isAnimating ? 1.1 : 1.0)
            .opacity(isAnimating ? 0.8 : 1.0)
            .animation(.easeInOut(duration: 1).repeatForever(autoreverses: true), value: isAnimating)
            .onAppear {
                isAnimating = true
            }
    }
}

Keyframe Animations

struct ShakeView: View {
    @State private var trigger = false

    var body: some View {
        Text("Shake me")
            .keyframeAnimator(initialValue: 0.0, trigger: trigger) { content, value in
                content.offset(x: value)
            } keyframes: { _ in
                KeyframeTrack {
                    SpringKeyframe(15, duration: 0.1)
                    SpringKeyframe(-15, duration: 0.1)
                    SpringKeyframe(10, duration: 0.1)
                    SpringKeyframe(-10, duration: 0.1)
                    SpringKeyframe(0, duration: 0.1)
                }
            }
            .onTapGesture {
                trigger.toggle()
            }
    }
}

Gestures

Drag Gesture

struct DraggableCard: View {
    @State private var offset = CGSize.zero
    @State private var isDragging = false

    var body: some View {
        RoundedRectangle(cornerRadius: 16)
            .fill(.blue)
            .frame(width: 200, height: 300)
            .offset(offset)
            .scaleEffect(isDragging ? 1.05 : 1.0)
            .gesture(
                DragGesture()
                    .onChanged { value in
                        withAnimation(.interactiveSpring()) {
                            offset = value.translation
                            isDragging = true
                        }
                    }
                    .onEnded { value in
                        withAnimation(.spring(duration: 0.3)) {
                            // Snap back or dismiss based on threshold
                            if abs(value.translation.width) > 150 {
                                // Dismiss
                                offset = CGSize(width: value.translation.width > 0 ? 500 : -500, height: 0)
                            } else {
                                offset = .zero
                            }
                            isDragging = false
                        }
                    }
            )
    }
}

Long Press with Preview

struct ItemRow: View {
    let item: Item
    @State private var isPressed = false

    var body: some View {
        Text(item.name)
            .scaleEffect(isPressed ? 0.95 : 1.0)
            .gesture(
                LongPressGesture(minimumDuration: 0.5)
                    .onChanged { _ in
                        withAnimation(.easeInOut(duration: 0.1)) {
                            isPressed = true
                        }
                        HapticEngine.medium()
                    }
                    .onEnded { _ in
                        withAnimation(.spring(duration: 0.2)) {
                            isPressed = false
                        }
                        showContextMenu()
                    }
            )
    }
}

Gesture Priority

struct ZoomableImage: View {
    @State private var scale: CGFloat = 1.0
    @State private var offset = CGSize.zero

    var body: some View {
        Image("photo")
            .resizable()
            .scaledToFit()
            .scaleEffect(scale)
            .offset(offset)
            .gesture(
                // Magnification takes priority
                MagnificationGesture()
                    .onChanged { value in
                        scale = value
                    }
                    .onEnded { _ in
                        withAnimation {
                            scale = max(1, scale)
                        }
                    }
                    .simultaneously(with:
                        DragGesture()
                            .onChanged { value in
                                offset = value.translation
                            }
                            .onEnded { _ in
                                withAnimation {
                                    offset = .zero
                                }
                            }
                    )
            )
    }
}

Loading States

Skeleton Loading

struct SkeletonView: View {
    @State private var isAnimating = false

    var body: some View {
        RoundedRectangle(cornerRadius: 8)
            .fill(
                LinearGradient(
                    colors: [.gray.opacity(0.3), .gray.opacity(0.1), .gray.opacity(0.3)],
                    startPoint: .leading,
                    endPoint: .trailing
                )
            )
            .frame(height: 20)
            .mask(
                Rectangle()
                    .offset(x: isAnimating ? 300 : -300)
            )
            .animation(.linear(duration: 1.5).repeatForever(autoreverses: false), value: isAnimating)
            .onAppear {
                isAnimating = true
            }
    }
}

struct LoadingListView: View {
    var body: some View {
        VStack(alignment: .leading, spacing: 16) {
            ForEach(0..<5) { _ in
                HStack {
                    SkeletonView()
                        .frame(width: 50, height: 50)
                    VStack(alignment: .leading, spacing: 8) {
                        SkeletonView()
                            .frame(width: 150)
                        SkeletonView()
                            .frame(width: 100)
                    }
                }
            }
        }
        .padding()
    }
}

Progress Indicators

struct ContentLoadingView: View {
    let progress: Double

    var body: some View {
        VStack(spacing: 16) {
            // Circular progress
            ProgressView(value: progress)
                .progressViewStyle(.circular)

            // Linear progress with percentage
            VStack {
                ProgressView(value: progress)
                Text("\(Int(progress * 100))%")
                    .font(.caption)
                    .foregroundStyle(.secondary)
            }

            // Custom circular
            ZStack {
                Circle()
                    .stroke(.gray.opacity(0.2), lineWidth: 8)
                Circle()
                    .trim(from: 0, to: progress)
                    .stroke(.blue, style: StrokeStyle(lineWidth: 8, lineCap: .round))
                    .rotationEffect(.degrees(-90))
                    .animation(.easeInOut, value: progress)
            }
            .frame(width: 60, height: 60)
        }
    }
}

Micro-interactions

Button Press Effect

struct PressableButton: View {
    let title: String
    let action: () -> Void
    @State private var isPressed = false

    var body: some View {
        Text(title)
            .padding()
            .background(.blue)
            .foregroundStyle(.white)
            .clipShape(RoundedRectangle(cornerRadius: 12))
            .scaleEffect(isPressed ? 0.95 : 1.0)
            .brightness(isPressed ? -0.1 : 0)
            .gesture(
                DragGesture(minimumDistance: 0)
                    .onChanged { _ in
                        withAnimation(.easeInOut(duration: 0.1)) {
                            isPressed = true
                        }
                    }
                    .onEnded { _ in
                        withAnimation(.spring(duration: 0.2)) {
                            isPressed = false
                        }
                        action()
                    }
            )
    }
}

Success Checkmark

struct SuccessCheckmark: View {
    @State private var isComplete = false

    var body: some View {
        ZStack {
            Circle()
                .fill(.green)
                .frame(width: 80, height: 80)
                .scaleEffect(isComplete ? 1 : 0)

            Image(systemName: "checkmark")
                .font(.system(size: 40, weight: .bold))
                .foregroundStyle(.white)
                .scaleEffect(isComplete ? 1 : 0)
                .rotationEffect(.degrees(isComplete ? 0 : -90))
        }
        .onAppear {
            withAnimation(.spring(duration: 0.5, bounce: 0.4).delay(0.1)) {
                isComplete = true
            }
            HapticEngine.success()
        }
    }
}

Pull to Refresh Indicator

struct CustomRefreshView: View {
    @Binding var isRefreshing: Bool

    var body: some View {
        if isRefreshing {
            HStack(spacing: 8) {
                ProgressView()
                Text("Updating...")
                    .font(.caption)
                    .foregroundStyle(.secondary)
            }
            .padding()
        }
    }
}

Scroll Effects

Parallax Header

struct ParallaxHeader: View {
    let minHeight: CGFloat = 200
    let maxHeight: CGFloat = 350

    var body: some View {
        GeometryReader { geometry in
            let offset = geometry.frame(in: .global).minY
            let height = max(minHeight, maxHeight + offset)

            Image("header")
                .resizable()
                .scaledToFill()
                .frame(width: geometry.size.width, height: height)
                .clipped()
                .offset(y: offset > 0 ? -offset : 0)
        }
        .frame(height: maxHeight)
    }
}

Scroll Position Effects

struct FadeOnScrollView: View {
    var body: some View {
        ScrollView {
            LazyVStack {
                ForEach(0..<50) { index in
                    Text("Item \(index)")
                        .padding()
                        .frame(maxWidth: .infinity)
                        .background(.background.secondary)
                        .clipShape(RoundedRectangle(cornerRadius: 8))
                        .scrollTransition { content, phase in
                            content
                                .opacity(phase.isIdentity ? 1 : 0.3)
                                .scaleEffect(phase.isIdentity ? 1 : 0.9)
                        }
                }
            }
            .padding()
        }
    }
}

Empty States

struct EmptyStateView: View {
    let icon: String
    let title: String
    let message: String
    let actionTitle: String?
    let action: (() -> Void)?

    var body: some View {
        VStack(spacing: 16) {
            Image(systemName: icon)
                .font(.system(size: 60))
                .foregroundStyle(.secondary)

            Text(title)
                .font(.title2.bold())

            Text(message)
                .font(.body)
                .foregroundStyle(.secondary)
                .multilineTextAlignment(.center)

            if let actionTitle, let action {
                Button(actionTitle, action: action)
                    .buttonStyle(.borderedProminent)
                    .padding(.top)
            }
        }
        .padding(40)
    }
}

// Usage
if items.isEmpty {
    EmptyStateView(
        icon: "tray",
        title: "No Items",
        message: "Add your first item to get started",
        actionTitle: "Add Item",
        action: { showNewItem = true }
    )
}

Best Practices

Respect Reduce Motion

@Environment(\.accessibilityReduceMotion) private var reduceMotion

var body: some View {
    Button("Action") { }
        .scaleEffect(isPressed ? 0.95 : 1.0)
        .animation(reduceMotion ? .none : .spring(), value: isPressed)
}

Consistent Timing

Use consistent animation durations:

  • Quick feedback: 0.1-0.2s
  • Standard transitions: 0.3s
  • Prominent animations: 0.5s

Haptic Pairing

Always pair animations with appropriate haptics:

  • Success animation → success haptic
  • Error shake → error haptic
  • Selection change → selection haptic

Reference: Project Scaffolding

Project Scaffolding

Complete setup guide for new iOS projects with CLI-only development workflow.

Install XcodeGen (one-time):

brew install xcodegen

Create a new iOS app:

mkdir MyApp && cd MyApp
mkdir -p MyApp/{App,Models,Views,Services,Resources} MyAppTests MyAppUITests
# Create project.yml (see template below)
# Create Swift files
xcodegen generate
xcodebuild -project MyApp.xcodeproj -scheme MyApp -destination 'platform=iOS Simulator,name=iPhone 16' build

project.yml Template

Complete iOS SwiftUI app with tests:

name: MyApp
options:
  bundleIdPrefix: com.yourcompany
  deploymentTarget:
    iOS: "18.0"
  xcodeVersion: "16.0"
  createIntermediateGroups: true

configs:
  Debug: debug
  Release: release

settings:
  base:
    SWIFT_VERSION: "5.9"
    IPHONEOS_DEPLOYMENT_TARGET: "18.0"
    TARGETED_DEVICE_FAMILY: "1,2"

targets:
  MyApp:
    type: application
    platform: iOS
    sources:
      - MyApp
    resources:
      - path: MyApp/Resources
        excludes:
          - "**/.DS_Store"
    info:
      path: MyApp/Info.plist
      properties:
        UILaunchScreen: {}
        CFBundleName: $(PRODUCT_NAME)
        CFBundleIdentifier: $(PRODUCT_BUNDLE_IDENTIFIER)
        CFBundleShortVersionString: "1.0"
        CFBundleVersion: "1"
        UIRequiredDeviceCapabilities:
          - armv7
        UISupportedInterfaceOrientations:
          - UIInterfaceOrientationPortrait
          - UIInterfaceOrientationLandscapeLeft
          - UIInterfaceOrientationLandscapeRight
        UISupportedInterfaceOrientations~ipad:
          - UIInterfaceOrientationPortrait
          - UIInterfaceOrientationPortraitUpsideDown
          - UIInterfaceOrientationLandscapeLeft
          - UIInterfaceOrientationLandscapeRight
    entitlements:
      path: MyApp/MyApp.entitlements
      properties:
        aps-environment: development
    settings:
      base:
        PRODUCT_BUNDLE_IDENTIFIER: com.yourcompany.myapp
        PRODUCT_NAME: MyApp
        CODE_SIGN_STYLE: Automatic
        DEVELOPMENT_TEAM: YOURTEAMID
        ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
        ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AccentColor
      configs:
        Debug:
          DEBUG_INFORMATION_FORMAT: dwarf-with-dsym
          SWIFT_OPTIMIZATION_LEVEL: -Onone
        Release:
          SWIFT_OPTIMIZATION_LEVEL: -Osize

  MyAppTests:
    type: bundle.unit-test
    platform: iOS
    sources:
      - MyAppTests
    dependencies:
      - target: MyApp
    settings:
      base:
        PRODUCT_BUNDLE_IDENTIFIER: com.yourcompany.myapp.tests

  MyAppUITests:
    type: bundle.ui-testing
    platform: iOS
    sources:
      - MyAppUITests
    dependencies:
      - target: MyApp
    settings:
      base:
        PRODUCT_BUNDLE_IDENTIFIER: com.yourcompany.myapp.uitests
        TEST_TARGET_NAME: MyApp

schemes:
  MyApp:
    build:
      targets:
        MyApp: all
        MyAppTests: [test]
        MyAppUITests: [test]
    run:
      config: Debug
    test:
      config: Debug
      gatherCoverageData: true
      targets:
        - MyAppTests
        - MyAppUITests
    profile:
      config: Release
    archive:
      config: Release

project.yml with SwiftData

Add SwiftData support:

targets:
  MyApp:
    # ... existing config ...
    settings:
      base:
        # ... existing settings ...
        SWIFT_ACTIVE_COMPILATION_CONDITIONS: "$(inherited) SWIFT_DATA"
    dependencies:
      - sdk: SwiftData.framework

project.yml with Swift Packages

packages:
  Alamofire:
    url: https://github.com/Alamofire/Alamofire
    from: 5.8.0
  KeychainAccess:
    url: https://github.com/kishikawakatsumi/KeychainAccess
    from: 4.2.0

targets:
  MyApp:
    # ... other config ...
    dependencies:
      - package: Alamofire
      - package: KeychainAccess

Alternative: Xcode GUI

For users who prefer Xcode:

  1. File > New > Project > iOS > App
  2. Settings: SwiftUI, Swift, SwiftData (optional)
  3. Save and close Xcode

File Structure

MyApp/
├── MyApp.xcodeproj/
├── MyApp/
│   ├── App/
│   │   ├── MyApp.swift
│   │   ├── AppState.swift
│   │   └── AppDependencies.swift
│   ├── Models/
│   ├── Views/
│   │   ├── ContentView.swift
│   │   ├── Screens/
│   │   └── Components/
│   ├── Services/
│   ├── Utilities/
│   ├── Resources/
│   │   ├── Assets.xcassets/
│   │   ├── Localizable.xcstrings
│   │   └── PrivacyInfo.xcprivacy
│   ├── Info.plist
│   └── MyApp.entitlements
├── MyAppTests/
└── MyAppUITests/

Starter Code

MyApp.swift

import SwiftUI

@main
struct MyApp: App {
    @State private var appState = AppState()

    init() {
        configureAppearance()
    }

    var body: some Scene {
        WindowGroup {
            ContentView()
                .environment(appState)
                .task {
                    await appState.initialize()
                }
        }
    }

    private func configureAppearance() {
        // Global appearance customization
    }
}

AppState.swift

import SwiftUI

@Observable
class AppState {
    // Navigation
    var navigationPath = NavigationPath()
    var selectedTab: Tab = .home

    // App state
    var isLoading = false
    var error: AppError?
    var user: User?

    // Feature flags
    var isPremium = false

    enum Tab: Hashable {
        case home, search, profile
    }

    func initialize() async {
        // Load initial data
        // Check purchase status
        // Request permissions if needed
    }

    func handleDeepLink(_ url: URL) {
        // Parse URL and update navigation
    }
}

enum AppError: LocalizedError {
    case networkError(Error)
    case dataError(String)
    case unauthorized

    var errorDescription: String? {
        switch self {
        case .networkError(let error):
            return error.localizedDescription
        case .dataError(let message):
            return message
        case .unauthorized:
            return "Please sign in to continue"
        }
    }
}

ContentView.swift

import SwiftUI

struct ContentView: View {
    @Environment(AppState.self) private var appState

    var body: some View {
        @Bindable var appState = appState

        TabView(selection: $appState.selectedTab) {
            HomeScreen()
                .tabItem {
                    Label("Home", systemImage: "house")
                }
                .tag(AppState.Tab.home)

            SearchScreen()
                .tabItem {
                    Label("Search", systemImage: "magnifyingglass")
                }
                .tag(AppState.Tab.search)

            ProfileScreen()
                .tabItem {
                    Label("Profile", systemImage: "person")
                }
                .tag(AppState.Tab.profile)
        }
        .overlay {
            if appState.isLoading {
                LoadingOverlay()
            }
        }
        .alert("Error", isPresented: .constant(appState.error != nil)) {
            Button("OK") { appState.error = nil }
        } message: {
            if let error = appState.error {
                Text(error.localizedDescription)
            }
        }
    }
}

Privacy Manifest

Required for App Store submission. Create PrivacyInfo.xcprivacy:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>NSPrivacyTracking</key>
    <false/>
    <key>NSPrivacyTrackingDomains</key>
    <array/>
    <key>NSPrivacyCollectedDataTypes</key>
    <array>
        <!-- Add collected data types here -->
    </array>
    <key>NSPrivacyAccessedAPITypes</key>
    <array>
        <dict>
            <key>NSPrivacyAccessedAPIType</key>
            <string>NSPrivacyAccessedAPICategoryUserDefaults</string>
            <key>NSPrivacyAccessedAPITypeReasons</key>
            <array>
                <string>CA92.1</string>
            </array>
        </dict>
    </array>
</dict>
</plist>

Entitlements Template

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <!-- Push Notifications -->
    <key>aps-environment</key>
    <string>development</string>

    <!-- App Groups (for shared data) -->
    <key>com.apple.security.application-groups</key>
    <array>
        <string>group.com.yourcompany.myapp</string>
    </array>
</dict>
</plist>

Xcode Project Creation

Create via command line using xcodegen or tuist, or create in Xcode and immediately close:

# Option 1: Using xcodegen
brew install xcodegen
# Create project.yml, then:
xcodegen generate

# Option 2: Create in Xcode, configure, close
# File > New > Project > iOS > App
# Configure settings, then close Xcode

Build Configuration

Development vs Release

# Debug build
xcodebuild -project MyApp.xcodeproj \
    -scheme MyApp \
    -configuration Debug \
    -destination 'platform=iOS Simulator,name=iPhone 16' \
    build

# Release build
xcodebuild -project MyApp.xcodeproj \
    -scheme MyApp \
    -configuration Release \
    -destination 'generic/platform=iOS' \
    build

Environment Variables

Use xcconfig files for different environments:

// Debug.xcconfig
API_BASE_URL = https://dev-api.example.com
ENABLE_LOGGING = YES

// Release.xcconfig
API_BASE_URL = https://api.example.com
ENABLE_LOGGING = NO

Access in code:

let apiURL = Bundle.main.infoDictionary?["API_BASE_URL"] as? String

Asset Catalog Setup

App Icon

  • Provide 1024x1024 PNG
  • Xcode generates all sizes automatically

Colors

Define semantic colors in Assets.xcassets:

  • AccentColor - App tint color
  • BackgroundPrimary - Main background
  • TextPrimary - Primary text

SF Symbols

Prefer SF Symbols for icons. Use custom symbols only when necessary.

Localization Setup

  1. Enable localization in project settings
  2. Create Localizable.xcstrings (Xcode 15+)
  3. Use String Catalogs for automatic extraction
// Strings are automatically extracted
Text("Welcome")
Text("Items: \(count)")

Reference: Push Notifications

Push Notifications

APNs setup, registration, rich notifications, and silent push.

Basic Setup

Request Permission

import UserNotifications

class PushService: NSObject {
    static let shared = PushService()

    func requestPermission() async -> Bool {
        let center = UNUserNotificationCenter.current()
        center.delegate = self

        do {
            let granted = try await center.requestAuthorization(options: [.alert, .sound, .badge])
            if granted {
                await registerForRemoteNotifications()
            }
            return granted
        } catch {
            print("Permission request failed: \(error)")
            return false
        }
    }

    @MainActor
    private func registerForRemoteNotifications() {
        UIApplication.shared.registerForRemoteNotifications()
    }

    func checkPermissionStatus() async -> UNAuthorizationStatus {
        let settings = await UNUserNotificationCenter.current().notificationSettings()
        return settings.authorizationStatus
    }
}

extension PushService: UNUserNotificationCenterDelegate {
    // Handle notification when app is in foreground
    func userNotificationCenter(
        _ center: UNUserNotificationCenter,
        willPresent notification: UNNotification
    ) async -> UNNotificationPresentationOptions {
        return [.banner, .sound, .badge]
    }

    // Handle notification tap
    func userNotificationCenter(
        _ center: UNUserNotificationCenter,
        didReceive response: UNNotificationResponse
    ) async {
        let userInfo = response.notification.request.content.userInfo

        // Handle action
        switch response.actionIdentifier {
        case UNNotificationDefaultActionIdentifier:
            // User tapped notification
            handleNotificationTap(userInfo)
        case "REPLY_ACTION":
            if let textResponse = response as? UNTextInputNotificationResponse {
                handleReply(textResponse.userText, userInfo: userInfo)
            }
        default:
            break
        }
    }

    private func handleNotificationTap(_ userInfo: [AnyHashable: Any]) {
        // Navigate to relevant screen
        if let itemID = userInfo["item_id"] as? String {
            // appState.navigateToItem(id: itemID)
        }
    }

    private func handleReply(_ text: String, userInfo: [AnyHashable: Any]) {
        // Send reply
    }
}

Handle Device Token

In your App or AppDelegate:

// Using UIApplicationDelegateAdaptor
@main
struct MyApp: App {
    @UIApplicationDelegateAdaptor(AppDelegate.self) var delegate

    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

class AppDelegate: NSObject, UIApplicationDelegate {
    func application(
        _ application: UIApplication,
        didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
    ) {
        let token = deviceToken.map { String(format: "%02.2hhx", $0) }.joined()
        print("Device Token: \(token)")

        // Send to your server
        Task {
            try? await sendTokenToServer(token)
        }
    }

    func application(
        _ application: UIApplication,
        didFailToRegisterForRemoteNotificationsWithError error: Error
    ) {
        print("Failed to register: \(error)")
    }

    private func sendTokenToServer(_ token: String) async throws {
        // POST to your server
    }
}

Rich Notifications

Notification Content Extension

  1. File > New > Target > Notification Content Extension
  2. Configure in Info.plist:
<key>NSExtension</key>
<dict>
    <key>NSExtensionAttributes</key>
    <dict>
        <key>UNNotificationExtensionCategory</key>
        <string>MEDIA_CATEGORY</string>
        <key>UNNotificationExtensionInitialContentSizeRatio</key>
        <real>0.5</real>
    </dict>
    <key>NSExtensionMainStoryboard</key>
    <string>MainInterface</string>
    <key>NSExtensionPointIdentifier</key>
    <string>com.apple.usernotifications.content-extension</string>
</dict>
  1. Implement NotificationViewController:
import UIKit
import UserNotifications
import UserNotificationsUI

class NotificationViewController: UIViewController, UNNotificationContentExtension {
    @IBOutlet weak var imageView: UIImageView!
    @IBOutlet weak var titleLabel: UILabel!

    func didReceive(_ notification: UNNotification) {
        let content = notification.request.content

        titleLabel.text = content.title

        // Load attachment
        if let attachment = content.attachments.first,
           attachment.url.startAccessingSecurityScopedResource() {
            defer { attachment.url.stopAccessingSecurityScopedResource() }

            if let data = try? Data(contentsOf: attachment.url),
               let image = UIImage(data: data) {
                imageView.image = image
            }
        }
    }
}

Notification Service Extension

Modify notification content before display:

  1. File > New > Target > Notification Service Extension
  2. Implement:
import UserNotifications

class NotificationService: UNNotificationServiceExtension {
    var contentHandler: ((UNNotificationContent) -> Void)?
    var bestAttemptContent: UNMutableNotificationContent?

    override func didReceive(
        _ request: UNNotificationRequest,
        withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void
    ) {
        self.contentHandler = contentHandler
        bestAttemptContent = (request.content.mutableCopy() as? UNMutableNotificationContent)

        guard let bestAttemptContent = bestAttemptContent else {
            contentHandler(request.content)
            return
        }

        // Download and attach media
        if let imageURLString = bestAttemptContent.userInfo["image_url"] as? String,
           let imageURL = URL(string: imageURLString) {
            downloadImage(from: imageURL) { attachment in
                if let attachment = attachment {
                    bestAttemptContent.attachments = [attachment]
                }
                contentHandler(bestAttemptContent)
            }
        } else {
            contentHandler(bestAttemptContent)
        }
    }

    override func serviceExtensionTimeWillExpire() {
        // Called just before extension is terminated
        if let contentHandler = contentHandler,
           let bestAttemptContent = bestAttemptContent {
            contentHandler(bestAttemptContent)
        }
    }

    private func downloadImage(from url: URL, completion: @escaping (UNNotificationAttachment?) -> Void) {
        let task = URLSession.shared.downloadTask(with: url) { location, _, error in
            guard let location = location, error == nil else {
                completion(nil)
                return
            }

            let tempDirectory = FileManager.default.temporaryDirectory
            let tempFile = tempDirectory.appendingPathComponent(UUID().uuidString + ".jpg")

            do {
                try FileManager.default.moveItem(at: location, to: tempFile)
                let attachment = try UNNotificationAttachment(identifier: "image", url: tempFile)
                completion(attachment)
            } catch {
                completion(nil)
            }
        }
        task.resume()
    }
}

Actions and Categories

Define Actions

func registerNotificationCategories() {
    // Actions
    let replyAction = UNTextInputNotificationAction(
        identifier: "REPLY_ACTION",
        title: "Reply",
        options: [],
        textInputButtonTitle: "Send",
        textInputPlaceholder: "Type your reply..."
    )

    let markReadAction = UNNotificationAction(
        identifier: "MARK_READ_ACTION",
        title: "Mark as Read",
        options: []
    )

    let deleteAction = UNNotificationAction(
        identifier: "DELETE_ACTION",
        title: "Delete",
        options: [.destructive]
    )

    // Category
    let messageCategory = UNNotificationCategory(
        identifier: "MESSAGE_CATEGORY",
        actions: [replyAction, markReadAction, deleteAction],
        intentIdentifiers: [],
        options: []
    )

    // Register
    UNUserNotificationCenter.current().setNotificationCategories([messageCategory])
}

Send with Category

{
    "aps": {
        "alert": {
            "title": "New Message",
            "body": "You have a new message from John"
        },
        "category": "MESSAGE_CATEGORY",
        "mutable-content": 1
    },
    "image_url": "https://example.com/image.jpg"
}

Silent Push

For background data updates:

Configuration

Add to entitlements:

<key>UIBackgroundModes</key>
<array>
    <string>remote-notification</string>
</array>

Handle Silent Push

class AppDelegate: NSObject, UIApplicationDelegate {
    func application(
        _ application: UIApplication,
        didReceiveRemoteNotification userInfo: [AnyHashable: Any]
    ) async -> UIBackgroundFetchResult {
        // Process in background
        do {
            try await syncData()
            return .newData
        } catch {
            return .failed
        }
    }

    private func syncData() async throws {
        // Fetch new data
    }
}

Send Silent Push

{
    "aps": {
        "content-available": 1
    },
    "data": {
        "type": "sync",
        "timestamp": "2025-01-01T00:00:00Z"
    }
}

Local Notifications

Schedule notifications without server:

class LocalNotificationService {
    func scheduleReminder(title: String, body: String, at date: Date, id: String) async throws {
        let content = UNMutableNotificationContent()
        content.title = title
        content.body = body
        content.sound = .default

        let components = Calendar.current.dateComponents([.year, .month, .day, .hour, .minute], from: date)
        let trigger = UNCalendarNotificationTrigger(dateMatching: components, repeats: false)

        let request = UNNotificationRequest(identifier: id, content: content, trigger: trigger)

        try await UNUserNotificationCenter.current().add(request)
    }

    func scheduleRepeating(title: String, body: String, hour: Int, minute: Int, id: String) async throws {
        let content = UNMutableNotificationContent()
        content.title = title
        content.body = body
        content.sound = .default

        var components = DateComponents()
        components.hour = hour
        components.minute = minute

        let trigger = UNCalendarNotificationTrigger(dateMatching: components, repeats: true)

        let request = UNNotificationRequest(identifier: id, content: content, trigger: trigger)

        try await UNUserNotificationCenter.current().add(request)
    }

    func cancel(_ id: String) {
        UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: [id])
    }

    func cancelAll() {
        UNUserNotificationCenter.current().removeAllPendingNotificationRequests()
    }
}

Badge Management

extension PushService {
    func updateBadge(count: Int) async {
        do {
            try await UNUserNotificationCenter.current().setBadgeCount(count)
        } catch {
            print("Failed to set badge: \(error)")
        }
    }

    func clearBadge() async {
        await updateBadge(count: 0)
    }
}

APNs Server Setup

Payload Format

{
    "aps": {
        "alert": {
            "title": "Title",
            "subtitle": "Subtitle",
            "body": "Body text"
        },
        "badge": 1,
        "sound": "default",
        "thread-id": "group-id",
        "category": "CATEGORY_ID"
    },
    "custom_key": "custom_value"
}

Sending with JWT

curl -v \
    --header "authorization: bearer $JWT" \
    --header "apns-topic: com.yourcompany.app" \
    --header "apns-push-type: alert" \
    --http2 \
    --data '{"aps":{"alert":"Hello"}}' \
    https://api.push.apple.com/3/device/$DEVICE_TOKEN

Best Practices

Request Permission at Right Time

// Don't request on launch
// Instead, request after value is demonstrated
func onFirstMessageReceived() {
    Task {
        let granted = await PushService.shared.requestPermission()
        if !granted {
            showPermissionBenefitsSheet()
        }
    }
}

Handle Permission Denied

func showNotificationSettings() {
    if let url = URL(string: UIApplication.openSettingsURLString) {
        UIApplication.shared.open(url)
    }
}

Group Notifications

{
    "aps": {
        "alert": "New message",
        "thread-id": "conversation-123"
    }
}

Time Sensitive (iOS 15+)

{
    "aps": {
        "alert": "Your order arrived",
        "interruption-level": "time-sensitive"
    }
}

Reference: Security

Security

Keychain, secure storage, biometrics, and secure coding practices.

Keychain

KeychainService

import Security

class KeychainService {
    enum KeychainError: Error {
        case saveFailed(OSStatus)
        case loadFailed(OSStatus)
        case deleteFailed(OSStatus)
        case dataConversionError
        case itemNotFound
    }

    private let service: String

    init(service: String = Bundle.main.bundleIdentifier ?? "app") {
        self.service = service
    }

    // MARK: - Data

    func save(_ data: Data, for key: String, accessibility: CFString = kSecAttrAccessibleWhenUnlocked) throws {
        // Delete existing
        try? delete(key)

        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrService as String: service,
            kSecAttrAccount as String: key,
            kSecValueData as String: data,
            kSecAttrAccessible as String: accessibility
        ]

        let status = SecItemAdd(query as CFDictionary, nil)
        guard status == errSecSuccess else {
            throw KeychainError.saveFailed(status)
        }
    }

    func load(_ key: String) throws -> Data {
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrService as String: service,
            kSecAttrAccount as String: key,
            kSecReturnData as String: true,
            kSecMatchLimit as String: kSecMatchLimitOne
        ]

        var result: AnyObject?
        let status = SecItemCopyMatching(query as CFDictionary, &result)

        guard status != errSecItemNotFound else {
            throw KeychainError.itemNotFound
        }

        guard status == errSecSuccess else {
            throw KeychainError.loadFailed(status)
        }

        guard let data = result as? Data else {
            throw KeychainError.dataConversionError
        }

        return data
    }

    func delete(_ key: String) throws {
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrService as String: service,
            kSecAttrAccount as String: key
        ]

        let status = SecItemDelete(query as CFDictionary)
        guard status == errSecSuccess || status == errSecItemNotFound else {
            throw KeychainError.deleteFailed(status)
        }
    }

    // MARK: - Convenience

    func saveString(_ value: String, for key: String) throws {
        guard let data = value.data(using: .utf8) else {
            throw KeychainError.dataConversionError
        }
        try save(data, for: key)
    }

    func loadString(_ key: String) throws -> String {
        let data = try load(key)
        guard let string = String(data: data, encoding: .utf8) else {
            throw KeychainError.dataConversionError
        }
        return string
    }

    func saveCodable<T: Codable>(_ value: T, for key: String) throws {
        let data = try JSONEncoder().encode(value)
        try save(data, for: key)
    }

    func loadCodable<T: Codable>(_ type: T.Type, for key: String) throws -> T {
        let data = try load(key)
        return try JSONDecoder().decode(type, from: data)
    }
}

Accessibility Options

// Available when unlocked
kSecAttrAccessibleWhenUnlocked

// Available when unlocked, not backed up
kSecAttrAccessibleWhenUnlockedThisDeviceOnly

// Available after first unlock (background access)
kSecAttrAccessibleAfterFirstUnlock

// Always available (not recommended)
kSecAttrAccessibleAlways

Biometric Authentication

Local Authentication

import LocalAuthentication

class BiometricService {
    enum BiometricType {
        case none, touchID, faceID
    }

    var biometricType: BiometricType {
        let context = LAContext()
        var error: NSError?

        guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) else {
            return .none
        }

        switch context.biometryType {
        case .touchID:
            return .touchID
        case .faceID:
            return .faceID
        default:
            return .none
        }
    }

    func authenticate(reason: String) async -> Bool {
        let context = LAContext()
        context.localizedCancelTitle = "Cancel"

        var error: NSError?
        guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) else {
            return false
        }

        do {
            return try await context.evaluatePolicy(
                .deviceOwnerAuthenticationWithBiometrics,
                localizedReason: reason
            )
        } catch {
            return false
        }
    }

    func authenticateWithFallback(reason: String) async -> Bool {
        let context = LAContext()

        do {
            // Try biometrics first, fall back to passcode
            return try await context.evaluatePolicy(
                .deviceOwnerAuthentication,  // Includes passcode fallback
                localizedReason: reason
            )
        } catch {
            return false
        }
    }
}

Biometric-Protected Keychain

extension KeychainService {
    func saveBiometricProtected(_ data: Data, for key: String) throws {
        try? delete(key)

        var error: Unmanaged<CFError>?
        guard let access = SecAccessControlCreateWithFlags(
            nil,
            kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
            .biometryCurrentSet,  // Invalidate if biometrics change
            &error
        ) else {
            throw error!.takeRetainedValue()
        }

        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrService as String: service,
            kSecAttrAccount as String: key,
            kSecValueData as String: data,
            kSecAttrAccessControl as String: access
        ]

        let status = SecItemAdd(query as CFDictionary, nil)
        guard status == errSecSuccess else {
            throw KeychainError.saveFailed(status)
        }
    }

    func loadBiometricProtected(_ key: String, prompt: String) throws -> Data {
        let context = LAContext()
        context.localizedReason = prompt

        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrService as String: service,
            kSecAttrAccount as String: key,
            kSecReturnData as String: true,
            kSecUseAuthenticationContext as String: context
        ]

        var result: AnyObject?
        let status = SecItemCopyMatching(query as CFDictionary, &result)

        guard status == errSecSuccess, let data = result as? Data else {
            throw KeychainError.loadFailed(status)
        }

        return data
    }
}

Secure Network Communication

Certificate Pinning

class PinnedURLSessionDelegate: NSObject, URLSessionDelegate {
    private let pinnedCertificates: [SecCertificate]

    init(certificates: [SecCertificate]) {
        self.pinnedCertificates = certificates
    }

    func urlSession(
        _ session: URLSession,
        didReceive challenge: URLAuthenticationChallenge
    ) async -> (URLSession.AuthChallengeDisposition, URLCredential?) {
        guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
              let serverTrust = challenge.protectionSpace.serverTrust else {
            return (.cancelAuthenticationChallenge, nil)
        }

        // Get server certificate
        guard let serverCertificate = SecTrustCopyCertificateChain(serverTrust)?
                .first else {
            return (.cancelAuthenticationChallenge, nil)
        }

        // Compare with pinned certificates
        let serverCertData = SecCertificateCopyData(serverCertificate) as Data

        for pinnedCert in pinnedCertificates {
            let pinnedCertData = SecCertificateCopyData(pinnedCert) as Data
            if serverCertData == pinnedCertData {
                let credential = URLCredential(trust: serverTrust)
                return (.useCredential, credential)
            }
        }

        return (.cancelAuthenticationChallenge, nil)
    }
}

App Transport Security

In Info.plist (avoid if possible):

<key>NSAppTransportSecurity</key>
<dict>
    <key>NSExceptionDomains</key>
    <dict>
        <key>legacy-api.example.com</key>
        <dict>
            <key>NSExceptionAllowsInsecureHTTPLoads</key>
            <true/>
            <key>NSExceptionMinimumTLSVersion</key>
            <string>TLSv1.2</string>
        </dict>
    </dict>
</dict>

Data Protection

File Protection

// Protect files on disk
let fileURL = documentsDirectory.appendingPathComponent("sensitive.dat")
try data.write(to: fileURL, options: .completeFileProtection)

// Check protection class
let attributes = try FileManager.default.attributesOfItem(atPath: fileURL.path)
let protection = attributes[.protectionKey] as? FileProtectionType

In-Memory Sensitive Data

// Clear sensitive data when done
var password = "secret"
defer {
    password.removeAll()  // Clear from memory
}

// For arrays
var sensitiveBytes = [UInt8](repeating: 0, count: 32)
defer {
    sensitiveBytes.withUnsafeMutableBytes { ptr in
        memset_s(ptr.baseAddress, ptr.count, 0, ptr.count)
    }
}

Secure Coding Practices

Input Validation

func processInput(_ input: String) throws -> String {
    // Validate length
    guard input.count <= 1000 else {
        throw ValidationError.tooLong
    }

    // Sanitize HTML
    let sanitized = input
        .replacingOccurrences(of: "<", with: "&lt;")
        .replacingOccurrences(of: ">", with: "&gt;")

    // Validate format if needed
    guard isValidFormat(sanitized) else {
        throw ValidationError.invalidFormat
    }

    return sanitized
}

SQL Injection Prevention

With SwiftData/Core Data, use predicates:

// Safe - parameterized
let predicate = #Predicate<Item> { $0.name == searchTerm }

// Never do this
// let sql = "SELECT * FROM items WHERE name = '\(searchTerm)'"

Avoid Logging Sensitive Data

func authenticate(username: String, password: String) async throws {
    // Bad
    // print("Authenticating \(username) with password \(password)")

    // Good
    print("Authenticating user: \(username)")

    // Use OSLog with privacy
    import os
    let logger = Logger(subsystem: "com.app", category: "auth")
    logger.info("Authenticating user: \(username, privacy: .public)")
    logger.debug("Password length: \(password.count)")  // Length only, never value
}

Jailbreak Detection

class SecurityChecker {
    func isDeviceCompromised() -> Bool {
        // Check for common jailbreak files
        let suspiciousPaths = [
            "/Applications/Cydia.app",
            "/Library/MobileSubstrate/MobileSubstrate.dylib",
            "/bin/bash",
            "/usr/sbin/sshd",
            "/etc/apt",
            "/private/var/lib/apt/"
        ]

        for path in suspiciousPaths {
            if FileManager.default.fileExists(atPath: path) {
                return true
            }
        }

        // Check if can write outside sandbox
        let testPath = "/private/jailbreak_test.txt"
        do {
            try "test".write(toFile: testPath, atomically: true, encoding: .utf8)
            try FileManager.default.removeItem(atPath: testPath)
            return true
        } catch {
            // Expected - can't write outside sandbox
        }

        // Check for fork
        let forkResult = fork()
        if forkResult >= 0 {
            // Fork succeeded - jailbroken
            return true
        }

        return false
    }
}

App Store Privacy

Privacy Manifest

Create PrivacyInfo.xcprivacy:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>NSPrivacyTracking</key>
    <false/>
    <key>NSPrivacyTrackingDomains</key>
    <array/>
    <key>NSPrivacyCollectedDataTypes</key>
    <array>
        <dict>
            <key>NSPrivacyCollectedDataType</key>
            <string>NSPrivacyCollectedDataTypeEmailAddress</string>
            <key>NSPrivacyCollectedDataTypeLinked</key>
            <true/>
            <key>NSPrivacyCollectedDataTypeTracking</key>
            <false/>
            <key>NSPrivacyCollectedDataTypePurposes</key>
            <array>
                <string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string>
            </array>
        </dict>
    </array>
    <key>NSPrivacyAccessedAPITypes</key>
    <array>
        <dict>
            <key>NSPrivacyAccessedAPIType</key>
            <string>NSPrivacyAccessedAPICategoryUserDefaults</string>
            <key>NSPrivacyAccessedAPITypeReasons</key>
            <array>
                <string>CA92.1</string>
            </array>
        </dict>
    </array>
</dict>
</plist>

App Tracking Transparency

import AppTrackingTransparency

func requestTrackingPermission() async -> ATTrackingManager.AuthorizationStatus {
    await ATTrackingManager.requestTrackingAuthorization()
}

// Check before tracking
if ATTrackingManager.trackingAuthorizationStatus == .authorized {
    // Can use IDFA for tracking
}

Security Checklist

Data Storage

  • Sensitive data in Keychain, not UserDefaults
  • Appropriate Keychain accessibility
  • File protection for sensitive files
  • Clear sensitive data from memory

Network

  • HTTPS only (ATS)
  • Certificate pinning for sensitive APIs
  • Secure token storage
  • No hardcoded secrets

Authentication

  • Biometric option available
  • Secure session management
  • Token refresh handling
  • Logout clears all data

Code

  • Input validation
  • No sensitive data in logs
  • Parameterized queries
  • No hardcoded credentials

Privacy

  • Privacy manifest complete
  • ATT compliance
  • Minimal data collection
  • Clear privacy policy

Reference: Storekit

StoreKit 2

In-app purchases, subscriptions, and paywalls for iOS apps.

Basic Setup

Product Configuration

Define products in App Store Connect, then load in app:

import StoreKit

@Observable
class PurchaseService {
    private(set) var products: [Product] = []
    private(set) var purchasedProductIDs: Set<String> = []
    private(set) var subscriptionStatus: SubscriptionStatus = .unknown

    private var transactionListener: Task<Void, Error>?

    enum SubscriptionStatus {
        case unknown
        case subscribed
        case expired
        case inGracePeriod
        case notSubscribed
    }

    init() {
        transactionListener = listenForTransactions()
    }

    deinit {
        transactionListener?.cancel()
    }

    func loadProducts() async throws {
        let productIDs = [
            "com.app.premium.monthly",
            "com.app.premium.yearly",
            "com.app.lifetime"
        ]
        products = try await Product.products(for: productIDs)
            .sorted { $0.price < $1.price }
    }

    func purchase(_ product: Product) async throws -> PurchaseResult {
        let result = try await product.purchase()

        switch result {
        case .success(let verification):
            let transaction = try checkVerified(verification)
            await updatePurchasedProducts()
            await transaction.finish()
            return .success

        case .userCancelled:
            return .cancelled

        case .pending:
            return .pending

        @unknown default:
            return .failed
        }
    }

    func restorePurchases() async throws {
        try await AppStore.sync()
        await updatePurchasedProducts()
    }

    private func checkVerified<T>(_ result: VerificationResult<T>) throws -> T {
        switch result {
        case .unverified(_, let error):
            throw StoreError.verificationFailed(error)
        case .verified(let safe):
            return safe
        }
    }

    func updatePurchasedProducts() async {
        var purchased: Set<String> = []

        // Check non-consumables and subscriptions
        for await result in Transaction.currentEntitlements {
            guard case .verified(let transaction) = result else { continue }
            purchased.insert(transaction.productID)
        }

        purchasedProductIDs = purchased
        await updateSubscriptionStatus()
    }

    private func updateSubscriptionStatus() async {
        // Check subscription group status
        guard let groupID = products.first?.subscription?.subscriptionGroupID else {
            subscriptionStatus = .notSubscribed
            return
        }

        do {
            let statuses = try await Product.SubscriptionInfo.status(for: groupID)
            guard let status = statuses.first else {
                subscriptionStatus = .notSubscribed
                return
            }

            switch status.state {
            case .subscribed:
                subscriptionStatus = .subscribed
            case .expired:
                subscriptionStatus = .expired
            case .inGracePeriod:
                subscriptionStatus = .inGracePeriod
            case .revoked:
                subscriptionStatus = .notSubscribed
            default:
                subscriptionStatus = .unknown
            }
        } catch {
            subscriptionStatus = .unknown
        }
    }

    private func listenForTransactions() -> Task<Void, Error> {
        Task.detached {
            for await result in Transaction.updates {
                guard case .verified(let transaction) = result else { continue }
                await self.updatePurchasedProducts()
                await transaction.finish()
            }
        }
    }
}

enum PurchaseResult {
    case success
    case cancelled
    case pending
    case failed
}

enum StoreError: LocalizedError {
    case verificationFailed(Error)
    case productNotFound

    var errorDescription: String? {
        switch self {
        case .verificationFailed:
            return "Purchase verification failed"
        case .productNotFound:
            return "Product not found"
        }
    }
}

Paywall UI

struct PaywallView: View {
    @Environment(PurchaseService.self) private var purchaseService
    @Environment(\.dismiss) private var dismiss
    @State private var selectedProduct: Product?
    @State private var isPurchasing = false
    @State private var error: Error?

    var body: some View {
        NavigationStack {
            ScrollView {
                VStack(spacing: 24) {
                    headerSection
                    featuresSection
                    productsSection
                    termsSection
                }
                .padding()
            }
            .navigationTitle("Go Premium")
            .navigationBarTitleDisplayMode(.inline)
            .toolbar {
                ToolbarItem(placement: .cancellationAction) {
                    Button("Close") { dismiss() }
                }
            }
            .task {
                try? await purchaseService.loadProducts()
            }
            .alert("Error", isPresented: .constant(error != nil)) {
                Button("OK") { error = nil }
            } message: {
                Text(error?.localizedDescription ?? "")
            }
        }
    }

    private var headerSection: some View {
        VStack(spacing: 8) {
            Image(systemName: "crown.fill")
                .font(.system(size: 60))
                .foregroundStyle(.yellow)

            Text("Unlock Premium")
                .font(.title.bold())

            Text("Get access to all features")
                .foregroundStyle(.secondary)
        }
        .padding(.top)
    }

    private var featuresSection: some View {
        VStack(alignment: .leading, spacing: 12) {
            FeatureRow(icon: "checkmark.circle.fill", title: "Unlimited items")
            FeatureRow(icon: "checkmark.circle.fill", title: "Cloud sync")
            FeatureRow(icon: "checkmark.circle.fill", title: "Priority support")
            FeatureRow(icon: "checkmark.circle.fill", title: "No ads")
        }
        .padding()
        .background(.background.secondary)
        .clipShape(RoundedRectangle(cornerRadius: 12))
    }

    private var productsSection: some View {
        VStack(spacing: 12) {
            ForEach(purchaseService.products) { product in
                ProductButton(
                    product: product,
                    isSelected: selectedProduct == product,
                    action: { selectedProduct = product }
                )
            }

            Button {
                Task {
                    await purchase()
                }
            } label: {
                if isPurchasing {
                    ProgressView()
                } else {
                    Text("Subscribe")
                }
            }
            .buttonStyle(.borderedProminent)
            .controlSize(.large)
            .disabled(selectedProduct == nil || isPurchasing)

            Button("Restore Purchases") {
                Task {
                    try? await purchaseService.restorePurchases()
                }
            }
            .font(.caption)
        }
    }

    private var termsSection: some View {
        VStack(spacing: 4) {
            Text("Subscription automatically renews unless canceled.")
            HStack {
                Link("Terms", destination: URL(string: "https://example.com/terms")!)
                Text("•")
                Link("Privacy", destination: URL(string: "https://example.com/privacy")!)
            }
        }
        .font(.caption)
        .foregroundStyle(.secondary)
    }

    private func purchase() async {
        guard let product = selectedProduct else { return }

        isPurchasing = true
        defer { isPurchasing = false }

        do {
            let result = try await purchaseService.purchase(product)
            if result == .success {
                dismiss()
            }
        } catch {
            self.error = error
        }
    }
}

struct FeatureRow: View {
    let icon: String
    let title: String

    var body: some View {
        HStack {
            Image(systemName: icon)
                .foregroundStyle(.green)
            Text(title)
            Spacer()
        }
    }
}

struct ProductButton: View {
    let product: Product
    let isSelected: Bool
    let action: () -> Void

    var body: some View {
        Button(action: action) {
            HStack {
                VStack(alignment: .leading) {
                    Text(product.displayName)
                        .font(.headline)
                    if let subscription = product.subscription {
                        Text(subscription.subscriptionPeriod.debugDescription)
                            .font(.caption)
                            .foregroundStyle(.secondary)
                    }
                }
                Spacer()
                Text(product.displayPrice)
                    .font(.headline)
            }
            .padding()
            .background(isSelected ? Color.accentColor.opacity(0.1) : Color.clear)
            .overlay(
                RoundedRectangle(cornerRadius: 12)
                    .stroke(isSelected ? Color.accentColor : Color.secondary.opacity(0.3), lineWidth: isSelected ? 2 : 1)
            )
        }
        .buttonStyle(.plain)
    }
}

Subscription Management

Check Subscription Status

extension PurchaseService {
    var isSubscribed: Bool {
        subscriptionStatus == .subscribed || subscriptionStatus == .inGracePeriod
    }

    func checkAccess(for feature: Feature) -> Bool {
        switch feature {
        case .basic:
            return true
        case .premium:
            return isSubscribed || purchasedProductIDs.contains("com.app.lifetime")
        }
    }
}

enum Feature {
    case basic
    case premium
}

Show Manage Subscriptions

Button("Manage Subscription") {
    Task {
        if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene {
            try? await AppStore.showManageSubscriptions(in: windowScene)
        }
    }
}

Handle Subscription Renewal

extension PurchaseService {
    func getSubscriptionRenewalInfo() async -> RenewalInfo? {
        for await result in Transaction.currentEntitlements {
            guard case .verified(let transaction) = result,
                  transaction.productType == .autoRenewable else { continue }

            guard let renewalInfo = try? await transaction.subscriptionStatus?.renewalInfo,
                  case .verified(let info) = renewalInfo else { continue }

            return RenewalInfo(
                willRenew: info.willAutoRenew,
                expirationDate: transaction.expirationDate,
                isInBillingRetry: info.isInBillingRetry
            )
        }
        return nil
    }
}

struct RenewalInfo {
    let willRenew: Bool
    let expirationDate: Date?
    let isInBillingRetry: Bool
}

Consumables

extension PurchaseService {
    func purchaseConsumable(_ product: Product, quantity: Int = 1) async throws {
        let result = try await product.purchase()

        switch result {
        case .success(let verification):
            let transaction = try checkVerified(verification)

            // Grant content
            await grantConsumable(product.id, quantity: quantity)

            // Must finish transaction for consumables
            await transaction.finish()

        case .userCancelled, .pending:
            break

        @unknown default:
            break
        }
    }

    private func grantConsumable(_ productID: String, quantity: Int) async {
        // Add to user's balance (e.g., coins, credits)
        // This should be tracked in your own storage
    }
}

Promotional Offers

extension PurchaseService {
    func purchaseWithOffer(_ product: Product, offerID: String) async throws -> PurchaseResult {
        // Generate signature on your server
        guard let keyID = await fetchKeyID(),
              let nonce = UUID().uuidString.data(using: .utf8),
              let signature = await generateSignature(productID: product.id, offerID: offerID) else {
            throw StoreError.offerSigningFailed
        }

        let result = try await product.purchase(options: [
            .promotionalOffer(
                offerID: offerID,
                keyID: keyID,
                nonce: UUID(),
                signature: signature,
                timestamp: Int(Date().timeIntervalSince1970 * 1000)
            )
        ])

        // Handle result same as regular purchase
        switch result {
        case .success(let verification):
            let transaction = try checkVerified(verification)
            await updatePurchasedProducts()
            await transaction.finish()
            return .success
        case .userCancelled:
            return .cancelled
        case .pending:
            return .pending
        @unknown default:
            return .failed
        }
    }
}

Testing

StoreKit Configuration File

Create Configuration.storekit for local testing:

  1. File > New > File > StoreKit Configuration File
  2. Add products matching your App Store Connect configuration
  3. Run with: Edit Scheme > Run > Options > StoreKit Configuration

Test Purchase Scenarios

#if DEBUG
extension PurchaseService {
    func simulatePurchase() async {
        purchasedProductIDs.insert("com.app.premium.monthly")
        subscriptionStatus = .subscribed
    }

    func clearPurchases() async {
        purchasedProductIDs.removeAll()
        subscriptionStatus = .notSubscribed
    }
}
#endif

Transaction Manager (Testing)

Use Transaction Manager in Xcode to:

  • Clear purchase history
  • Simulate subscription expiration
  • Test renewal scenarios
  • Simulate billing issues

App Store Server Notifications

Configure in App Store Connect to receive:

  • Subscription renewals
  • Cancellations
  • Refunds
  • Grace period events

Handle on your server to update user access accordingly.

Best Practices

Always Update UI After Purchase

func purchase(_ product: Product) async throws -> PurchaseResult {
    let result = try await product.purchase()
    // ...
    await updatePurchasedProducts()  // Always update
    return result
}

Handle Grace Period

if purchaseService.subscriptionStatus == .inGracePeriod {
    // Show warning but allow access
    showGracePeriodBanner()
}

Finish Transactions Promptly

// Always finish after granting content
await transaction.finish()

Test on Real Device

StoreKit Testing is great for development, but always test with sandbox accounts on real devices before release.


Reference: Swiftui Patterns

SwiftUI Patterns

Modern SwiftUI patterns for iOS 26 with iOS 18 compatibility.

View Composition

Small, Focused Views

// Bad: Massive view
struct ContentView: View {
    var body: some View {
        VStack {
            // 200 lines of UI code
        }
    }
}

// Good: Composed from smaller views
struct ContentView: View {
    var body: some View {
        VStack {
            HeaderView()
            ItemList()
            ActionBar()
        }
    }
}

struct HeaderView: View {
    var body: some View {
        // Focused implementation
    }
}

Extract Subviews

struct ItemRow: View {
    let item: Item

    var body: some View {
        HStack {
            iconView
            contentView
            Spacer()
            chevronView
        }
    }

    private var iconView: some View {
        Image(systemName: item.icon)
            .foregroundStyle(.accent)
            .frame(width: 30)
    }

    private var contentView: some View {
        VStack(alignment: .leading) {
            Text(item.name)
                .font(.headline)
            Text(item.subtitle)
                .font(.caption)
                .foregroundStyle(.secondary)
        }
    }

    private var chevronView: some View {
        Image(systemName: "chevron.right")
            .foregroundStyle(.tertiary)
            .font(.caption)
    }
}

Async Data Loading

Task Modifier

struct ItemList: View {
    @State private var items: [Item] = []
    @State private var isLoading = true
    @State private var error: Error?

    var body: some View {
        Group {
            if isLoading {
                ProgressView()
            } else if let error {
                ErrorView(error: error, retry: load)
            } else {
                List(items) { item in
                    ItemRow(item: item)
                }
            }
        }
        .task {
            await load()
        }
    }

    private func load() async {
        isLoading = true
        defer { isLoading = false }

        do {
            items = try await fetchItems()
        } catch {
            self.error = error
        }
    }
}

Refresh Control

struct ItemList: View {
    @State private var items: [Item] = []

    var body: some View {
        List(items) { item in
            ItemRow(item: item)
        }
        .refreshable {
            items = try? await fetchItems()
        }
    }
}

Task with ID

Reload when identifier changes:

struct ItemDetail: View {
    let itemID: UUID
    @State private var item: Item?

    var body: some View {
        Group {
            if let item {
                ItemContent(item: item)
            } else {
                ProgressView()
            }
        }
        .task(id: itemID) {
            item = try? await fetchItem(id: itemID)
        }
    }
}

Lists and Grids

Swipe Actions

List {
    ForEach(items) { item in
        ItemRow(item: item)
            .swipeActions(edge: .trailing) {
                Button(role: .destructive) {
                    delete(item)
                } label: {
                    Label("Delete", systemImage: "trash")
                }

                Button {
                    archive(item)
                } label: {
                    Label("Archive", systemImage: "archivebox")
                }
                .tint(.orange)
            }
            .swipeActions(edge: .leading) {
                Button {
                    toggleFavorite(item)
                } label: {
                    Label("Favorite", systemImage: item.isFavorite ? "star.fill" : "star")
                }
                .tint(.yellow)
            }
    }
}

Lazy Grids

struct PhotoGrid: View {
    let photos: [Photo]
    let columns = [GridItem(.adaptive(minimum: 100), spacing: 2)]

    var body: some View {
        ScrollView {
            LazyVGrid(columns: columns, spacing: 2) {
                ForEach(photos) { photo in
                    AsyncImage(url: photo.thumbnailURL) { image in
                        image
                            .resizable()
                            .aspectRatio(1, contentMode: .fill)
                    } placeholder: {
                        Color.gray.opacity(0.3)
                    }
                    .clipped()
                }
            }
        }
    }
}

Sections with Headers

List {
    ForEach(groupedItems, id: \.key) { section in
        Section(section.key) {
            ForEach(section.items) { item in
                ItemRow(item: item)
            }
        }
    }
}
.listStyle(.insetGrouped)

Forms and Input

Form with Validation

struct ProfileForm: View {
    @State private var name = ""
    @State private var email = ""
    @State private var bio = ""

    private var isValid: Bool {
        !name.isEmpty && email.contains("@") && email.contains(".")
    }

    var body: some View {
        Form {
            Section("Personal Info") {
                TextField("Name", text: $name)
                    .textContentType(.name)

                TextField("Email", text: $email)
                    .textContentType(.emailAddress)
                    .keyboardType(.emailAddress)
                    .autocapitalization(.none)
            }

            Section("About") {
                TextField("Bio", text: $bio, axis: .vertical)
                    .lineLimit(3...6)
            }

            Section {
                Button("Save") {
                    save()
                }
                .disabled(!isValid)
            }
        }
    }
}

Pickers

struct SettingsView: View {
    @State private var selectedTheme = Theme.system
    @State private var fontSize = 16.0

    var body: some View {
        Form {
            Picker("Theme", selection: $selectedTheme) {
                ForEach(Theme.allCases) { theme in
                    Text(theme.rawValue).tag(theme)
                }
            }

            Section("Text Size") {
                Slider(value: $fontSize, in: 12...24, step: 1) {
                    Text("Font Size")
                } minimumValueLabel: {
                    Text("A").font(.caption)
                } maximumValueLabel: {
                    Text("A").font(.title)
                }
                .padding(.vertical)
            }
        }
    }
}

Sheets and Alerts

Sheet Presentation

struct ContentView: View {
    @State private var showingSettings = false
    @State private var selectedItem: Item?

    var body: some View {
        List(items) { item in
            Button(item.name) {
                selectedItem = item
            }
        }
        .toolbar {
            Button {
                showingSettings = true
            } label: {
                Image(systemName: "gear")
            }
        }
        .sheet(isPresented: $showingSettings) {
            SettingsView()
        }
        .sheet(item: $selectedItem) { item in
            ItemDetail(item: item)
        }
    }
}

Confirmation Dialogs

struct ItemRow: View {
    let item: Item
    @State private var showingDeleteConfirmation = false

    var body: some View {
        HStack {
            Text(item.name)
            Spacer()
            Button(role: .destructive) {
                showingDeleteConfirmation = true
            } label: {
                Image(systemName: "trash")
            }
        }
        .confirmationDialog(
            "Delete \(item.name)?",
            isPresented: $showingDeleteConfirmation,
            titleVisibility: .visible
        ) {
            Button("Delete", role: .destructive) {
                delete(item)
            }
        } message: {
            Text("This action cannot be undone.")
        }
    }
}

iOS 26 Features

Liquid Glass

struct GlassCard: View {
    var body: some View {
        VStack {
            Text("Premium Content")
                .font(.headline)
        }
        .padding()
        .background(.regularMaterial)
        .clipShape(RoundedRectangle(cornerRadius: 16))
        // iOS 26 glass effect
        .glassEffect()
    }
}

// Availability check
struct AdaptiveCard: View {
    var body: some View {
        if #available(iOS 26, *) {
            GlassCard()
        } else {
            StandardCard()
        }
    }
}

WebView

import WebKit

// iOS 26+ native WebView
struct WebContent: View {
    let url: URL

    var body: some View {
        if #available(iOS 26, *) {
            WebView(url: url)
                .ignoresSafeArea()
        } else {
            WebViewRepresentable(url: url)
        }
    }
}

// Fallback for iOS 18
struct WebViewRepresentable: UIViewRepresentable {
    let url: URL

    func makeUIView(context: Context) -> WKWebView {
        WKWebView()
    }

    func updateUIView(_ webView: WKWebView, context: Context) {
        webView.load(URLRequest(url: url))
    }
}

@Animatable Macro

// iOS 26+
@available(iOS 26, *)
@Animatable
struct PulsingCircle: View {
    var scale: Double

    var body: some View {
        Circle()
            .scaleEffect(scale)
    }
}

Custom Modifiers

Reusable Styling

struct CardModifier: ViewModifier {
    func body(content: Content) -> some View {
        content
            .padding()
            .background(.background)
            .clipShape(RoundedRectangle(cornerRadius: 12))
            .shadow(color: .black.opacity(0.1), radius: 4, y: 2)
    }
}

extension View {
    func cardStyle() -> some View {
        modifier(CardModifier())
    }
}

// Usage
Text("Content")
    .cardStyle()

Conditional Modifiers

extension View {
    @ViewBuilder
    func `if`<Content: View>(_ condition: Bool, transform: (Self) -> Content) -> some View {
        if condition {
            transform(self)
        } else {
            self
        }
    }
}

// Usage
Text("Item")
    .if(isHighlighted) { view in
        view.foregroundStyle(.accent)
    }

Preview Techniques

Multiple Configurations

#Preview("Light Mode") {
    ItemRow(item: .sample)
        .preferredColorScheme(.light)
}

#Preview("Dark Mode") {
    ItemRow(item: .sample)
        .preferredColorScheme(.dark)
}

#Preview("Large Text") {
    ItemRow(item: .sample)
        .environment(\.sizeCategory, .accessibilityExtraLarge)
}

Interactive Previews

#Preview {
    @Previewable @State var isOn = false

    Toggle("Setting", isOn: $isOn)
        .padding()
}

Preview with Mock Data

extension Item {
    static let sample = Item(
        name: "Sample Item",
        subtitle: "Sample subtitle",
        icon: "star"
    )

    static let samples: [Item] = [
        Item(name: "First", subtitle: "One", icon: "1.circle"),
        Item(name: "Second", subtitle: "Two", icon: "2.circle"),
        Item(name: "Third", subtitle: "Three", icon: "3.circle")
    ]
}

#Preview {
    List(Item.samples) { item in
        ItemRow(item: item)
    }
}

Reference: Testing

Testing

Unit tests, UI tests, snapshot tests, and testing patterns for iOS apps.

Swift Testing (Xcode 16+)

Basic Tests

import Testing
@testable import MyApp

@Suite("Item Tests")
struct ItemTests {
    @Test("Create item with name")
    func createItem() {
        let item = Item(name: "Test")
        #expect(item.name == "Test")
        #expect(item.isCompleted == false)
    }

    @Test("Toggle completion")
    func toggleCompletion() {
        var item = Item(name: "Test")
        item.isCompleted = true
        #expect(item.isCompleted == true)
    }
}

Async Tests

@Test("Fetch items from network")
func fetchItems() async throws {
    let service = MockNetworkService()
    service.mockResult = [Item(name: "Test")]

    let viewModel = ItemListViewModel(networkService: service)
    await viewModel.load()

    #expect(viewModel.items.count == 1)
    #expect(viewModel.items[0].name == "Test")
}

@Test("Handle network error")
func handleNetworkError() async {
    let service = MockNetworkService()
    service.mockError = NetworkError.noConnection

    let viewModel = ItemListViewModel(networkService: service)
    await viewModel.load()

    #expect(viewModel.items.isEmpty)
    #expect(viewModel.error != nil)
}

Parameterized Tests

@Test("Validate email", arguments: [
    ("[email protected]", true),
    ("invalid", false),
    ("@example.com", false),
    ("test@", false)
])
func validateEmail(email: String, expected: Bool) {
    let isValid = EmailValidator.isValid(email)
    #expect(isValid == expected)
}

Test Lifecycle

@Suite("Database Tests")
struct DatabaseTests {
    let database: TestDatabase

    init() async throws {
        database = try await TestDatabase.create()
    }

    @Test func insertItem() async throws {
        try await database.insert(Item(name: "Test"))
        let items = try await database.fetchAll()
        #expect(items.count == 1)
    }
}

XCTest (Traditional)

Basic XCTest

import XCTest
@testable import MyApp

class ItemTests: XCTestCase {
    var sut: Item!

    override func setUp() {
        super.setUp()
        sut = Item(name: "Test")
    }

    override func tearDown() {
        sut = nil
        super.tearDown()
    }

    func testCreateItem() {
        XCTAssertEqual(sut.name, "Test")
        XCTAssertFalse(sut.isCompleted)
    }

    func testToggleCompletion() {
        sut.isCompleted = true
        XCTAssertTrue(sut.isCompleted)
    }
}

Async XCTest

func testFetchItems() async throws {
    let service = MockNetworkService()
    service.mockResult = [Item(name: "Test")]

    let viewModel = ItemListViewModel(networkService: service)
    await viewModel.load()

    XCTAssertEqual(viewModel.items.count, 1)
}

Mocking

Protocol-Based Mocks

// Protocol
protocol NetworkServiceProtocol {
    func fetch<T: Decodable>(_ endpoint: Endpoint) async throws -> T
}

// Mock
class MockNetworkService: NetworkServiceProtocol {
    var mockResult: Any?
    var mockError: Error?
    var fetchCallCount = 0

    func fetch<T: Decodable>(_ endpoint: Endpoint) async throws -> T {
        fetchCallCount += 1

        if let error = mockError {
            throw error
        }

        guard let result = mockResult as? T else {
            fatalError("Mock result type mismatch")
        }

        return result
    }
}

Testing with Mocks

@Test func loadItemsCallsNetwork() async {
    let mock = MockNetworkService()
    mock.mockResult = [Item]()

    let viewModel = ItemListViewModel(networkService: mock)
    await viewModel.load()

    #expect(mock.fetchCallCount == 1)
}

Testing SwiftUI Views

View Tests with ViewInspector

import ViewInspector
@testable import MyApp

@Test func itemRowDisplaysName() throws {
    let item = Item(name: "Test Item")
    let view = ItemRow(item: item)

    let text = try view.inspect().hStack().text(0).string()
    #expect(text == "Test Item")
}

Testing View Models

@Test func viewModelUpdatesOnSelection() async {
    let viewModel = ItemListViewModel()
    viewModel.items = [Item(name: "A"), Item(name: "B")]

    viewModel.select(viewModel.items[0])

    #expect(viewModel.selectedItem?.name == "A")
}

UI Testing

Basic UI Test

import XCTest

class MyAppUITests: XCTestCase {
    let app = XCUIApplication()

    override func setUpWithError() throws {
        continueAfterFailure = false
        app.launchArguments = ["--uitesting"]
        app.launch()
    }

    func testAddItem() {
        // Tap add button
        app.buttons["Add"].tap()

        // Enter name
        let textField = app.textFields["Item name"]
        textField.tap()
        textField.typeText("New Item")

        // Save
        app.buttons["Save"].tap()

        // Verify
        XCTAssertTrue(app.staticTexts["New Item"].exists)
    }

    func testSwipeToDelete() {
        // Assume item exists
        let cell = app.cells["Item Row"].firstMatch

        // Swipe and delete
        cell.swipeLeft()
        app.buttons["Delete"].tap()

        // Verify
        XCTAssertFalse(cell.exists)
    }
}

Accessibility Identifiers

struct ItemRow: View {
    let item: Item

    var body: some View {
        HStack {
            Text(item.name)
        }
        .accessibilityIdentifier("Item Row")
    }
}

struct NewItemView: View {
    @State private var name = ""

    var body: some View {
        TextField("Item name", text: $name)
            .accessibilityIdentifier("Item name")
    }
}

Launch Arguments for Testing

@main
struct MyApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
                .onAppear {
                    if CommandLine.arguments.contains("--uitesting") {
                        // Use mock data
                        // Skip onboarding
                        // Clear state
                    }
                }
        }
    }
}

Snapshot Testing

Using swift-snapshot-testing:

import SnapshotTesting
import XCTest
@testable import MyApp

class SnapshotTests: XCTestCase {
    func testItemRow() {
        let item = Item(name: "Test", subtitle: "Subtitle")
        let view = ItemRow(item: item)
            .frame(width: 375)

        assertSnapshot(of: view, as: .image)
    }

    func testItemRowDarkMode() {
        let item = Item(name: "Test", subtitle: "Subtitle")
        let view = ItemRow(item: item)
            .frame(width: 375)
            .preferredColorScheme(.dark)

        assertSnapshot(of: view, as: .image, named: "dark")
    }

    func testItemRowLargeText() {
        let item = Item(name: "Test", subtitle: "Subtitle")
        let view = ItemRow(item: item)
            .frame(width: 375)
            .environment(\.sizeCategory, .accessibilityExtraLarge)

        assertSnapshot(of: view, as: .image, named: "large-text")
    }
}

Testing SwiftData

@Suite("SwiftData Tests")
struct SwiftDataTests {
    @Test func insertAndFetch() async throws {
        // In-memory container for testing
        let config = ModelConfiguration(isStoredInMemoryOnly: true)
        let container = try ModelContainer(for: Item.self, configurations: config)
        let context = container.mainContext

        // Insert
        let item = Item(name: "Test")
        context.insert(item)
        try context.save()

        // Fetch
        let descriptor = FetchDescriptor<Item>()
        let items = try context.fetch(descriptor)

        #expect(items.count == 1)
        #expect(items[0].name == "Test")
    }
}

Testing Network Calls

Using URLProtocol

class MockURLProtocol: URLProtocol {
    static var requestHandler: ((URLRequest) throws -> (HTTPURLResponse, Data))?

    override class func canInit(with request: URLRequest) -> Bool {
        return true
    }

    override class func canonicalRequest(for request: URLRequest) -> URLRequest {
        return request
    }

    override func startLoading() {
        guard let handler = MockURLProtocol.requestHandler else {
            fatalError("Handler not set")
        }

        do {
            let (response, data) = try handler(request)
            client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
            client?.urlProtocol(self, didLoad: data)
            client?.urlProtocolDidFinishLoading(self)
        } catch {
            client?.urlProtocol(self, didFailWithError: error)
        }
    }

    override func stopLoading() {}
}

@Test func fetchItemsReturnsData() async throws {
    // Configure mock
    let config = URLSessionConfiguration.ephemeral
    config.protocolClasses = [MockURLProtocol.self]
    let session = URLSession(configuration: config)

    let mockItems = [Item(name: "Test")]
    let mockData = try JSONEncoder().encode(mockItems)

    MockURLProtocol.requestHandler = { request in
        let response = HTTPURLResponse(
            url: request.url!,
            statusCode: 200,
            httpVersion: nil,
            headerFields: nil
        )!
        return (response, mockData)
    }

    // Test
    let service = NetworkService(session: session)
    let items: [Item] = try await service.fetch(.items)

    #expect(items.count == 1)
}

Test Helpers

Factory Methods

extension Item {
    static func sample(
        name: String = "Sample",
        isCompleted: Bool = false,
        priority: Int = 0
    ) -> Item {
        Item(name: name, isCompleted: isCompleted, priority: priority)
    }

    static var samples: [Item] {
        [
            .sample(name: "First"),
            .sample(name: "Second", isCompleted: true),
            .sample(name: "Third", priority: 5)
        ]
    }
}

Async Test Utilities

func waitForCondition(
    timeout: TimeInterval = 1.0,
    condition: @escaping () -> Bool
) async throws {
    let start = Date()
    while !condition() {
        if Date().timeIntervalSince(start) > timeout {
            throw TestError.timeout
        }
        try await Task.sleep(nanoseconds: 10_000_000) // 10ms
    }
}

enum TestError: Error {
    case timeout
}

Running Tests from CLI

# Run all tests
xcodebuild test \
    -project MyApp.xcodeproj \
    -scheme MyApp \
    -destination 'platform=iOS Simulator,name=iPhone 16'

# Run specific test
xcodebuild test \
    -project MyApp.xcodeproj \
    -scheme MyApp \
    -destination 'platform=iOS Simulator,name=iPhone 16' \
    -only-testing:MyAppTests/ItemTests

# With code coverage
xcodebuild test \
    -project MyApp.xcodeproj \
    -scheme MyApp \
    -destination 'platform=iOS Simulator,name=iPhone 16' \
    -enableCodeCoverage YES \
    -resultBundlePath TestResults.xcresult

Best Practices

Test Naming

// Describe what is being tested and expected outcome
@Test func itemListViewModel_load_setsItemsFromNetwork()
@Test func purchaseService_purchaseProduct_updatesEntitlements()

Arrange-Act-Assert

@Test func toggleCompletion() {
    // Arrange
    var item = Item(name: "Test")

    // Act
    item.isCompleted.toggle()

    // Assert
    #expect(item.isCompleted == true)
}

One Assertion Per Test

Focus each test on a single behavior:

// Good
@Test func loadSetsItems() async { ... }
@Test func loadSetsLoadingFalse() async { ... }
@Test func loadClearsError() async { ... }

// Avoid
@Test func loadWorks() async {
    // Too many assertions
}
#iphone #apps

数据统计

总访客 -- 总访问 --
ESC
输入关键词开始搜索