Development of modern mobile phones applications on the Android platform rarely do without local storage of information. When you create a project in Android Studioenvironment, you inevitably face the task of organizing a stable data layer. Connecting a database (DB) allows you to save user settings, cache content from the network, and enable the application to work offline.

The data warehouse integration process has evolved from the direct use of a low-level API SQLite to the use of powerful abstractions such as a library Room. This solution from Google greatly simplifies working with SQL queries, providing compile-time checks and convenient work with Java or Kotlin objects. Correct configuration of this component is the foundation for the stable operation of any serious project.

In this article we will analyze in detail the algorithm of actions necessary to implement a storage system in your project. We will look not only at the basic configuration of build files, but also at the nuances of creating entities, DAO interfaces, and database schema versioning.

Selecting an architecture and preparing a project

Before writing code, you need to decide on the tools. Although direct access to SQLiteDatabase is still possible, the de facto library Android Jetpack Room. It provides an abstraction layer on top of SQLite that allows you to write cleaner, more maintainable code. Using Room minimizes the amount of boilerplate code and reduces the likelihood of errors when writing SQL queries.

To get started, make sure that your project is synchronized with the latest versions of the repositories. The root file settings.gradle (or settings.gradle.kts) must indicate the repository google(). This is critical because the Android Jetpack libraries are distributed through this channel. Without this step, the compiler simply will not be able to find the necessary artifacts when building the project.

Next you need to open the application module assembly file, usually build.gradle (Module: app). This is where all the dependencies required to work with the database are written down. You will need to add several lines to the block dependenciesincluding the Room library itself, the annotation compiler and, if necessary, coroutine or RxJava support for asynchronous operations.

๐Ÿ’ก

Use version variables (ext {}) in the root build.gradle to centrally manage library versions and avoid conflicts between different project modules.

Please note that starting from version 2.5.0, the Room library requires the use of JDK 8 or higher. Check the compiler settings in the block android { compileOptions {.. } } and make sure that compatibility with Java 8 is set there. Ignoring this requirement will lead to compilation errors associated with lambda expressions and default interfaces.

Setting up Gradle dependencies

The process of connecting libraries in the Android ecosystem is strictly regulated by the system Gradle builds. Errors at this stage occur most often, especially among novice developers who confuse configurations implementation and kapt (or ksp). For Room to work correctly, you need to add three main dependencies: a runtime library, an annotation compiler and optional test modules.

If you use the Kotlin language, it is recommended to use a plugin KSP (Kotlin Symbol Processing) instead of the outdated one kapt. KSP works much faster and consumes less system resources when building a project. However, if your project is Legacy or you prefer the standard approach, the annotation processor kapt is still fully supported and works reliably in most scenarios.

Below is an example configuration for a file build.gradle using current versions of the libraries. Note the compiler line: it is required to generate the database code at compile time. Without it, annotations @Entity and @Dao will not be processed and the project will not be built.

dependencies {

def room_version = "2.6.0"

implementation "androidx.room:room-runtime:$room_version"

annotationProcessor "androidx.room:room-compiler:$room_version"

// For Kotlin, use kapt or ksp

kapt "androidx.room:room-compiler:$room_version"

// Optional: coroutine support

implementation "androidx.room:room-ktx:$room_version"

}

After making changes to the file assembly, be sure to click the button Sync Now in the top panel of Android Studio. This process may take some time depending on the speed of your Internet connection and the power of your computer. If you encounter Resolve dependencies errors, check your network connection and proxy settings if you are working on a corporate network.

๐Ÿ“Š What programming language do you use for Android development?
Kotlin
Java
C++
Other

Creating Database Entities

The central element of the Room architecture is the entity class, annotated as @Entity. Each such class corresponds to a table in a relational SQLite database. Class fields automatically become table columns unless otherwise specified. The default table name is the same as the class name, but it can be changed via the annotation parameter tableName.

Each entity must have a Primary Key, which uniquely identifies each record in the table. Annotation @PrimaryKeyis used for this. You can specify whether the key will be generated automatically (auto-increment) or assigned manually. It is also possible to create composite primary keys consisting of multiple fields, which is useful for many-to-many relationship tables.

Consider an example of creating an entity Userthat will store user data. In this class we will define ID, name and age. Pay attention to the use of annotation @ColumnInfo, which allows you to set a custom name for a column in the database, different from the name of the field in the class. This gives you the flexibility to refactor code without having to migrate the database.

@Entity(tableName = "users")

data class User(

@PrimaryKey(autoGenerate = true) val id: Int = 0,

@ColumnInfo(name = "user_name") val name: String,

@ColumnInfo(name = "user_age") val age: Int

)

Room supports most primitive types, as well as strings and byte arrays. Complex objects will require the use of type converters (TypeConverter), which convert the object to a string (for example, JSON format) before saving and back when reading.

What are ignored fields?

If you do not want to save a specific class field to the database, use the @Ignore annotation. Room will skip this field when creating the table schema, and it will only exist in the application's memory at runtime.

Developing a Data Access Object (DAO)

