Zero to Compose: Room, Hilt, & Nav

This Way (‘Nav’) to Your Compose Room with a Hilt View(Model)

YLabZ
20 min readSep 2, 2024

Where your app finds its true direction & your View(Model) always has a Room(DB) to grow!

The Code of Android.

Creating a scalable, maintainable, and efficient Android application requires thoughtful architecture and the integration of modern libraries and best practices. In this article, we’ll walk you through the journey of transforming a base Android app into a sophisticated, modular application leveraging Hilt for dependency injection, Room for database management, and Jetpack Compose for a seamless UI experience and navigation. By the end of this guide, you’ll have a clear understanding of how to layer these components to build a robust Android application.

Setps:

  1. Project Setup
  2. Adding Hilt for Dependency Injection
  3. Integrating Room for Database Management
  4. Modularizing the Application (2 Features) Compose (VM w/ State)
  5. Implementing Navigation with Jetpack Compose

We setup a GitHub repo to help you layer the technology in these steps. Each commit adds one step to the base project generated by Android Studio Jetpack Compose project: Android Studio Ladybug | 2024.2.1 Canary 7

Just start with a basic compose project.

Base Production Code — used for all our projects:

Each commit is another layer. This should make it very easy to follow how they all work together.

Each commit is a layer of tech.

Build Your Project

To create a new project called YouProject based on the existing BasePro project, you can follow these steps. This process involves copying the project, renaming files and directories, and adjusting settings to reflect the new project name.

Step 1: Copy the Project

Copy the Entire Project Directory:

  • Go to your file explorer and copy the entire BasePro project directory.
  • Paste it into the desired location and rename the copied directory to YourProject.

Step 2: Rename the Project Internally

Open the New Project in Android Studio:

  • Open Android Studio and select “Open an existing project.”
  • Navigate to the YourProject directory you just created and open it.

Update the Project Name:

  • In Android Studio, open settings.gradle.kts and change the root project name to YourProject:
rootProject.name = "YourProject"

Update Package Names:

  • Change the package name (e.g., from com.ylabz.basepro to com.your.project), you need to do the following:
  • In the app/src/main/java/ directory, right-click the old package name and choose Refactor > Rename.
  • Enter the new package name (e.g., com.your.project).
  • Android Studio will prompt you to refactor the package name across all files. Accept this to update all references. This works GREAT!

Update Application ID:

  • Open app/build.gradle.kts and change the applicationId to match the new package name:
