Getting started with Firebase Cloud Firestore and LiveData Android 20.10.2019

Cloud Firestore is a NoSQL database similar to the Realtime Database. It stores data in a structure that looks like a tree, but where data is stored as documents. It’s designed to overcome all of the drawbacks of the Realtime database.

Firestore uses collections and documents to store data. A document is an entry that contains any fields. Documents are combined into collections.

The Firestore API is quite simple and it just have a few methods to work with. They include all the possible cases that we could need to build our application. These methods are: get, set, add, update and delete.

The Firebase console is where you set up and manage your apps. From there, you can view all of your projects as well as create new ones.

To add the Cloud Firestore to your app, you only need to add one more dependency. Open up build.gradle file, if you haven't got it open and add the following dependency, making sure you're using the latest version:

com.google.firebase:firebase-firestore-ktx:21.4.3

Model

Firestore allow us to upload and modify data though models representations in our codes. This representations are plain classes that contains all the values of our model. They are usually called POJO (plain old java object).

In Kotlin we can simply create a new dataclass for our model like the next one:

data class User(
    val first: String = "",
    val last: String = "",
    val birth: Int = 0
)

Get data

Let's create a reference to users collection

val db = Firebase.firestore
val users = db.collection("users")

The following code shows how to get a document

val user1 = users.document("uid1")

user1.get().addOnSuccessListener { snapshot -> 
    if (snapshot != null) {
        Log.d(TAG, "DocumentSnapshot data: ${snapshot.data}")
    } else {
        Log.d(TAG, "No such document")
    }
}.addOnFailureListener { exception ->
    Log.d(TAG, "get failed with ", exception)
}                         

Get custom object

user1.get().addOnSuccessListener { snapshot ->
    val usr = snapshot.toObject<User>()
}

In order to get all the documents of a collection, the following code is enough

users.get()
    .addOnSuccessListener { snapshot ->
        for (document in snapshot) {
            Log.d(TAG, "${document.id} => ${document.data}")
        }
    }
    .addOnFailureListener { exception -> 
       Log.w(TAG, "Error getting documents: ", exception)
    }

The following code shows how to get documents from the collection by condition

users.whereEqualTo("birth", 1985)
    .get()
    .addOnSuccessListener { snapshot -> }
    .addOnFailureListener { exception -> }

While getting the documents, they can be immediately converted into our data classes

users.get()
    .addOnSuccessListener { snapshot ->
        val items: List<User> = snapshot.toObjects(User::class.java)
    }
    .addOnFailureListener { exception -> }

Get Multple Documents by Ids

fun getItems(ids: List<String>): List<Deferred<DocumentSnapshot>> {
    val firestore = FirebaseFirestore.getInstance()

    return ids.map { id ->
        firestore.collection("users").document(id).get().asDeferred()
    }
}    

launch(Dispatchers.Default) {
    val ids = ...
    val docs = getItems(ids).awaitAll()
}

Read more about queries in Cloud Firestore.

Write data

For writing, you have to create a Hashmap with data (where the name of the field acts as a key, and the value of this field as a value) and transfer it to the library. You can see that in the following code

val usr = hashMapOf(
    "first" to "Joe",
    "last" to "Black",
    "birth" to 1985
)

users.add(usr)
    .addOnSuccessListener {}
    .addOnFailureListener { e -> Log.w(TAG, "Error writing document", e) }

To set your own ID you need to do the following

users.document("uid1")
    .set(usr)
    .addOnSuccessListener { }
    .addOnFailureListener { e -> Log.w(TAG, "Error writing document", e) }

Write custom object

val usr2 = User("Joe", "Black", 1985) 
users.document("uid2").set(usr2)

Update a document

users.document("uid2").update("last", "Green")

Delete data

users.document("uid2").delete()    

Subscribe to changes

Firestore allows you to subscribe to data changes. You can subscribe to changes in the collection as well as changes to a specific document

users.document("usr1").addSnapshotListener { snapshot, e ->
    if (e != null) {
        Log.w(TAG, "Listen failed.", e)
        return@addSnapshotListener
    }

    if (snapshot != null && snapshot.exists()) {
        Log.d(TAG, "Current data: ${snapshot.data}")
    } else {
        Log.d(TAG, "Current data: null")
    }
}

