Table of Contents
When developing an application with some extension, a common issue is their intercommunication and reaction to changes made in one another.
Here at Fleksy, we are used to working around it as we develop device keyboards and want to provide all our help to those in need.
The main obstacle
Usually, when adding an app extension to your app project, there exists some one-way communication strictly related to the usage of the extension (i.e., for keyboards, you can access the proxy document settings about the text input), but that’s the best Apple can give to the developer user without getting into some more obscure documentation.
There is also the consideration that, even though an app extension’s bundle is nested within the hosting app’s bundle, they cannot access each other’s container directly, so sharing files and UserDefaults settings becomes troublesome.
Some tools in the shed
To be able to have proper communication between apps, we need at least two features:
- Transfer information
- Notify changes
Establishing this is the bare minimum for two-way communication.
Using App groups to share
You might be asking, what is an App Group? For those who haven’t got a clue, this is our first step to intertwine a hosting app and its extensions. Multiple apps and extensions can share a container or folder and other processes under the same app group.
We will first need to create an app group for both our targets, the hosting app, and the app extension. For this, go into Signing and Capabilities in your project settings and add an app group capability. After this, you can create one and add both to this group.
Now you can access your shared folder with the FileManager in the following way.
let sharedFolder: URL? = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: <Your-App-Group-Name>)
Over this sharedFolder we will be able to find anything we want our apps to have in common, even the UserDefaults settings dictionary they share, which is different from the one on each app. That’s right, and the standard UserDefaults can only be accessed by the app owning it. If we want to read on the shared one, we need to call it using the app group.
let localUserDefaults: UserDefaults = UserDefaults.standard
let sharedUserDefaults: UserDefaults? = UserDefaults(suiteName: <Your-App-Group-Name>)
This works similarly because the UserDefaults is no more than a .plist file saved on a container.
Establishing communication with Darwin notifications
We have one task remaining, to notify, and what’s better for that than a Notification Centre? I’m sure most of you know about the usual NotificafionCenter and how to use it to communicate different parts of your app that don’t have a direct connection.
The Darwin Notification Centre‘s main feature is to distribute notifications across the whole system, meaning you can communicate with other apps and, in our case, with app extensions. There are some differences between the two centers.
Default Notification Centre | Darwin Notifications Centre |
---|---|
Notifies within the app | Notifies across the system |
Knows the object sending the notification | Is oblivious about who sends a notification |
Knows who is observing the notification | |
Can send a userInfo dictionary | Cannot send a userInfo dictionary |
Can create and subclass multiple centers | There exists only one per App |
As you can see, one major drawback in using Darwin notifications is the inability to send extra information when posting, but here is when we can use the shared folder for the App Group we mentioned earlier. Another one is the inability to see who is sending a notification, as other apps’ private information, such as classes and other objects, is only accessible to them.
You can use this center directly, but we recommend using a manager to ease this work and try to have a cleaner code. Of course, we will guide you a little bit with the job giving some basic direction on how to use and implement it.
Here is the minimum you need to set up the notification center
- Notification structure
- Notification center
- Add notification observations.
- Post notification to observers.
- Remove notification from the observer.
Notifications
The notification should be able to recognize the object we are going to use as userInfo. In our case, we will call it a Payload. The payload will be saved as a string on a file we can read in our shared folder. Most structures and objects can be expressed as a dictionary, so they will not face many problems.
protocol DarwinPayload {
// Read the Payload from a string
init?(payloadString: String?)
// Write the Payload to string
func toString() -> String
}
struct DarwinNotification<Payload> where Payload: DarwinPayload {
var name: Name
struct Name: Equatable {
fileprivate var rawValue: CFString
var title: String { self.rawValue as String }
}
fileprivate init(_ name: Name) {
self.name = name
}
init(_ rawValue: String) {
self.name = Name(rawValue: rawValue as CFString)
}
init(_ cfNotificationName: CFNotificationName) {
self.name = Name(rawValue: cfNotificationName.rawValue)
}
// Equatable is not applicable for generic class using different types
func isEqual<Other>(to other: DarwinNotification<Other>) -> Bool {
if Other.self == Payload.self,
let otherAsPayload = other as? DarwinNotification<Payload> {
return self.name == otherPlAsPayload.name
}
return false
}
}
As an example, we will implement it like this:
extension String: DarwinPayload {
init?(payloadString: String?) {
guard let payloadString = payloadString else { return nil }
self = payloadString
}
func toString() -> String {
return self
}
}
extension DarwinNotification {
static var exampleDarwinNotification: DarwinNotification<String> {
DarwinNotification<String>("EXAMPLE_DARWIN_NOTIFICATION")
}
}
Notification Centre
The notification center should be able to handle all the observations we pass, read and write in the shared folder to send the payloads we saved. We recommend having all payloads under a subfolder to avoid conflicts. We will also have a specific queue for these notifications, so we don’t overload the main thread.
final class DarwinNotificationCenter {
static let shared = DarwinNotificationCenter()
private let center = CFNotificationCenterGetDarwinNotifyCenter()
private let queue = DispatchQueue(label: "Darwin_queue", qos: .default, autoreleaseFrequency: .workItem)
private typealias NotificationHandler = (String?) -> Void
private var handlers = [String : NotificationHandler]()
private init() {}
private func getNotificationsFolder() -> URL? {
// Return your payload folder under the shared one
}
private func payloadPath(forNotification name: String) -> String? {
// Return the path for the payload assoicated with a notification
}
func addObserver<Payload: DarwinPayload>(_ notification: DarwinNotification<Payload>, using completion: @escaping ((Payload?) -> Void)) {
self.queue.async {
let name = notification.name
let handler: NotificationHandler = {
let payload = Payload(payloadString: $0)
completion(payload)
}
self.handlers[name.title] = handler
CFNotificationCenterAddObserver(self.center, Unmanaged.passRetained(self).toOpaque(), self.notificationHandler, name.rawValue, nil, .coalesce)
}
}
func post<Payload: DarwinPayload>(_ notification: DarwinNotification<Payload>, payload: Payload) {
self.queue.async {
let name = notification.name
guard let payloadFile = self.payloadPath(forNotification: name.title)
else { return }
do {
try payload.toString().write(toFile: payloadFile, atomically: true, encoding: .utf8)
} catch {
// Manage your errors
}
CFNotificationCenterPostNotification(self.center, CFNotificationName(name.rawValue), nil, nil, false)
}
}
func removeObserver<Payload: DarwinPayload>(_ notification: DarwinNotification<Payload>) {
self.queue.async {
let name = notification.name
self.handlers[name.title] = nil
guard let payloadPath = self.payloadPath(forNotification: name.title)
else { return }
if FileManager.default.fileExists(atPath: payloadPath) {
do {
try FileManager.default.removeItem(atPath: payloadPath)
} catch {
// Manage your errors
}
}
}
}
// This is the actual callback when we register a notification
private var notificationHandler: CFNotificationCallback {
return { (_, observer, notfName, _, _) in
guard let name = notfName?.rawValue as String?,
let observer = observer
else { return }
let observedSelf = Unmanaged<DarwinNotificationCenter>.fromOpaque(observer).takeUnretainedValue()
if let handler = observedSelf.handlers[name],
let payloadPath = observedSelf.payloadPath(forNotification: name) {
let content = try? String(contentsOfFile: payloadPath, encoding: .utf8)
handler(content)
}
}
}
...
}
Now all you need is to enable the different targets on the file inspector and try for yourself in the following way:
DarwinNotificationCenter.shared.addObserver(.exampleDarwinNotification) { _ in
// Do something
}
DarwinNotificationCenter.shared.post(.exampleDarwinNotification, payload: "Posted")
DarwinNotificationCenter.shared.removeObserver(.exampleDarwinNotification)
As we stated earlier, this is a basic implementation, and there is much more to do when managing these notifications. We used the same manager as the observer for our notifications to simplify the example. Still, one can try and handle the different observers for a notification, ceasing the observation when deallocated. We leave research work up to you to improve and expand your knowledge and skills. If you want to discuss this article or need help with your project, don’t hesitate to contact us directly or on our Discord server. Our team will be happy to help you.