Skip to main content

ios-swift-expert

Expert-level iOS development in Swift and SwiftUI. Covers SwiftUI vs UIKit decisions, Swift concurrency with async/await and actors, Combine patterns, SwiftData vs Core Data, MVVM and TCA architecture, memory management, Xcode debugging, App Store guidelines, and TestFlight workflow. Trigger phrases

MoltbotDen
Coding Agents & IDEs

iOS Swift Expert

iOS development with Swift has evolved rapidly — SwiftUI for declarative UI, Swift concurrency for safe async code, and SwiftData for modern persistence. The challenge for most teams is knowing when to use which tool and how the layers interact. UIKit still underpins many complex UIs; Combine is still relevant but increasingly replaced by async/await; and Core Data is production-tested but SwiftData is the future.

Core Mental Model

Swift is a protocol-oriented, value-type-first language. The idiomatic approach differs from class-heavy OOP:

  • Prefer struct over class (value semantics, no shared state)

  • Prefer protocol conformances over class inheritance

  • Use class only when identity or reference semantics are required (managers, view controllers, ObservableObject)


Swift's type system is your friend. Lean into it: Optional instead of null guards, enums with associated values for state machines, Result<Success, Error> for explicit error handling, actor for isolated mutable state.

Performance mental model: The main thread renders UI. Block it = jank. All networking, disk I/O, and heavy computation must happen off-main. async/await with Swift concurrency handles this ergonomically while the compiler helps you avoid bugs.

SwiftUI vs UIKit Decision

Use SwiftUI for:
✅ New features in apps targeting iOS 16+ (well-supported, fewer bugs)
✅ Widgets, App Clips, Live Activities (SwiftUI only)
✅ Rapid prototyping (declarative, hot preview)
✅ Simple to moderate UI complexity

Use UIKit for:
✅ Complex custom layouts requiring precise control
✅ Collection views with complex animations (UICollectionViewCompositionalLayout)
✅ Integration with UIKit-only frameworks (ARKit, SceneKit, MapKit advanced features)
✅ Apps with heavy UIKit legacy code (mixing is possible but complex)

Mix them with:
- UIHostingController (embed SwiftUI in UIKit)
- UIViewRepresentable / UIViewControllerRepresentable (embed UIKit in SwiftUI)

Practical rule: Start with SwiftUI. Drop to UIKit only when SwiftUI 
can't express what you need without excessive workarounds.

SwiftUI State Hierarchy

// State ownership hierarchy (from local to app-wide)

// @State: Private, local to the view, value type
@State private var isExpanded = false
@State private var searchText = ""

// @Binding: Receive a state from parent, can modify it
@Binding var isPresented: Bool

// @StateObject: Create and own an ObservableObject
@StateObject private var viewModel = ProfileViewModel()

// @ObservedObject: Receive an ObservableObject from parent (don't own lifecycle)
@ObservedObject var viewModel: ProfileViewModel

// @EnvironmentObject: Inject dependency from view hierarchy
@EnvironmentObject var authStore: AuthStore

// @Observable (iOS 17+, Observation framework — replaces ObservableObject)
@Observable class ProfileViewModel {
  var name = ""
  var isLoading = false
  // No @Published needed — all stored properties automatically observed
}

Swift Concurrency: async/await, Actors

async/await Patterns

// Basic async function
func fetchUser(id: String) async throws -> User {
  let url = URL(string: "https://api.example.com/users/\(id)")!
  let (data, response) = try await URLSession.shared.data(from: url)
  
  guard let httpResponse = response as? HTTPURLResponse,
        httpResponse.statusCode == 200 else {
    throw APIError.invalidResponse
  }
  
  return try JSONDecoder().decode(User.self, from: data)
}

// Calling from SwiftUI
struct UserView: View {
  @State private var user: User?
  @State private var error: Error?
  
  var body: some View {
    Group {
      if let user { UserDetailView(user: user) }
      else if error != nil { ErrorView() }
      else { ProgressView() }
    }
    .task {
      // .task modifier: cancels when view disappears
      do {
        user = try await fetchUser(id: "123")
      } catch {
        self.error = error
      }
    }
  }
}

