Complete iOS Interview Guide - Part 1
Complete iOS Interview Guide - Part 1
1. CLEAN Architecture
CLEAN Architecture is a software design philosophy that emphasizes separation of concerns and independence of frameworks, UI, and databases. Created by Robert C. Martin (Uncle Bob), it's widely used in iOS development.
Core Principles
1. Independence:
- Independent of frameworks (UIKit, SwiftUI)
- Independent of UI
- Independent of database
- Independent of external agencies
- Testable without UI, database, or external elements
2. Separation of Concerns: Each layer has a specific responsibility and doesn't know about layers above it.
The Layers (from innermost to outermost)
Entities (Enterprise Business Rules):
- Core business objects
- Most stable, least likely to change
- Pure Swift objects with no framework dependencies
- Example: User, Product, Order models
// Entity
struct User {
let id: String
let name: String
let email: String
func isValidEmail() -> Bool {
// Business logic
return email.contains("@")
}
}
Use Cases (Application Business Rules):
- Contains application-specific business rules
- Orchestrates flow of data to/from entities
- Implements business logic
- Also called Interactors
// Use Case / Interactor
protocol FetchUserUseCase {
func execute(userId: String) async throws -> User
}
class FetchUserUseCaseImpl: FetchUserUseCase {
private let userRepository: UserRepository
init(userRepository: UserRepository) {
self.userRepository = userRepository
}
func execute(userId: String) async throws -> User {
// Business logic here
let user = try await userRepository.getUser(id: userId)
// Additional business rules
guard user.isValidEmail() else {
throw UserError.invalidEmail
}
return user
}
}
Interface Adapters (Controllers, Presenters, Gateways):
- Converts data between use cases and external agencies
- ViewModels, Presenters live here
- Repository implementations
// Presenter/ViewModel
class UserViewModel: ObservableObject {
@Published var userName: String = ""
@Published var isLoading: Bool = false
@Published var errorMessage: String?
private let fetchUserUseCase: FetchUserUseCase
init(fetchUserUseCase: FetchUserUseCase) {
self.fetchUserUseCase = fetchUserUseCase
}
func loadUser(id: String) {
isLoading = true
Task {
do {
let user = try await fetchUserUseCase.execute(userId: id)
await MainActor.run {
self.userName = user.name
self.isLoading = false
}
} catch {
await MainActor.run {
self.errorMessage = error.localizedDescription
self.isLoading = false
}
}
}
}
}
Frameworks & Drivers (UI, Database, Network):
- Outermost layer
- UIKit/SwiftUI views
- Network clients (URLSession, Alamofire)
- Database frameworks (CoreData, Realm)
// View (SwiftUI)
struct UserView: View {
@StateObject var viewModel: UserViewModel
var body: some View {
VStack {
if viewModel.isLoading {
ProgressView()
} else {
Text(viewModel.userName)
}
}
.onAppear {
viewModel.loadUser(id: "123")
}
}
}
Dependency Rule
Critical rule: Dependencies point inward. Outer layers depend on inner layers, never the reverse.
Frameworks/UI → Interface Adapters → Use Cases → Entities
Inner layers never import or know about outer layers.
Data Flow
- User interaction → View calls ViewModel
- ViewModel → Calls Use Case
- Use Case → Calls Repository (through protocol)
- Repository → Fetches from Network/Database
- Data returns through the same chain
- ViewModel → Updates published properties
- View → Automatically updates
Protocols for Inversion
Use protocols to invert dependencies:
// Domain layer defines protocol
protocol UserRepository {
func getUser(id: String) async throws -> User
}
// Data layer implements it
class UserRepositoryImpl: UserRepository {
private let networkService: NetworkService
func getUser(id: String) async throws -> User {
let response = try await networkService.fetch(endpoint: "/users/\(id)")
return User(from: response)
}
}
Advantages
1. Testability:
// Mock repository for testing
class MockUserRepository: UserRepository {
var mockUser: User?
var shouldThrowError = false
func getUser(id: String) async throws -> User {
if shouldThrowError {
throw NSError(domain: "test", code: 0)
}
return mockUser ?? User(id: id, name: "Test", email: "test@test.com")
}
}
// Test use case in isolation
func testFetchUser() async throws {
let mockRepo = MockUserRepository()
mockRepo.mockUser = User(id: "1", name: "John", email: "john@test.com")
let useCase = FetchUserUseCaseImpl(userRepository: mockRepo)
let user = try await useCase.execute(userId: "1")
XCTAssertEqual(user.name, "John")
}
2. Maintainability: Clear separation makes code easier to understand and modify
3. Scalability: Easy to add features without affecting existing code
4. Flexibility: Can swap implementations (different databases, APIs) without changing business logic
Disadvantages
- More files and boilerplate
- Steeper learning curve
- May be overkill for small apps
- Initial setup takes longer
When to Use
Good for:
- Large, complex applications
- Apps with multiple developers
- Long-term projects requiring maintainability
- Apps with changing requirements
Overkill for:
- Prototypes or MVPs
- Very simple apps
- Solo projects with clear, stable requirements
2. Dependency Injection (DI)
Dependency Injection is a design pattern where objects receive their dependencies from external sources rather than creating them internally.
What Problem Does It Solve?
Without DI (Tight Coupling):
class UserViewModel {
let repository = UserRepository() // Creates dependency
let analytics = AnalyticsService()
func loadUser() {
// Now UserViewModel is tightly coupled to specific implementations
// Hard to test, hard to change
}
}
Problems:
- Can't easily swap implementations
- Hard to test (can't use mock repository)
- Creates dependencies in constructor (tight coupling)
With DI (Loose Coupling):
class UserViewModel {
private let repository: UserRepositoryProtocol
private let analytics: AnalyticsProtocol
init(repository: UserRepositoryProtocol,
analytics: AnalyticsProtocol) {
self.repository = repository
self.analytics = analytics
}
func loadUser() {
// Uses injected dependencies
}
}
Benefits:
- Easy to swap implementations
- Easy to test with mocks
- Loose coupling
- Single Responsibility Principle
Types of Dependency Injection
1. Constructor Injection (Most Common in iOS):
class LoginViewController: UIViewController {
private let viewModel: LoginViewModel
// Dependencies injected through initializer
init(viewModel: LoginViewModel) {
self.viewModel = viewModel
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
Advantages:
- Dependencies explicit and required
- Immutable (let)
- Clear what object needs
2. Property Injection:
class ProfileViewController: UIViewController {
var viewModel: ProfileViewModel! // Injected after initialization
func configure(with viewModel: ProfileViewModel) {
self.viewModel = viewModel
}
}
Use when:
- Storyboard/XIB initialization required
- Circular dependencies (rare, usually design smell)
3. Method Injection:
class ImageProcessor {
func process(image: UIImage, with filter: ImageFilter) {
// Filter injected as method parameter
}
}
Use when:
- Dependency varies per method call
- Not all methods need the dependency
Dependency Injection Container
For complex apps with many dependencies, use a DI container:
protocol DIContainer {
func resolve<T>() -> T
}
class AppDIContainer: DIContainer {
// Singleton dependencies
private lazy var networkService: NetworkService = NetworkServiceImpl()
private lazy var userRepository: UserRepository = UserRepositoryImpl(
networkService: networkService
)
func resolve<T>() -> T {
switch T.self {
case is NetworkService.Type:
return networkService as! T
case is UserRepository.Type:
return userRepository as! T
case is FetchUserUseCase.Type:
return FetchUserUseCaseImpl(
userRepository: resolve()
) as! T
default:
fatalError("No registration for \(T.self)")
}
}
}
Usage:
let container = AppDIContainer()
let useCase: FetchUserUseCase = container.resolve()
let viewModel = UserViewModel(fetchUserUseCase: useCase)
Protocol-Oriented DI
// Define protocol
protocol UserService {
func fetchUser(id: String) async throws -> User
}
// Production implementation
class ProductionUserService: UserService {
func fetchUser(id: String) async throws -> User {
// Real API call
}
}
// Test implementation
class MockUserService: UserService {
var mockUser: User?
func fetchUser(id: String) async throws -> User {
return mockUser ?? User(id: id, name: "Mock", email: "mock@test.com")
}
}
// Injection
class UserViewModel {
private let userService: UserService
init(userService: UserService) {
self.userService = userService
}
}
// Production
let viewModel = UserViewModel(userService: ProductionUserService())
// Testing
let viewModel = UserViewModel(userService: MockUserService())
Environment Objects (SwiftUI DI Pattern)
class AppState: ObservableObject {
let userRepository: UserRepository
let authService: AuthService
init(userRepository: UserRepository, authService: AuthService) {
self.userRepository = userRepository
self.authService = authService
}
}
// App level
@main
struct MyApp: App {
@StateObject var appState = AppState(
userRepository: UserRepositoryImpl(),
authService: AuthServiceImpl()
)
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(appState)
}
}
}
// View level
struct UserProfileView: View {
@EnvironmentObject var appState: AppState
var body: some View {
Text("Profile")
.onAppear {
// Access injected dependencies
appState.userRepository.fetchUser()
}
}
}
Third-Party DI Frameworks
Swinject (Popular Swift DI Container):
import Swinject
let container = Container()
// Registration
container.register(UserRepository.self) { _ in
UserRepositoryImpl()
}
container.register(FetchUserUseCase.self) { resolver in
FetchUserUseCaseImpl(
userRepository: resolver.resolve(UserRepository.self)!
)
}
// Resolution
let useCase = container.resolve(FetchUserUseCase.self)!
Best Practices
- Prefer constructor injection for required dependencies
- Use protocols for abstraction and testability
- Inject interfaces, not implementations
- Keep constructors simple (no logic, just assignment)
- Avoid service locator pattern (anti-pattern where objects fetch their own dependencies)
- Use DI container for complex dependency graphs
- Document dependencies if not obvious from types
Common Pitfalls
1. Over-injection:
// Too many dependencies (code smell)
class ViewController {
init(dep1: A, dep2: B, dep3: C, dep4: D, dep5: E, dep6: F) {
// Too many! Split into smaller classes
}
}
2. Circular Dependencies:
// A depends on B, B depends on A (BAD)
class A {
let b: B
init(b: B) { self.b = b }
}
class B {
let a: A
init(a: A) { self.a = a }
}
Solution: Use protocols or rethink design
3. ARC (Automatic Reference Counting)
ARC is Swift's memory management system that automatically tracks and manages app memory by counting references to objects.
How ARC Works
1. Reference Counting:
Every time you create a new reference to a class instance, ARC increments the reference count. When a reference goes away, ARC decrements it. When count reaches zero, the object is deallocated.
class Person {
let name: String
init(name: String) {
self.name = name
print("\(name) is initialized")
}
deinit {
print("\(name) is deinitialized")
}
}
var reference1: Person? = Person(name: "John") // Count: 1
var reference2: Person? = reference1 // Count: 2
var reference3: Person? = reference1 // Count: 3
reference1 = nil // Count: 2
reference2 = nil // Count: 1
reference3 = nil // Count: 0 → deinit called
2. Only Reference Types:
ARC applies only to class instances (reference types), not structs or enums (value types).
struct MyStruct { } // Value type - copied, not counted
class MyClass { } // Reference type - counted by ARC
Strong References (Default)
class Apartment {
let unit: String
var tenant: Person?
init(unit: String) {
self.unit = unit
}
}
class Person {
let name: String
var apartment: Apartment?
init(name: String) {
self.name = name
}
}
var john: Person? = Person(name: "John")
var unit4A: Apartment? = Apartment(unit: "4A")
john?.apartment = unit4A // Person → Apartment (strong)
unit4A?.tenant = john // Apartment → Person (strong)
// Strong reference cycle! Both keep each other alive
john = nil // Person still has 1 reference (from Apartment)
unit4A = nil // Apartment still has 1 reference (from Person)
// Memory leak! Neither is deallocated
Weak References
Weak references don't increase reference count and automatically become nil when the referenced object is deallocated.
class Apartment {
let unit: String
weak var tenant: Person? // weak reference
init(unit: String) {
self.unit = unit
}
}
class Person {
let name: String
var apartment: Apartment?
init(name: String) {
self.name = name
}
deinit {
print("\(name) deinitialized")
}
}
var john: Person? = Person(name: "John")
var unit4A: Apartment? = Apartment(unit: "4A")
john?.apartment = unit4A // Person → Apartment (strong)
unit4A?.tenant = john // Apartment → Person (WEAK)
john = nil // Person count: 0 → deallocated
// Apartment's tenant automatically becomes nil
Rules:
- Must be var (can change to nil)
- Must be optional type
- Used for optional parent-child relationships
Common use case - Delegates:
protocol DataSourceDelegate: AnyObject {
func didReceiveData()
}
class DataSource {
weak var delegate: DataSourceDelegate? // Prevent retain cycle
}
class ViewController: UIViewController, DataSourceDelegate {
let dataSource = DataSource()
override func viewDidLoad() {
super.viewDidLoad()
dataSource.delegate = self // VC → DataSource (strong)
// DataSource → VC (weak)
}
func didReceiveData() {
print("Data received")
}
}
Unowned References
Like weak, but assumes the reference will never be nil during its lifetime.
class Customer {
let name: String
var card: CreditCard?
init(name: String) {
self.name = name
}
}
class CreditCard {
let number: String
unowned let customer: Customer // Will always exist
init(number: String, customer: Customer) {
self.number = number
self.customer = customer
}
}
var john: Customer? = Customer(name: "John")
john?.card = CreditCard(number: "1234", customer: john!)
// Customer → CreditCard (strong)
// CreditCard → Customer (unowned)
john = nil // Both are deallocated
// Card can't exist without customer
Rules:
- Can be let or var
- Not optional
- Accessing deallocated unowned reference crashes
- Use when you're certain the reference will outlive or be deallocated simultaneously
Weak vs Unowned
| Feature | weak | unowned |
|---|---|---|
| Optionality | Always optional | Can be non-optional |
| Mutability | Must be var | Can be let |
| Safety | Becomes nil safely | Crashes if accessed after deallocation |
| Use case | May become nil | Never nil during lifetime |
Choose weak when:
- Reference might become nil
- Parent-child where child can outlive parent
Choose unowned when:
- Reference should never be nil
- Referenced object same or longer lifetime
- Closure captures in non-escaping contexts
Closures and Capture Lists
Closures capture strong references by default, causing retain cycles:
class ViewController: UIViewController {
var name = "VC"
func setupHandler() {
apiService.fetchData { data in
// Closure captures self strongly
print(self.name) // Retain cycle if apiService holds closure
}
}
}
Solution - Capture Lists:
// 1. Weak self (most common)
apiService.fetchData { [weak self] data in
guard let self = self else { return }
print(self.name) // Safe, self might be nil
}
// 2. Unowned self
apiService.fetchData { [unowned self] data in
print(self.name) // Crashes if self is deallocated
}
// 3. Weak capture of specific properties
apiService.fetchData { [weak self] data in
self?.updateUI() // Optional chaining
}
When to use each:
// weak - asynchronous, self might not exist
URLSession.shared.dataTask(with: url) { [weak self] data, _, _ in
self?.handleResponse(data)
}.resume()
// unowned - self definitely exists during closure execution
func delayedExecution() {
DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [unowned self] in
self.someMethod() // OK if VC definitely exists
}
}
// No capture needed - doesn't use self
let numbers = [1, 2, 3]
numbers.map { $0 * 2 } // No retain cycle
Common Memory Issues
1. Retain Cycles with Closures:
class ViewController: UIViewController {
var onComplete: (() -> Void)?
func setup() {
onComplete = {
self.dismiss(animated: true) // Cycle! VC → closure → VC
}
}
}
// Fix
func setup() {
onComplete = { [weak self] in
self?.dismiss(animated: true)
}
}
2. Capture List in Escaping Closures:
class ImageDownloader {
var completion: ((UIImage) -> Void)?
func download(from url: URL) {
URLSession.shared.dataTask(with: url) { data, _, _ in
if let data = data, let image = UIImage(data: data) {
self.completion?(image) // Potential cycle
}
}.resume()
}
}
// Fix
func download(from url: URL) {
URLSession.shared.dataTask(with: url) { [weak self] data, _, _ in
guard let self = self,
let data = data,
let image = UIImage(data: data) else { return }
self.completion?(image)
}.resume()
}
3. Timer Retain Cycles:
class ViewController: UIViewController {
var timer: Timer?
override func viewDidLoad() {
super.viewDidLoad()
// Timer strongly retains target (self)
timer = Timer.scheduledTimer(
timeInterval: 1.0,
target: self,
selector: #selector(update),
userInfo: nil,
repeats: true
)
}
@objc func update() {
print("Update")
}
deinit {
timer?.invalidate() // Must invalidate to break cycle
}
}
// Better: Use block-based timer (iOS 10+)
timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
self?.update()
}
Debugging Memory Issues
1. Instruments - Leaks:
- Detects memory leaks
- Shows leaked objects
- Provides call stack
2. Instruments - Allocations:
- Tracks memory allocations
- Shows memory growth over time
- Identifies temporary vs persistent allocations
3. Debug Memory Graph:
- Xcode → Debug → View Memory Graph Hierarchy
- Shows all objects in memory
- Identifies retain cycles visually
- Purple exclamation marks indicate cycles
4. Print deinit:
class MyClass {
let name: String
init(name: String) {
self.name = name
print("✅ \(name) initialized")
}
deinit {
print("❌ \(name) deinitialized")
}
}
If deinit doesn't print when expected, you have a memory leak.
Best Practices
- Use weak for delegates
- Use [weak self] in escaping closures
- Invalidate timers in deinit
- Use unowned only when absolutely certain
- Prefer structs over classes when possible (no ARC needed)
- Profile regularly with Instruments
- Add deinit logging during development
- Be careful with two-way strong references
4. Grand Central Dispatch (GCD)
GCD is Apple's low-level API for managing concurrent operations. It uses dispatch queues to manage tasks.
Core Concepts
1. Queues:
A queue is a data structure that manages tasks. GCD has two types:
Serial Queue:
- Executes one task at a time
- Tasks execute in FIFO order
- Each task completes before next starts
let serialQueue = DispatchQueue(label: "com.app.serialQueue")
serialQueue.async {
print("Task 1 starts")
sleep(2)
print("Task 1 ends")
}
serialQueue.async {
print("Task 2 starts") // Waits for Task 1
sleep(1)
print("Task 2 ends")
}
// Output:
// Task 1 starts
// Task 1 ends
// Task 2 starts
// Task 2 ends
Concurrent Queue:
- Can execute multiple tasks simultaneously
- Tasks start in FIFO order
- Tasks can execute in parallel
let concurrentQueue = DispatchQueue(label: "com.app.concurrent", attributes: .concurrent)
concurrentQueue.async {
print("Task 1 starts")
sleep(2)
print("Task 1 ends")
}
concurrentQueue.async {
print("Task 2 starts") // Doesn't wait
sleep(1)
print("Task 2 ends")
}
// Output (order may vary):
// Task 1 starts
// Task 2 starts
// Task 2 ends
// Task 1 ends
2. System Queues:
Main Queue (Serial):
DispatchQueue.main.async {
// Update UI here - always on main thread
self.label.text = "Updated"
}
- Always use for UI updates
- Serial queue
- Runs on main thread
Global Queues (Concurrent):
DispatchQueue.global(qos: .userInitiated).async {
// Background work
let data = downloadData()
DispatchQueue.main.async {
// Update UI with result
self.imageView.image = UIImage(data: data)
}
}
Quality of Service (QoS) levels:
// User-Interactive (highest priority)
// - UI updates, animations
// - Work that must complete immediately
DispatchQueue.global(qos: .userInteractive).async {
// Critical UI work
}
// User-Initiated
// - Work user requested and is waiting for
// - Opening document, loading data user explicitly asked for
DispatchQueue.global(qos: .userInitiated).async {
// User waiting for result
}
// Default
// - General purpose
DispatchQueue.global(qos: .default).async {
// Standard background work
}
// Utility
// - Long-running tasks with progress indicator
// - Downloads, computations
DispatchQueue.global(qos: .utility).async {
// Progress-tracked work
}
// Background (lowest priority)
// - Not time-sensitive
// - Prefetching, maintenance, cleanup
DispatchQueue.global(qos: .background).async {
// Non-urgent work
}
sync vs async
async (Asynchronous):
- Returns immediately
- Doesn't block current thread
- Task executes in background
print("Before")
DispatchQueue.global().async {
print("Inside async")
sleep(2)
}
print("After")
// Output:
// Before
// After
// Inside async (after 2 seconds)
sync (Synchronous):
- Blocks current thread
- Waits for task to complete
- Returns after task finishes
print("Before")
DispatchQueue.global().sync {
print("Inside sync")
sleep(2)
}
print("After")
// Output:
// Before
// Inside sync
// (2 second pause)
// After
⚠️ Deadlock Warning:
DispatchQueue.main.sync {
// DEADLOCK! Main thread waiting for itself
print("Never executes")
}
Never call sync on the current queue.
Dispatch Work Items
Encapsulates work to execute:
let workItem = DispatchWorkItem {
print("Work item executing")
sleep(2)
}
// Execute
DispatchQueue.global().async(execute: workItem)
// Cancel (if not started)
workItem.cancel()
// Wait for completion
workItem.wait()
// Notify when complete
workItem.notify(queue: .main) {
print("Work item completed")
}
Dispatch Groups
Execute multiple tasks and get notified when all complete:
let group = DispatchGroup()
// Method 1: enter/leave
group.enter()
DispatchQueue.global().async {
print("Task 1")
sleep(1)
group.leave()
}
group.enter()
DispatchQueue.global().async {
print("Task 2")
sleep(2)
group.leave()
}
// Notify when all tasks complete
group.notify(queue: .main) {
print("All tasks completed")
}
// Or wait synchronously
group.wait() // Blocks until all complete
Method 2: Automatic entry:
let group = DispatchGroup()
let queue = DispatchQueue.global()
queue.async(group: group) {
print("Task 1")
}
queue.async(group: group) {
print("Task 2")
}
group.notify(queue: .main) {
print("Both complete")
}
Real-world example - Loading multiple images:
func loadImages(urls: [URL], completion: @escaping ([UIImage]) -> Void) {
var images: [UIImage] = []
let group = DispatchGroup()
let queue = DispatchQueue.global(qos: .userInitiated)
for url in urls {
group.enter()
queue.async {
if let data = try? Data(contentsOf: url),
let image = UIImage(data: data) {
images.append(image)
}
group.leave()
}
}
group.notify(queue: .main) {
completion(images)
}
}
Dispatch Barriers
Ensures exclusive access in concurrent queue:
class ThreadSafeArray<T> {
private var array: [T] = []
private let queue = DispatchQueue(label: "com.app.array", attributes: .concurrent)
// Read (concurrent)
func get(at index: Int) -> T? {
var result: T?
queue.sync {
guard index < array.count else { return }
result = array[index]
}
return result
}
// Write (exclusive with barrier)
func append(_ element: T) {
queue.async(flags: .barrier) {
self.array.append(element)
}
}
}
Barrier blocks execute exclusively:
- Wait for all previous tasks to complete
- Execute alone (no other tasks run)
- Subsequent tasks wait for barrier
Comments
Post a Comment