Senior iOS Developer Interview Questions: A Complete Guide
Senior iOS Developer Interview Questions: A Complete Guide
Landing a senior iOS developer role requires deep technical knowledge and practical experience. This guide covers the most important interview topics with detailed explanations and real-world code examples.
1. Memory Management & ARC
Question: Explain retain cycles and how to prevent them.
Answer: Retain cycles occur when two objects hold strong references to each other, preventing ARC from deallocating them. This leads to memory leaks.
swift
// ❌ Retain Cycle Example
class ViewController: UIViewController {
var onDataLoaded: (() -> Void)? override func viewDidLoad() {
super.viewDidLoad()
// Retain cycle: self captures closure, closure captures self
onDataLoaded = {
self.updateUI() // Strong reference to self
}
}
}
// ✅ Solution 1: Weak Reference
class ViewController: UIViewController {
var onDataLoaded: (() -> Void)?
override func viewDidLoad() {
super.viewDidLoad()
onDataLoaded = { [weak self] in
self?.updateUI()
}
}
}
// ✅ Solution 2: Unowned Reference (when you're certain self won't be nil)
class DataManager {
let updateHandler: () -> Void
init(viewController: ViewController) {
updateHandler = { [unowned viewController] in
viewController.updateUI()
}
}
}
Key Differences:
- weak: Creates an optional reference, automatically set to nil when deallocated
- unowned: Non-optional reference, crashes if accessed after deallocation (use when certain it won't be nil)
2. Swift Concurrency & async/await
Question: How does Swift's new concurrency model work? Explain actors and async/await.
Answer:
swift
// Actor for thread-safe data access
actor BankAccount {
private var balance: Double = 0 func deposit(amount: Double) {
balance += amount
}
func withdraw(amount: Double) throws {
guard balance >= amount else {
throw BankError.insufficientFunds
}
balance -= amount
}
func getBalance() -> Double {
return balance
}
}
// Using async/await for API calls
class UserService {
func fetchUser(id: String) 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)
}
// Parallel execution with async let
func fetchUserWithDetails(id: String) async throws -> (User, [Post]) {
async let user = fetchUser(id: id)
async let posts = fetchUserPosts(id: id)
return try await (user, posts) // Wait for both to complete
}
// Task groups for dynamic concurrency
func fetchMultipleUsers(ids: [String]) async throws -> [User] {
try await withThrowingTaskGroup(of: User.self) { group in
for id in ids {
group.addTask {
try await self.fetchUser(id: id)
}
}
var users: [User] = []
for try await user in group {
users.append(user)
}
return users
}
}
}
Key Concepts:
- Actors: Provide data isolation, preventing data races
- async/await: Structured way to write asynchronous code
- Task Groups: Execute multiple concurrent operations
- MainActor: Ensures UI updates happen on main thread
3. Protocol-Oriented Programming
Question: What are the advantages of protocol-oriented programming over OOP?
Answer:
swift
// Protocol with default implementation
protocol Loggable {
func log(_ message: String)
}extension Loggable {
func log(_ message: String) {
print("[\(type(of: self))] \(message)")
}
}
// Protocol composition
protocol Fetchable {
associatedtype DataType
func fetch() async throws -> DataType
}
protocol Cacheable {
associatedtype CacheKey: Hashable
func cache(_ data: Any, forKey key: CacheKey)
func getCached(forKey key: CacheKey) -> Any?
}
// Combine protocols
class UserRepository: Fetchable, Cacheable, Loggable {
typealias DataType = User
typealias CacheKey = String
private var cache: [String: User] = [:]
func fetch() async throws -> User {
if let cached = getCached(forKey: "current_user") as? User {
log("Returning cached user")
return cached
}
log("Fetching user from API")
let user = try await fetchFromAPI()
cache(user, forKey: "current_user")
return user
}
func cache(_ data: Any, forKey key: String) {
if let user = data as? User {
cache[key] = user
}
}
func getCached(forKey key: String) -> Any? {
return cache[key]
}
private func fetchFromAPI() async throws -> User {
// API call implementation
fatalError("Not implemented")
}
}
Advantages:
- Value Types: Protocols work with structs and enums
- Multiple Conformance: Types can conform to multiple protocols
- Testability: Easy to create mock implementations
- Default Implementations: Reduce code duplication
4. Architecture Patterns
Question: Compare MVVM, VIPER, and Clean Architecture. When would you use each?
Answer:
MVVM (Model-View-ViewModel)
- Best for: Small to medium apps, SwiftUI apps
- Pros: Simple, reactive, testable
- Cons: Can become bloated in large apps
swift
// MVVM Example
class LoginViewModel: ObservableObject {
@Published var email: String = ""
@Published var password: String = ""
@Published var isLoading: Bool = false
@Published var errorMessage: String? - Best for: Large, complex apps with multiple modules
private let authService: AuthService
init(authService: AuthService = AuthService()) { self.authService = authService }
@MainActor func login() async { isLoading = true errorMessage = nil
do { try await authService.login(email: email, password: password) } catch { errorMessage = error.localizedDescription }
isLoading = false } }
VIPER (View-Interactor-Presenter-Entity-Router)
- Pros: Clear separation, highly testable, scalable
- Cons: Boilerplate code, steep learning curve
Clean Architecture
- Best for: Enterprise apps, long-term maintenance
- Pros: Independence of frameworks, highly testable
- Cons: More complex, initial setup overhead
5. Combine Framework
Question: How does Combine compare to async/await? When would you use each?
Answer:
swift
import Combine - Combine: UI bindings, reactive streams, multiple value emissions
class SearchViewModel: ObservableObject { @Published var searchText: String = "" @Published var results: [SearchResult] = []
private var cancellables = Set
init(searchService: SearchService = SearchService()) { self.searchService = searchService setupSearchPipeline() }
private func setupSearchPipeline() { $searchText .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main) .removeDuplicates() .filter { $0.count >= 3 } .flatMap { [weak self] query -> AnyPublisher<[SearchResult], Never> in guard let self = self else { return Just([]).eraseToAnyPublisher() }
return self.searchService.search(query: query) .catch { _ in Just([]) } .eraseToAnyPublisher() } .receive(on: DispatchQueue.main) .assign(to: &$results) } }
// Async/await version class SearchViewModelAsync: ObservableObject { @Published var searchText: String = "" @Published var results: [SearchResult] = []
private var searchTask: Task
func search() { searchTask?.cancel()
searchTask = Task { @MainActor in guard searchText.count >= 3 else { results = [] return }
try? await Task.sleep(nanoseconds: 300_000_000) // Debounce
guard !Task.isCancelled else { return }
do { results = try await searchService.search(query: searchText) } catch { results = [] } } } }
When to Use:
- async/await: One-time operations, API calls, simple async flows
6. Performance Optimization
Question: How would you optimize a scrolling UITableView/UICollectionView?
Answer:
swift
class OptimizedTableViewCell: UITableViewCell {
// 1. Reuse identifiers
static let reuseIdentifier = "OptimizedCell"
// 2. Lazy loading private lazy var thumbnailImageView: UIImageView = { let imageView = UIImageView() imageView.contentMode = .scaleAspectFill imageView.clipsToBounds = true return imageView }()
// 3. Image caching
private let imageCache = NSCache
func configure(with item: Item) { // 4. Cancel previous image loads thumbnailImageView.image = nil
// 5. Check cache first let cacheKey = item.imageURL as NSString if let cachedImage = imageCache.object(forKey: cacheKey) { thumbnailImageView.image = cachedImage return }
// 6. Load image asynchronously Task { if let image = try? await loadImage(from: item.imageURL) { await MainActor.run { self.thumbnailImageView.image = image self.imageCache.setObject(image, forKey: cacheKey) } } } }
// 7. Avoid expensive operations in cellForRow override func prepareForReuse() { super.prepareForReuse() thumbnailImageView.image = nil } }
// 8. Prefetching class MyViewController: UIViewController { func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) { for indexPath in indexPaths { // Prefetch data for upcoming cells dataSource.prefetchData(at: indexPath) } } }
Optimization Techniques: 1. Cell reuse with proper identifiers 2. Lazy initialization 3. Image caching (NSCache) 4. Cancel previous operations 5. Background image processing 6. Prefetching API 7. Avoid layout calculations in cellForRow 8. Use estimatedRowHeight for dynamic content
7. Unit Testing
Question: How do you write testable code? Show dependency injection and mocking.
Answer:
swift
// Protocol for dependency injection
protocol NetworkServiceProtocol {
func fetchUser(id: String) async throws -> User
}// Production implementation
class NetworkService: NetworkServiceProtocol {
func fetchUser(id: String) async throws -> User {
// Real API call
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)
}
}
// Mock for testing
class MockNetworkService: NetworkServiceProtocol {
var shouldFail = false
var mockUser: User?
func fetchUser(id: String) async throws -> User {
if shouldFail {
throw NetworkError.serverError
}
return mockUser ?? User(id: id, name: "Test User")
}
}
// Testable ViewModel
class UserViewModel {
private let networkService: NetworkServiceProtocol
// Dependency injection through initializer
init(networkService: NetworkServiceProtocol = NetworkService()) {
self.networkService = networkService
}
func loadUser(id: String) async throws -> User {
return try await networkService.fetchUser(id: id)
}
}
// Unit Tests
import XCTest
class UserViewModelTests: XCTestCase {
var sut: UserViewModel!
var mockNetworkService: MockNetworkService!
override func setUp() {
super.setUp()
mockNetworkService = MockNetworkService()
sut = UserViewModel(networkService: mockNetworkService)
}
func testLoadUserSuccess() async throws {
// Given
let expectedUser = User(id: "123", name: "John Doe")
mockNetworkService.mockUser = expectedUser
// When
let user = try await sut.loadUser(id: "123")
// Then
XCTAssertEqual(user.id, expectedUser.id)
XCTAssertEqual(user.name, expectedUser.name)
}
func testLoadUserFailure() async {
// Given
mockNetworkService.shouldFail = true
// When/Then
do {
_ = try await sut.loadUser(id: "123")
XCTFail("Should throw error")
} catch {
XCTAssertTrue(error is NetworkError)
}
}
}
Conclusion
These senior-level topics form the foundation of expert iOS development. Focus on understanding the "why" behind each concept, not just the "how." In interviews, demonstrate your problem-solving approach and real-world experience with these patterns.
Key Takeaways:
- Modern concurrency simplifies async code
- Protocol-oriented programming enables flexible architecture
- Choose architecture based on project size and complexity
- Performance optimization is crucial for user experience
Keep practicing, and good luck with your senior iOS interviews! 🚀