users.whereEqualTo("birth", 1985)
    .addSnapshotListener { snapshot, e ->
        if (e != null) {
            Log.w(TAG, "Listen failed.", e)
            return@addSnapshotListener
        }

        val names = ArrayList<String>()
        for (doc in value!!) {
            doc.getString("first")?.let {
                names.add(it)
            }
        }
        Log.d(TAG, "Current cites in CA: $cities")
    }

snapshot contains two useful fields

  • snapshot.documents — contains an updated list of all documents
  • snapshot.documentChanges — contains a list of changes. Each object contains a modified document and a type of change. Three types of changes are possible: ADDED — documents added, MODIFIED — documents updated, REMOVED — documents deleted.

It is often useful to see the actual changes to query results between query snapshots, instead of simply using the entire query snapshot.

users.whereEqualTo("birth", 1985)
    .addSnapshotListener { snapshots, e ->
        if (e != null) {
            Log.w(TAG, "listen:error", e)
            return@addSnapshotListener
        }

        for (dc in snapshots!!.documentChanges) {
            when (dc.type) {
                DocumentChange.Type.ADDED -> Log.d(TAG, "New user: ${dc.document.data}")
                DocumentChange.Type.MODIFIED -> Log.d(TAG, "Modified user: ${dc.document.data}")
                DocumentChange.Type.REMOVED -> Log.d(TAG, "Removed user: ${dc.document.data}")
            }
        }
    }

Detach a listener

When you are no longer interested in listening to your data, you must detach your listener so that your event callbacks stop getting called. This allows the client to stop using bandwidth to receive updates. For example:

val registration = users.addSnapshotListener { snapshots, e ->
}

// Stop listening to changes
registration.remove()

Query data and pagination

Cloud Firestore provides powerful query functionality for specifying which documents you want to retrieve from a collection. These queries can also be used with either get() or ddSnapshotListener(), as described above.

We can make use of the next methods over our database references to query our data:

  • whereEqualTo() does an == comparation.
  • whereLessThan() does an < comparation.
  • whereLessThanOrEqualTo() does an <= comparation.
  • whereGreaterThan() does an > comparation.
  • whereGreaterThanOrEqualTo() does an >= comparation.
  • orderBy() allow us to order by a given field.
  • limit() limit the number of DocumentSnapshot received.

To paginate our data, Firestore give us the startAt, startAfter and endAt, endBefore methods, which receives a DocumentSnapshot and uses it for paginate data. The startAt method includes the start point, while the startAfter method excludes it. This means that we will need to keep in the memory the last DocumentSnapshot given by each query done when we will want to implement pagination over it.

val query = users.orderBy("birth").limit(5)

query.get().addOnSuccessListener { snapshots ->
    // Get the last visible document
    val lastVisible = snapshots.documents[snapshots.size() - 1]

    // Construct a new query starting at this document,
    // get the next 5 items.
    val next = users.orderBy("birth")
            .startAfter(lastVisible)
            .limit(5)

    // Use the query for pagination
    // ...
}

Firestore also allow us to save geographical coordinates in our Database. Using the class GeoPoint we can query by distance. If you want to read more about it you can check post.

Batches writes

If you do not need to read any documents in your operation set, you can execute multiple write operations as a single batch that contains any combination of set(), update(), or delete() operations. A batch of writes completes atomically and can write to multiple documents.

Batched writes are also useful for migrating large data sets to Cloud Firestore. Write batches can contain up to 500 operations and they reduces connection overhead resulting in faster data migration. Let’s see an example:

val user1 = users.document("uid1")
val user2 = users.document("uid2")
val user3 = users.document("uid3")

db.runBatch { batch ->
    batch.set(user1, User("Al", "Pacino", 1940))

    batch.update(user2, "birth", 1990)

    batch.delete(user3)
}.addOnCompleteListener {
    // ...
}

Example with LiveData

You need a way to talk to the Cloud Firestore. Let's create CloudFirestoreManager class that helps us to manage connection and value fetching.

private const val POSTS_COLLECTION = "posts"

class CloudFirestoreManager {
    private val database = FirebaseFirestore.getInstance()
    private val postsValues = MutableLiveData<List<Post>>()
    private lateinit var postsRegistration: ListenerRegistration