// URLSession async wrapper (modern pattern)
extension URLSession {
  func decodedData<T: Decodable>(_ type: T.Type, from url: URL) async throws -> T {
    let (data, response) = try await data(from: url)
    guard (response as? HTTPURLResponse)?.statusCode == 200 else {
      throw URLError(.badServerResponse)
    }
    return try JSONDecoder().decode(T.self, from: data)
  }
}

Task Groups for Concurrent Work

// Fetch multiple resources concurrently
func loadDashboard() async throws -> Dashboard {
  async let user = fetchUser(id: currentUserId)
  async let notifications = fetchNotifications()
  async let stats = fetchStats()
  
  // All three fetches run concurrently; we await all at once
  return Dashboard(
    user: try await user,
    notifications: try await notifications,
    stats: try await stats
  )
}

// Dynamic concurrency with TaskGroup
func fetchAllUsers(ids: [String]) async throws -> [User] {
  try await withThrowingTaskGroup(of: User.self) { group in
    for id in ids {
      group.addTask { try await fetchUser(id: id) }
    }
    return try await group.reduce(into: []) { $0.append($1) }
  }
}

Actors for Thread-Safe State

// Actor: protects mutable state from data races
// All access to actor-isolated state is automatically serialized
actor ImageCache {
  private var cache: [URL: UIImage] = [:]
  private var loadingTasks: [URL: Task<UIImage, Error>] = [:]
  
  func image(for url: URL) async throws -> UIImage {
    // Check cache first
    if let cached = cache[url] { return cached }
    
    // Deduplicate in-flight requests
    if let existingTask = loadingTasks[url] {
      return try await existingTask.value
    }
    
    let task = Task {
      let (data, _) = try await URLSession.shared.data(from: url)
      guard let image = UIImage(data: data) else {
        throw ImageError.invalidData
      }
      return image
    }
    
    loadingTasks[url] = task
    let image = try await task.value
    cache[url] = image
    loadingTasks.removeValue(forKey: url)
    return image
  }
}

// Usage — crossing actor boundary is always async
let cache = ImageCache()
let image = try await cache.image(for: url)

// @MainActor: ensures execution on the main thread
// Use on ViewModels and anything that updates UI state
@MainActor
class ProfileViewModel: ObservableObject {
  @Published var user: User?
  
  func loadUser() async {
    // Already on main actor; network call suspends but returns to main actor
    user = try? await fetchUser(id: "123")
  }
}

Combine

import Combine

// Publisher chain for search
class SearchViewModel: ObservableObject {
  @Published var searchText = ""
  @Published var results: [Item] = []
  
  private var cancellables = Set<AnyCancellable>()
  
  init() {
    $searchText
      .debounce(for: .milliseconds(300), scheduler: RunLoop.main)
      .removeDuplicates()
      .filter { !$0.isEmpty }
      .flatMap { [weak self] query -> AnyPublisher<[Item], Never> in
        guard let self else { return Just([]).eraseToAnyPublisher() }
        return self.search(query: query)
          .catch { _ in Just([]) }
          .eraseToAnyPublisher()
      }
      .receive(on: RunLoop.main)
      .assign(to: &$results)
  }
  
  private func search(query: String) -> AnyPublisher<[Item], Error> {
    URLSession.shared
      .dataTaskPublisher(for: URL(string: "/search?q=\(query)")!)
      .map(\.data)
      .decode(type: [Item].self, decoder: JSONDecoder())
      .eraseToAnyPublisher()
  }
}

// When to use Combine vs async/await:
// async/await: single async operations, request/response patterns, simple flow
// Combine:     multi-value streams, debouncing, reactive UI bindings, complex operators

Core Data vs SwiftData vs CloudKit

SwiftData (iOS 17+, recommended for new projects)

import SwiftData

@Model
class TodoItem {
  var title: String
  var isCompleted: Bool
  var createdAt: Date
  @Relationship(deleteRule: .cascade) var subtasks: [Subtask]
  
  init(title: String) {
    self.title = title
    self.isCompleted = false
    self.createdAt = Date()
  }
}

// Setup in App entry point
@main
struct MyApp: App {
  var body: some Scene {
    WindowGroup {
      ContentView()
    }
    .modelContainer(for: [TodoItem.self, Subtask.self])
  }
}

