Getting Started with Firebase Cloud Firestore in iOS Project

Back to Blog Home

Getting Started with Firebase Cloud Firestore in iOS Project

Firebase Cloud Firestore is a new addition to Firebase product, it’s still in beta, and it’s an upgrade for Firebase Realtime Database. Just like a Firebase Realtime Database, Cloud Firestore aims to help developers (backend and frontend/mobile) by providing a flexible and scalable database to store and sync data. Using Cloud Firestore, it’s easy to get data, listen to real-time changes inside our data and handling data while users are offline. In this tutorial, we’re going to look at the Cloud Firestore and how to use it inside an iOS project.

Cloud Firestore

Cloud Firestore’s data model is based on NoSQL Document-Oriented Database. The data model may look like a key-value based database in Firebase Realtime Database, but Firestore has more advanced operations, more data types and improved performance. Firestore’s data model looks like this:

It has collections, a collection contains documents, and a document contains data. A document can also have subcollection. So the structure of the data is very flexible. The only limitation is the subcollection can only be nested to 100 levels deep (which is A LOT).

Setup Firestore inside XCode Project

Make sure you already have a Google account, then login to https://console.firebase.google.com/. Create a new project. Inside project settings, click on Add app and select iOS.

Fill the forms and then it asks you to download and add GoogleService-Info.plist.If you skip the step to add GoogleService-Info.plist, don’t worry, you can always download it in app list.

Run pod install and wait for it to finish as there are many dependencies need to be installed. Then build your XCode project.

Inside your didFinishLaunchingWithOptions add Firebase initialization:

FirebaseApp.configure()

Run your project, and if the console shows no error, then the setup is a success. To get the default instance of a Firestore database, declare the variable with type Firestore and call the firestore method.

var db: Firestore!

func viewDidLoad() {
    super.viewDidLoad()

    db = Firestore.firestore()
}

firestore will return a default (singleton) Firestore database instance, and it will return the same instance wherever you call it.

Add Data

To add a new data, we need to have a collection first and a document inside that collection. There are 2 ways to add a document:

  • Named document. If you already have a meaningful name for your data collection or you want to manually handle the naming of your data.
db.collection("animal").document("bird").setData([
    "name": "Peacock",
    "type": "Herbivore",
    "colors": ["Green", "White", "Blue", "Black"]
])

db.collection("animal").document("mammal").setData([
    "name": "Bat",
    "type": "Herbivore",
    "norturnal": true,
    "colors": ["Brown", "Black"]
], merge: true)

For the second code above, if you  add merge: true  parameter, the data parameter will update the “mammal” document if it can find it. If the “mammal” document isn’t present, the code will add a new “mammal” document and set its value with the data parameter.

  • Generated document name. If the document name is not essential and you want Firestore to handle it for you. It will generate a hash string for the document name.
let ref = db.collection("class").addDocument(data: [
    "name": "Steve Gates",
    "gender": "Male"
]) { err in
    if let err = err {
        print("Error adding document: \(err)")
    } else {
        print("Document added with ref: \(ref)")
    }
}

If you choose to handle the document name generation to Firestore, whenever the document was added it would return a reference to the newly added document. You can use this reference for further data manipulation.

Update Data

If you have a named document, you can update your data by calling setData and set merge parameter to true.

db.collection("animal").document("bird").setData(["type": "Carnivore"], merge: true)

However, a standard approach to update the document data is through a reference. Because by using a reference, we can know whether the update was a success or failure. To update “bird” document above, we can use this code:

let ref = db.collection("animal").document("bird")
ref.updateData([
    "type": "Omnivore"
]) { err in
    if let err = err {
        print("Error updating document, reason: \(err)")
    } else {
        print("Document successfully updated")
    }
}

To update a document with a hash value, the above code is still valid. Usually, this document’s data is shown inside a list of UITableView/UICollectionView. When a user selects an item, we can retrieve this document ID.


db.collection("class").getDocuments { [weak self] (snapshot, error) in
    ...
    self?.documents = snapshot.documents
    self?.tableView.reloadData
}

/// implementation of a tableview delegate

override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
    let docId = documents[indexPath.row].documentID

    let ref = db.collection("class").document(docId)
    ref.updateData([
        "name": "Bill Jobs"
    ]) { err in
        if let err = err {
            print("Unable to update data, reason: \(err)")
        } else {
            print("Data updated successfully")
        }
    }
}

Delete Data

To delete a document, get a reference to the document and call delete method.

db.collections("animal").document("bird").delete() { err in
    if let err = err {
        print("Unable to delete document, reason: \(err)")
    } else {
        print("Data deleted successfully")
    }
}

To delete a field in the document data, update the field with FieldValue.delete() as its value. Maybe you wonder, why don’t we just set the data to null? We can’t because null is a valid value and a valid data type.

db.collections("animal").document("mammal").updateData(["norturnal": FieldValue.delete()]) { err in
    if let err = err {
        print("Unable to delete document, reason: \(err)")
    } else {
        print("Data deleted successfully")
    }
}

Transaction