Data Access Object (DAO) is an interface or abstract class that defines methods for accessing a database. In Room, the DAO is the main place where you write SQL queries. Annotations allow you to describe data manipulation operations in a declarative way, making your code readable and easy to maintain. Methods in a DAO can return different data types depending on your needs. You can get a single object, a list of objects, the number of rows affected (int), or even reactive data streams ( @Insert, @Delete, @Update And @Query allow you to describe data manipulation operations in a declarative way, which makes the code readable and easy to maintain.

Methods in a DAO can return different types of data depending on your needs. You can get a single object, a list of objects, the number of rows affected (int), or even reactive data streams (LiveData, Flow). Using Flow is especially recommended as it allows you to automatically update the UI when data in the database changes without manual polling.

โš ๏ธ Attention: All database write operations (insert, update, delete) must be performed in a background thread. Executing them in the main thread (UI Thread) will result in an exception being thrown android.database.sqlite.SQLiteCantOpenDatabaseException or the interface freezing. Room forces this to be checked at the compilation level if the appropriate setting is enabled.

An example DAO interface for an entity User demonstrates basic operations. The method getAllUsers returns a data stream, which allows you to subscribe to changes in the list of users. The method insertUser is marked with a suffix suspend, which indicates its asynchronous nature when using Kotlin coroutines.

@Dao

interface UserDao {

@Query("SELECT * FROM users")

fun getAllUsers(): Flow>

@Query("SELECT * FROM users WHERE id = :userId")

suspend fun getUserById(userId: Int): User?

@Insert(onConflict = OnConflictStrategy.REPLACE)

suspend fun insertUser(user: User)

@Delete

suspend fun deleteUser(user: User)

}

The conflict resolution strategy (OnConflictStrategy) plays an important role when inserting data. You can choose to ignore the new entry, replace the existing one, or abort the transaction in error. Choosing the right strategy depends on the business logic of your application and how duplicate data should be processed.

Initializing the database through RoomDatabase

The final step in setting up is to create a database class that inherits from RoomDatabase. This class is the entry point for obtaining DAO instances and managing the database itself. It should be annotated as @Database, which specifies the list of entities and the schema version (version).

It is extremely important to implement this class as a Singleton. Creating multiple database instances for the same file on disk is not allowed and will result in access errors. To implement the Singleton pattern in Android, it is convenient to use a combination of lazy initialization (lazy) and locking for thread safety.

In an abstract method createDao you bind the DAO interface to the database. Room will automatically generate an implementation of this method at compile time. You can also define migrations here if your database structure changes from version to version. Failure to migrate when changing the schema will cause the application to crash on startup.

Component Purpose Annotation
@Entity Defines a table in the database Class data
@Dao Interface for SQL queries Interface
@Database Main access class Abstract class
@PrimaryKey Unique identifier Entity field
๐Ÿ’ก

The database instance should be created once during the lifetime of the application and passed through Dependency Injection (for example, Hilt or Koin) for easy access from any components.

@Database(entities = [User::class], version = 1)

abstract class AppDatabase : RoomDatabase() {

abstract fun userDao(): UserDao

companion object {

@Volatile

private var INSTANCE: AppDatabase? = null

fun getDatabase(context: Context): AppDatabase {

return INSTANCE ?: synchronized(this) {

val instance = Room.databaseBuilder(

context.applicationContext,

AppDatabase::class.java,

"app_database"

).build()

INSTANCE = instance

instance

}

}

}

}

Data migration and management versions

As the application develops, the database structure inevitably changes: new columns are added, field types are changed, or new tables are created. Room requires an explicit indication of the behavior strategy for such changes. If you simply increase the version number in the annotation @Database without providing a migration object, the application will crash the first time you run it after the update.

The migration object (Migration) describes the SQL commands needed to move the schema from one state to another. You must specify the start and target versions, and then run the appropriate ALTER TABLE queries. This allows you to save user data when updating the application, which is critical for the user experience.

In debug builds, it is often convenient to use the fallbackToDestructiveMigration()option. It allows you to delete the old database and create a new one from scratch when the schema version changes. This saves the developer time on writing migrations for test data, but it is strictly forbidden to use this method in production versions, as it will lead to the loss of all user data.

โš ๏ธ Attention: Interfaces and conditions for working with databases in Android can be updated with new versions of the Android SDK. Always check the annotation syntax and available methods in the official Google Developers documentation before implementing complex migration schemes in new versions of the project.

An example of adding a migration for version 1 to version 2, where we add a new column email to the users table. Please note that the SQL query must be written exactly as it would be executed in the SQLite console. An error in the SQL syntax will lead to a migration failure and, as a result, to a crash of the application.

โ˜‘๏ธ Checklist before releasing a database update

Done: 0 / 5
val MIGRATION_1_2 = object : Migration(1, 2) {

override fun migrate(database: SupportSQLiteDatabase) {

database.execSQL("ALTER TABLE users ADD COLUMN email TEXT NOT NULL DEFAULT ''")

}

}

// In the database builder:

.addMigrations(MIGRATION_1_2)

Is it possible to use Room with asynchrony without coroutines?

Yes, Room supports returning data in the form LiveData or using callback interfaces via annotation @Insert(onComplete =..) in older versions. However, coroutines (suspending functions) are the modern standard and the preferred way to work with asynchronous operations in Kotlin.

How to view the contents of the database while debugging?

In Android Studio, open the tab App Inspection (or Database Inspector in older versions). Select your process and database. You'll be able to view tables, edit records in real time, and run arbitrary SQL queries right from the IDE interface.

What to do if the database locks on write?

Most often this happens due to an attempt to perform a heavy operation on the main thread or lack of correct indexes. Make sure that all queries are marked as suspend or executed in Dispatchers.IO, and also check for indexes on fields that are frequently searched.

Does Room support data encryption?

The standard version of Room does not support encryption out of the box. To implement an encrypted database (SQLCipher), you need to use a special version of the library room-sqlcipher or configure the database provider via SupportSQLiteOpenHelper.