Exploring Firebase Firestore in Android with Jetpack Compose and Kotlin DSL
Introduction
In modern Android app development, creating efficient and dynamic databases is essential for delivering seamless user experiences. Firebase Firestore, a cloud-hosted NoSQL database, has gained immense popularity due to its real-time capabilities, scalability, and ease of integration. Combined with Jetpack Compose—a modern UI toolkit for Android—it opens up new possibilities for building responsive and visually appealing, database-driven applications.
In this post, we will explore how to implement full CRUD (Create, Read, Update, Delete) operations using Firebase Firestore in an Android app with Kotlin, Jetpack Compose, and the Kotlin DSL for Gradle. We will also refactor the code for improved structure and maintainability.
What is Firebase Firestore?
Firebase Firestore is a cloud-based NoSQL database that allows real-time synchronization of data across multiple devices and platforms. It organizes data into collections and documents, which makes managing and retrieving information straightforward. Firestore’s real-time capabilities make it an ideal choice for collaborative and data-intensive applications.
Project Setup
Creating a Firebase Project
- Access Firebase Console:
Go to Firebase Console and create a new project. - Add Your Android App:
Click on the Android icon and follow the instructions to register your app. - Download the
google-services.json
File:
Place the downloaded file in the root directory of your app module.
Integrating with Android Studio
- Open Android Studio and use the Firebase Assistant from
View > Tool Windows > Firebase
. - Connect your project to Firebase and add the required dependencies for Firestore.
Gradle Configuration with Kotlin DSL
Modern Gradle configuration benefits from Kotlin DSL, which provides compile-time safety and a more expressive syntax. Below is an example:
//[title="build.gradle.kts"]
plugins {
id("com.android.application")
kotlin("android")
kotlin("kapt")
id("com.google.gms.google-services")
}
android {
compileSdk = 33
defaultConfig {
applicationId = "com.firebase_firestore.app"
minSdk = 21
targetSdk = 33
versionCode = 1
versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
getByName("release") {
isMinifyEnabled = false
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
buildFeatures {
compose = true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.5.0"
}
}
dependencies {
// Firebase Firestore (latest stable version)
implementation("com.google.firebase:firebase-firestore-ktx:24.4.0")
// Jetpack Compose
implementation("androidx.compose.ui:ui:1.5.0")
implementation("androidx.compose.material:material:1.5.0")
implementation("androidx.compose.ui:ui-tooling:1.5.0")
// Kotlin Coroutines for Android
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4")
// Additional dependencies
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.1")
implementation("androidx.activity:activity-compose:1.7.1")
}
{{note:Adjust the versions as needed for the latest updates.},{type:success}}
Firestore Database Structure
Firestore organizes data into collections and documents. For example, in a note-taking app:
- Collection:
notes
- Document: Each note document contains fields such as
id
,title
, andcontent
.
This flexible structure makes it easy to scale and add new fields without complex migrations.
Implementing CRUD Operations
Below are refactored and updated examples for implementing CRUD operations.
Defining the Data Model
We create a Kotlin data class to represent each note:
//[title="src/main/java/com.firebase_firestore.app/models/Note.kt"]
package com.firebase_firestore.app.model
data class Note(
val id: String = "",
val title: String = "",
val content: String = ""
)
Create Operation
A function to add a new note to Firestore:
//[title="src/main/java/com.firebase_firestore.app/data/FirestoreRepository.kt"]
package com.firebase_firestore.app.data
import com.google.firebase.firestore.FirebaseFirestore
import com.firebase_firestore.app.model.Note
object FirestoreRepository {
private val firestore = FirebaseFirestore.getInstance()
private val notesCollection = firestore.collection("notes")
fun addNote(note: Note, onSuccess: () -> Unit, onFailure: (Exception) -> Unit) {
notesCollection.add(note)
.addOnSuccessListener { onSuccess() }
.addOnFailureListener { exception -> onFailure(exception) }
}
}
Read Operation
A function to fetch notes and update the UI in real time:
//[title="src/main/java/com.firebase_firestore.app/data/FirestoreRepository.kt"]
package com.firebase_firestore.app.data
import androidx.compose.runtime.mutableStateListOf
import com.google.firebase.firestore.FirebaseFirestore
import com.firebase_firestore.app.models.Note
object FirestoreRepository {
private val firestore = FirebaseFirestore.getInstance()
private val notesCollection = firestore.collection("notes")
fun fetchNotes( // [!code ++]
onNotesFetched: (List<Note>) -> Unit, // [!code ++]
onError: (Exception) -> Unit // [!code ++]
) { // [!code ++]
notesCollection.get() // [!code ++]
.addOnSuccessListener { result -> // [!code ++]
val notes = result.documents.mapNotNull { it.toObject(Note::class.java) } // [!code ++]
onNotesFetched(notes) // [!code ++]
} // [!code ++]
.addOnFailureListener { exception -> // [!code ++]
onError(exception) // [!code ++]
} // [!code ++]
} // [!code ++]
}
Update Operation
A function to update an existing note using its document ID:
//[title="src/main/java/com.firebase_firestore.app/data/FirestoreRepository.kt"]
package com.firebase_firestore.app.data
import com.google.firebase.firestore.FirebaseFirestore
import com.firebase_firestore.app.model.Note
object FirestoreRepository {
private val firestore = FirebaseFirestore.getInstance()
private val notesCollection = firestore.collection("notes")
fun updateNote(noteId: String, newNote: Note, onSuccess: () -> Unit, onFailure: (Exception) -> Unit) { // [!code ++]
notesCollection.document(noteId).set(newNote) // [!code ++]
.addOnSuccessListener { onSuccess() } // [!code ++]
.addOnFailureListener { exception -> onFailure(exception) } // [!code ++]
} // [!code ++]
}
Delete Operation
A function to delete a note from the collection:
//[title="src/main/java/com.firebase_firestore.app/data/FirestoreRepository.kt"]
package com.firebase_firestore.app.data
import com.google.firebase.firestore.FirebaseFirestore
object FirestoreRepository {
private val firestore = FirebaseFirestore.getInstance()
private val notesCollection = firestore.collection("notes")
fun deleteNote(noteId: String, onSuccess: () -> Unit, onFailure: (Exception) -> Unit) { // [!code ++]
notesCollection.document(noteId).delete() // [!code ++]
.addOnSuccessListener { onSuccess() } // [!code ++]
.addOnFailureListener { exception -> onFailure(exception) } // [!code ++]
} // [!code ++]
}
Integrating Firestore with Jetpack Compose
Below is how to integrate Firestore operations with a Jetpack Compose UI. This example demonstrates a simple screen to add and display notes.
Initializing Firebase in the Application Class
It is recommended to initialize Firebase in your Application
class to ensure it is ready when needed:
//[title="src/main/java/com.firebase_firestore.app/MainActivity.kt"]
package com.firebase_firestore.app
import android.app.Application
import com.google.firebase.FirebaseApp
class MainActivity : Application() {
override fun onCreate() {
super.onCreate()
FirebaseApp.initializeApp(this)
}
}
{{note:Remember to register this class in your AndroidManifest.xml
},{type:warning}}
Main Screen with Jetpack Compose
The following is a main screen that allows users to add a note and view the updated list:
//[title="src/main/java/com.firebase_firestore.app/ui/presentation/NoteAppScreen.kt"]
package com.firebase_firestore.app.ui
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.firebase_firestore.app.data.FirestoreRepository
import com.firebase_firestore.app.model.Note
@Composable
fun NoteAppScreen() {
var title by remember { mutableStateOf("") }
var content by remember { mutableStateOf("") }
val notes = remember { mutableStateListOf<Note>() }
var errorMessage by remember { mutableStateOf("") }
LaunchedEffect(Unit) {
FirestoreRepository.fetchNotes(
onNotesFetched = { fetchedNotes ->
notes.clear()
notes.addAll(fetchedNotes)
},
onError = { exception ->
errorMessage = exception.message ?: "Error loading notes"
}
)
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(text = "Firestore Notes") }
)
},
content = { padding ->
Column(
modifier = Modifier
.padding(padding)
.padding(16.dp)
) {
OutlinedTextField(
value = title,
onValueChange = { title = it },
label = { Text("Title") },
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = content,
onValueChange = { content = it },
label = { Text("Content") },
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = {
val newNote = Note(title = title, content = content)
FirestoreRepository.addNote(
note = newNote,
onSuccess = {
title = ""
content = ""
},
onFailure = { exception ->
errorMessage = exception.message ?: "Error adding note"
}
)
},
modifier = Modifier.fillMaxWidth()
) {
Text("Add Note")
}
Spacer(modifier = Modifier.height(16.dp))
if (errorMessage.isNotEmpty()) {
Text(text = errorMessage, color = MaterialTheme.colors.error)
}
Spacer(modifier = Modifier.height(16.dp))
Text(text = "Notes List", style = MaterialTheme.typography.h6)
LazyColumn {
items(notes) { note ->
Card(
elevation = 4.dp,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp)
) {
Column(modifier = Modifier.padding(8.dp)) {
Text(text = note.title, style = MaterialTheme.typography.subtitle1)
Text(text = note.content, style = MaterialTheme.typography.body2)
}
}
}
}
}
}
)
}
Best Practices and Improvements
- Repository Pattern:
Centralize your data access logic in a repository for reusability and easier unit testing. - Recommended Architecture:
Consider using architectural patterns like MVVM along with dependency injection frameworks such as Hilt or Dagger. - Real-time Listeners:
Instead of one-time fetch calls, useaddSnapshotListener
for continuous UI updates. - Error Handling and Caching:
Implement robust error handling and consider local caching to improve user experience in low connectivity scenarios. - Modularization:
Separate Firestore functions into modules or layers to keep your code clean and scalable.
Conclusion
Integrating Firebase Firestore with Jetpack Compose using Kotlin DSL allows you to build modern, efficient, and high-performance Android applications. With the approach presented in this post, you can implement complete CRUD operations, leverage Compose's reactivity, and maintain a modular, maintainable codebase.
This updated guide not only enhances the structure and readability of your code but also facilitates the integration of new technologies and best practices in Android app development.
Additional Resources
- Official Firebase Firestore Documentation
- Jetpack Compose Guide
- Gradle Kotlin DSL Documentation
- MVVM Best Practices
Appendix: Real-Time Listener Example
To further enhance the user experience, it's ideal to use a real-time listener that updates the UI as data changes. Below is an additional example:
//[title="src/main/java/com.firebase_firestore.app/data/FirestoreRealtimeRepository"]
package com.firebase_firestore.app.data
import androidx.compose.runtime.mutableStateListOf
import com.google.firebase.firestore.FirebaseFirestore
import com.google.firebase.firestore.ListenerRegistration
import com.firebase_firestore.app.model.Note
object FirestoreRealtimeRepository {
private val firestore = FirebaseFirestore.getInstance()
private val notesCollection = firestore.collection("notes")
private var listenerRegistration: ListenerRegistration? = null
fun startListening(onNotesChanged: (List<Note>) -> Unit, onError: (Exception) -> Unit) {
listenerRegistration = notesCollection.addSnapshotListener { snapshot, exception ->
if (exception != null) {
onError(exception)
return@addSnapshotListener
}
snapshot?.let {
val notes = it.documents.mapNotNull { document -> document.toObject(Note::class.java) }
onNotesChanged(notes)
}
}
}
fun stopListening() {
listenerRegistration?.remove()
listenerRegistration = null
}
}
Complete Integrated UI Example with Listener
Combine the real-time listener with a Jetpack Compose UI for continuous data updates:
//[title="src/main/java/com.firebase_firestore.app/ui/presentation/NoteRealtimeScreen.kt"]
package com.firebase_firestore.app.ui
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.firebase_firestore.app.data.FirestoreRealtimeRepository
import com.firebase_firestore.app.model.Note
@Composable
fun NoteRealtimeScreen() {
val notes = remember { mutableStateListOf<Note>() }
var errorMessage by remember { mutableStateOf("") }
DisposableEffect(Unit) {
FirestoreRealtimeRepository.startListening(
onNotesChanged = { newNotes ->
notes.clear()
notes.addAll(newNotes)
},
onError = { exception ->
errorMessage = exception.message ?: "Error loading real-time data"
}
)
onDispose {
FirestoreRealtimeRepository.stopListening()
}
}
Scaffold(
topBar = {
TopAppBar(title = { Text("Real-Time Notes") })
},
content = { padding ->
Column(
modifier = Modifier
.padding(padding)
.padding(16.dp)
) {
if (errorMessage.isNotEmpty()) {
Text(text = errorMessage, color = MaterialTheme.colors.error)
}
Spacer(modifier = Modifier.height(16.dp))
LazyColumn {
items(notes) { note ->
Card(
elevation = 4.dp,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp)
) {
Column(modifier = Modifier.padding(8.dp)) {
Text(text = note.title, style = MaterialTheme.typography.subtitle1)
Text(text = note.content, style = MaterialTheme.typography.body2)
}
}
}
}
}
}
)
}
Final Considerations
- Continuous Updates: Always keep your dependencies up-to-date.
- Security: Implement proper security rules in Firestore to safeguard your data.
- Testing: Modularize your data access logic to facilitate unit testing.
- Performance: Consider using local caching to enhance performance in low connectivity scenarios.
Happy coding!