Add and update data are not atomic. It means that if a concurrent update to a document happens at the same time, the operation may overlap. For example, if you have a counter app that increments a counter every time a user pressed a button, when 3 users pressed a button at the same time, if the counter value is 0 in the beginning, the final value should be 3. However, a non-atomic operation produces 1 as the final value. An atomic operation guarantees that the final value is 3. Cloud Firestore supports atomic operation via Transaction. To update a counter like the use case above via Transaction:

let ref = db.collection("statistic").document("visitor")
db.runTransaction({ (transaction, errPointer) -> Any? in
    let dosSnapshot: DocumentSnapshot

    // get the document via transaction
    do {
        try docSnapshot = transaction.getDocument(ref)
    } catch let fetchError as NSError {
        errPointer?.pointee = fetchError
        return nil
    }

    // get the counter value
    guard let counter = docSnapshot.data()?["counter"] as? Int else {
        let err = NSError(domain: "AppErrorDomain", code: -1, userInfo: [NSLocalizedDescriptionKey: "Unable to retrieve counter"])
        errPointer?.pointee = err
        return nil
    }

    // update counter value via transaction
    transaction.updateData(["counter": counter + 1], forDocument: self.ref)
            return nil
        }) { (object, err) in
            if let err = err {
                print("Transaction failed, reason: \(err)")
            } else {
                print("Transaction success"g)
            }
        }

Transaction allows Read and Write operation. However, Read operations must come before Write operations. If you only want Write operations, you can use Batched Writes instead.

The limitations of Transaction are:

  1. A transaction always failed when the device is offline.
  2. Read operations must come before Write operations.
  3. A transaction always succeeds all operations or failed all operations. If something happened when operations are still running, Cloud Firestore automatically doing the rollback operation.
  4. The transaction function might run more than once when a concurrent edit affects a document that the transaction read.
  5. Do not modify application state inside transaction function, because this function is not guaranteed to run on the UI thread. If you want to get any information from a transaction, return the value instead of nil. In the above code, modify the codes like this:
// update counter value via transaction
let newCounter = counter + 1
transaction.updateData(["counter": newCounter], forDocument: self.ref)
return newCounter

Batched Writes

Batched Writes offers a simpler code for writing data compared to Transaction. It also has fewer failure cases because no read operations are allowed. To use Batched Writes, first, we need to initialize new write batch, then we add as whatever write operations inside the batch (up to 500 operations) and finally we commit the batch.

let batch = db.batch()

let mammalRef = db.collection("animal").document("mammal")
batch.updateData(["name": "Tarsius"], forDocument: mammalRef)

let birdRef = db.collection("animal").document("bird")
batch.deleteDocument(birdRef)

batch.commit() { err in
    if let err = err {
        print("Batch write failed, reason: \(err)")
    } else {
        print("Batch write succeeded.")
    }
}

Get Data (Once)

There are two ways to query data in Firestore, get data (once) and get realtime data. The difference is in a realtime data we use a listener that gets notified whenever there was a data update. This update also includes any updates that happened in the children.
To get a document data, use getDocument:

let ref = db.collection("animal").document("mammal")
ref.getDocument { (snapshot, err) in
    if let data = snapshot?.data() {
        print(data["name"])
    } else {
        print("Couldn't find the document")
    }
}

To get a list of documents inside a collection, use getDocuments:

let collectionRef = db.collection("animal")
collectionRef.getDocuments { (querySnapshot, err) in
    if let docs = querySnapshot?.documents {
        for docSnapshot in docs {
            print(docSnapshot.data())
        }
    }
}

To filter a list of documents inside a collection, instead of filtering the data after the data comes, we can use the Cloud Firestore queries to do the filtering before the data comes. Doing it this way reduces the data size and reduces the time & power to filter it inside the device.

let query = db.collection("animal").whereField("name", isEqualTo: "Peacock")
// or
// query = db.collection("animal").whereField("colors", arrayContains: "Black")
// to filter data by an array value

query.getDocuments { (querySnapshot, err) in
    if let docs = querySnapshot?.documents {
        for docSnapshot in docs {
            print(docSnapshot.data())
        }
    }
}

Get Realtime Data

A case where realtime data can be very useful is a chat app. In a chat app, it’s best to show a friend’s message as soon as the message was sent. Cloud Firestore provides addSnapshotListener to listen for any changes happened in document or collection. This change includes new data added, a data updated, or a data deleted.

db.collection("animal").document("mammal").addSnapshotListener { (snapshot, error) in
    if let err = error {
        print(err.localizedDescription)
        return
    }
    
    if let data = snapshot?.data() {
        print(data)
    }
}

Just like in the Get Data (Once) section, you can also listen to multiple documents changes or filter the data and listen to the filtered data changes.

// Listen to multiple documents changes
let collectionRef = db.collection("animal")
collectionRef.addSnapshotListener { (querySnapshot, err) in
    ...
}

// Listen fo filtered documents changes
let query = db.collection("animal").whereField("name", isEqualTo: "Peacock")
query.getDocuments { (querySnapshot, err) in
    ...
}

Reference

Documentation: https://firebase.google.com/docs/firestore/