    fun addPost(content: String, onSuccessAction: () -> Unit, onFailureAction: () -> Unit) {
        val documentReference = database.collection(POSTS_COLLECTION).document()
        val post = HashMap<String, Any>()

        post[AUTHOR_KEY] = authenticationManager.getCurrentUser()
        post[CONTENT_KEY] = content
        post[TIMESTAMP_KEY] = System.currentTimeMillis()
        post[ID_KEY] = documentReference.id

        documentReference
            .set(post)
            .addOnSuccessListener { onSuccessAction() }
            .addOnFailureListener { onFailureAction() }
    }

    fun onPostsValuesChange(): LiveData<List<Post>> {
        listenForPostsValueChanges()
        return postsValues
    }

    private fun listenForPostsValueChanges() {
        postsRegistration = database.collection(POSTS_COLLECTION)
            .addSnapshotListener(EventListener<QuerySnapshot> { value, error ->
                if (error != null || value == null) {
                    return@EventListener
                }

                if (value.isEmpty) {
                    postsValues.postValue(emptyList())
                } else {
                    val posts = ArrayList<Post>()
                    for (doc in value) {
                        val post = doc.toObject(Post::class.java)
                        posts.add(post)
                    }
                    postsValues.postValue(posts)
                }
            })
    }

    fun stopListeningForPostChanges() = postsRegistration.remove()

    fun updatePostContent(key: String, content: String, 
        onSuccessAction: () -> Unit, onFailureAction: () -> Unit) {

        val updatedPost = HashMap<String, Any>()
        updatedPost[CONTENT_KEY] = content
        database.collection(POSTS_COLLECTION)
            .document(key)
            .update(updatedPost)
            .addOnSuccessListener { onSuccessAction() }
            .addOnFailureListener { onFailureAction() }
    }

    fun deletePost(key: String, onSuccessAction: () -> Unit, onFailureAction: () -> Unit) {
        database.collection(POSTS_COLLECTION)
            .document(key)
            .delete()
            .addOnSuccessListener { onSuccessAction() }
            .addOnFailureListener { onFailureAction() }
    }
}

We can use this help class like so

class MainActivity : AppCompatActivity() {
    private val cfm by lazy { CloudFirestoreManager() }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
    }

    override fun onStart() {
        super.onStart()
        listenForPostsUpdates()
    }

    override fun onStop() {
        super.onStop()
        cfm.stopListeningForPostChanges()
    }

    private fun listenForPostsUpdates() {
        cfm.onPostsValuesChange()
            .observe(this, Observer(::onPostsUpdate))
    }

    private fun onPostsUpdate(posts: List<Post>) {
        Log.d("TAG", posts)
    }
}

Using FirebaseUI to populate a RecyclerView

FirebaseUI offers two types of RecyclerView adapters for Cloud Firestore:

  • FirestoreRecyclerAdapter — binds a Query to a RecyclerView and responds to all real-time events included items being added, removed, moved, or changed. Best used with small result sets since all results are loaded at once.
  • FirestorePagingAdapter — binds a Query to a RecyclerView by loading data in pages. Best used with large, static data sets. Real-time events are not respected by this adapter, so it will not detect new/removed items or changes to items already loaded.

Example of FirestoreRecyclerAdapter

Import the Dependency

implementation 'com.firebaseui:firebase-ui-firestore:4.3.1'

Example of Adapter

class UserAdapter(options: FirestoreRecyclerOptions<User>) : FirestoreRecyclerAdapter<User, UserAdapter.ViewHolder>(options) {
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
        val view = LayoutInflater.from(parent.context)
                .inflate(R.layout.user_item, parent, false)
        return ViewHolder(view)
    }

    override fun onBindViewHolder(holder: ViewHolder, position: Int, item: User) {
        holder.apply {
            tvName.text = item.name
        }
    }

    inner class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {
        var tvName: TextView = view.findViewById(R.id.tvName)
    }
}

Example of Activity

class UsersActivity : AppCompatActivity() {
    var adapter: UserAdapter? = null

