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 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. In part 2, we will look at Firebase Authentication and how it helps us to implement the sign up and login flows. In part 3, we will see how to keep track of crashes in our application using Crashlytics. part 4 will cover how to use Cloud Firestore to store data in the cloud.

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.

Architecture overview diagram
Architecture overview diagram

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:

Recomposition flow
Recomposition flow

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:

Data flow between ViewModel and Service
Data flow between ViewModel and Service

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, feel free to reach out to me on Twitter.