Type-safe Notification handling in Swift
“Avoid notification.object and userInfo”
Cocoa notifications are one of those unavoidable Cocoa pain points in Swift’s strongly-typed world. If you are like me and can’t stand the sight of Any
in your code, then notification.userInfo
must be driving you mad.
It’s quite understandable why that’s typed as [AnyHashable: Any]
– you should be able to add any type of information into that dictionary. For the iOS SDK stuff you just can’t avoid it as it comes to you packaged as userInfo
and the best you can do is create your own type with init?(dictionary: [AnyHashable: Any?]
or something similar. Then one or two guard let
s and you’re ok.
However, for notifications you declare, post and handle in your own app, things can be much different.
All the info bits you would add to userInfo
are certainly properly typed on the posting side; the ideal solution would be to wrap both object and userInfo into a particular custom type and then access the instance of that type directly in the notification handler.
That is the gist of the Notifying micro-library from my Swift Essentials pack. It’s entirely based on the approach described in episodes 27 and 28 of the Swift Talk.
(side note: if you are not subscribed to Swift Talk, you are missing out a lot. Chris and Florian are amazing.)
Usage
Let’s say you have an AccountManager instance in your app, which keeps track of the currently active Account (logged-in customer or similar).
final class Account {
let accountId: Int
let name: String
}
final class AccountManager {
var account: Account?
}
When the login is successful and account details are loaded, you want to broadcast that info to the rest of the app, passing the accountId
in the userInfo.
The regular, old Cocoa
Regular Cocoa way would be something like this:
extension Notification.Name {
static let AccountManagerDidLogin = "AccountManagerDidLoginNotification"
}
extension AccountManager {
func login(...) {
...
let name = Notification.Name(AccountManagerDidLogin)
let userInfo: [String: Int] = ["accountId": account.accountId]
let notification = Notification(name: name, object: self, userInfo: userInfo)
NotificationCenter.default.post(notification)
}
}
On the receiving side, it would be:
let nc = NotificationCenter.default
nc.addObserver(forName: NSNotification.Name.AccountManagerDidLogin, object: nil, queue: .main) {
[weak self] notification in
guard let `self` = self else { return }
// validate...
guard
let am = notification.object as? AccountManager,
let userInfo = notification.userInfo as? [String: Int]
else { return }
// process...
}
This is tedious and very error-prone in the long run as you need to keep both sides type-happy.
- If you decide that you now want to pass
Account
and notAccount.accountId
, then you need to change both the posting and receiving side - There is no guarantee that userInfo really contains the
"account"
key so you need toguard
like crazy.
Improvement: custom payload type
One way to improve this – and the only real improvement you can do with system-originating notifications – is to create a custom type for each kind of notification payload you are interested in.
extension AccountManager {
struct LoginPayload {
let object: AccountManager
let accountId: Int
init?(_ notification: Notification) {
...
}
}
}
And then just try to build that object in the handler:
nc.addObserver(forName: NSNotification.Name.AccountManagerDidLogin, object: nil, queue: .main) {
[weak self] notification in
guard let `self` = self else { return }
// validate...
guard
let payload = AccountManager.LoginPayload(notification)
else { return }
// process...
}
And this way you have the single-point of truth for any number of notification handlers.
Notifying way
Now, look closely to the LoginPayload
. If we have the object
property already, we don’t really need userInfo
at all - we can directly access the logged-in Account using simply payload.object.account
. Thus our notification is simplified since we only need the originating object.
Notifying is abstracting away the Notification itself in a way that it gives you the object as the handler argument. So instead of:
[weak self] notification in
you will have:
[weak self] accountManager in
Notifying also makes sure that the object is kept in memory, in case it’s something short-lived and not AccountManager which is likely a singleton instance. Plus it makes sure that notification observer properly de-registers itself.
For detailed explanation of the NotificationToken
and NotificationDescriptor
I will refer you to the Swift Talk episode 28. Here’s how the didLogin would be declared with Notifying:
extension AccountManager {
enum Notify: String {
case didLogin = "AccountManagerDidLoginNotification"
var name: Notification.Name {
return Notification.Name(rawValue: self.rawValue)
}
// Descriptors
static let didLoginDescriptor = NotificationDescriptor<DataManager>(name: didLogin.name)
}
}
This is how you post it:
extension AccountManager {
func login(...) {
...
let nc = NotificationCenter.default
nc.post(Notify.didLoginDescriptor, value: self)
}
}
And this is how you receive and handle it:
final class ViewController: UIViewController {
var tokenDidLogin: NotificationToken?
override func viewDidLoad() {
super.viewDidLoad()
setupNotificationTokens()
}
}
fileprivate extension ViewController {
func setupNotificationTokens() {
tokenDidLogin = nc.addObserver(for: AccountManager.Notify.didLoginDescriptor, queue: .main){
[weak self] accountManager in
guard let `self` = self else { return }
// process...
}
}
}
This is clean, 100% strongly-typed and it allows extraction into some protocol / protocol extension, to keep the code DRY.
I have been using this approach in multiple projects in the last 6+ months with great success.
You can pickup just the Notifying micro-library or the whole Swift Essentials pack.