    var userListener: ListenerRegistration? = null
    var items = mutableListOf<User>()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_users)

        val db = FirebaseFirestore.getInstance()
        val query = db.collection("users").orderBy("name")
        val options = FirestoreRecyclerOptions.Builder<User>()
                .setQuery(query, User::class.java)
                .build()

        adapter = UserAdapter(options)

        val layoutManager = LinearLayoutManager(applicationContext)
        rvItems.layoutManager = layoutManager
        rvItems.itemAnimator = DefaultItemAnimator()
        rvItems.adapter = adapter
    }

    override fun onStart() {
        super.onStart()
        adapter?.startListening()
    }

    override fun onStop() {
        super.onStop()
        adapter?.stopListening()
    }

    override fun onDestroy() {
        super.onDestroy()
        userListener?.remove()
    }

    fun setupUserListener() {
        val db = FirebaseFirestore.getInstance()
        userListener = db.collection("users")
                .addSnapshotListener(EventListener { documentSnapshots, e ->
                    if (e != null) {
                        Log.e(TAG, "Listen failed!", e)
                        return@EventListener
                    }

                    items = mutableListOf()

                    for (doc in documentSnapshots) {
                        val item = doc.toObject(User::class.java)
                        items.add(item)
                    }

                    rvItems.adapter = adapter
                    adapter?.notifyDataSetChanged()
                })        
    }
}

The FirestoreRecyclerAdapter uses a snapshot listener to monitor changes to the Firestore query. To begin listening for data, call the startListening() method. Similarly, the stopListening() call removes the snapshot listener and all data in the adapter.

Example of FirestorePagingAdapter

The FirestorePagingAdapter binds a Query to a RecyclerView by loading documents in pages. This results in a time and memory efficient binding, however it gives up the real-time events afforted by the FirestoreRecyclerAdapter.

The FirestorePagingAdapter is built on top of the Android Paging Support Library. Before using the adapter in your application, you must add a dependency on the support library:

implementation 'android.arch.paging:runtime:2.1.2'

First, configure the adapter by building FirestorePagingOptions.

val query = FirebaseFirestore.getInstance()
    .collection("users")
    .orderBy("name", Query.Direction.ASCENDING)

val config = PagedList.Config.Builder()
        .setEnablePlaceholders(false)
        .setPrefetchDistance(5)
        .setPageSize(10)
        .build()

val options = FirestorePagingOptions.Builder<User>()
        .setLifecycleOwner(this)
        .setQuery(query, config, User::class.java)
                .build()

Next, create the FirestorePagingAdapter class. You should already have a ViewHolder subclass for displaying each item.

class UserPageAdapter(options: FirestorePagingOptions<User>) : FirestorePagingAdapter<User, UserPageAdapter.ViewHolder>(options) {
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
        val view = LayoutInflater.from(parent.context)
                .inflate(R.layout.user_item, parent, false)
        return ViewHolder(view)
    }

    override fun onBindViewHolder(holder: ViewHolder, position: Int, item: User) {
        holder.apply {
            tvName.text = item.name
        }
    }

    override fun onLoadingStateChanged(state: LoadingState) {
        when(state) {
            LoadingState.LOADING_INITIAL -> {
                Log.d(Constants.TAG, "LOADING_INITIAL: ");
            }

            LoadingState.LOADING_MORE -> {
                Log.d(Constants.TAG, "LOADING_MORE: ");
            }
        }

    }

    inner class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {
        var tvName: TextView = view.findViewById(R.id.tvName)
    }
}

Finally attach the adapter to your RecyclerView with the setAdapter() method.

class UsersActivity : AppCompatActivity() {
    val TAG = Constants.TAG;
    var adapter: UserPageAdapter? = null

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_users)

        val query = FirestoreUtils.getCollection(Constants.FIREBASE_USERS_DB).orderBy("firstName", Query.Direction.ASCENDING)

        val config = PagedList.Config.Builder()
                .setEnablePlaceholders(false)
                .setPrefetchDistance(5)
                .setPageSize(10)
                .build()

        val options = FirestorePagingOptions.Builder<User>()
                .setLifecycleOwner(this)
                .setQuery(query, config, User::class.java)
                        .build()

        adapter = UserPageAdapter(options)


        val layoutManager = LinearLayoutManager(applicationContext)
        rvItems.layoutManager = layoutManager
        rvItems.itemAnimator = DefaultItemAnimator()

        rvItems.adapter = adapter
    }

    override fun onStart() {
        super.onStart()
        adapter?.startListening()
    }

    override fun onStop() {
        super.onStop()
        adapter?.stopListening()
    }
}

Useful links