Building a Custom Observer in Swift: A Deep Dive into Notifications with Async/Await
Building a Custom Observer in Swift: A Deep Dive into Notifications with Async/Await
In iOS development, effective communication between different components of an application is essential. Apple’s NotificationCenter has long been the go-to solution for broadcasting messages across different parts of an app without tightly coupling them together. While NotificationCenter serves many use cases well, as your app becomes more complex, the need for greater flexibility, control, and custom behavior can arise.
That’s where building a custom notification system comes in.
Interestingly, this idea for a custom notification center first crossed my path during a technical interview with a well-known tech company in the Netherlands back in 2018. I was presented with a coding challenge to create a notification system from scratch. At the time, I found it quite strange and beyond my comfort zone, and honestly, I didn’t feel confident enough to approach it with the level of skill it required. Unfortunately, I wasn’t accepted for the position, but that experience stuck with me.
Fast forward to today, and with the advancements in Swift — particularly the introduction of async/await — I decided to revisit this concept. Not only is it no longer “strange,” but it’s now a fantastic way to leverage modern concurrency while building a custom solution that’s more suited for complex app architectures.
In this article, I’ll walk you through how to build a custom notification system using Swift. By incorporating async/await, you can ensure a cleaner, more efficient communication mechanism between your app’s components, while maintaining control and flexibility over how notifications are handled.
Why Build a Custom Notification Center?
Apple’s built-in NotificationCenter is a reliable and widely used system, so why would you ever need to replace it? While NotificationCenter is great for most standard use cases, building a custom solution offers several important advantages, especially when working on larger, more complex projects. Here’s why:
1. Greater Flexibility and Control
• Fine-tuned Notifications: A custom notification center allows you to design notifications that are better suited to your app’s needs. You can easily customize notification behavior, such as how observers are notified, what kind of data is passed, and whether notifications should be delivered synchronously or asynchronously.
• Precise Observer Management: When you implement your own system, you can improve how observers are registered and removed, giving you more control over who receives which notifications and when.
2. Modern Asynchronous Support
• With Swift’s async/await, handling asynchronous tasks has become more readable and efficient. Apple’s NotificationCenter still relies on older paradigms like callback closures, which can make it difficult to work with concurrent code. By creating a custom notification center, you can seamlessly integrate async/await, ensuring non-blocking operations and making your code more future-proof.
3. Better Memory Management
• One of the common issues with NotificationCenter is dealing with memory leaks when observers are not removed correctly. A custom solution can take advantage of Swift’s weak references, automatically cleaning up observers when they are deallocated. This makes the system less prone to memory-related bugs.
4. Improved Debugging
• When using NotificationCenter, it’s easy to lose track of who’s observing what, especially in large projects. A custom notification center can give you better visibility into registered observers, making it easier to track down bugs related to notifications and improve your overall debugging experience.
5. Custom Features
• You may need additional features that NotificationCenter doesn’t support, such as delayed notifications, priority-based notification handling, or batch processing. A custom notification center lets you design exactly the features you need, without workarounds.
6. Testability
• Custom implementations can make unit testing easier. You can inject dependencies and mock behavior in your tests, which can be difficult with Apple’s NotificationCenter due to its singleton nature. A custom system allows for better isolation and more reliable unit tests.
Step-by-Step Guide to Building a Custom Observer
1. Creating the Notification Name
- Introduce a struct to represent notification names (e.g., CustomNotificationName), making the notification system more type-safe than using strings.
struct CustomNotificationName: Hashable {
let rawValue: String
}
2. Designing the Custom Observer
- Introduce the CustomObserver class. This class will hold weak references to observers and use an async closure to handle notifications.
final class CustomObserver {
weak var observer: AnyObject?
let selector: (CustomNotification) async -> Void
init(observer: AnyObject, selector: @escaping (CustomNotification) async -> Void) {
self.observer = observer
self.selector = selector
}
}
3. Implementing the Notification Service
In this section, we’ll dive deep into the implementation of the Custom Notification Service. The core responsibility of this service is to manage notifications, handle observers, and broadcast messages to the appropriate listeners.
By the end of this section, you’ll understand how to:
• Add observers who want to receive notifications.
• Post notifications asynchronously using Swift’s async/await.
• Remove observers properly, avoiding common memory management issues like retain cycles.
Let’s break it down step by step.
3.1 Designing the Notification Service
At the heart of our notification system is the CustomNotificationService class. It is a singleton, just like Apple’s NotificationCenter, ensuring that the same instance is used throughout the app.
Here’s the structure we’ll be building:
final class CustomNotificationService {
static let shared = CustomNotificationService() // Singleton instance
private var observers = [CustomNotificationName: [CustomObserver]]() // Holds observers for each notification
private init() {} // Private initializer to enforce singleton pattern
}
Singleton Pattern: We make the class a singleton by having a static let shared property. This ensures only one instance of the service exists and can be accessed globally.
- Observers Storage: We’re using a dictionary where the keys are CustomNotificationName (our custom struct for notification names), and the values are arrays of CustomObserver objects. This allows each notification to have multiple observers that listen for it.
3.2 Adding Observers
To allow components of your app to “listen” for notifications, you need a way to add observers. Each observer will be stored along with a closure (using async/await), which will be executed when the corresponding notification is posted.
Here’s the addObserver method:
func addObserver(
_ observer: AnyObject,
name: CustomNotificationName,
using closure: @escaping (CustomNotification) async -> Void
) {
let customObserver = CustomObserver(observer: observer, selector: closure)
if observers[name] != nil {
observers[name]?.append(customObserver)
} else {
observers[name] = [customObserver]
}
}
Parameters:
• observer: The object that wants to listen for the notification (we keep a weak reference to avoid memory leaks).
• name: The CustomNotificationName that this observer is interested in.
• closure: An async closure that will be executed when the notification is posted.
• CustomObserver:
• We wrap the observer in a CustomObserver class, which holds a weak reference to the observer to avoid retain cycles.
- The closure (selector) is an async closure that will be invoked when the notification is posted.
Example of Adding an Observer:
CustomNotificationService.shared.addObserver(self, name: CustomNotificationName("DataUpdated")) { notification in
await self.handleDataUpdate(notification)
}
In this example, we add an observer that listens for the “DataUpdated” notification and will call handleDataUpdateasynchronously when the notification is posted.
3.3 Posting Notifications
Once observers are registered, we need to broadcast notifications. The post method sends the notification to all observers registered for a given CustomNotificationName.
Here’s the post method:
func post(name: CustomNotificationName, userInfo: [AnyHashable: Any]? = nil) async {
let notification = CustomNotification(name: name, userInfo: userInfo)
if let observersList = observers[name] {
for customObserver in observersList {
if let _ = customObserver.observer {
await customObserver.selector(notification)
}
}
}
}
Creating a Notification: A new CustomNotification is created with the given name and optional userInfo.
• userInfo is a dictionary that can hold extra data passed with the notification, similar to how NSNotification works.
• Iterating Over Observers:
• We fetch all the observers listening for the given notification name.
• For each observer, we check if it’s still alive (i.e., hasn’t been deallocated).
• If the observer is valid, we call the async closure (selector) and pass the notification object.
• Async Execution:
• Using async/await ensures that each observer can handle the notification asynchronously without blocking the main thread.
- This is particularly useful when observers need to perform network calls or complex tasks in response to the notification.
Example of Posting a Notification:
Task {
await CustomNotificationService.shared.post(name: CustomNotificationName("DataUpdated"), userInfo: ["newData": "value"])
}
This example posts the “DataUpdated” notification with additional data (“newData”: “value”), which will be received by all registered observers.
3.4 Removing Observers
It’s crucial to remove observers when they’re no longer needed to prevent memory leaks and unintended behavior. The removeObserver method handles this by filtering out any observers that match the given observer.
Here’s the removeObserver method:
func removeObserver(_ observer: AnyObject, name: CustomNotificationName) {
observers[name] = observers[name]?.filter { $0.observer !== observer }
}
Parameters:
• observer: The object you want to stop observing for the specified notification.
• name: The CustomNotificationName that the observer was listening to.
- Filtering: We filter the observer list for the specified notification name, keeping only those observers whose references don’t match the given observer. If the observer has already been deallocated, it will be automatically cleaned up.
Example of Removing an Observer:
CustomNotificationService.shared.removeObserver(self, name: CustomNotificationName("DataUpdated"))
In this example, the observer (self) will no longer receive notifications for “DataUpdated”.
4. Testing Your Custom Notification Center with Apple’s New Testing Framework
With the new testing framework from Apple, testing asynchronous code and event-driven systems like custom notification centers has become much cleaner and more straightforward. One of the most powerful new features is the confirmation function, which allows you to easily handle async testing scenarios by confirming that certain code paths are reached (or not reached) the expected number of times.
Let’s go through how you can write effective tests for your custom notification center using these modern tools.
4.1 Setting Up Tests with confirmation
The new confirmation function is a declarative and powerful way to verify that asynchronous events occur as expected. It is perfect for testing notification systems where callbacks might be fired asynchronously.
Here’s how to start setting up your tests:
import Testing
@testable import CustomerNotificationService
4.2 Testing Observers Receiving Notifications
Using the confirmation function, you can easily check if the observers are receiving notifications as expected. Here’s an example:
final class CustomerNotificationServicewithNewTesting {
@Test func observerReceiveNotiifcation() async {
let notificationName = CustomNoticationName(rawValue: "test notification")
await confirmation("", expectedCount: 1) { receiveNotification in
CustomNotificationService.shared.addObserver(
self,
name: notificationName
) { notification in
#expect(notification.name == notificationName)
receiveNotification()
}
await CustomNotificationService.shared.post(name: notificationName, userInfo: ["key": "value"])
}
}
}
Explanation:
• Confirmation Block: This block ensures the notification is received exactly once (expectedCount: 1).
• Observer Check: Inside the observer, we use #expect to verify that the notification’s name matches the expected value.
- Receive Notification: The receiveNotification function marks the point where the test will confirm that the notification was delivered successfully, we can say that is equivalent to expectation.fullfill() function in XCTest framework
4.3 Ensuring Observers Are Removed Correctly
We can also use the confirmation function to make sure that removed observers do not receive notifications.
@Test
func testObserverShouldNotReceiveNotificationAfterRemoval() async {
let notificationName = CustomNotificationName(rawValue: "TestNotification")
await confirmation("", expectedCount: 0) { receiveNotification in
// Add an observer
CustomNotificationService.shared.addObserver(self, name: notificationName) { notification in
#expect(notification.name == notificationName)
receiveNotification() // This should never be called
}
// Remove the observer
CustomNotificationService.shared.removeObserver(self, name: notificationName)
// Post the notification
await CustomNotificationService.shared.post(name: notificationName, userInfo: ["key": "value"])
}
}
Explanation:
• Inverted Test: By setting expectedCount to 0, we’re asserting that the observer should not receive the notification after being removed.
- Notification Removal: We add and then immediately remove the observer. The test will fail if receiveNotification is called after removal.
This is the same code using XCTest
func testRemoveObservershouldNotReceiveNotification() async {
let notificationName = CustomNoticationName(rawValue: "test notification")
let expectation = XCTestExpectation(description: " Observer should not receive notification")
expectation.isInverted = true
CustomNotificationService.shared.addObserver(
self,
name: notificationName
) { notification in
XCTAssertEqual(notificationName, notification.name)
expectation.fulfill()
}
CustomNotificationService.shared.removeObserver(self, name: notificationName)
await CustomNotificationService.shared.post(name: notificationName, userInfo: ["key": "value"])
await fulfillment(of: [expectation], timeout: 1.0)
}
With just two key tests, you can ensure that your custom notification center is functioning as expected:
1. Observer Notification: Verifies that registered observers receive notifications correctly.
2. Observer Removal: Ensures that removed or deallocated observers no longer receive notifications.
These tests provide a solid foundation for verifying the core functionality of your custom notification center while leveraging the power of Apple’s new testing framework. Stay tuned for more insights on Swift development!
Summary
In this article, we explored how to build a custom notification center in Swift, leveraging modern features like async/await for asynchronous operations. While Apple’s NotificationCenter works well for many use cases, a custom solution provides greater flexibility, control, and improved memory management. We implemented a notification service that ensures observers receive notifications efficiently while also cleaning up properly to avoid memory leaks.
Subscribe to my newsletter
Read articles from Walid SASSI directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
Walid SASSI
Walid SASSI
🚀 Lead Apple Developer | Podcast Host | Innovator in iOS Solutions I’m a passionate Tunisian Lead Apple Developer at LVMH, currently spearheading iOS development at Sephora, where I’ve been crafting innovative solutions for the beauty and retail industry for over 4 years. My journey started as a Lead iOS Developer at MobilePowered Tunisia, where I developed a range of applications for trips and hikes, honing my skills in building user-centric mobile experiences. I later expanded my expertise at RATP, creating essential applications for transportation services. 💡 Key Achievements: • 𝐒𝐞𝐩𝐡𝐨𝐫𝐚: Led the design and development of high-performance iOS applications, partnering closely with design and product teams to deliver seamless and engaging user experiences. • 𝐑𝐀𝐓𝐏: Played a crucial role in developing and deploying iOS applications that enhanced transportation services under strict deadlines. • 𝐌𝐨𝐛𝐢𝐥𝐞𝐏𝐨𝐰𝐞𝐫𝐞𝐝 𝐓𝐮𝐧𝐢𝐬𝐢𝐚: Developed multiple applications focused on trips and hikes, building intuitive and functional solutions for adventure enthusiasts. 🎙️ Podcast Host: In addition to my development work, I’m the creator and host of the Swift Academy Podcast, where I interview global experts in iOS development and tech, delving into topics like mobile security, legacy code refactoring, and cutting-edge Swift practices. 🛠️ Key Skills: iOS Development | Swift | Xcode | Interface Builder | Team Leadership | Cross-functional Collaboration | Technical Troubleshooting 🌟 My Passion: I thrive on creating impactful mobile experiences and pushing the boundaries of iOS development. Whether through app innovation or sharing knowledge via my podcast, I’m always eager to evolve and inspire the iOS community. 📚 Academic Enrichment: I’ve also contributed to academia as a Teaching Assistant at Carthage University in Tunisia, where I had the opportunity to teach Distributed Systems and System Programming, empowering students to excel in these critical areas. Let’s connect to discuss collaboration opportunities or exchange ideas on iOS development, technology trends, or podcasting!