Combine, being introduced by Apple at last years WWDC 2019, is a rather new framework but is already seeing a lot of excitement in the iOS community. It is already seen as the predecessor to reactive frameworks like RxSwift. One drawback with Combine though is that it is only supporting iOS 13 and above, making it hard for already existing apps to integrate it.
At work I recently had the chance to actually use it in a real world application. We were asked to build an application with a minimum deployment target of iOS 13 (🎉). Furthermore this app was built upon Firebase Cloud Storage. This gave us the opportunity to try out Combine paired with Firebase and see what is actually possible.
This post should give some insights of how we utilized Combine in this setting and showcases some code snippets which bridge the gap between the Combine framework and Firebase. I assume some basic knowledge of the two Frameworks here. There are lot of good resources on both online, so check them out first if you do not know them yet.
The idea
I think it's worth of pointing out what the app should actually do before going into code. The app was designed to heavily utilizing the offline-first capabilities of the Firebase Cloud Storage. Most parts of the apps are still usable even without an active internet connection. When the user goes online the Firebase SDK is automatically updating and pushing the local data, that might has changed while being offline, and fetching new data from the server. Furthermore it is possible to use the app with multiple devices at the same time with modified data being pushed to every app instance of that specific user. At this point Combine comes into play.
Firebase Cloud Storage is a document store. The SDK is offering callbacks for listening to changes in collections and documents. Documents are the way Firebase Cloud Storage is saving data into the database. Multiple documents can be combined to so called collections. Collections and documents are stored at explicit paths inside the database. For example you can have a path to a collection like /documents
and /documents/<UUID>
being a path to a specific document inside that collection.
The callbacks of the SDK are triggered every time something changes in a document/collection, whether being a local change or a change being pushed from another device.
In order for us to abstract away from the underlying data sources we searched for a suitable solution. In the end we came up with Combine. We evaluated RxSwift as well but in the end decided to not use it since Combine is a first class citizen in iOS. Additionally being maintained by Apple it will be probably around for many years now. So we started investigating how we can use it in combination with the Firebase SDK.
Bridging the gap
Unfortunately the Firebase SDK does not offer any Combine support yet, so it was necessary to write the glue code by ourself. We had to write custom Subscriptions
and Publishers
for the different listeners Firebase is already offering.
In general we only needed two wrap two different methods. The first one being the addSnapshotListener
method on DocumentReference
and the other one being addSnapshotListener
on the CollectionReference
type. In this post I only want to highlight how to write the wrapper for the addSnapshotListener
method on the DocumentReference
type. The code for the CollectionReference
type is very similar and therefore I will not show that code here.
The ideas of the following code where heavily inspired by the code presented on this gist: https://gist.github.com/marty-suzuki/96f9d6cd0c4419555b5eac7c4b34cbd6 by Marty Suzuki.
Custom Subscriber
extension DocumentReference {
public var combine: CombineFIRDocument {
return CombineFIRDocument(document: self)
}
}
public struct CombineFIRDocument {
fileprivate let document: DocumentReference
}
The first thing we did was to create a separate naming space where we write the extensions. To accomplish that, an extension, called combine
on the DocumentReference
of type CombineFIRDocument
, is created. CombineFIRDocument
is the starting point of every method that has something to do with Combine and holds a reference to the actual DocumentReference
instance. Next need to find a way to wrap the addSnapshotListener
into an subscription.
For that purpose we are extending the CombineFIRDocument
type.
extension CombineFIRDocument {
public final class Subscription<S: Subscriber>: Combine.Subscription where S.Input == DocumentSnapshot, S.Failure == Error {
private var subscriber: S?
private let document: DocumentReference
private let _cancel: () -> Void
...
}
On line 3 we are declaring what the input and the failure type of the subscription should be - simply a DocumentReference
and a plain Error
type. Then we declare us some helpers inside the Subscription class: The subscriber S
, a reference to the document
and a cancel
method.
// snip
fileprivate init(subscriber: S,
document: DocumentReference,
addListener: @escaping (DocumentReference, @escaping (DocumentSnapshot?, Error?) -> Void) -> ListenerRegistration,
removeListener: @escaping (ListenerRegistration) -> Void) {
self.subscriber = subscriber
self.document = document
// This is the strong reference for an ListenerRegistration from Firebase
// Here we pipe the "messages" from Firebase to our subscriber
let listener = addListener(document) { documentSnapshot, error in
if let error = error {
subscriber.receive(completion: .failure(error))
} else if let documentSnapshot = documentSnapshot {
_ = subscriber.receive(documentSnapshot)
}
}
self._cancel = {
removeListener(listener)
}
}
public func request(_ demand: Subscribers.Demand) {}
public func cancel() {
_cancel()
subscriber = nil
}
// snip
I personally think that the most interesting part happens in the init
method. Particularly noteworthy are the addListener
and removeListener
methods. These are directly handling the Firebase SDK flows for reacting to changes inside the data. The task inside the subscription struct is to keep a reference to a ListenerRegistration
instance. This is necessary to receive updates about data changes from the Firebase SDK. If you do not keep this reference it is immediately deallocated and changes are not populated. So the idea of the addListener
parameter here is, that it is providing all of the necessary information to create and maintain this reference. With the help of that parameter we can create the ListenerRegistration
reference and use the callback to react to data changes by providing the output (or error) to the attached subscriber.
When the subscription is somehow cancelled we also want to cancel the underlying ListenerRegistration
instance. For this purpose the removeListener
parameter is introduced. This method is used inside the cancel
method of the subscription.
The last thing that is missing to be conform to the Combine.Subscription
protocol is to implement the request
method. In our case the implementation of that method is empty, meaning we always request an infinite amount of data updates.
Publisher
Having the Subscriber
implemented, we need the corresponding Publisher
as well.
public struct Publisher: Combine.Publisher {
public typealias Output = DocumentSnapshot
public typealias Failure = Error
//snip
At first we need to declare the output and failure type. When working with DocumentReference
types we want the output to be of the DocumentSnapshot
type, which represents the recent changes on that document. The failure type is again just a plain Error
type.
// snip
private let document: DocumentReference
private let addListener: (DocumentReference, @escaping (DocumentSnapshot?, Error?) -> Void) -> ListenerRegistration
private let removeListener: (ListenerRegistration) -> Void
init(document: DocumentReference,
addListener: @escaping (DocumentReference, @escaping (DocumentSnapshot?, Error?) -> Void) -> ListenerRegistration,
removeListener: @escaping (ListenerRegistration) -> Void) {
self.document = document
self.addListener = addListener
self.removeListener = removeListener
}
public func receive<S>(subscriber: S) where S: Subscriber, S.Failure == Failure, S.Input == Output {
let subscription = Subscription(subscriber: subscriber,
document: document,
addListener: addListener,
removeListener: removeListener)
subscriber.receive(subscription: subscription)
}
// snip
The init
of the publisher is rather straight forward. It basically mimics the init
of the subscription by simply taking the same parameters and respective types.
Inside the receive
method we are creating a corresponding Subscription
instance and attach it to the subscriber.
The last part
The only thing which is missing now, is to make the newly created publisher available for the outer world.
extension CombineFIRDocument {
public func snapshotPublisher() -> AnyPublisher<DocumentSnapshot, Error> {
return Publisher(document: document,
addListener: { $0.addSnapshotListener($1) }, // $0 is the document reference, $1 is the method that is called when the listener is fired
removeListener: { $0.remove() }
).eraseToAnyPublisher()
}
}
We extend our CombineFIRDocument
with an snapshotPublisher
method that returns an AnyPublisher<DocumentSnapshot, Error>
type. Again the most interesting thing here is the addListener
and removeListener
. The first one calls the addSnapshotListener
method on the document reference with the method from the subscriber that is called when the snapshot listener is firing a data update. The latter declares to call the remove
method when the subscription is cancelled.
Use it
That was quite a lot of code. Now let's see how we can actually use it. Imagine having a document reference and you want to be notified when the underlying data changes. Probably you will have some setup like this.
let store: Firestore = Firestore.firestore()
let doc: DocumentReference = store.document("/documents/<UUID>")
If you now want to use the newly created bridge to receive data changes through Combine, you can do the following.
let sub = doc
.combine
.snapshotPublisher()
.sink(
receiveCompletion: { print ($0) },
receiveValue: { print ($0) }
)
Conclusion
Within this article we showed an approach on how to extend the Firebase SDK to be used with Combine. We created a custom subscriber that is taking take of converting the Firebase methods to be used within Combine. Then we declared a corresponding publisher implementation that is using the subscriber. In the end we can use our snapshotPublisher
method easily in any Combine setup.
This approach could also be ported to other libraries that rely on reactive events but do not support Combine yet.
I hope with this post I was able to show you how to wrap existing code into Combine and use it in your codebase. Furthermore I think it is definitely worth to adapt Combine in your codebase (if possible), since it tremendously simplifies asynchronous and reactive code.