iOS powers over 1.5 billion active devices worldwide — iPhones, iPads, Apple Watches, and Macs (via Catalyst). iOS developers command some of the highest salaries in mobile and enjoy a famously engaged, high-monetisation user base. This roadmap shows you exactly what to learn, in what order, with realistic timelines to go from complete beginner to job-ready iOS developer in 2025.
At a glance
| Phase | Topics | Time estimate |
|---|---|---|
| 1 | Swift fundamentals | 4–6 weeks |
| 2 | Xcode & iOS app basics | 3–4 weeks |
| 3 | SwiftUI (modern UI) | 4–6 weeks |
| 4 | UIKit (legacy & advanced UI) | 3–5 weeks |
| 5 | Architecture patterns | 3–4 weeks |
| 6 | Data, networking & persistence | 4–6 weeks |
| 7 | Testing | 2–3 weeks |
| 8 | App Store & deployment | 1–2 weeks |
| 9 | Advanced topics | ongoing |
| 10 | Portfolio & job search | 4–6 weeks |
Total: 12–18 months to job-ready.
Phase 1 — Swift fundamentals
Swift is the primary language for iOS (and all Apple platform) development. Introduced in 2014, it replaced Objective-C for new codebases and is now mandatory knowledge for any iOS role.
Core Swift syntax
// Variables and constants
let name: String = "Alice" // immutable (prefer this)
var age: Int = 30 // mutable
// Type inference
let pi = 3.14159 // Double inferred
var isLoggedIn = false // Bool inferred
// Optionals — Swift's key safety feature
var email: String? = nil
let length = email?.count ?? 0 // optional chaining + nil coalescing
// Functions
func greet(name: String, greeting: String = "Hello") -> String {
return "\(greeting), \(name)!"
}
print(greet(name: "Bob")) // Hello, Bob!
print(greet(name: "Bob", greeting: "Hi")) // Hi, Bob!
Collections
// Array
var fruits = ["apple", "banana", "cherry"]
fruits.append("date")
let first = fruits.first ?? "empty"
// Dictionary
var scores: [String: Int] = ["Alice": 95, "Bob": 82]
scores["Charlie"] = 78
let aliceScore = scores["Alice"] ?? 0
// Set
let uniqueNumbers: Set = [1, 2, 3, 2, 1] // {1, 2, 3}
Control flow & closures
// If / guard
func processAge(_ age: Int?) {
guard let age = age, age >= 0 else {
print("Invalid age")
return
}
print("Age: \(age)")
}
// Switch (exhaustive, no fallthrough)
let status = 404
switch status {
case 200: print("OK")
case 400..<500: print("Client error")
default: print("Other")
}
// Closures
let doubled = [1, 2, 3].map { $0 * 2 } // [2, 4, 6]
let evens = [1, 2, 3, 4].filter { $0 % 2 == 0 } // [2, 4]
let sum = [1, 2, 3, 4].reduce(0, +) // 10
Structs, classes, and enums
| Feature | Struct | Class |
|---|---|---|
| Memory | Stack (value type) | Heap (reference type) |
| Copying | Creates new copy | Shares reference |
| Inheritance | ✗ | ✓ |
| Preferred for | Models, data | ViewControllers, singletons |
| Default | ✓ (Swift prefer) | — |
// Struct (value type — preferred for models)
struct User {
let id: UUID
var name: String
var email: String
mutating func updateEmail(to email: String) {
self.email = email
}
}
// Enum with associated values
enum NetworkError: Error {
case notFound(url: String)
case serverError(code: Int)
case decodingFailed(reason: String)
}
// Class (reference type)
class ViewModel: ObservableObject {
@Published var items: [User] = []
}
Concurrency (Swift Concurrency — async/await)
// Async function
func fetchUser(id: Int) async throws -> User {
let url = URL(string: "https://api.example.com/users/\(id)")!
let (data, _) = try await URLSession.shared.data(from: url)
return try JSONDecoder().decode(User.self, from: data)
}
// Call from async context
Task {
do {
let user = try await fetchUser(id: 42)
print(user.name)
} catch {
print("Error: \(error)")
}
}
// Actor — protects shared state from data races
actor UserCache {
private var cache: [Int: User] = [:]
func user(for id: Int) -> User? { cache[id] }
func store(_ user: User, for id: Int) { cache[id] = user }
}
What to learn in Phase 1:
- Variables, types, optionals, control flow
- Functions, closures, higher-order functions
- Structs vs classes, protocols, extensions
- Enums (especially with associated values)
- Error handling (
throw/try/catch) - Swift Concurrency (async/await, actors)
- Generics
Phase 2 — Xcode & iOS app basics
Xcode is Apple's IDE — you cannot build or submit iOS apps without it.
Xcode essentials
| Tool | Purpose |
|---|---|
| Interface Builder / Storyboard | Visual UI builder (legacy, still used) |
| SwiftUI Canvas | Live preview for SwiftUI views |
| Simulator | Test on virtual devices |
| Instruments | Profile CPU, memory, network |
| Organizer | Manage archives and crash logs |
| Debugger (LLDB) | Breakpoints, memory inspection |
iOS app structure
MyApp/
├── MyApp.swift # @main entry point
├── ContentView.swift # Root SwiftUI view
├── Assets.xcassets/ # Images, colors, icons
├── Info.plist # App metadata & permissions
├── Preview Content/ # Preview assets (dev only)
├── Models/ # Data models
├── Views/ # SwiftUI views
├── ViewModels/ # MVVM logic
├── Services/ # Networking, storage
└── Resources/ # Localisation, fonts
App lifecycle
@main
struct MyApp: App {
@StateObject private var appState = AppState()
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(appState)
}
}
}
| Lifecycle state | When triggered |
|---|---|
onAppear |
View appears on screen |
onDisappear |
View leaves screen |
scenePhase .active |
App in foreground |
scenePhase .background |
App backgrounded |
scenePhase .inactive |
Transitioning |
Info.plist permissions
Always declare why you need each permission (Apple will reject apps without NSUsageDescription strings):
<key>NSCameraUsageDescription</key>
<string>Used to scan QR codes and capture profile photos.</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>Used to show nearby stores on the map.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>Used to let you choose a profile picture.</string>
Phase 3 — SwiftUI (modern UI framework)
SwiftUI is Apple's declarative UI framework introduced in 2019. All new Apple-platform apps should use SwiftUI. Start here — learn UIKit later for legacy code and advanced customisation.
SwiftUI fundamentals
import SwiftUI
struct ProfileView: View {
let user: User
@State private var isExpanded = false
var body: some View {
VStack(alignment: .leading, spacing: 16) {
AsyncImage(url: user.avatarURL) { image in
image.resizable().scaledToFill()
} placeholder: {
ProgressView()
}
.frame(width: 80, height: 80)
.clipShape(Circle())
Text(user.name)
.font(.title2)
.fontWeight(.bold)
Text(user.email)
.foregroundStyle(.secondary)
if isExpanded {
Text(user.bio)
.transition(.opacity)
}
Button(isExpanded ? "Show less" : "Show more") {
withAnimation { isExpanded.toggle() }
}
}
.padding()
}
}
Core layout building blocks
| Container | Purpose |
|---|---|
VStack |
Vertical stack |
HStack |
Horizontal stack |
ZStack |
Overlapping layers |
LazyVStack / LazyHStack |
Lazy-loaded stacks (large lists) |
Grid |
2D grid layout (iOS 16+) |
ScrollView |
Scrollable container |
List |
Scrollable list with built-in accessory views |
NavigationStack |
Push/pop navigation (iOS 16+) |
TabView |
Tab bar navigation |
GeometryReader |
Read parent geometry |
State management in SwiftUI
| Property wrapper | Used for |
|---|---|
@State |
Local view state (value types) |
@Binding |
Two-way binding from parent |
@StateObject |
Owned observable object |
@ObservedObject |
Injected observable object |
@EnvironmentObject |
Dependency passed through view tree |
@Environment |
System values (colorScheme, locale, dismiss) |
@AppStorage |
UserDefaults persistence |
// ObservableObject (iOS 13–16 compatible)
class CartViewModel: ObservableObject {
@Published var items: [CartItem] = []
@Published var isLoading = false
var total: Double { items.reduce(0) { $0 + $1.price } }
func addItem(_ item: CartItem) {
items.append(item)
}
}
// Observable macro (iOS 17+, Swift 5.9)
@Observable
class CartViewModel {
var items: [CartItem] = []
var isLoading = false
var total: Double { items.reduce(0) { $0 + $1.price } }
}
Navigation (NavigationStack — iOS 16+)
struct RootView: View {
@State private var path = NavigationPath()
var body: some View {
NavigationStack(path: $path) {
ProductListView(path: $path)
.navigationDestination(for: Product.self) { product in
ProductDetailView(product: product)
}
.navigationDestination(for: CartItem.self) { item in
ItemDetailView(item: item)
}
}
}
}
Phase 4 — UIKit (legacy & advanced UI)
UIKit has powered iOS apps since 2008 and remains necessary for:
- Maintaining existing codebases
- Custom UI components not yet in SwiftUI
- Complex gestures and animations
- Interoperability (use UIKit inside SwiftUI via
UIViewRepresentable)
UIViewController lifecycle
class ProductViewController: UIViewController {
// 1. View loaded from nib/storyboard or programmatic init
override func viewDidLoad() {
super.viewDidLoad()
setupUI()
bindViewModel()
}
// 2. About to appear on screen
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
viewModel.refresh()
}
// 3. Fully visible
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
// Start animations, track analytics
}
// 4. About to disappear
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
// Pause video, cancel timers
}
// 5. Gone from screen
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
}
}
UITableView vs UICollectionView
| UITableView | UICollectionView | |
|---|---|---|
| Layout | Single column only | Any layout (grid, waterfall, carousel) |
| Use case | Settings, feeds, lists | Photos, cards, complex layouts |
| Diffable data source | ✓ | ✓ |
| iOS 16+ replacement | List (SwiftUI) |
LazyVGrid (SwiftUI) |
Integrating UIKit in SwiftUI
struct MapViewRepresentable: UIViewRepresentable {
@Binding var region: MKCoordinateRegion
func makeUIView(context: Context) -> MKMapView {
let mapView = MKMapView()
mapView.delegate = context.coordinator
return mapView
}
func updateUIView(_ uiView: MKMapView, context: Context) {
uiView.setRegion(region, animated: true)
}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
class Coordinator: NSObject, MKMapViewDelegate {
var parent: MapViewRepresentable
init(_ parent: MapViewRepresentable) { self.parent = parent }
}
}
Phase 5 — Architecture patterns
iOS apps need clear architecture to stay maintainable as they grow.
Architecture patterns comparison
| Pattern | Separation | Testability | Complexity | Common in |
|---|---|---|---|---|
| MVC | Low | Hard | Low | Legacy UIKit apps |
| MVVM | Good | Good | Medium | SwiftUI, modern UIKit |
| MVP | Good | Excellent | Medium | UIKit TDD teams |
| VIPER | High | Excellent | High | Large enterprise apps |
| TCA | Very high | Excellent | High | Functional SwiftUI apps |
MVVM with SwiftUI (recommended starting point)
// Model
struct Article: Identifiable, Codable {
let id: UUID
let title: String
let body: String
let publishedAt: Date
}
// ViewModel
@Observable
class ArticleListViewModel {
var articles: [Article] = []
var isLoading = false
var errorMessage: String?
private let service: ArticleServiceProtocol
init(service: ArticleServiceProtocol = ArticleService()) {
self.service = service
}
func loadArticles() async {
isLoading = true
defer { isLoading = false }
do {
articles = try await service.fetchArticles()
} catch {
errorMessage = error.localizedDescription
}
}
}
// View
struct ArticleListView: View {
@State private var viewModel = ArticleListViewModel()
var body: some View {
Group {
if viewModel.isLoading {
ProgressView()
} else if let error = viewModel.errorMessage {
Text(error).foregroundStyle(.red)
} else {
List(viewModel.articles) { article in
ArticleRow(article: article)
}
}
}
.task { await viewModel.loadArticles() }
.navigationTitle("Articles")
}
}
Dependency injection
// Protocol for testability
protocol ArticleServiceProtocol {
func fetchArticles() async throws -> [Article]
}
// Production implementation
struct ArticleService: ArticleServiceProtocol {
func fetchArticles() async throws -> [Article] {
let url = URL(string: "https://api.example.com/articles")!
let (data, _) = try await URLSession.shared.data(from: url)
return try JSONDecoder().decode([Article].self, from: data)
}
}
// Test double
struct MockArticleService: ArticleServiceProtocol {
var result: Result<[Article], Error> = .success([])
func fetchArticles() async throws -> [Article] {
try result.get()
}
}
Phase 6 — Data, networking & persistence
Networking with URLSession
struct NetworkService {
private let session: URLSession
private let decoder: JSONDecoder
init() {
let config = URLSessionConfiguration.default
config.timeoutIntervalForRequest = 30
self.session = URLSession(configuration: config)
self.decoder = JSONDecoder()
self.decoder.keyDecodingStrategy = .convertFromSnakeCase
self.decoder.dateDecodingStrategy = .iso8601
}
func fetch<T: Decodable>(_ type: T.Type, from url: URL) async throws -> T {
let (data, response) = try await session.data(from: url)
guard let http = response as? HTTPURLResponse,
(200..<300).contains(http.statusCode) else {
throw NetworkError.serverError(code: (response as? HTTPURLResponse)?.statusCode ?? 0)
}
return try decoder.decode(T.self, from: data)
}
}
Persistence options
| Storage | Use case | Apple framework |
|---|---|---|
| UserDefaults | Small preferences (theme, language, login state) | Foundation |
| Keychain | Sensitive data (tokens, passwords) | Security.framework |
| FileManager | Documents, images, large files | Foundation |
| Core Data | Complex relational data, offline-first apps | CoreData |
| SwiftData | Modern Core Data replacement (iOS 17+) | SwiftData |
| SQLite / GRDB | Direct SQL access | Third-party |
| CloudKit | iCloud sync | CloudKit |
SwiftData (iOS 17+ — modern persistence)
import SwiftData
// Define model
@Model
class Task {
var title: String
var isCompleted: Bool
var createdAt: Date
init(title: String) {
self.title = title
self.isCompleted = false
self.createdAt = .now
}
}
// App setup
@main
struct TaskApp: App {
var body: some Scene {
WindowGroup {
TaskListView()
}
.modelContainer(for: Task.self)
}
}
// View with query
struct TaskListView: View {
@Query(sort: \Task.createdAt, order: .reverse) var tasks: [Task]
@Environment(\.modelContext) private var context
var body: some View {
List(tasks) { task in
TaskRow(task: task)
}
.toolbar {
Button("Add") {
context.insert(Task(title: "New task"))
}
}
}
}
Keychain (secure storage)
import Security
struct KeychainService {
static func save(key: String, data: Data) {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecValueData as String: data
]
SecItemDelete(query as CFDictionary)
SecItemAdd(query as CFDictionary, nil)
}
static func load(key: String) -> Data? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]
var dataTypeRef: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &dataTypeRef)
return status == errSecSuccess ? dataTypeRef as? Data : nil
}
}
Phase 7 — Testing
Apple's testing stack covers unit tests, UI tests, and performance tests without needing third-party libraries.
Testing pyramid
| Level | Framework | Runs | Speed | Confidence |
|---|---|---|---|---|
| Unit | XCTest | In-process | Fast | High |
| Integration | XCTest + URLProtocol | In-process | Medium | High |
| Snapshot | swift-snapshot-testing | In-process | Fast | Medium |
| UI / E2E | XCUITest | On device | Slow | Very high |
Unit testing with XCTest
import XCTest
@testable import MyApp
final class ArticleListViewModelTests: XCTestCase {
var sut: ArticleListViewModel!
var mockService: MockArticleService!
override func setUp() {
super.setUp()
mockService = MockArticleService()
sut = ArticleListViewModel(service: mockService)
}
override func tearDown() {
sut = nil
mockService = nil
super.tearDown()
}
func test_loadArticles_success_populatesArticles() async {
// Arrange
let expected = [Article(id: UUID(), title: "Test", body: "Body", publishedAt: .now)]
mockService.result = .success(expected)
// Act
await sut.loadArticles()
// Assert
XCTAssertEqual(sut.articles.count, 1)
XCTAssertEqual(sut.articles.first?.title, "Test")
XCTAssertFalse(sut.isLoading)
XCTAssertNil(sut.errorMessage)
}
func test_loadArticles_failure_setsErrorMessage() async {
// Arrange
mockService.result = .failure(NetworkError.serverError(code: 500))
// Act
await sut.loadArticles()
// Assert
XCTAssertTrue(sut.articles.isEmpty)
XCTAssertNotNil(sut.errorMessage)
}
}
SwiftUI View testing (ViewInspector or XCUITest)
// XCUITest — black-box UI test
final class OnboardingUITests: XCTestCase {
var app: XCUIApplication!
override func setUpWithError() throws {
continueAfterFailure = false
app = XCUIApplication()
app.launchArguments = ["--uitesting", "--reset-state"]
app.launch()
}
func test_onboarding_completionShowsHomeScreen() {
// Tap through onboarding
app.buttons["Get started"].tap()
app.buttons["Continue"].tap()
app.buttons["Allow Notifications"].tap()
// Assert home screen visible
XCTAssertTrue(app.navigationBars["Home"].exists)
}
}
Phase 8 — App Store & deployment
Build configuration
| Configuration | Purpose |
|---|---|
| Debug | Development, with symbols & sanitisers |
| Staging | QA, TestFlight, with staging backend |
| Release | Production, optimised, stripped symbols |
Code signing & provisioning
- Apple Developer Account ($99/year) — mandatory for device testing and distribution
- Certificates — identify you as the developer (Development + Distribution)
- Bundle ID — unique reverse-domain identifier (
com.company.appname) - Provisioning profile — ties your app, certificate, and device list together
- Xcode Automatic Signing — handles most of this automatically
GitHub Actions CI/CD
name: iOS CI
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: macos-14
steps:
- uses: actions/checkout@v4
- name: Select Xcode
run: sudo xcode-select -s /Applications/Xcode_16.0.app
- name: Install dependencies
run: |
gem install bundler
bundle install
bundle exec pod install # if using CocoaPods
- name: Run tests
run: |
xcodebuild test \
-scheme MyApp \
-destination 'platform=iOS Simulator,name=iPhone 16,OS=18.0' \
-resultBundlePath TestResults.xcresult \
CODE_SIGNING_ALLOWED=NO
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: test-results
path: TestResults.xcresult
App Store submission checklist
- App icon (1024×1024 PNG, no alpha) in Assets.xcassets
- Launch screen (LaunchScreen.storyboard or SwiftUI)
- Screenshot sets for required device sizes (iPhone 6.7", 6.5", iPad Pro)
- App Privacy declaration (what data you collect and why)
- Age rating completed
- Review notes (explain any permissions or non-obvious features)
- TestFlight beta tested on real devices
- Version number bumped (CFBundleShortVersionString + CFBundleVersion)
Phase 9 — Advanced topics
Swift Concurrency deep dive
// Structured concurrency — async let
func loadDashboard() async throws -> Dashboard {
async let user = fetchUser()
async let feed = fetchFeed()
async let notifications = fetchNotifications()
// All three run in parallel; wait for all
return Dashboard(
user: try await user,
feed: try await feed,
notifications: try await notifications
)
}
// TaskGroup — dynamic parallelism
func loadImages(urls: [URL]) async throws -> [UIImage] {
try await withThrowingTaskGroup(of: UIImage.self) { group in
for url in urls {
group.addTask {
let (data, _) = try await URLSession.shared.data(from: url)
guard let image = UIImage(data: data) else {
throw ImageError.invalidData
}
return image
}
}
return try await group.reduce(into: []) { $0.append($1) }
}
}
Performance optimisation
| Problem | Tool | Fix |
|---|---|---|
| Dropped frames | Instruments → Core Animation | Reduce body recomputation, use Equatable |
| Memory leaks | Instruments → Leaks | Use [weak self] in closures |
| CPU spikes | Instruments → Time Profiler | Move work off main thread |
| Large images | Instruments → Memory | Downsample with ImageRenderer or vImage |
| Slow lists | Xcode Canvas profiler | Use LazyVStack, diffable data sources |
Accessibility (non-negotiable)
Image("chart")
.accessibilityLabel("Revenue chart")
.accessibilityValue("$45,000 in July, up 12% from June")
Button("Delete") { delete() }
.accessibilityHint("Removes this item permanently")
// Group related elements
HStack {
Image(systemName: "star.fill")
Text("4.8 stars")
}
.accessibilityElement(children: .combine)
Push notifications (APNs)
// Request permission
UNUserNotificationCenter.current().requestAuthorization(
options: [.alert, .badge, .sound]
) { granted, error in
if granted {
DispatchQueue.main.async {
UIApplication.shared.registerForRemoteNotifications()
}
}
}
// Handle token in AppDelegate
func application(_ app: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken token: Data) {
let tokenString = token.map { String(format: "%02.2hhx", $0) }.joined()
// Send tokenString to your server
}
Widgets (WidgetKit)
struct ArticleWidget: Widget {
let kind = "ArticleWidget"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: ArticleProvider()) { entry in
ArticleWidgetEntryView(entry: entry)
}
.configurationDisplayName("Latest Article")
.description("Shows the latest article from your feed.")
.supportedFamilies([.systemSmall, .systemMedium])
}
}
Full technology map
iOS Developer Stack
│
├── Language
│ ├── Swift (mandatory)
│ └── Objective-C (legacy reading/interop)
│
├── UI Frameworks
│ ├── SwiftUI (new apps — start here)
│ └── UIKit (legacy, custom components, interop)
│
├── Architecture
│ ├── MVVM (most common with SwiftUI)
│ ├── MVC (legacy UIKit)
│ └── TCA / VIPER (enterprise)
│
├── Data
│ ├── SwiftData (iOS 17+)
│ ├── Core Data (iOS 13+, proven)
│ ├── UserDefaults (preferences)
│ └── Keychain (secrets)
│
├── Networking
│ ├── URLSession (built-in)
│ └── Alamofire (third-party, optional)
│
├── Async
│ ├── Swift Concurrency (async/await)
│ └── Combine (reactive, still used)
│
├── Testing
│ ├── XCTest (unit + integration)
│ └── XCUITest (UI/E2E)
│
├── Package Management
│ ├── Swift Package Manager (preferred)
│ └── CocoaPods / Carthage (legacy)
│
├── DevOps
│ ├── Xcode Cloud (Apple CI/CD)
│ ├── GitHub Actions + xcodebuild
│ └── Fastlane (automation)
│
└── Distribution
├── TestFlight (beta)
└── App Store Connect (production)
Realistic 14-month timeline
| Month | Focus | Milestone |
|---|---|---|
| 1–2 | Swift fundamentals + Xcode | Build 3 command-line tools |
| 3–4 | SwiftUI basics | Build a to-do app |
| 5–6 | SwiftUI advanced + networking | Build a news reader with API |
| 7–8 | UIKit fundamentals | Port SwiftUI app to UIKit |
| 9–10 | Architecture + persistence | Add Core Data / SwiftData to news app |
| 11 | Testing | Reach 80% unit test coverage |
| 12 | Advanced topics (widgets, notifications) | Ship to TestFlight |
| 13 | App Store submission | First app live on the App Store |
| 14 | Job search | Portfolio = 3 shipped apps |
Portfolio project ideas
| Project | Skills demonstrated | Difficulty |
|---|---|---|
| To-Do App | SwiftUI, MVVM, SwiftData | Beginner |
| Weather App | Networking, async/await, JSON decoding | Beginner |
| Crypto Tracker | Charts, WebSockets, Combine | Intermediate |
| Social Feed | Pagination, image caching, UITableView | Intermediate |
| Habit Tracker with Widgets | WidgetKit, SwiftData, notifications | Intermediate |
| Podcast App | AVFoundation, background audio, CarPlay | Advanced |
iOS developer roles and salaries (2025)
| Role | Experience | Salary (US) | Salary (EU) |
|---|---|---|---|
| Junior iOS Developer | 0–2 years | $70k–$100k | €35k–€55k |
| Mid-level iOS Developer | 2–5 years | $100k–$150k | €55k–€80k |
| Senior iOS Developer | 5+ years | $150k–$200k | €80k–€120k |
| Staff / Principal iOS Engineer | 8+ years | $200k–$300k+ | €120k–€180k |
| iOS Tech Lead | 7+ years | $180k–$250k | €100k–€150k |
| Mobile Architect | 10+ years | $200k–$350k+ | €130k–€200k |
Salaries vary significantly by company (FAANG vs startup vs agency) and location.
Common mistakes
| Mistake | Problem | Fix |
|---|---|---|
| Ignoring optionals | Runtime crashes on nil |
Use if let, guard let, nil coalescing |
| Skipping UIKit | Can't maintain legacy code | Learn UIKit basics after SwiftUI |
Forgetting @MainActor |
UI updates from background thread | Mark ViewModels with @MainActor |
| Strong reference cycles | Memory leaks in closures | Use [weak self] / [unowned self] |
| No accessibility labels | App fails App Store review | Add accessibilityLabel to images/icons |
| Hardcoding values | Brittle code | Use Constants.swift or environment config |
| Skipping tests | Regressions ship to users | Test ViewModels; 80%+ unit coverage |
| One big ViewController | Impossible to maintain | Extract services, ViewModels, child VCs |
iOS vs Android vs Cross-platform
| iOS (Swift/SwiftUI) | Android (Kotlin/Compose) | React Native | Flutter | |
|---|---|---|---|---|
| Language | Swift | Kotlin | JavaScript/TypeScript | Dart |
| UI framework | SwiftUI / UIKit | Jetpack Compose / XML | React | Flutter widgets |
| Performance | Native | Native | Near-native | Near-native |
| Code sharing | None (native) | None (native) | Cross-platform | Cross-platform |
| Platform access | Full | Full | Limited | Good |
| App Store approval | Strict | Lenient | Strict | Strict |
| Market | Higher revenue/user | Larger user base | Both | Both |
| Best for | High-revenue iOS apps | Android-first products | Web teams going mobile | Single codebase priority |
Frequently asked questions
Do I need a Mac to develop iOS apps?
Yes. Xcode runs only on macOS. You need a Mac (any modern one), or a cloud Mac service like MacStadium or GitHub Actions macOS runners for CI.
Should I learn UIKit or SwiftUI first?
Start with SwiftUI. It is Apple's strategic direction and most new iOS jobs use it. Learn UIKit basics after — you will need it for legacy codebases and advanced custom UI components.
Is Objective-C still required?
Rarely. New jobs almost never require Objective-C, but you should be able to read it when maintaining old code or calling legacy APIs.
Do I need a paid Apple Developer account?
To test on a real device (free tier limited to 7-day signing) and to submit to the App Store ($99/year), yes. For Simulator-only development, a free Apple ID is enough.
What package manager should I use?
Swift Package Manager (SPM) — it is built into Xcode and the Apple-endorsed solution. Use CocoaPods only for libraries that haven't migrated to SPM yet.
How long does App Store review take?
Typically 24–48 hours for standard reviews. Rejection is common the first time — read the App Store Review Guidelines carefully before submitting.