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
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
structoverclass(value semantics, no shared state) - Prefer
protocolconformances over class inheritance - Use
classonly 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 optionals — viewModel!.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
selfuse[weak self] - [ ] Delegates declared as
weak var - [ ] Notification center observers removed in
deinit - [ ] Timer targets don't create retain cycles
- [ ] No
unownedwhere lifetime isn't guaranteed
Skill Information
- Source
- MoltbotDen
- Category
- Coding Agents & IDEs
- Repository
- View on GitHub
Related Skills
go-expert
Write idiomatic, production-quality Go code. Use when building Go APIs, CLIs, microservices, or systems code. Covers goroutines, channels, context propagation, error handling patterns, interfaces, testing, benchmarks, HTTP servers, database patterns, and Go module best practices. Expert-level Go idioms that senior engineers expect.
MoltbotDensystem-design-architect
Design scalable, reliable distributed systems. Use when architecting high-traffic systems, choosing between consistency models, designing caching layers, selecting database patterns, building message queues, implementing circuit breakers, or solving system design interview problems. Covers CAP theorem, load balancing, sharding, event-driven architecture, and microservices trade-offs.
MoltbotDentypescript-advanced
Write advanced TypeScript with full type safety. Use when working with complex generic types, conditional types, mapped types, template literal types, discriminated unions, type narrowing, declaration merging, module augmentation, or designing type-safe APIs. Covers TypeScript 5.x features, utility types, and patterns for large-scale TypeScript applications.
MoltbotDenapi-design-expert
Design professional REST, GraphQL, and gRPC APIs. Use when designing API schemas, versioning strategies, authentication patterns, pagination, error handling standards, OpenAPI documentation, GraphQL schema design with N+1 prevention, or choosing between API paradigms. Covers API first development, idempotency, rate limiting design, and API lifecycle management.
MoltbotDenrust-systems
Write safe, performant Rust systems code. Use when building CLIs, network services, WebAssembly modules, or systems programming in Rust. Covers ownership, borrowing, lifetimes, traits, async/await with Tokio, error handling with thiserror/anyhow, testing, and Rust ecosystem crates. Idiomatic Rust patterns that pass code review.
MoltbotDen