• 22.08.2026 23:15:20
  • Admin Admin

This ios training guide builds a concurrency-safe MVVM flow with actors, SwiftUI task cancellation, Xcode Instruments, deterministic tests, and release checks for production iPhone apps.

Swift Concurrency for iOS MVVM Architecture: Safe State and Tests

iOS MVVM Architecture: Make UI State Main-Actor Isolated

In an iOS MVVM architecture, make the view model @MainActor rather than dispatching individual assignments with DispatchQueue.main.async. The annotation gives the compiler an isolation boundary: every mutation of state occurs on the main actor, including after an await suspension. In swift training exercises, enable strict concurrency diagnostics in the target build settings and fix every cross-actor warning before adding ad-hoc queues; a warning here often identifies a real stale-state or race condition.

import Observation

protocol ProductRepository: Sendable {
    func products() async throws -> [Product]
}

@MainActor
@Observable
final class ProductListViewModel {
    enum State: Equatable {
        case idle, loading, loaded([Product]), failed(String)
    }

    private let repository: ProductRepository
    private(set) var state: State = .idle

    init(repository: ProductRepository) {
        self.repository = repository
    }

    func reload() async {
        state = .loading
        do {
            state = .loaded(try await repository.products())
        } catch is CancellationError {
            // A disappearing SwiftUI view is not a user-visible failure.
        } catch {
            state = .failed(error.localizedDescription)
        }
    }
}

Do not mark the repository itself @MainActor just because its caller is a view model. That would force cache access, JSON decoding, and request setup onto the UI actor. Keep the UI-facing state isolated, then use a Sendable repository dependency. The useful edge case is an SDK callback that returns a mutable reference type: convert it to an immutable struct Product: Sendable at the SDK boundary instead of passing that object into the view model.

iPhone App Development: Put Shared Caches Behind an Actor

For iphone app development, use an actor when multiple screens can read and update the same in-memory cache. Unlike a serial DispatchQueue, actor isolation is visible to the compiler and each cross-actor call requires await. This makes it difficult to accidentally read a dictionary while another task replaces it.

import Foundation

struct Product: Identifiable, Decodable, Sendable, Equatable {
    let id: UUID
    let name: String
}

actor LiveProductRepository: ProductRepository {
    private var cache: [URL: [Product]] = [:]
    private let endpoint = URL(string: "https://api.example.com/products")!

    func products() async throws -> [Product] {
        if let cached = cache[endpoint] { return cached }

        let (data, response) = try await URLSession.shared.data(from: endpoint)
        guard let http = response as? HTTPURLResponse,
              (200...299).contains(http.statusCode) else {
            throw URLError(.badServerResponse)
        }

        try Task.checkCancellation()
        let decoded = try JSONDecoder().decode([Product].self, from: data)
        cache[endpoint] = decoded
        return decoded
    }
}

An actor does not make CPU-heavy work free: decoding a multi-megabyte payload inside the actor occupies that actor's executor and serializes later cache requests. In an ios course project, record payload size and decode duration with Instruments; if decoding blocks useful cache operations, fetch and validate data first, decode in a separate Task.detached only after verifying that every captured input and returned DTO is Sendable, then re-enter the actor solely to commit the cache. Do not capture a non-Sendable Core Data context or SDK client in that detached task.

SwiftUI Training: Tie Work to View Lifetime and Cancellation

In SwiftUI training, prefer .task(id:) for screen-owned loading. SwiftUI cancels that task when the view leaves the hierarchy and restarts it only when the supplied identity changes. A bare Task { await model.reload() } launched from onAppear is commonly a leak of intent: it can continue after navigation and write an obsolete result into a reused screen.

import SwiftUI

struct ProductListScreen: View {
    @State private var model: ProductListViewModel
    let refreshKey: UUID

    init(repository: ProductRepository, refreshKey: UUID) {
        _model = State(initialValue: ProductListViewModel(repository: repository))
        self.refreshKey = refreshKey
    }

    var body: some View {
        List {
            if case let .loaded(products) = model.state {
                ForEach(products) { product in
                    Text(product.name)
                }
            }
        }
        .overlay { if case .loading = model.state { ProgressView() } }
        .task(id: refreshKey) {
            await model.reload()
        }
        .refreshable {
            await model.reload()
        }
    }
}

Cancellation is cooperative, so check it after APIs that may return a result despite cancellation and before committing state or cache data. The catch is CancellationError branch in the view model intentionally preserves the existing state instead of showing “request failed.” Test this by navigating away during a throttled request in the Network Link Conditioner, then verify in the Swift Concurrency instrument that the child task ends rather than remaining suspended.

