Top 10 Interview Questions For Every iOS Engineers


If you're an experienced iOS engineer preparing for interviews, you’ve probably wondered: what questions should you focus on? Whether you're targeting a junior role or a senior position, mastering core topics is important. In this article, we’ve compiled 10 must-know interview questions that frequently asked in interviews. Let’s make your preparation seamless!
Q. What are the differences between class and structure?
Class and Structure both are used for creating custom data types. While they share similarities with their counterparts, there are some specific differences and considerations you should know.
To understand the differences between them, we will use the below example:
class MediaAssetClass {
var name: String
var type: String
init(name: String, type: String) {
self.name = name
self.type = type
}
}
struct MediaAssetStruct {
var name: String
var type: String
}
Reference Types Vs. Value Types
When you assign an instance of a class to a variable or pass it as an argument to a function, you're working with a reference to the original instance. Any changes made to that reference affect the original instance. For example:
var jpgMedia = MediaAssetClass(name: "ProfilePhoto_123", type: "JPG")
var jpgMediaDuplicate = jpgMedia
// creating a reference to the original instance
jpgMediaDuplicate.name = "Profile_123"
print(jpgMedia.name) // Print: Profile_123
print(jpgMediaDuplicate.name) // Print: Profile_123
In the above example, jpgMedia
and jpgMediaDuplicate
are references to the same instance. When you modify the name property of jpgMediaDuplicate
, it also changes the name property of the original jpgMedia
instance.
When you assign an instance of a structure to a variable or pass it as an argument to a function, you're working with a copy of the original instance. Changes made to the copy do not affect the original instance unless explicitly mutated using the mutating keyword. For example:
var movMedia = MediaAssetStruct(name: "Video_123", type: "MOV")
var movMediaDuplicate = movMedia
// creating a copy of the original instance
movMediaDuplicate.name = "VideoFile_123"
print(movMedia.name) // Print: Video_123
print(movMediaDuplicate.name) // Print: VideoFile_123
In the above example, movMedia
and movMediaDuplicate
are separate instances. When you modify the name property of movMediaDuplicate
, it does not affect the original movMedia instance.
Inheritance
Classes support inheritance, allowing one class to inherit properties and methods from another class. While, structure doesn't support inheritance. You cannot subclass a structure. For example:
// attempting to define a struct that inherits from another struct - This will result in a compilation error.
struct PhotoAssetStruct: MediaAssetStruct {}
// Compilation Error: 'Inheritance from non-protocol, non-class type 'MediaAssetStruct''
If you need to achieve similar behaviour to inheritance with structs, you can use protocols and protocol extensions, but this would not be true inheritance.
Identity Checking
Classes have identity, and you can check if two references point to the same instance using the ===
operator. For example:
var jpgMedia = MediaAssetClass(name: "ProfilePhoto_123", type: "JPG")
var jpgMediaDuplicate = jpgMedia
jpgMediaDuplicate.name = "Profile_123"
if jpgMedia === jpgMediaDuplicate {
print("Both class objects point to the same instance.")
} else {
print("Both class objects do not point to the same instance.")
}
// Prints: Both class objects point to the same instance.
Structure do not have identity checks like classes. You compare instances of struct by comparing their properties. For example:
The ==
operator is not automatically defined for structs. Therefore, we need to explicitly define how to compare instances of our custom struct.
var movMedia = MediaAssetStruct(name: "Video_123", type: "MOV")
var movMediaDuplicate = movMedia
movMediaDuplicate.name = "VideoFile_123"
// error: binary operator '==' cannot be applied to two 'MediaAssetStruct' operands
if movMedia == movMediaDuplicate {
print("Both struct objects have the same properties.")
} else {
print("Both struct objects do not have the same properties.")
}
Let's correct the example by implementing the Equatable
protocol:
struct MediaAssetStruct: Equatable {
var name: String
var type: String
}
// Run the above example now and you will see the output like:
// Both struct objects do not have the same properties.
By conforming to Equatable
, the compiler will compare all the properties of both the instances. In case of custom comparison with Equatable
protocol, you can override static ==
function.
Immutability
Instances of classes can have mutable properties, and you can modify these properties even if the class instance is declared as a constant (using let
).
By default, instances of struct are immutable (constants). To modify the properties, you need to mark the method that performs the modification with the mutating keyword. For example:
struct MediaAssetStruct: Equatable {
var name: String
var type: String
// error: mark method 'mutating' to make 'self' mutable
func modifyName(newName: String) {
self.name = newName
}
}
Deinitializers
In classes, deinitializers are called immediately before an instance of the class is deallocated. Deinitializers can also access properties and other members of the class instance and can perform any cleanup necessary for those members. For example:
class MediaAssetClass {
deinit {
print("class instance is deallocated.")
}
}
var jpgMedia: MediaAssetClass? = MediaAssetClass()
jpgMedia = nil
// Prints: class instance is deallocated.
Because structs are value types and are copied when passed around, there's no concept of deinitializing an instance of a struct in the same way as with classes. For example:
// error: deinitializers may only be declared within a class, actor, or non-copyable type
struct MediaAssetStruct {
deinit {
print("struct instance is deallocated.")
}
}
In summary, you should consider the differences between both based on reference vs. value semantics, inheritance, immutability, deinitlaization etc.
Q. How does method dispatch differ between classes and structures?
Method dispatch determines which implementation of a method or function should be invoked at runtime based on the type of the object or value. It's essentially how Swift compiler decides which code to execute when a method or function is called.
When you call a method on an object, the compiler needs to determine which specific implementation of that method to invoke, especially in cases where inheritance and polymorphism are involved.
Dynamic Dispatch
In dynamic dispatch, also known as runtime dispatch, the method implementation to call is determined at runtime based on the actual type of the object or value. This type of dispatch is commonly used for reference types such as classes, where the actual implementation of a method may vary depending on subclassing and overriding.
When a class is marked as final
, it means that the class cannot be subclassed. Since there's no possibility of method overriding. Therefore, the compiler can always determine at compile-time which specific implementation of a method to call based on the static type of the object.
class MediaAssetClass {
var name: String
init(name: String) {
self.name = name
}
func displayInfo() {
print("MediaAssetClass's Name: \(name)")
}
}
class Movie: MediaAssetClass {
var duration: Int
init(name: String, duration: Int) {
self.duration = duration
super.init(name: name)
}
override func displayInfo() {
print("Movie's Name: \(name), Duration: \(duration) minutes")
}
}
let audioAsset = MediaAssetClass(name: "Audio File")
audioAsset.displayInfo() // Prints: MediaAssetClass's Name: Audio File
let movie = Movie(name: "Inception", duration: 148)
movie.displayInfo() // Prints: Movie's Name: Inception, Duration: 148 minutes
When we call the displayInfo()
method on the movie object, it prints out the details of the movie, including its name and duration. Since displayInfo()
is overridden in the Movie
subclass, it prints out the details with the duration included that is decided on run-time by dynamic dispatch.
Static Dispatch
In static dispatch, also known as compile-time dispatch, the compiler determines which method or function implementation to call based on the declared type of the variable or constant at compile-time. This type of dispatch is used for value types such as structures and enums, where the method implementation is known at compile-time.
struct MediaAssetStruct {
var name: String
func displayInfo() {
print("Name: \(name)")
}
}
let mediaAsset = MediaAssetStruct(name: "Nature")
mediaAsset.displayInfo() // Prints: Name: Nature
The method dispatch for displayInfo()
is static, meaning the method to be called is determined at compile-time based on the type of the variable (mediaAsset), and there's no concept of inheritance involved.
Q. How Observer pattern relates to NotificationCenter?
The Observer pattern and NotificationCenter are closely related, as NotificationCenter is essentially an implementation of the Observer pattern in Swift. Let's explore this connection.
We know that, the Observer pattern is a behavioural design pattern that defines a one-to-many dependency or communication between objects. When one object (known as the subject or publisher) changes state, all its dependents (known as observers or subscribers) are notified and updated automatically.
Imagine you're making a fitness tracking app. In this app, you have a workout session screen and a dashboard screen. You want the dashboard to update in real-time whenever the user completes a workout, without tightly coupling these two components of your app.
Here's how we can use NotificationCenter to implement the Observer pattern:
extension Notification.Name {
static let workoutCompleted = Notification.Name("workoutCompleted")
}
class WorkoutSession {
var duration: TimeInterval = 0
var caloriesBurned: Double = 0
func completeWorkout() {
duration = 3600 // 1 hour
caloriesBurned = 500
// post a notification when the workout is completed
NotificationCenter.default.post(name: .workoutCompleted, object: self)
}
}
In the above code, when you mark workout is completed, posting a notification with the object (self).
class Dashboard {
var totalWorkouts: Int = 0
var totalCaloriesBurned: Double = 0
init() {
// register as an observer for workout completed notifications
NotificationCenter.default.addObserver(self,
selector: #selector(workoutCompletedHandler),
name: .workoutCompleted,
object: nil)
}
@objc func workoutCompletedHandler(notification: Notification) {
guard let workout = notification.object as? WorkoutSession else { return }
totalWorkouts += 1
totalCaloriesBurned += workout.caloriesBurned
updateUI()
}
func updateUI() {
print("Dashboard updated: Total workouts: \(totalWorkouts), Total calories burned:
\(totalCaloriesBurned)")
}
deinit {
// don't forget to remove the observer when the object is deallocated
NotificationCenter.default.removeObserver(self)
}
}
In the above code, when you receive a notification when a workout is completed, we’re updating the UI accordingly. In addition, observer should be removed in deinit()
method.
let dashboard = Dashboard()
let workoutSession = WorkoutSession()
workoutSession.completeWorkout()
workoutSession.completeWorkout()
In the above code, call completeWorkout()
method to mark workout complete. In the above example:
The
WorkoutSession
class acts as the subject (or publisher). It posts a notification when a workout is completed.The
Dashboard
class acts as the observer (or subscriber). It registers itself to receive notifications when workouts are completed.NotificationCenter serves as the mediator, decoupling the subject from the observer.
Here's how the Observer pattern relates to NotificationCenter:
Loose Coupling: The WorkoutSession doesn't need to know anything about the Dashboard. It simply broadcasts a notification when a workout is completed. Any number of observers can listen for this notification without the WorkoutSession needing to be aware of them.
One-to-Many Relationship: Multiple observers can register for the same notification. For instance, we could have a LeaderboardViewController that also observes workout completions without changing the WorkoutSession class.
Push-based Communication: When a workout is completed, the notification is pushed to all registered observers, allowing them to update immediately.
Dynamic Registration: Observers can register and unregister for notifications at runtime, allowing for flexible and dynamic relationships between objects.
The importance of Observer pattern:
Decoupling: It allows different parts of your app to communicate without being tightly coupled, improving modularity and maintainability.
Flexibility: You can easily add new observers without modifying the subject, adhering to the Open-Closed Principle.
Real-time Updates: Observers are notified immediately when an event occurs, enabling real-time updates across your app.
System-wide Communication: NotificationCenter allows for communication between unrelated parts of your app, or even between your app and the system (e.g., keyboard notifications).
Memory Management: With Swift's closure-based API, you don't need to worry about removing observers manually (though it's still good practice with the selector-based API).
By implementing the Observer pattern through NotificationCenter, you can create more modular, flexible, and responsive iOS apps. However, it's important to use it very carefully, as overuse can lead to hard-to-track notification chains and potential performance issues.
Q. Explain the difference between AppDelegate and SceneDelegate.
They both are two essential components that serve distinct purposes in managing an app's lifecycle. The introduction of the SceneDelegate came with the release of iOS 13 and iPadOS 13, as part of Apple's efforts to support multiple windows and scenes in an app. Let’s understand the differences between both of them.
AppDelegate
It has been a core part of iOS apps since the early days of the development.
It receives notifications when the app is launched or terminated. This is where you can perform initialization and cleanup tasks.
It is responsible for managing the overall lifecycle of the application, including handling events such as application launch, termination, and background/foreground transitions.
It is also responsible for handling app-level tasks like registering for remote notifications, handling app URLs, and managing the app's Core Data stack or other shared resources.
In iOS 13 and later, it still plays a role, but its responsibilities have been reduced to handle app-level events and tasks that are not specific to any particular scene or window.
It receives memory warnings, which indicate that the app is running low on memory.
class AppDelegate: NSObject, UIApplicationDelegate {
func application(_ application: UIApplication, didFinishLaunchingWithOptions
launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
// initialize app-level settings and configurations
return true
}
func applicationDidEnterBackground(_ application: UIApplication) {
// handle app backgrounding
}
func applicationWillEnterForeground(_ application: UIApplication) {
// handle app foregrounding
}
}
SceneDelegate
It is a new class introduced in iOS 13 and iPadOS 13 to support multiple scenes and windows within an app.
A scene represents a window or a group of windows that display content for a particular task or mode of operation within the app.
It is responsible for managing the lifecycle events of individual scenes, such as scene creation, activation, deactivation, and destruction.
It handles scene-specific tasks like configuring the initial user interface, responding to environment changes (e.g., light/dark mode), and managing state restoration for scenes.
An app can have multiple SceneDelegate instances, one for each active scene, while there is only one AppDelegate instance for the entire application.
It is responsible for handling scene-based multitasking on iPad, which allows users to have multiple scenes open simultaneously.
class SceneDelegate: NSObject, UIWindowSceneDelegate {
func scene(_ scene: UIScene,
willConnectTo session: UISceneSession,
options connectionOptions: UIScene.ConnectionOptions) {
// create and configure the scene's UI
}
func sceneDidEnterBackground(_ scene: UIScene) {
// handle scene backgrounding
}
func sceneWillEnterForeground(_ scene: UIScene) {
// handle scene foregrounding
}
}
So, the AppDelegate handles app-level tasks and events, while the SceneDelegate manages the lifecycle and state of individual scenes or windows within the app. This separation of concerns allows for better support for multi-window and multi-scene apps, as well as more efficient management of resources and state for each scene.
Q. Discuss the limitations of using extensions.
Extensions are allows you to add new functionality to an existing class, structure, enumeration, or protocol type. However extensions have their own limitations and considerations to keep in mind. Let's discuss some of the key limitations of using extensions.
Cannot Add Stored Properties
Extensions cannot add stored properties to an existing type. They can only add computed properties, methods, initializers, and nested types. This limitation exists because stored properties require additional memory allocation, which is not possible through extensions since they are designed to add functionality to existing types without modifying their underlying structure. For example:
struct Point {
var x: Double
var y: Double
}
extension Point {
// error: extensions must not contain stored properties
var z: Double
}
Overriding Limitations
Extensions cannot override existing methods or properties of a type. They can provide an alternative implementation, but the original method or property will still be available. Additionally, extensions cannot add or override designated initializers of a class. For example:
class BaseClass {
func someMethod() {
print("BaseClass method")
}
init() {
// designated initializer
}
}
extension BaseClass {
// error: cannot override existing methods or properties
override func someMethod() {
print("extension method")
}
// error: designated initializer cannot be declared in an extension
init(value: Int) {
// ...
}
}
Cannot Add Deinitializers
Extensions cannot add deinitializers to a class. Deinitializers must be defined in the original class implementation. This is why because deinitializers are tightly coupled with the lifecycle of the class instance. Allowing deinitializers in extensions could lead to confusion and complexity in managing the cleanup logic, as the deinitialization process would be spread across multiple places. For example:
class MediaAsset {
var fileName: String
var fileType: String?
init(name: String) {
self.fileName = name
}
}
extension MediaAsset {
// error: deinitializers may only be declared within a class
deinit {
print("\(fileName) is being deallocated")
}
}
Deinitializers need to interact closely with initializers and properties defined in the main class body. Splitting this logic into extensions could make it harder to understand and manage the lifecycle of instances.
Initializers
Extensions cannot add designated initializers. Designated initializers are integral to the class's initialization chain and ensure that all properties are correctly initialized. Allowing designated initializers in extensions could break the initialization guarantees and the initialization chain required by the class and its subclasses. While extensions can add convenience initializers to classes. For example:
extension MediaAsset {
// error: designated initializer cannot be declared in an extension
init(name: String) {
self.fileName = name
}
// this is allowed in extension
convenience init() {
self.init(name: "")
}
}
It's important to understand these limitations when working with extensions. Extensions are designed to add functionality to existing types in a non-invasive way, without modifying their underlying structure or breaking encapsulation principles.
Q. What is the purpose of prepareForReuse() in UITableViewCell? Why is it important for improving performance?
The prepareForReuse()
method is an important part of the cell reuse mechanism in UITableView
, which is important for improving performance when working with large data sets.
This method is recommended to reset the state of a reused cell so that it can be properly configured with new data. When a table view needs to display a new cell, it first looks for an available reusable cell in its reuse queue. If a reusable cell is found, it is dequeued and its prepareForReuse()
method is called before it is configured with the new data. For example:
override func prepareForReuse() {
super.prepareForReuse()
// reset label text
titleLabel.text = nil
// reset image view
imageView.image = nil
// reset any other properties or subviews here
// ...
}
How prepareForReuse() is important for improving performance?
Memory Efficiency: Reusing cells avoids the overhead of creating new cell instances repeatedly, which can be a relatively expensive operation, especially for complex cell layouts with many subviews. By reusing cells, the app can make better use of memory and avoid potential memory spikes.
Scrolling Performance: When a table view is scrolled, new cells come into view, and old cells go out of view. Without cell reuse, the table view would have to create and deallocate many cells during scrolling, which could lead to performance issues, especially on older devices or when dealing with large data sets. By reusing cells, the table view can efficiently display new cells without creating and deallocating many objects, resulting in smoother scrolling performance.
State Management: When a cell is reused, it may still have some residual state from its previous configuration. The prepareForReuse() method provides a convenient place to reset any properties or subviews that need to be cleared or reset before the cell is configured with new data. This ensures that the cell's visual appearance and state are properly reset, preventing visual glitches or incorrect data display.
To improve performance with prepareForReuse()
, you should reset any properties or subviews that were customized or modified when the cell was previously configured. This could include resetting label text, image views, progress views, or any other custom subviews or state that the cell holds. It's also a good practice to clear any strong references to external objects that might cause memory leaks.
Q. Explain the purpose of the makeKeyAndVisible() method in UIWindow.
The makeKeyAndVisible()
method in UIWindow is used to make a window the key window and make it visible on the screen. The key window is the window that receives user input events, such as touch events or keyboard events. Let’s understand the purpose and functionality of makeKeyAndVisible()
method.
Key Window
The method makes the window the key window, meaning it becomes the main window that receives keyboard and other non-touch related events.
Only one window at a time can be the key window.
The window becomes the focal point for user interactions.
The system sends keyboard events and other input events to the key window. This is important for text input fields and other interactive UI elements.
Visible Window
The method makes the window visible on the screen.
It sets the isHidden property of the window to false, ensuring that the window and its contents are drawn and presented to the user.
The window is added to the app’s visible windows, and it starts rendering its content.
This action is necessary to display the window’s view hierarchy on the screen.
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow?
func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions:
[UIApplication.LaunchOptionsKey: Any]?) -> Bool {
// initialize the window
window = UIWindow(frame: UIScreen.main.bounds)
// create a view controller
let viewController = UIViewController()
// set the root view controller of the window
window?.rootViewController = viewController
// make the window key and visible
window?.makeKeyAndVisible()
return true
}
}
Using makeKeyAndVisible()
is required for displaying content in a new UIWindow and ensuring that it can interact with the user. Without calling this method, the window would not be shown to the user, and it would not receive input events, rendering it effectively invisible and non-interactive.
You should only call makeKeyAndVisible() on the main thread, as it updates the UI and sets the window as the main window. If you have multiple windows in your app, you should only call makeKeyAndVisible() on the window that you want to be the main window.
Q. What are the different methods of passing data between view controllers?
In iOS apps, it's often necessary to pass data between different view controllers for various reasons, such as sharing user input, displaying data from a previous screen, or communicating between parent and child view controllers. iOS provides several techniques to facilitate data transfer between view controllers, each with its own strengths, use cases, and trade-offs. Let’s explore some most common techniques.
Using Closure/Callback
You can use closures or callbacks to pass data back from a child view controller to its parent. The parent view controller passes a closure to the child view controller, and the child view controller calls the closure with the data when needed. For example:
class ParentViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
presentChildController()
}
func presentChildController() {
let childController = ChildViewController()
// pass a closure to the child controller
childController.buttonClickHandler = { [weak self] data in
guard let self = self else { return }
// handle the data passed back from the child controller
}
present(childController, animated: true, completion: nil)
}
}
Before presenting the ChildViewController, we pass a closure to its buttonClickHandler
property. This closure will be called later by the child view controller when it needs to pass data back to the parent.
class ChildViewController: UIViewController {
var buttonClickHandler: ((String) -> Void)?
override func viewDidLoad() {
super.viewDidLoad()
// configure button as per requirement...
}
@objc func sendDataButtonTapped(_ sender: UIButton) {
let data = "Some data from the child view controller"
buttonClickHandler?(data)
// dismiss this controller if required...
}
}
In this example, we use a closure to pass data back from the child view controller to its parent without relying on delegates. The parent view controller creates an instance of the child view controller and passes a closure to it. When the child view controller needs to send data back to its parent, it simply calls the closure with the data.
This approach is useful when you want to pass data back from a child view controller to its parent in a simple and direct manner, without the overhead of setting up delegates or handling segues.
Using Delegate Pattern
The delegate pattern is a widely used method for passing data back from a child view controller to its parent. The parent view controller acts as the delegate for the child view controller, and the child view controller communicates with the parent through a protocol. For example:
protocol ChildViewControllerDelegate: AnyObject {
func childViewController(_ childVC: ChildViewController, didSelectData data: String)
}
class ParentViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
presentChildViewController()
}
func presentChildViewController() {
let childVC = ChildViewController()
childVC.delegate = self
present(childVC, animated: true, completion: nil)
}
}
We define a protocol ChildViewControllerDelegate
with a method that will be used to pass data from the child view controller to its delegate (the parent view controller).
extension ParentViewController: ChildViewControllerDelegate {
func childViewController(_ childVC: ChildViewController,
didSelectData data: String) {
// handle the data passed back from the child view controller
}
}
The ParentViewController
conforms to the protocol and implements its method. This method will be called by the child view controller when it needs to pass data back to the parent.
class ChildViewController: UIViewController {
weak var delegate: ChildViewControllerDelegate?
override func viewDidLoad() {
super.viewDidLoad()
// configure button as per requirement...
}
@objc func sendDataButtonTapped(_ sender: UIButton) {
let data = "Some data from the child view controller"
delegate?.childViewController(self, didSelectData: data)
}
}
After the button is tapped, we calls the delegate method on the delegate
object, passing the data to it.
In this example, we use the delegate pattern to pass data back from the child view controller to its parent. The parent view controller sets itself as the delegate of the child view controller. When the child view controller needs to send data back to its parent, it calls the appropriate delegate method on the delegate
object, passing the data as a parameter.
This approach is useful when you want to establish a communication channel between a child view controller and its parent, allowing the child to pass data back to the parent in a structured and decoupled manner.
Using Notification Center
It is a central broadcast system that allows objects to send notifications and other objects to observe and receive those notifications. It provides a way for different parts of an app to communicate with each other without having direct dependencies or knowledge of each other's implementations. This approach follows the Observer design pattern, where the broadcasting object (the sender) doesn't need to know anything about the receiving objects (the observers). Let’s see an example.
class BroadcasterViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
// configure button as per requirement...
}
@objc func sendDataButtonTapped(_ sender: UIButton) {
let data = ["full_name": "Swiftable", "username": "dev.swiftable"]
NotificationCenter.default.post(name: Notification.Name("DataBroadcast"),
object: nil,
userInfo: data)
}
}
After the button tapped, it creates a dictionary data
with some sample data and posts a notification named "DataBroadcast"
to the default NotificationCenter using the post(name:object:userInfo:)
method. The userInfo
parameter contains the data dictionary that needs to be broadcast.
class ObserverViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
NotificationCenter.default.addObserver(self,
selector: #selector(handleDataBroadcast(_:)),
name: Notification.Name("DataBroadcast"),
object: nil)
}
@objc func handleDataBroadcast(_ notification: Notification) {
if let data = notification.userInfo as? [String: String] {
// handle the data received from the notification
}
}
deinit {
NotificationCenter.default.removeObserver(self)
}
}
In the above controller, we add an observer for the "DataBroadcast"
notification using the addObserver(self:selector:name:object:)
method. This means that whenever a "DataBroadcast"
notification is posted, the handleDataBroadcast(_:)
method will be called. Also, in the deinit()
method of the, we remove the observer from the NotificationCenter to avoid potential memory leaks.
The Notification Center approach is useful when you need to communicate data between view controllers that are not directly related or don't have a parent-child relationship. It allows for a loosely coupled communication mechanism, where the broadcaster and observer don't need to know about each other's implementations.
These are some additional approaches for passing data between view controllers like Unwind Segue, Shared Instance, KVO, Persistent Storage, etc. The choice of approach depends on factors such as the complexity of the data being passed, the relationships between the view controllers, the application's architecture, and the specific requirements of your project.
Q. What is a retain cycle, and how does it occur in iOS apps? Can you provide an example?
A retain cycle (also known as a strong reference cycle) occurs when two or more objects hold strong references to each other, preventing them from being deallocated, even when they are no longer needed. This can lead to a memory leak, because the objects continue to occupy memory without being released.
Let's consider an example using in which we have two classes: User
and MediaAsset
.
class User {
var name: String
var favoriteAsset: MediaAsset?
init(name: String) {
self.name = name
}
deinit {
print("\(name) is deallocated")
}
}
class MediaAsset {
var name: String
var owner: User?
init(name: String) {
self.name = name
}
deinit {
print("\(name) is deallocated")
}
}
Now, let's set up a scenario where a retain cycle occurs:
var user: User? = User(name: "Swiftable")
var asset: MediaAsset? = MediaAsset(name: "ProfilePhoto")
user?.favoriteAsset = asset
asset?.owner = user
// now, both user and asset have strong references to each other
user = nil
asset = nil
Even after setting both user
and asset
to nil, neither object will be deallocated because they're still holding strong references to each other. This leads to a memory leak.
If you see that no logs gets printed even after made objects both nil. Why? Because they both made a strong reference cycle here and that’s why both the objects freezed to being deallocated. How to solve it?
To prevent a retain cycle, you can use weak references. In the example above, you can make the owner
property in MediaAsset
weak:
weak var owner: User?
This way, the MediaAsset
won't keep a strong reference to the User
, breaking the retain cycle and allowing both objects to be deallocated properly when there are no other strong references to them.
Run the code again and see the logs after making the weak reference of owner:
Swiftable is deallocated
ProfilePhoto is deallocated
A weak reference does not keep a strong reference on the instance it refers to and so does not stop ARC from deallocating the referenced instance. This behavior prevents the reference from becoming part of a strong reference cycle.
Q. Explain the Dependency Inversion Principle and its role in writing maintainable and scalable iOS apps.
It suggests that high-level modules should not depend on low-level modules but should depend on abstractions. Additionally, it states that abstractions should not depend on details; rather, details should depend on abstractions.
Suppose you have a MediaAsset
protocol that represents different types of media assets such as images, videos, or audio files. You want to implement a feature that allows users to filter media assets based on certain criteria. To apply the Dependency Inversion Principle, you might design your solution as follows:
protocol MediaAsset {
var title: String { get }
var type: MediaType { get }
// additional properties and methods
}
enum MediaType {
case image
case video
case audio
}
Implement concrete types conforming to the protocol:
struct ImageAsset: MediaAsset {
let title: String
let type: MediaType = .image
// implement properties and methods specific to image assets
}
struct VideoAsset: MediaAsset {
let title: String
let type: MediaType = .video
// implement properties and methods specific to video assets
}
struct AudioAsset: MediaAsset {
let title: String
let type: MediaType = .audio
// implement properties and methods specific to audio assets
}
Create a service or manager class that operates on media assets using the protocol:
class MediaAssetService {
func filterAssetsByType(assets: [MediaAsset], type: MediaType) -> [MediaAsset] {
return assets.filter { $0.type == type }
}
// other methods for working with media assets
}
The MediaAssetService
class depends on the MediaAsset
protocol rather than specific implementations. This makes it easier to extend and maintain because it's not tightly coupled to concrete types.
Adding new types of media assets (e.g., adding support for PDF files) is straightforward. You just need to create a new struct conforming to the MediaAsset
protocol.
Unit testing becomes easier as you can use mock objects or stubs conforming to the MediaAsset
protocol.
By following to the Dependency Inversion Principle leads to more maintainable and scalable apps by promoting decoupling, abstraction, testability, flexibility, and modular design. This helps you to build robust, adaptable, and high-quality iOS apps.
In today's competitive job market, having access to quality questions can give you a significant advantage over your peers. It equips you with the tools and knowledge needed to stand out during the interview process, increasing your chances of securing a good job. Start your preparation for iOS interviews with “iOS Interview Handbook“, includes:
✅ 290+ top interview Q&A covering a wide range of topics
✅ 520+ pages with examples in most of the questions
✅ Expert guidance and a roadmap for interview preparation
✅ Exclusive access to a personalised 1:1 session for doubts
Thank you for taking the time to read this article! If you found it helpful, don’t forget to like, share, and leave a comment with your thoughts or feedback. Your support means a lot!
Keep reading,
— Swiftable
Subscribe to my newsletter
Read articles from Nitin directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by

Nitin
Nitin
Lead Software Engineer, Technical Writer, Book Author, A Mentor