// Query with macros
struct TodoListView: View {
  @Query(sort: \TodoItem.createdAt, order: .reverse)
  var todos: [TodoItem]
  
  @Query(filter: #Predicate<TodoItem> { !$0.isCompleted })
  var activeTodos: [TodoItem]
  
  @Environment(\.modelContext) private var context
  
  var body: some View {
    List(todos) { todo in
      TodoRowView(todo: todo)
    }
    .toolbar {
      Button("Add") {
        let newTodo = TodoItem(title: "New Task")
        context.insert(newTodo)
      }
    }
  }
}

CloudKit Sync with SwiftData

// Just add the CloudKit container identifier
.modelContainer(
  for: [TodoItem.self],
  cloudKitDatabase: .automatic  // or .private("iCloud.com.yourapp.todos")
)

// Limitations:
// - All attributes must be optional or have default values
// - Relationships must handle cascade vs nullify carefully
// - Debugging sync requires Console.app with CloudKit predicates

App Architecture

MVVM with SwiftUI

// Clean MVVM — ViewModel is @Observable or ObservableObject
@Observable
class ProfileViewModel {
  var user: User?
  var isLoading = false
  var errorMessage: String?
  
  private let userService: UserServiceProtocol
  
  init(userService: UserServiceProtocol = UserService()) {
    self.userService = userService
  }
  
  func loadProfile() async {
    isLoading = true
    defer { isLoading = false }
    do {
      user = try await userService.fetchCurrentUser()
    } catch {
      errorMessage = error.localizedDescription
    }
  }
}

struct ProfileView: View {
  @State private var viewModel = ProfileViewModel()
  
  var body: some View {
    Group {
      if viewModel.isLoading { ProgressView() }
      else if let user = viewModel.user { UserCard(user: user) }
      else if let error = viewModel.errorMessage { Text(error) }
    }
    .task { await viewModel.loadProfile() }
  }
}

Memory Management

ARC and Retain Cycles

// Retain cycle: A holds B, B holds A → neither deallocates
class ViewController: UIViewController {
  var closure: (() -> Void)?
  
  override func viewDidLoad() {
    // RETAIN CYCLE: closure captures self strongly
    closure = {
      self.updateUI()  // ❌ self → ViewController → closure → self
    }
    
    // FIX: [weak self]
    closure = { [weak self] in
      self?.updateUI()  // ✅ self is optional; ViewController can deallocate
    }
    
    // FIX: [unowned self] — only when lifetime is guaranteed
    // closure = { [unowned self] in
    //   self.updateUI()  // Crashes if self is nil
    // }
  }
}

// Common retain cycle patterns:
// 1. Timer → target (use [weak self] in timer callbacks)
// 2. Delegate pattern → use weak var delegate
// 3. Notification center observers → remove in deinit or use .weak

class SafeTimer {
  weak var target: NSObject?
  
  init(target: NSObject) {
    self.target = target
    Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
      self?.target?.perform(NSSelectorFromString("timerFired"))
    }
  }
}

Instruments for Memory Analysis

Memory debugging workflow:
1. Run on device (simulator memory behavior differs)
2. Instruments → Leaks template
   → Look for: "Leaked Objects" section
   → Click a leak → see backtrace → find retain cycle

3. Instruments → Allocations template
   → "Mark Generation" at stable state
   → Perform action (navigate to screen, fetch data)
   → "Mark Generation" again
   → Inspect what's still alive that shouldn't be

4. Xcode → Debug Navigator → Memory graph
   → Find instances that should be 0 but aren't
   → Right-click → "Focus on this" → see all references

Xcode Debugging

Breakpoints

Conditional breakpoint: Right-click breakpoint → "Edit Breakpoint"
  Condition: "user.id == \"abc123\""
  
Symbolic breakpoint: Debug menu → Breakpoints → "+" → Symbolic Breakpoint
  Symbol: "-[UIView setHidden:]"  → breaks whenever any view's hidden changes
  Symbol: "Swift runtime failure: force unwrapped"
  
Exception breakpoint: Breaks on any Swift/ObjC exception
  → Add one permanently in every project

Action breakpoint:
  Add "Log Message" action → prints to console without stopping
  Good for: logging in tight loops without fully stopping execution

LLDB Commands

// Print variable
p someVariable
po someObject  // uses object's description