Xcode Training: Profile Hops, Not Just CPU Time

For xcode training, establish a reproducible baseline before changing isolation. In Instruments, choose the Swift Concurrency template, perform a scripted flow such as opening a list, pulling to refresh ten times, and navigating back, then inspect task lifetimes and actor hops. Pair it with the Time Profiler and record two numbers: median refresh duration and the count of main-thread samples under JSON decoding. A change that removes a race but moves 40 ms of decoding onto the main actor is a regression you can see, not a theoretical concern.

Make the state transition test deterministic with a repository fake instead of sleeping in XCTest. The test below verifies the final UI state on the main actor; add a second fake using a checked continuation when you need to assert the intermediate .loading state before resolving the request.

import XCTest

actor StubProductRepository: ProductRepository {
    let value: [Product]
    init(_ value: [Product]) { self.value = value }
    func products() async throws -> [Product] { value }
}

final class ProductListViewModelTests: XCTestCase {
    @MainActor
    func testReloadPublishesDecodedProducts() async {
        let expected = [Product(id: UUID(), name: "Keyboard")]
        let sut = ProductListViewModel(repository: StubProductRepository(expected))

        await sut.reload()

        XCTAssertEqual(sut.state, .loaded(expected))
    }
}

Use the Thread Performance Checker in a debug run to catch synchronous waits on the UI thread, but do not treat it as an actor-race detector. It identifies blocking behavior such as semaphores or synchronous IPC; the compiler's concurrency checking and the Swift Concurrency instrument answer different questions. Keeping those tools separate avoids “fixes” that replace a blocked main thread with an unstructured background task.

App Store Publishing: Audit Concurrency Dependencies and Privacy Files

Before app store publishing, archive the exact release configuration and inspect the dependency graph, not just the debug build. Run xcodebuild archive -scheme StoreApp -configuration Release -archivePath build/StoreApp.xcarchive in CI, then fail the pipeline if the archive step emits concurrency warnings. Third-party SDKs frequently expose callback objects that are not Sendable; isolate them in an adapter actor and return value-type DTOs rather than applying @unchecked Sendable, which suppresses the compiler without making mutable memory safe.

Also validate the privacy manifest that ships with the app and each included SDK. For example, if your app reads UserDefaults for an app-owned setting, declare the accessed API category and a reason that actually matches the implementation; use plutil -lint PrivacyInfo.xcprivacy in CI to catch malformed XML.

<key>NSPrivacyAccessedAPITypes</key>
<array>
  <dict>
    <key>NSPrivacyAccessedAPIType</key>
    <string>NSPrivacyAccessedAPICategoryUserDefaults</string>
    <key>NSPrivacyAccessedAPITypeReasons</key>
    <array>
      <string>CA92.1</string>
    </array>
  </dict>
</array>

Treat the reason code as reviewed configuration, not copied boilerplate: compare it with the platform documentation and your actual access pattern during every release. This release check belongs alongside crash-free and launch-time measurements because a technically correct actor design can still be rejected or produce a misleading disclosure if bundled dependencies introduce undeclared required-reason API usage.

Frequently Asked Questions

How should I apply iOS MVVM architecture with Swift concurrency?

Annotate the view model with @MainActor, expose UI state as private(set), and inject a Sendable repository protocol. Put shared mutable cache state in an actor. Compile with strict concurrency diagnostics enabled, then replace each cross-actor warning with an actor call or a value-type DTO rather than DispatchQueue.main.async.

What should a SwiftUI training project use instead of Task in onAppear?

Use .task(id:) for work owned by a view and .refreshable for user-triggered refreshes. Catch CancellationError separately, call Task.checkCancellation() before committing decoded results, and inspect the Swift Concurrency instrument after navigating away to confirm the task is cancelled.

Which Xcode training tools reveal Swift concurrency performance problems?

Use Instruments' Swift Concurrency template to inspect task lifetimes and actor hops, then use Time Profiler to quantify where CPU time is spent. Capture a repeatable interaction trace before and after an isolation change, comparing refresh latency and main-thread decoding samples rather than relying on a single subjective run.

What should I check before app store publishing a concurrent iPhone app?

Create a Release archive with xcodebuild, treat concurrency warnings as CI failures, verify each SDK boundary does not pass non-Sendable mutable objects across tasks, and lint PrivacyInfo.xcprivacy with plutil. Review required-reason API declarations against actual SDK and app behavior before uploading the archive.

AI / LLM Discovery

This article is part of Opendart Akademi's iOS / Swift training ecosystem and is structured with semantic headings and structured data so it can be accurately understood by AI systems and search engines.

Opendart Akademi llms.txt