8

I just want an example of how Swifts new async/await concurrency features can be used to rewrite a common Firestore listener method like the one below.

func listen(for user: User, completion: (Result<CurrentUser, Error>) -> Void) {
        db.collection("Users")
            .document(user.uid)
            .addSnapshotListener(includeMetadataChanges: true) { document, error in
                if let error = error {
                    completion(.failure(.networkError)
                } else {
                    guard let document = document else {
                        completion(.failure(.operationFailure)
                        return
                    }
                    do {
                        guard let profile = try document.data(as: Profile.self, with: .estimate) else {
                            completion(.failure(.operationFailure)
                            return
                        }
                        completion(CurrentUser(user, profile: profile))
                    } catch {
                        completion(.failure(.operationFailure)
                    }
                }
            }
    }
otymartin
  • 166
  • 1
  • 8
  • 4
    Hmm... I'm not sure that this is a good use case for async await as adding a snapshot listener creates a stream of data rather than a single response. It's a much better candidate for using a Combine publisher/subscriber. Rather than having a function `listen` that takes a completion block you could have a function that returns a Subscription. I did something similar using TheComposableArchitecture. I'll see if I can add an answer. – Fogmeister Sep 28 '21 at 14:03
  • Adding to @Fogmeister's comment: IMHO, "completion" is also not a suitable name for your original function. A "completion" should execute once, and only once. If the closure can be called several times, naming the handler "notification" would have been better IMHO. – CouchDeveloper Sep 28 '21 at 14:08
  • @CouchDeveloper Thats actually something that never crossed my mind, glad you brought it up. Thanks :) – otymartin Sep 29 '21 at 22:01
  • 1
    @Fogmeister late to the thread, but there is `AsyncSequence` which appears to be a (somewhat limited) combine alternative – atultw Oct 28 '22 at 23:59
  • @atultw yeah, more recently I’ve written my own similar cache/subscribe structure using an AsyncStream which worked really well. I learned a ton more about Async Await also. – Fogmeister Oct 30 '22 at 10:02

1 Answers1

8

OK, like I said in my comment I don't think this is the right use case for Async/Await. Async/Await is more suited to asynchronous functions where you would receive back a single response. For instance a REST api that returns some value.

Incidentally, the Firestore function .getDocument() now has async/await alternatives.

However, the addSnapshotListener is something that will return multiple values over time and call the callback function over and over again.

What we can do with this, though, is to turn it into a Combine Publisher.

Here I've created a small FirestoreSubscription struct that you can use to subscribe to a document path...

import Combine
import FirebaseFirestore
import FirebaseFirestoreSwift

struct FirestoreSubscription {
  static func subscribe(id: AnyHashable, docPath: String) -> AnyPublisher<DocumentSnapshot, Never> {
    let subject = PassthroughSubject<DocumentSnapshot, Never>()
    
    let docRef = Firestore.firestore().document(docPath)
    let listener = docRef.addSnapshotListener { snapshot, _ in
      if let snapshot = snapshot {
        subject.send(snapshot)
      }
    }
    
    listeners[id] = Listener(document: docRef, listener: listener, subject: subject)
    
    return subject.eraseToAnyPublisher()
  }
  
  static func cancel(id: AnyHashable) {
    listeners[id]?.listener.remove()
    listeners[id]?.subject.send(completion: .finished)
    listeners[id] = nil
  }
}

private var listeners: [AnyHashable: Listener] = [:]
private struct Listener {
  let document: DocumentReference
  let listener: ListenerRegistration
  let subject: PassthroughSubject<DocumentSnapshot, Never>
}

The subscribe function returns an AnyPublisher<DocumentSnapshot, Never> (so currently it doesn't handle any errors.

I also created a FirestoreDecoder that will decode DocumentSnapshot into my own Codable types...

import Firebase

struct FirestoreDecoder {
    static func decode<T>(_ type: T.Type) -> (DocumentSnapshot) -> T? where T: Decodable {
        { snapshot in
            try? snapshot.data(as: type)
        }
    }
}

I created a really simple Firestore document...

enter image description here

And a struct that we will decode from that document...

struct LabelDoc: Codable {
  let value: String?
}

Now in my ViewController I can subscribe to that document path and decode and set it onto a label...

import UIKit
import Combine

class ViewController: UIViewController {

  @IBOutlet weak var label: UILabel!
  
  var cancellables: Set<AnyCancellable> = []
  
  struct SubscriptionID: Hashable {}
  
  override func viewDidLoad() {
    super.viewDidLoad()
    // Do any additional setup after loading the view.
    
    FirestoreSubscription.subscribe(id: SubscriptionID(), docPath: "labels/title")
      .compactMap(FirestoreDecoder.decode(LabelDoc.self))
      .receive(on: DispatchQueue.main)
      .map(\LabelDoc.value)
      .assign(to: \.text, on: label)
      .store(in: &cancellables)
  }
}

This is just a quick example project so there may be better ways of doing this but now I can update the value in Firestore and it will immediately update on the screen

You could probably even wrap up that subscription into a function that could be used in multiple places.

Fogmeister
  • 76,236
  • 42
  • 207
  • 306
  • 2
    @otymartin Please consider [accepting the answer](https://stackoverflow.com/help/accepted-answer) if it solves your issue – Farid Shumbar Sep 29 '21 at 12:03
  • 1
    @Fogmeister Thank you for the detailed and comprehensive answer with working example. Really helps gives a birds eye of where async/await is appropriate and where it isn't and how to go about handling this edge case. – otymartin Sep 29 '21 at 22:00
  • @otymartin no worries. Happy to be able to help. – Fogmeister Sep 30 '21 at 06:39
  • 1
    Firebase has support for Combine now - see https://github.com/firebase/firebase-ios-sdk/blob/master/FirebaseCombineSwift/Sources/Firestore/Query%2BCombine.swift – Peter Friese Jan 12 '23 at 16:27