This is the first part of a series of articles that dive into creating a new Android application from scratch using Jetpack Compose for the user interface and some other tools offered by Google, like Firebase Authentication, Crashlytics, Cloud Firestore, Performance Monitoring, Remote Config, Firebase Extensions, Firebase Cloud Messaging and Hilt.
In this first part, we will take a look at what you can do with this app, how it is structured and what technologies we will be covering in this series. Part 2 shows how to implement login and sign up flows using Firebase Authentication. Part 3 covers how to add Firebase Crashlytics to handle errors. Part 4 covers how to store data in the cloud using Cloud Firestore. Part 5 shows how to add Kotlin Coroutines and Kotlin Flow to the application. Part 6 covers how to improve the app’s quality with Firebase Performance Monitoring and part 7 shows how to control the behavior and appearance of the app remotely using Firebase Remote Config. Part 8 covers how to quickly and easily add new features to the app with Firebase Extensions and Part 9 shows how to display push notifications to users with Firebase Cloud Messaging. Part 10 teaches you how to build complex queries to filter, aggregate and order documents stored in Cloud Firestore.
Make it So
What can I do with this application?
The app we are going to build here is called Make It So. It’s a simple to-do list application that allows the user to add and edit to-do items, add flags, priorities and due dates, and mark the tasks as completed. To give users the best possible experience, we want to enable them to start using the app without having to sign in first. To achieve this, we will use a Firebase Authentication feature called Anonymous Authentication, which we will cover in a later part of this series. This is how the app looks like:
Why Jetpack Compose?
Compose is Android’s modern toolkit for building native UI - it’s intuitive and requires less code than writing .xml
files and binding them to Activities
, Fragments
or Views
. The first stable version of Compose was launched in 2021 and the APIs are ready to be used in production. It is essential that we understand how this new toolkit connects to other powerful tools like Firebase, since this new way of making UI is increasingly common among applications around the world and therefore promises to be a tool that will be maintained in the long term.
Now that we already know what we can do with this application, how it looks like, and the motivation behind the toolkit we choose, let’s take a closer look at the architecture!
Application Architecture
Overview
The architecture of this application follows the best practices recommended in the Android documentation. We have the UI layer (that we call a Screen
), responsible for showing data to users, and a data layer that is divided between ViewModels
and Services
, as we can see in the diagram below.
The data exchange between these layers must happen in the following way: the Screen
sends events to the ViewModels
(these events can be generated according to the screen lifecycle or through a user interaction, such as a click on a button). The Screen
also needs to observe a state (commonly called a UiState
) and update the data shown to the user as soon as this state changes.
The ViewModel
is responsible for calling the methods of the Services
according to the events received from the Screen
and dealing with the updates published by these Services
. In other words, the ViewModel
is responsible for the business logic. The Services
are responsible for calling the methods from the different Firebase APIs that we will use, and for returning the results to the ViewModels
.
Understanding Compose architecture
In Compose, each screen usually has one file for the Screen
and another one for the ViewModel
. Some screens might also require using a UiState
class for managing how the data is exchanged between the ViewModel
and the top level composable of your screen (like we saw in the diagram above). To learn more about Compose, check out the official documentation.
The ViewModel
should contain all the business logic, which should be isolated from the composable functions. Compose follows the declarative UI model where the Screen
observes the state of an object and, upon realizing that this state has changed, the framework automatically re-executes the composable functions, making the necessary changes on the UI. This process is called recomposition:
Accessing ViewModel in composable functions
In order to listen to these states, we need to provide the ViewModel
to the composable functions. The best way to do this in Android apps is to use dependency injection: a technique to provide everything an object depends on to do everything it needs to do. This prevents objects (like services and ViewModels
) from having to be recreated and passed between screens all the time.
There are many tools available to use dependency injection in Android. The one we are going to use in Make it So is called Hilt and is built on top of Dagger, another well known dependency injection library. We chose to use Hilt because it is very easy to learn and simple to use, and still has all the other benefits already known in Dagger (such as runtime performance, scalability and Android Studio support).
Once we’ve tagged a ViewModel
as a @HiltViewModel
, we can access it from anywhere in the code. All we need to do is specify what type of ViewModel
we want, and call the method hiltViewModel()
. In the code snippet below, we are calling this method from a composable function, specifying that the ViewModel
is of type EditTaskViewModel
:
@Composable
fun EditTaskScreen(
taskId: String,
popUpScreen: () -> Unit,
viewModel: EditTaskViewModel = hiltViewModel()
) {
val task by viewModel.task
…
}
Accessing Services in ViewModels
Before we talk about how to access Services
, let’s remember how the data flow works in this application. We are following the unidirectional data flow, which means that the app has a one-way flow where the data can move in only one direction when being transferred between the different components of the software.
Composable functions also follow the unidirectional data flow pattern, and this is achieved by the fact that composable functions shouldn’t know anything about the services and the business logic, they should just observe the state changes emitted by the ViewModel
. That said, only the ViewModels
will access the Services
:
Once again, we will use Hilt to inject the dependencies into the ViewModels
. As there is no specific method to inject Services
(as there is the hiltViewModel()
to inject ViewModels
on the Screens
), we need to add the @Inject
annotation in the constructor
of our class, and declare the dependencies that we want to inject inside of it. Through this annotation Hilt knows that it needs to provide all Services
declared in the ViewModel
constructor
.
@HiltViewModel
class EditTaskViewModel @Inject constructor(
private val logService: LogService,
private val storageService: StorageService,
private val accountService: AccountService
) : ViewModel() {
var task = mutableStateOf(Task())
private set
…
}
The last thing is to configure the Hilt Module that will provide the service implementations to the ViewModels
. Not to be confused with a Gradle module, a Hilt Module is a class (that must be annotated with @Module
) that informs Hilt how to provide instances of certain types. In the example below, we are informing Hilt that whenever a class asks for an instance of the StorageService
, it should be provided with the StorageServiceImpl
class.
@Module
@InstallIn(ViewModelComponent::class)
abstract class ServiceModule {
@Binds
abstract fun provideStorageService(impl: StorageServiceImpl): StorageService
…
}
Services interfaces and implementations
Another important decision for the project was to use service interfaces and to provide implementations for those interfaces. This makes it easier to maintain the project in the long run, as you can quickly switch the implementation for the services methods in case it is needed.
In the ServiceModule
example above, we can see that we are injecting the StorageServiceImpl
as the implementation for the StorageService
interface. If the implementation for StorageService
changes in the future (we might want to use a remote storage option rather than a local one, for example), we can easily and quickly reference the new implementation as a parameter for the method above, and all the ViewModels
in the application will access the new one.
Initializing the composable functions
You may have noticed from the code snippets above that the composable functions of each screen receive some click listeners as parameters:
@Composable
fun TasksScreen(
openAddTask: () -> Unit,
openEditTask: (String) -> Unit,
openSettings: () -> Unit,
viewModel: TasksViewModel = hiltViewModel()
)
Now you might be asking: who does initialize these screens and pass these parameters forward? Who is responsible for navigating between screens? The answer is two classes: MakeItSoApp
and MakeItSoAppState
.
MakeItSoApp
configures the basis for all screens: from the theme to be used, to the objects and classes that will be used on all screens, such as the navigation host and the SnackbarManager
(which is used to display messages to the user). It’s also in this file that we keep a reference to the general state of the application to observe changes within the main screen.
Configuring the Navigation Graph
Last (but not least!) we configure the application’s navigation graph, which defines the possible screens to which we can navigate. The first three screens of our application are the SplashScreen
, the TasksScreen
(with the list of to-do items) and the EditTaskScreen
(that receives the taskId
to retrieve the task we want to edit). This is how we create the navigation graph for the app:
fun NavGraphBuilder.makeItSoGraph(appState: MakeItSoAppState) {
composable(SPLASH_SCREEN) {
SplashScreen(openAndPopUp = { route, popUp -> appState.navigateAndPopUp(route, popUp) })
}
composable(TASKS_SCREEN) {
TasksScreen(openScreen = { route -> appState.navigate(route) })
}
composable(
route = "$EDIT_TASK_SCREEN$TASK_ID_ARG",
arguments = listOf(navArgument(TASK_ID) { defaultValue = TASK_DEFAULT_ID })
) {
EditTaskScreen(
popUpScreen = { appState.popUp() },
taskId = it.arguments?.getString(TASK_ID) ?: TASK_DEFAULT_ID
)
}
}
A navigation graph defines all possible routes into the application, and what arguments each of those routes requires. Inside each route we declare which Screen
is associated with it. Each of the screens will receive one or more callbacks as a parameter. For example: the TasksScreen
receives the openScreen
parameter. The moment the user clicks to edit a task, the composable function will send this click event to the TasksViewModel
, along with the openScreen
callback. Then the TasksViewModel
will call the openScreen
callback, passing the route of the new screen as a parameter (which in this case would be EDIT_TASK_SCREEN
).
Inside the openScreen
callback we run appState.navigate(route)
, as seen in the snippet above. This is what the navigate
method does:
fun navigate(route: String) {
navController.navigate(route)
}
It basically uses the NavController
(an Android class) to navigate to the next screen. The NavController
will find the next screen to navigate by looking for it in the navigation graph. If the route does not exist in the graph, an exception is thrown. Otherwise the NavController
adds the new screen to the backstack. Find out more about this navigation model in this Github repository.
Now we already know how the app architecture looks like. We built the basis so that it is possible to create our app’s functionalities. With this structure it was possible to create the UI of the initial screens and to configure the navigation between them.
What’s next
In part 2 of this series, we will take a closer look at the creation of the login and sign up flows. We will also dive deeper into how the ViewModel
connects the business logic using composable functions.
You can clone the source code on the Github repository. If you’re also interested in iOS/Mac development, the same application is available for these platforms. The iOS/Mac version uses SwiftUI for building the User Interface, which is very similar to how we build UIs in Compose. You can find the source code on this GitHub repository.
If you have any questions or suggestions, feel free to reach out to us on the discussions page of the repository.