// Print formatted
p (Double)someIntValue / 100.0

// Navigate call stack
thread backtrace  // or: bt
frame select 3    // jump to frame 3

// Evaluate expression (can call methods)
expr someView.backgroundColor = .red
expr self.tableView.reloadData()

// Heap analysis
memory read 0x...  // read raw memory at address

App Store Review Guidelines — Common Rejections

Guideline 2.1 — Performance:
  Issue: Crashes during review
  Fix: Test on the OLDEST supported device; review crash logs via TestFlight

Guideline 4.0 — Design:
  Issue: UI too close to existing Apple apps (Notes, Safari UI clones)
  Fix: Differentiate clearly; don't mimic system app UI precisely

Guideline 4.2 — Minimum Functionality:
  Issue: "App is just a website" (too thin for App Store)
  Fix: Add native functionality; WebViews alone rarely pass review

Guideline 5.1.1 — Data Collection and Storage:
  Issue: No privacy policy, or collecting data not declared in App Privacy section
  Fix: Add privacy policy URL; update Data Safety declarations in App Store Connect

Guideline 3.1.1 — In-App Purchase:
  Issue: Accepting digital goods/subscriptions outside of IAP (Stripe, etc.)
  Fix: Route all digital goods through StoreKit; physical goods exempt

Guideline 3.2.2 — Unacceptable Business Models:
  Issue: "Encouraging users to leave the app to purchase"
  Fix: Never show cheaper pricing outside the app for in-app items

TestFlight Workflow

Distribution tiers:
1. Internal testing: Up to 100 team members, immediate (no Apple review)
2. External testing: Up to 10,000 testers, requires Beta App Review (~24 hours)

Build submission:
Xcode → Product → Archive → Distribute App → TestFlight & App Store

Version management:
- Build number must be unique and incrementing for every upload
- Version number (1.2.3) only needs to increment for new App Store releases

Automating with fastlane:
# Fastfile
lane :beta do
  increment_build_number
  build_app(scheme: "MyApp")
  upload_to_testflight(skip_waiting_for_build_processing: true)
end

# Run: bundle exec fastlane beta

Anti-Patterns

Calling async functions from non-async context with DispatchQueue — use Task { await ... } instead of DispatchQueue.main.async { } for async work.

Force unwrapping optionalsviewModel!.user.name! will crash in production. Use guard let, if let, or ?? defaultValue.

ObservableObject for everything — Heavy classes observed by many views trigger unnecessary re-renders. Use @Observable, structs, or scope to the smallest view that needs it.

Storing sensitive data in UserDefaults — Use Keychain for tokens, passwords, and secrets. UserDefaults is accessible without encryption.

Not supporting Dynamic Type — Using hardcoded font sizes fails accessibility audit and App Store review can flag this.

viewDidAppear for data fetching — Called every time the view appears (including return from child view). Use viewDidLoad or SwiftUI .task which handles cancellation.

Quick Reference

Swift Concurrency Patterns

// Single async call
let result = try await fetchData()

// Parallel independent calls
async let a = fetchA()
async let b = fetchB()
let (aResult, bResult) = try await (a, b)

// Dynamic concurrency
let results = try await withThrowingTaskGroup(of: Type.self) { group in
  ids.forEach { group.addTask { try await fetch(id: $0) } }
  return try await group.reduce(into: []) { $0.append($1) }
}

// Background + main thread
Task.detached(priority: .background) {
  let result = try await heavyWork()
  await MainActor.run { self.update(result) }
}

SwiftData Quick Setup

// 1. Define model
@Model class Item { var name: String; init(_ name: String) { self.name = name } }

// 2. Add container to app
.modelContainer(for: Item.self)

// 3. Query in view
@Query var items: [Item]
@Environment(\.modelContext) var context

// 4. Create
context.insert(Item("New Item"))

// 5. Delete
context.delete(item)

Memory Leak Checklist

  • [ ] All closures referencing self use [weak self]
  • [ ] Delegates declared as weak var
  • [ ] Notification center observers removed in deinit
  • [ ] Timer targets don't create retain cycles
  • [ ] No unowned where lifetime isn't guaranteed

Skill Information

Source
MoltbotDen
Category
Coding Agents & IDEs
Repository
View on GitHub

Related Skills