Firebase has an impressive feature list for getting your apps up and running quickly. I love that I can go from idea to working app in a matter of hours! But for developers who haven't had much experience with asynchronous programming, it's easy to get bogged down in the details. Heck, I've been working with Firebase for two years and I still find myself pausing to think about how I want to handle asynchronous calls. What will my view look like while waiting to populate data? How should I set up my data class so that it can alert the view controller about updates? After lots of practice and exploring options, I feel like I finally have an answer that works for me.
Now I'm not going to get into the what and why of asynchronous programming with Firebase because Doug Stevenson already addressed this in this great blog post. Instead, I want to get into how to get a function that uses an async callback working the way you want it to in Swift.
Take this example function I have below from this photo app I've created. It queries Cloud Firestore for 10 documents in a collection called "cats". Once the Cat data is downloaded, I want to use it to populate a UITableView with information about the cat photos.
// ViewController.swift var dbRef = Firestore.firestore().collection("cats") var cats = [Cat]() func downloadCats() { // order the posts by timestamp let query = dbRef.order(by: "timestamp", descending: true).limit(to: 10) query.getDocuments { snapshot, error in print(error ?? "No error.") // iterate through the documents and create Cat objects guard let snapshot = snapshot else { return } for doc in snapshot.documents { let cat = Cat(snapshot: doc) self.cats.append(cat) } } print(cats) // 1 what will this print? tableView.reloadData() }
What will be printed at "1"? Since the call to getDocuments is asynchronous, the closure will be run when the download of data is completed sometime in the future. Commands that I write following the closure will be run immediately -- they do not wait for the work in the closure to be complete. So chances are, in the scenario above, that print statement is going to print an empty array. The UITableView will reload data immediately, so any Cat objects that were supposed to populate the view will not appear. That's not the behavior we want. So what can we do?
getDocuments
print
.
UITableView
Cat
One simple option is to move the functionality I want into the closure, like so:
// ViewController.swift func downloadCats() { let query = dbRef.order(by: "timestamp", descending: true).limit(to: 10) query.getDocuments { snapshot, error in print(error ?? "No error.") for doc in snapshot!.documents { let cat = Cat(snapshot: doc) self.cats.append(cat) } print(cats) // 1 what will this print now? tableView.reloadData() } }
What is printed at "1" now? We should now see the full array of Cat objects, and when tableView.reloadData() is called, the UITableView can populate with the data of those Cats, assuming you've set up your UITableViewDataSource for it. This is a viable method for handling data in the closure, but it means you'll have to include this functionality in your UIViewController. As you add more functionality, such as different kinds of queries, Auth, Cloud Storage, and callable Cloud Functions, you'll quickly have a Massive View Controller on your hands.
tableView.reloadData()
UITableViewDataSource
UIViewController
I like to keep this functionality separate from my view controller whenever possible. I can do this by wrapping getDocuments() in a function with a completion handler. If I include a completion handler in this function, I can call completion() when the array of Cats is populated.
getDocuments()
completion()
// CatManager.swift var dbRef = Firestore.firestore().collection("cats") var cats = [Cat]() // function includes an completion handler, marked with @escaping func downloadCats(completion: @escaping () -> Void) { let query = dbRef.order(by: "timestamp", descending: true).limit(to: 10) query.getDocuments { snapshot, error in print(error ?? "No error.") self.cats = [] guard let snapshot = snapshot else { completion() return } for doc in snapshot.documents { let cat = Cat(snapshot: doc) self.cats.append(cat) } completion() } } }
Then inside the view controller, I can call my function like this:
// ViewController.Swift func initializeCats() { activityIndicator.startAnimating() CatManager.sharedInstance.downloadCats() { // Data for UITableView is populated from the CatManager singleton self.tableView.reloadData() self.activityIndicator.stopAnimating() } }
Now I'm controlling the view from the view controller, reloading the tableView and indicating when the activityIndicator should run. The data is being handled in the CatManager class. This does, however, require a singleton, so while it may prevent a massive view controller from developing, singletons come with their own host of issues.
tableView
activityIndicator
CatManager
Instead of passing void in the completion, I can pass some useful values: an array of Cats and an optional error.
error
// CatDownloader.swift // Use instead of CatManager func downloadCats(completion: @escaping ([Cat], Error) -> Void) { var catArray = [Cat]() let query = dbRef.order(by: "timestamp", descending: true).limit(to: 10) query.getDocuments { snapshot, error in if let error = error { print(error) completion(catArray, error) return } for doc in snapshot!.documents { let cat = Cat(snapshot: doc) catArray.append(cat) } completion(catArray, nil) } } }
With this function, since I pass the error in the closure, the view controller can be made aware of an error and display it to the user. Here I made a function called alert() that displays a UIAlertController. I can also create an instance of CatDownloader in the class because I don't need to depend on a single CatManger to keep track of the Cat array.
alert()
CatDownloader
CatManger
// ViewController.swift // create an instance of CatDownloader let catDownloader = CatDownloader() //… func initializeCats() { activityIndicator.startAnimating() catDownloader.downloadCats() { catArray, error in self.activityIndicator.stopAnimating() if let error = error { self.alert(title: "Error", message: error.localizedDescription) return } self.cats = catArray self.tableView.reloadData() } } func alert(title: String, message: String) { let alertController = UIAlertController(title: title, message: message, preferredStyle: .alert) let action = UIAlertAction(title: "OK", style: .default, handler: nil) alertController.addAction(action) navigationController?.present(alertController, animated: true, completion: nil) }
You may want to display a custom error message to the user instead of the error description, but you can see how the closure made it easy to get the info I needed to the right place in my app. You can use this same configuration for other Firebase features that incorporate closures, including the Realtime Database, Authentication, and ML Kit.
And there you have it! Now go forth and write your own functions with closures to help you update your views. What other examples would you like to see? Do you have any cool solutions you've found that you'd like to share? Reach out to me on Twitter at @ThatJenPerson to tell me all about it!