android {
namespace = "com.your.project"
compileSdk = 34

defaultConfig {
applicationId = "com.your.project"
minSdk = 31
targetSdk = 34
versionCode = 1
versionName = "1.0"

Step3: Update Project-Specific Settings

Update the Namespace in Each Module:

  • For each module (e.g., app, data, feature), open their respective build.gradle.kts files and update the namespace to reflect the new package name. For example:
android {     
namespace = "com.your.project" // other configurations }

Update Any Hardcoded Strings:

  • Use the “Find and Replace” feature in Android Studio (Cmd + Shift + R on Mac or Ctrl + Shift + R on Windows) to search for the old project name (BasePro) and replace it with YourProject. This includes any hardcoded references in comments, logs, or other parts of the codebase.

Step 4: Clean and Rebuild the Project

Clean and Rebuild:

  • After making all the necessary changes, clean and rebuild the project:
  • Build > Clean Project
  • Build > Rebuild Project

This step will ensure that all changes are properly applied and that there are no lingering references to the old project.

Step 5: Version Control

Update Git Repository:

  • If you are using Git for version control, you might want to remove the existing .git directory in the new YourProject project and initialize a new repository:
  • Delete the .git directory inside YourProject.
  • Run git init to start a new repository.
  • Add all files and commit the initial version of YourProject.

Final Check

Run the Project:

  • Once everything is set up, try running the project to ensure that it has been properly renamed and is functioning as expected.

This process effectively duplicates your original project and renames it, allowing you to start a new project (YourProject) with the same foundation as BasePro.

Let’s party with the Droid

Introduction

Building a modern Android application involves more than just writing code. It requires structuring your project in a way that promotes scalability, testability, and maintainability. In this article, we’ll explore how to enhance a basic Android app by integrating Hilt for dependency injection, Room for local data storage, and Jetpack Compose for building dynamic UIs. We’ll also demonstrate how to organize your app into modules, facilitating a clean separation of concerns and easier collaboration.

We write a lot about Android Development:

Android Dev

14 stories

And iOS Development:

iOS Dev

5 stories

And Android & iOS Dev Together:

Initializing the Base Android Project

Every journey begins with a single step, and ours starts with creating a new Android project. We chose Jetpack Compose as the UI toolkit right from the beginning, setting the stage for a fully declarative and reactive UI. This initial setup is straightforward, but it lays the groundwork for everything that follows.

Start by creating a new Android project in Android Studio:

  1. Open Android Studio and select “New Project”.
  2. Configure your project settings:
  • Name: TwinCamApp
  • Package name: com.ylabz.twincam
  • Language: Kotlin
  • Minimum SDK: API 31 or higher
  1. Click “Finish” to create the project.

Your initial project structure will look something like this:

TwinCamApp/
├── app/
│ ├── src/
│ │ ├── main/
│ │ ├── java/com/ylabz/twincam/
│ │ └── MainActivity.kt
│ │
│ └── build.gradle
├── build.gradle
└── settings.gradle

TOML

This TOML file is a well-organized configuration for managing dependencies, plugins, and versions in an Android project using Gradle’s version catalog feature. Here’s a broad overview of what this file represents:

Versions Section

Purpose: This section defines the versions of various dependencies and plugins used across the project. By centralizing version definitions, it ensures consistency and makes it easier to update dependencies.

  • agp: Refers to the Android Gradle Plugin, which is essential for building Android applications.
  • kotlin: The version of the Kotlin programming language used in the project.
  • coreKtx: Kotlin extensions for Android core libraries, making Android API usage more idiomatic in Kotlin.
  • junit, junitVersion, espressoCore: Libraries for testing, including JUnit for unit tests and Espresso for UI testing.
  • lifecycleRuntimeKtx: Extensions for managing lifecycle-aware components.
  • activityCompose, composeBom: Dependencies related to Jetpack Compose, a modern UI toolkit for Android.
  • Additional Libraries: Includes versions for Hilt (dependency injection), Room (database), WorkManager (background tasks), and various AndroidX components.

Libraries Section

Purpose: This section maps specific library dependencies to their respective modules and versions. It allows for easy inclusion of these libraries throughout the project.

  • Core Libraries: Libraries like androidx-core-ktx, junit, androidx-junit, and androidx-espresso-core provide core functionality and testing capabilities.
  • Lifecycle and Compose Libraries: These include support for lifecycle management (androidx-lifecycle-runtime-ktx) and UI development using Jetpack Compose (androidx-ui, androidx-material3).
  • Hilt Libraries: Provide dependency injection support, integrating with Android components and managing dependencies across the app.
  • Room and WorkManager: Manage local data storage (room-runtime, room-ktx) and background work (work-runtime-ktx).
  • Navigation: androidx-navigation-compose facilitates navigation within the app, particularly for Jetpack Compose.

Plugins Section

Purpose: This section defines the Gradle plugins needed to build the project. Plugins add specific capabilities, such as building Android apps, enabling Kotlin support, and integrating Hilt for dependency injection.

  • android-application: Plugin for building Android applications.
  • kotlin-android, kotlin-compose: Plugins that enable Kotlin support and Jetpack Compose integration.
  • hilt-gradle: Integrates Hilt for dependency injection.
  • ksp: Kotlin Symbol Processing (KSP) for annotation processing, used as an alternative to Kotlin Annotation Processing (KAPT).

This TOML file serves as a centralized configuration for managing a modern Android project. It leverages Jetpack Compose for UI development, Room for data persistence, Hilt for dependency injection, and WorkManager for background tasks. By organizing versions, libraries, and plugins in this way, the file ensures consistency across the project and simplifies the process of updating dependencies.

Notes: We tried to future proof this as much as possible.

  • We use module not group in TOML file, but Google generates mixes them in their example apps. Not sure why?
hilt-ext-work = { group = "androidx.hilt", name = "hilt-work", version.ref = "hiltExt" }
javax-inject = { module = "javax.inject:javax.inject", version = "1" }
  • We use KSP not Kapt because it is the future.

Warning: Dagger’s KSP support is currently in alpha.

Commit TOML file

Android with Hilt Dependency Injection

Adding Hilt for Dependency Injection

Hilt is a dependency injection library for Android that reduces the boilerplate of doing manual dependency injection in your project. It is built on top of Dagger and integrates seamlessly with Android components.

Let’s count the ways Hilts is the center of Android development

Hilt Usage in Android Development
  • Hilt is the Swiss Arm Knife of Kotlin/Compose Android Dev.
  • And Kotlin/Compose is the future of Android.
Hilt is the best tool for Android

Dependency injection is like a Swiss Army knife for developers — it makes your code more modular, testable, and scalable. To avoid the boilerplate associated with manual dependency injection, we integrated Hilt, a powerful DI framework built on top of Dagger.

By setting up Hilt, we ensured that dependencies are injected automatically, making our ViewModels, repositories, and other components easier to manage. We also created a custom Application class, which allowed Hilt to do its magic across the entire app.

Add Hilt Dependencies

  1. In your project’s build.gradle (project-level)
  2. In your app/build.gradle (module-level) file, apply the Hilt plugin and add dependencies

Commit Hilt

Showing 9 changed files with 40 additions and 11 deletions.
Android RoomDB

Integrating Room for Database Management

Next, we tackled the data layer by integrating Room, a database library that abstracts SQLite and provides an easy-to-use API for local data storage. Room allowed us to define our database schema and manage data with minimal hassle. By connecting Room with Hilt, we ensured that our database and data access objects (DAOs) were injected seamlessly into the components that needed them. This step brought our app to life by giving it the ability to store and retrieve data efficiently.

Integrating Room for Database Management

Local data storage is a critical component of many Android applications, and Room provides a robust, easy-to-use abstraction layer over SQLite, making it the go-to choice for handling persistent data. In our project, we integrated Room to manage local data and set up Hilt to seamlessly inject our database components where needed.

Building the Room Database

To start, we defined the structure of our local database using Room. This involved creating an Entity to represent the data structure in the database, a DAO (Data Access Object) to define the operations (such as queries, inserts, and deletes) that can be performed on the data, and finally, a Database class that brings it all together.

  1. Entity Creation: The Entity is a simple Kotlin data class annotated with @Entity. This class defines the table structure, with each property representing a column in the table.
  2. DAO Definition: The DAO is an interface annotated with @Dao. It contains method signatures for all the database operations we need, such as fetching all records, inserting new records, and deleting specific records. Each method is annotated with a specific Room annotation like @Query, @Insert, or @Delete.
  3. Database Class: The Database class is an abstract class annotated with @Database, and it serves as the main entry point to the Room database. It includes an abstract method that returns the DAO, and it’s where we define the list of entities (tables) that our database will contain.

Setting Up Hilt Dependency Injection

With the Room database components defined, the next step was to set up Hilt to inject the Room database and DAO into the parts of the app that need them. This setup makes it easier to manage dependencies and keeps the code clean and testable.

  1. Creating the Hilt Module: We created a Hilt module by defining an object class annotated with @Module and @InstallIn(SingletonComponent::class). This module is responsible for providing the Room database instance and the DAO.
  2. Providing the Database Instance: Inside the Hilt module, we defined a method annotated with @Provides that creates and returns an instance of the Room database. This method uses Room.databaseBuilder to build the database and ensure it's available as a singleton throughout the app.
  3. Providing the DAO: Another method in the Hilt module provides the DAO instance. This method simply calls the DAO getter on the Room database instance we provided earlier. By doing this, we ensure that the DAO is injected wherever it’s needed.
  4. Injecting the Repository: To make the database operations available to our ViewModels, we created a repository class that acts as a mediator between the DAO and the ViewModels. This repository is also provided by the Hilt module, ensuring that all dependencies are managed and injected automatically.

Bringing It All Together

With Room and Hilt integrated, accessing the database became as simple as injecting the repository into a ViewModel using Hilt’s @Inject annotation. This setup allowed us to keep the database operations encapsulated within the repository while maintaining a clean separation of concerns.

For example, in a ViewModel, we could inject the repository and use it to fetch data from the database or perform write operations. Hilt takes care of injecting the correct instances of the Room database and DAO, making our codebase more modular and easier to maintain.

This integration of Room and Hilt not only streamlined data management in our app but also ensured that the code remained scalable and testable as the app grows. The use of dependency injection via Hilt means that our ViewModels and other components don’t need to worry about how their dependencies are created — they simply receive them, ready to use.

Commit Room

Showing 19 changed files with 451 additions and 0 deletions.
Modulare Android

Modularizing the Application

As apps grow, so does the complexity of managing different features. To keep things organized, we split our project into modules. Modularization is like Marie Kondo for your codebase — it keeps everything tidy, organized, and easier to maintain.

By creating separate modules for features like the camera interface and settings, we promoted a clean separation of concerns. Each module could be developed, tested, and maintained independently, making our app more resilient to change.

Jetpack Compose

Building a Dynamic, Stateless, and Side-Effect-Free UI

Jetpack Compose is the centerpiece of our Android application, enabling us to build a dynamic and responsive user interface declaratively. Compose allows us to create UIs that are not only visually appealing but also reactive to state changes. However, to fully harness the power of Compose and ensure our UI is both predictable and easy to maintain, we adopted a layered approach that emphasizes stateless and side-effect-free composables. This approach is rooted in the principles of unidirectional data flow and separation of concerns.

The Layers of Compose: Ensuring a Stateless and Side-Effect-Free UI

To achieve a clean, maintainable, and testable UI, we divided the responsibilities of our Compose-based UI into several distinct layers:

  1. UIState: Represents the state of the UI at any given moment.
  2. UIEvent: Defines the events or actions that the user can trigger.
  3. ViewModel: Manages the state and business logic, processing events and updating the UI state.
  4. UIRoute: Acts as the middle layer between the ViewModel and the stateless composables, handling state observation and passing events.
  5. Stateless Composables: Pure UI components that display data without holding any state or causing side effects.

UIState: The Single Source of Truth

The UIState layer defines all the data that the UI needs to render itself. By encapsulating all relevant information in a single state object, we ensure that the UI is driven by a single source of truth. This makes the UI predictable, as any change in the UI state leads to a corresponding change in the UI.

  • Example: In a settings screen, the UIState might contain fields like theme, notificationsEnabled, and languagePreference. Each of these fields represents a part of the UI that can change in response to user actions.

UIEvent: Handling User Actions

The UIEvent layer defines the actions that the user can trigger within the UI. These events are typically defined as sealed classes or enums, making it easy to handle them in a type-safe manner. When the user interacts with the UI (e.g., by tapping a button), an event is generated and sent to the ViewModel.

  • Example: In our settings screen, UIEvent might include actions like ChangeTheme, ToggleNotifications, or UpdateLanguage. Each event corresponds to a specific user interaction.

UIRoute: Bridging ViewModel (below 👇) & Stateless Composables

The UIRoute layer acts as a bridge between the ViewModel and the stateless composables. It observes the UIState provided by the ViewModel and passes this state down to the appropriate composables. It also listens for user interactions and forwards the corresponding UIEvents to the ViewModel.

  • Simplifying UI Logic: By handling the interaction between the ViewModel and the UI, UIRoute allows us to keep the UI composables simple and focused solely on rendering.
  • State Observation: The UIRoute layer typically uses collectAsStateWithLifecycle() to observe the state from the ViewModel and then passes this state to the stateless composables.
    val onEvent: (WeatherEvent) -> Unit = viewModel::onEvent
val state by viewModel.uiState.collectAsStateWithLifecycle()

Stateless Composables: Pure UI Rendering

Stateless composables are the building blocks of our UI. They receive data as parameters and render it without holding any internal state or causing side effects. These composables are easy to test and reuse because they are purely declarative — they only display what they are given.

  • Two Parameters: To keep the composables stateless and side-effect-free, each composable typically takes just two parameters: data and onEvent. The data parameter contains the UIState needed to render the component, while onEvent is a callback function that handles user interactions.
  • Data: This is the information needed to render the UI. It could be a list of items, a user profile, or any other piece of data that the composable needs to display.
  • onEvent: This is a function passed down from the ViewModel through UIRoute. It allows the composable to trigger UIEvents when the user interacts with the UI (e.g., clicking a button). This event is then handled by the ViewModel, keeping the composable free from any logic or state management.
  • No Side Effects: Stateless composables do not trigger any actions like database updates or network requests. They only display the data they receive and invoke the onEvent callback provided by the UIRoute.
  • Pure Functions: These composables act like pure functions in functional programming — they take input (data) and produce output (UI), with no hidden dependencies or side effects.

Implementing ViewModels and State Management

No modern app is complete without state management, and ViewModels are the go-to solution in Android. We created ViewModels for each feature, managing the UI state and handling events like data loading, user interactions, and more.

With Hilt injecting dependencies into our ViewModels, state management became a breeze. Whether updating settings or handling user input, our app was always in sync with the latest data.

Use hiltViewModel() to get an instance of a ViewModel (annotated with @HiltViewModel)

@Composable
fun CamUIRoute(
modifier: Modifier = Modifier,
navTo: (String) -> Unit,
viewModel: CamViewModel = hiltViewModel()
) {

ViewModel: Managing State and Logic

The ViewModel is the workhorse that connects the UIState and UIEvent layers. It is responsible for managing the app’s state and handling the business logic. The ViewModel observes UI events, processes them (e.g., by updating the database, making a network request, or calculating new data), and then updates the UIState accordingly.

  • Separation of Concerns: By keeping all business logic in the ViewModel/Repository, we ensure that the composables remain focused on rendering the UI, without worrying about how data is fetched or processed.
  • State Management: The ViewModel uses StateFlow or MutableState to expose the UIState to the composables. It listens for changes in UI events and updates the UI state in response.
private val _uiState = MutableStateFlow<CamUIState>(CamUIState.Loading)

Putting It All Together

Here’s how these layers work together to create a clean, maintainable, and testable UI:

  1. User Interaction: The user interacts with the UI by performing actions like clicking buttons or entering text. These interactions trigger UIEvents.
  2. Event Handling: The UIRoute layer captures these events and forwards them to the ViewModel.
  3. State Management: The ViewModel processes the events, performs the necessary business logic, and updates the UIState.
  4. UI Rendering: The updated UIState is observed by the UIRoute, which passes the data to the stateless composables to render the updated UI.

By structuring our Compose-based UI in this layered manner, we ensure that each part of the UI is focused on a single responsibility, making the code easier to reason about and maintain. The clear separation between UI rendering, state management, and business logic also makes the UI more predictable, easier to test, and free from unintended side effects.

Please find more info here:

Commit Modular Feature

Showing 11 changed files with 238 additions and 34 deletions.

Commit Second Modular Feature

Showing 18 changed files with 365 additions and 5 deletions.
Droid is excellent at navigation

Compose, Hilt, and Navigation

Seamless Navigation with Jetpack Compose

In modern Android applications, navigation is a key component that drives user experience. With Jetpack Compose, Hilt, and the Navigation library, we can create a clean, modular, and highly maintainable navigation structure that makes it easy to move between different parts of the app. In this section, we’ll explore how we integrated Compose with Hilt and Navigation to create a fluid user experience, focusing on how we structured our navigation using a MainNavGraph and RootNavGraph, and the role of Screen objects in managing routes.

Overview of the Navigation Structure

The navigation in our app is built on two key concepts:

  1. MainNavGraph: This is the core navigation graph for the main sections of our app. It handles the navigation between different screens like the home screen, settings screen, and any other primary destinations.
  2. RootNavGraph: This serves as the top-level navigation graph that defines the overall structure of the app. It delegates to MainNavGraph or other navigation graphs based on the current route.
  3. Screen Objects: We use sealed classes or objects to define the different routes in our app. These screen objects make it easy to manage and reference routes throughout the app.

Setting Up the Main Navigation Graph

The MainNavGraph is where the primary navigation logic resides. It defines the available routes within the main sections of the app and maps them to their respective composable functions.

  • Composable Functions as Destinations: Each route in the MainNavGraph is associated with a composable function that defines the UI for that screen. For example, the HomeScreen route loads the CamUIRoute composable, while the SettingsScreen route loads the SettingsUiRoute composable.
  • Navigation Actions: We use the NavController to navigate between screens. For instance, when a user interacts with the UI, we trigger navigation actions that instruct the NavController to switch to a different route using navController.navigate(path).
  • Passing Parameters: The CamUIRoute, SettingsUiRoute, and other composables are designed to be stateless and side-effect-free. They typically accept three parameters: data (which contains the UI state), onEvent (a callback for handling user interactions), and navTo (for handling navigation actions). In the navigation setup, we pass the necessary data, onEvent, and navTo handlers via lambda functions, ensuring the composables remain decoupled from both the navigation logic and state management.
    Ease of Use: The simplicity of onEvent and navTo makes them very easy to work with. Using onEvent, any user interaction can be captured and processed through a single, consistent handler, while navTo makes navigation as simple as calling navTo(path) to move between screens. This approach ensures that the UI remains focused on presentation, leaving business logic and navigation to these clean, reusable callbacks.

Integrating Hilt with Navigation

Hilt plays a crucial role in managing dependencies within the navigation structure. By integrating Hilt with Compose and the Navigation library, we ensure that dependencies such as ViewModels are automatically injected wherever needed, without manual wiring.

  • ViewModel Injection: In our MainNavGraph, the ViewModels needed by each composable are injected using Hilt’s hiltViewModel() function. This allows the composables to receive their respective ViewModels automatically, making the code cleaner and reducing boilerplate.
  • State Management Across Screens: With Hilt managing the lifecycle of ViewModels, we can easily share state across different screens or scopes. For example, the SettingsViewModel might be shared across multiple settings-related screens, ensuring that user preferences are consistent and up-to-date.

Implementing the Root Navigation Graph

The RootNavGraph sits at the top of our navigation hierarchy. It defines the overall structure of the app and delegates navigation to more specific graphs like MainNavGraph.

  • Single Entry Point: The RootNavGraph is initialized with a start destination, which is typically the main screen or an onboarding screen. It contains a NavHost that handles navigation between high-level sections of the app.
  • Modular Structure: By separating navigation into different graphs (e.g., MainNavGraph, PhotoNavGraph), we maintain a modular structure where each part of the app can be developed and tested independently.

Handling Bottom Navigation

In our app, we implemented a bottom navigation bar that allows users to quickly switch between key sections like Home, Hold, and Settings. This is achieved using Compose’s NavigationBar component, which integrates seamlessly with our navigation structure.

  • Dynamic Navigation Bar: The bottom navigation bar dynamically updates based on the selected tab. Each tab corresponds to a route in the MainNavGraph, and clicking a tab triggers a navigation action via the NavController.
  • Badge Support: We also added support for badges in the navigation bar, indicating notifications or updates for specific sections. This enhances user engagement by providing visual cues about new content.

Commit Navigation

Showing 14 changed files with 489 additions and 51 deletions.
Done! 🥳 It’s Party Time 🪩

Bringing It All Together

The combination of Jetpack Compose, Hilt, and the Navigation library allowed us to create a clean, scalable, and maintainable navigation structure. Here’s how everything fits together:

  1. Main Entry Point: The RootNavGraph serves as the entry point, setting up the overall navigation structure.
  2. Navigation Management: The MainNavGraph handles the primary navigation within the app, associating routes with their respective composables.
  3. ViewModel Integration: Hilt seamlessly injects ViewModels into the composables, ensuring that each screen has access to the data and logic it needs.
  4. Stateless Composables: The composables themselves remain stateless and side-effect-free, taking data and onEvent as parameters, and focusing solely on rendering the UI.
  5. User Experience: The bottom navigation bar and other navigation components ensure that users can easily move between different parts of the app, creating a smooth and intuitive user experience.

By adhering to these principles, we built an app that is not only user-friendly but also easy to maintain and extend. The modular navigation structure, combined with the power of Compose and Hilt, sets the foundation for a robust and scalable Android application.

Please look for the next part of the series where we move all the common Gradle build files into one directory.

~Ash (SeaDragon 🐉)

--

--