Skip to content
ExponaBLOG
SWIFT, SWIFTUI & IOS APPS · DAY 082 / 100

iOS developer roadmap: a Swift & iOS guide for developers

What to learn, in order.

Expona Admin10 min read

Introduction

By day 82 of your iOS development journey, you've built enough projects to know that there's always another layer to master. An iOS developer roadmap: a Swift & iOS guide for developers isn't just a checklist—it's a mental model that helps you prioritize what matters most when you're balancing new features, shipping to production, and staying current with Apple's ecosystem. We've worked through dozens of production apps across e-commerce, SaaS, and consumer platforms, and we've learned which concepts separate teams that ship reliably from those that ship slowly and painfully.

This guide maps the core pillars every iOS developer needs to master: the language itself, the UI framework, the services your app depends on, and the discipline required to ship and iterate. Let's walk through them in the order that builds the strongest foundation.

Swift, Optionals and Protocols

Everything flows from Swift itself. If you're at day 82, you're past "Hello World," but optionals and protocols are where many teams still stumble in production. Optionals aren't a quirk—they're a correctness guarantee. They force you to answer the question: what happens when this value is missing?

In real production code, we've seen crashes that could've been nil-coalescing checks. Consider a user profile screen that loads a user's avatar URL:

swift
struct User {
  let id: String
  let name: String
  let avatarURL: URL?
}

// Without thinking about optionals:
let imageURL = user.avatarURL  // Type is URL?
// Later in view code:
Image(uiImage: UIImage(contentsOfFile: imageURL.absoluteString))
// Crash: cannot call method on optional

// With optional unwrapping:
if let url = user.avatarURL {
  AsyncImage(url: url) { image in
    image.resizable()
  } placeholder: {
    Image(systemName: "person.crop.circle")
  }
}

Protocols are the backbone of testable, maintainable iOS code. A protocol defines a contract—what methods or properties something must have. In production, protocols let you inject mock objects during testing and swap implementations without rewriting your whole app.

swift
protocol UserRepository {
  func fetchUser(id: String) async throws -> User
  func saveUser(_ user: User) async throws
}

class APIUserRepository: UserRepository {
  private let session: URLSession
  
  func fetchUser(id: String) async throws -> User {
    let url = URL(string: "https://api.example.com/users/\(id)")!
    let (data, _) = try await session.data(from: url)
    return try JSONDecoder().decode(User.self, from: data)
  }
  
  func saveUser(_ user: User) async throws {
    // POST to API
  }
}

class MockUserRepository: UserRepository {
  func fetchUser(id: String) async throws -> User {
    return User(id: "1", name: "Test User", avatarURL: nil)
  }
  
  func saveUser(_ user: User) async throws { }
}

// In your view model:
@MainActor
class UserViewModel: ObservableObject {
  @Published var user: User?
  private let repository: UserRepository
  
  init(repository: UserRepository = APIUserRepository()) {
    self.repository = repository
  }
  
  func load(userId: String) async {
    user = try? await repository.fetchUser(id: userId)
  }
}

By making your data fetching, storage, and business logic depend on protocols, not concrete types, you build an iOS app that your team can test, refactor, and reason about. This is the foundation everything else rests on.

SwiftUI Views, State and Navigation

SwiftUI is the modern UI layer, and it's fundamentally different from UIKit. The key mental shift: your UI is a function of your state. When state changes, the UI rebuilds. State management—knowing what owns state and how it flows—determines whether your UI code is elegant or a nightmare.

We've seen teams introduce state in the wrong places and end up with bugs that only appear on the third tap. Let's walk through a real scenario: a product list with filtering and sorting.

swift
@MainActor
class ProductListViewModel: ObservableObject {
  @Published var products: [Product] = []
  @Published var isLoading = false
  @Published var selectedCategory: String = "all"
  @Published var sortBy: SortOption = .name
  
  private let repository: ProductRepository
  
  init(repository: ProductRepository) {
    self.repository = repository
  }
  
  func loadProducts() async {
    isLoading = true
    do {
      let all = try await repository.fetchProducts()
      products = filterAndSort(all)
      isLoading = false
    } catch {
      isLoading = false
      // Handle error—publish it to show in UI
    }
  }
  
  func filterAndSort(_ items: [Product]) -> [Product] {
    let filtered = selectedCategory == "all"
      ? items
      : items.filter { $0.category == selectedCategory }
    
    return filtered.sorted { a, b in
      switch sortBy {
      case .name:
        return a.name < b.name
      case .price:
        return a.price < b.price
      case .rating:
        return a.rating > b.rating
      }
    }
  }
  
  func updateCategory(_ category: String) {
    selectedCategory = category
    Task {
      await loadProducts()
    }
  }
}

struct ProductListView: View {
  @StateObject private var viewModel = ProductListViewModel(
    repository: APIProductRepository()
  )
  
  var body: some View {
    NavigationStack {
      List {
        Picker("Category", selection: $viewModel.selectedCategory) {
          ForEach(["all", "electronics", "books"], id: \.self) { cat in
            Text(cat).tag(cat)
          }
        }
        .onChange(of: viewModel.selectedCategory) { _, newValue in
          Task {
            await viewModel.loadProducts()
          }
        }
        
        if viewModel.isLoading {
          ProgressView()
        } else {
          ForEach(viewModel.products) { product in
            NavigationLink(destination: ProductDetailView(product: product)) {
              ProductRowView(product: product)
            }
          }
        }
      }
      .navigationTitle("Products")
      .task {
        await viewModel.loadProducts()
      }
    }
  }
}

