With every Worldwide Developers Conference (WWDC), a new set of OSs is released. This often means tweaking our current projects to accommodate new APIs, provide backwards compatibility, and explore new and shiny features. All those changes can be tedious; even more so if you're maintaining multiple platforms such as iOS, watchOS, tvOS, Linux, and so on.
Let's focus on a simple case: registering for push notifications. We can abstract the intention of registering with the following protocol:
typealias ResultBlock = (Bool, Error?) -> Void
protocol PushNotificationService {
func register(_ done: ResultBlock)
}
PushNotificationService is an object that has a register(:) function, with a ResultBlock output that will indicate whether the operation was successful or not.
Now let's say we're supporting macOS and iOS apps in our code base. They both have different push notification APIs, which we need to consume in order to register our application for push.
I will not go into the details of the implementation, but we can abstract this for iOS as follows:
struct iOSPushNotificationService: PushNotificationService {
func register(_ done: ResultBlock) { /* Your implementation here */ }
}
This is how we do it for macOS:
struct macOSPushNotificationService: PushNotificationService {
func register(_ done: ResultBlock) { /* Your implementation here */ }
}
The struct for each one conforms to the same protocol, defined as PushNotificationService.
Now that we have those objects, we could use them directly—but for every single place in our app that we require those implementations, we'd need to test whether we're on macOS or iOS. Also, we may have further services based on either the iOS or macOS platform, or even want to add additional platforms in the future.
As such, we'll create a ServicesFactoryType protocol that will represent all those services we may need, as follows:
protocol ServicesFactoryType {
func getPushService() -> PushNotificationService
/* Add more services here as your project grows */
}
Now, with that interface defined, we can implement a ServicesFactoryType for each platform:
struct macOSServicesFactory: ServicesFactoryType {
func getPushService() -> PushNotificationService {
if #available(OSX 10.9, *) { // Mavericks and above only
return macOSPushNotificationService()
}
/* add additional platform support here */
fatalError("Push notificaitons are not supported on macOS < 10.9")
}
}
struct iOSServicesFactory: ServicesFactoryType {
func getPushService() -> PushNotificationService {
if #available(iOS 10.0, *) { // New API based on UNNotification
return iOSPushNotificationService()
}
/* additional platform support here */
fatalError("Push notificaitons are not supported on iOS < 10.0")
}
}
Our two structs will properly create instances of PushNotificationService, based on availability, and we can use macOSServicesFactory and iOSServicesFactory.
Before we do that, let's abstract one further layer, so our application has no knowledge of which kind of factory there is for which kind of platform support.
struct ServicesFactory {
static let shared: ServicesFactoryType = {
if #available(OSX 10.0, *) {
return macOSServicesFactory()
} else if #available(iOS 1.0, *) {
return iOSServicesFactory()
}
}()
private init() {}
}
Now, throughout our applications and projects, we'll have a single simple interface that will let us register our users for push notifications (or throw a fatalError when the platform is unsupported):
let pushService = ServicesFactory.shared.getPushService()
pushService.register { (success, error) in
// handle success
}
We successfully leveraged the abstract factory pattern in order to hide complex implementation details from the eyes of the developer.
Let's dive a bit further in and see how to expand upon it with common use cases.