Teabyte

Mobile app development for iOS and Swift

Using Firebase with Combine

2020-04-18

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.