Navigation in SwiftUI has evolved. The modern approach uses NavigationStack and NavigationLink with value-based routing. This keeps your navigation logic decoupled from your views and makes deep linking much easier to implement.

The key principle: keep state as high in the view hierarchy as needed, but no higher. If two views need to share state, hoist it to their common parent. If only one view needs it, keep it as @State in that view. Use @ObservedObject or @StateObject only when you need reference semantics or complex logic.

Networking, SwiftData and Concurrency

Your app needs to talk to servers, store data locally, and do it without blocking the user. Three things matter here: async/await for clean concurrency, SwiftData for local persistence, and URLSession for HTTP.

Async/await transformed iOS development. Before it, callback-based code created "pyramid of doom" and made error handling error-prone. Now it reads like synchronous code but runs concurrently:

swift
// Old approach (callbacks):
func fetchUserAndPosts(userId: String, completion: @escaping (User?, [Post]?, Error?) -> Void) {
  fetchUser(userId) { user, error in
    guard let user = user else { completion(nil, nil, error); return }
    fetchPosts(for: user.id) { posts, error in
      guard let posts = posts else { completion(user, nil, error); return }
      completion(user, posts, nil)
    }
  }
}

// Modern approach (async/await):
func fetchUserAndPosts(userId: String) async throws -> (User, [Post]) {
  let user = try await fetchUser(userId)
  let posts = try await fetchPosts(for: user.id)
  return (user, posts)
}

SwiftData is Apple's modern persistence framework. It replaces Core Data for many use cases. It's simpler, type-safe, and works beautifully with SwiftUI:

swift
import SwiftData

@Model
final class SavedProduct {
  var id: String
  var name: String
  var price: Double
  var savedAt: Date
  
  init(id: String, name: String, price: Double) {
    self.id = id
    self.name = name
    self.price = price
    self.savedAt = Date()
  }
}

struct ProductDetailView: View {
  @Environment(\.modelContext) private var modelContext
  @Query private var savedProducts: [SavedProduct]
  
  let product: Product
  
  var isSaved: Bool {
    savedProducts.contains { $0.id == product.id }
  }
  
  var body: some View {
    VStack {
      Text(product.name)
      Text("\(product.price, format: .currency(code: "USD"))")
      
      Button(isSaved ? "Remove from Saves" : "Save") {
        if isSaved {
          if let saved = savedProducts.first(where: { $0.id == product.id }) {
            modelContext.delete(saved)
          }
        } else {
          let saved = SavedProduct(
            id: product.id,
            name: product.name,
            price: product.price
          )
          modelContext.insert(saved)
        }
        try? modelContext.save()
      }
    }
  }
}

Concurrency extends beyond async/await. The MainActor attribute ensures UI updates happen on the main thread. Use it on your view models and any code that touches @Published properties. Tasks let you bridge from synchronous code into async contexts. Combine them properly, and your networking code remains responsive and testable.

Testing, Performance and App Store Release

By day 82, you should be thinking about shipping. Testing, performance profiling, and App Store deployment aren't afterthoughts—they're part of your definition of done.

Unit tests are fastest. Test your view models, repositories, and business logic in isolation using mock dependencies:

swift
class ProductListViewModelTests: XCTestCase {
  var viewModel: ProductListViewModel!
  var mockRepository: MockProductRepository!
  
  override func setUp() {
    super.setUp()
    mockRepository = MockProductRepository()
    viewModel = ProductListViewModel(repository: mockRepository)
  }
  
  func testFilteringByCategory() async {
    mockRepository.products = [
      Product(id: "1", name: "Laptop", category: "electronics", price: 999),
      Product(id: "2", name: "Book", category: "books", price: 15)
    ]
    
    await viewModel.loadProducts()
    viewModel.updateCategory("electronics")
    
    XCTAssertEqual(viewModel.products.count, 1)
    XCTAssertEqual(viewModel.products.first?.name, "Laptop")
  }
}

Performance matters. Use Xcode's Instruments to profile memory, CPU, and frame rates. Common issues we've fixed: retained cycles (especially in closures), main thread blocking during network requests, and excessive view rebuilds in SwiftUI. The @MainActor attribute and proper async/await usage prevent most of these.

App Store release is straightforward once your app is solid: build for release, upload via Xcode or App Store Connect, fill in metadata, and submit for review. Have a TestFlight beta period first. Real users on real devices find issues that simulators miss.

Common Mistakes

  • Not using protocols: Hardcoding dependencies makes testing impossible. Always depend on protocols, not concrete types.
  • Managing state in the wrong place: Putting every piece of data in a ViewModel wastes memory and makes view logic complicated. Use @State for local UI state, @Published for shared state.
  • Blocking the main thread: Never perform network requests or heavy computation directly in SwiftUI views. Use @MainActor view models and background Tasks.
  • Ignoring optionals: Force-unwrapping with ! is a code smell. It's a crash waiting to happen. Use guard, if-let, or nil-coalescing instead.
  • Skipping tests: "I'll test it manually" doesn't scale. By day 82, you should have unit tests for your core logic and snapshot tests for your views.

Key Takeaways

  • Swift's optionals and protocols are your foundation. Master them to write crash-resistant, testable code.
  • SwiftUI state management determines whether your UI is predictable. Keep state as local as possible and use @MainActor to ensure thread safety.
  • Async/await, SwiftData, and URLSession cover 90% of what real apps need. Use them properly and your app will be responsive and reliable.
  • Testing, performance profiling, and App Store workflows are not optional. They're part of shipping production software.
  • The iOS developer roadmap isn't about knowing everything—it's about knowing what to learn next and why it matters.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles