Development of a mobile application is rarely complete without the need to store data directly on the userโ€™s device. Local storage allows you to save settings, cache content for offline use, and display information instantly without network delays. In the ecosystem, the de facto standard for such tasks is a relational database, which is built into the operating system by default. Android the de facto standard for such tasks is a relational database SQLite, which is built into the operating system by default.

The process of integrating storage into a project may seem difficult to beginners due to the abundance of boilerplate code and the nuances of working with threads. However, modern tools, such as Google's library, greatly simplify this process, making working with queries more secure and understandable. You don't need to write complex queries manually if you can use annotations and ready-made abstractions. Room from Google, greatly simplify this process by working with SQL-queries more secure and understandable. You don't have to write complex queries by hand when you can use annotations and ready-made abstractions.

In this article we will look at a step-by-step algorithm for connecting a database, starting from adding dependencies and ending with the first queries to the table. We will consider both the classic approach through SQLiteOpenHelper, and the more modern one, based on Room Persistence Library.

Preparing the project and adding dependencies

The first step before starting any serious work with data is to correctly configure the assembly file build.gradle (Module: app). Without connecting the appropriate libraries, the compiler simply will not understand your commands for working with Room or SQLite. Open the configuration file and find the block dependencies.

To use the Room library, you need to add several artifacts: a runtime component, an annotation compiler and, optionally, Kotlin support. This will ensure that code is generated during compilation and requests are checked for errors. SQL- requests for errors.

dependencies {

def room_version ="2.5.2"

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

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

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

}

After making changes to the assembly file, be sure to click the button Sync Now at the top of the editor window. This action will download the necessary libraries from the repository Maven and prepare the project to work with new classes. If you use Kotlin, make sure that the plugin kapt (Kotlin Annotation Processing Tool) is activated in the main file build.gradle of the project.

๐Ÿ’ก

Use a variable for the Room version (room_version) to easily update the library in all dependencies at once, without manually searching for them in code.

Creating an Entity for a table

Any database consists of tables, and in object-oriented programming tables are mapped to classes. In Room terminology, such classes are called Entity. Each instance of a class represents one row in a table, and the fields of the class correspond to the columns.

To create an entity, you need to annotate the class with a keyword @Entity and specify the table name. Inside the class there must be a field marked as @PrimaryKey, which will serve as a unique identifier for the record. Typically this is a field of type Int or Long with auto-increment.

@Entity(tableName ="users")

data class User(

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

@ColumnInfo(name ="first_name") val firstName: String,

@ColumnInfo(name ="last_name") val lastName: String,

val age: Int

)

Pay attention to the annotation @ColumnInfo. It allows you to set column names in the database independently of field names in Kotlin code. This gives flexibility when refactoring code: you can rename a variable in a class without breaking the existing structure of the SQLitefile.

Why data class?

Using the data class in Kotlin automatically generates equals, hashCode and toString methods, which is critical for Room to work correctly when comparing objects and logging.

Development of a data access interface (DAO)

The next component of the architecture is DAO (Data Access Object). It is an interface or abstract class that defines methods for interacting with the database. This is where you describe which CRUD (Create, Read, Update, Delete) operations will be available to your application.

Each method in a DAO should be annotated with an appropriate query: @Insert for adding, @Delete for deleting, @Update for modifying, and @Query to sample data. The Room compiler will analyze the code inside the annotations and check its validity at the project assembly stage. It is important to note that methods can return not only specific objects, but also data streams ( SQL-code inside the annotations and will check its validity at the project assembly stage.

@Dao

interface UserDao {

@Query("SELECT * FROM users")

fun getAll: List

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

fun getUserById(userId: Int): User?

@Insert

fun insertAll(vararg users: User)

@Delete

fun delete(user: User)

}

It is important to note that methods can return not only concrete objects, but also data streams (Flow, LiveData). This allows you to implement a reactive approach, where the application interface is automatically updated when data in the database changes, without the need to call UI update methods.

โ˜‘๏ธ DAO check before assembly

Completed: 0 / 4

Database initialization via RoomDatabase

The central element of the entire system is an abstract class that inherits from RoomDatabase. This class acts as an entry point and manages the connection to the physical database file on the device's disk. You cannot create an instance of this class directly; for this, the pattern is used Singleton.

Inside the descendant class, you must specify a list of all DAOs (via annotation @Dao not necessary, the list is passed to the abstract method) and the schema version. Method onCreate is called at the first launch, when the database file does not yet exist, and onUpgrade when the schema version is changed.

@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

}

return instance

}

}

}

The use of @Volatile and synchronized in the implementation Singleton guarantees thread safety. This prevents the creation of multiple database instances at the same time, which could lead to data desync and file system access errors.

๐Ÿ’ก

Never create a RoomDatabase instance in the Main Thread, always use a separate thread or coroutine, otherwise the application will throw an exception.

Working with the database in asynchronous mode

Database operations are blocking, meaning they take time to read or write from disk. If you execute such a request in the main thread of the application, the interface will freeze until the operation is completed, which will lead to an error NetworkOnMainThreadException or simply a bad user experience (ANR).

To solve this problem, coroutines ( Android coroutines are used (Coroutinesare used) to solve this problem. The library room-ktx provides extensions that allow you to run database transactions within the coroutine scope. This makes the code clean, readable and safe for the UI.

lifecycleScope.launch {

val db = AppDatabase.getDatabase(applicationContext)

val dao = db.userDao

// Write data

dao.insertAll(User(firstName ="Ivan", lastName ="Ivanov", age = 25))

// Read data

val users = withContext(Dispatchers.IO) {

dao.getAll

}

// Update the UI with the received data

}

When used LiveData or Flow as a return type in DAO, Room will take over thread management itself. You donโ€™t have to manually switch contexts; the library itself will subscribe to changes and deliver the latest data to the main thread to update the interface.

๐Ÿ“Š Which approach to asynchrony do you prefer?
Coroutines
RxJava
LiveData
Regular threads
I donโ€™t know

Migration and updating the database schema

As the application develops, the data structure inevitably changes: new fields are added, old tables are deleted, or data types change. If you simply increase the version number in the annotation @Database, the application crashes when launched for users who already have an old version of the database installed.

To avoid data loss and failures, it is necessary to implement a migration strategy. In Room, this is done through an object Migration, where you write commands to convert the old scheme to the new one. These commands are executed automatically when the application is updated. SQL-commands for converting an old schema to a new one. These commands run automatically when the application is updated.

Version Action SQL Command
1 -> 2 Add email column ALTER TABLE users ADD COLUMN email TEXT
2 -> 3 Rename table ALTER TABLE users RENAME TO clients
3 -> 4 Create a new table CREATE TABLE logs (...)

If the data in the old version is not critical (for example, it is just a cache), you can use the strategy fallbackToDestructiveMigration. It will simply delete the old database and create a new one from scratch when upgrading the version. This is easier to implement, but the user will lose all his local data.

โš ๏ธ Attention: Always test the migration on a real device with the old version of the APK installed. The emulator may hide problems related to file access rights or the actual amount of memory.

Frequent errors and debugging

Even experienced developers encounter problems when working with SQLite. One of the most common mistakes is a data type mismatch between the Java/Kotlin code and the database schema. For example, an attempt to save String to a field defined as INTEGERwill throw an exception.

The "Database is locked" problem is also common. This occurs when multiple processes or threads try to access a record at the same time and one of them does not release the transaction. Room tries to minimize such situations, but when manually managing transactions (@Transaction), you need to be careful.

It is convenient to use the tool for debugging App Inspection in Android Studio. It allows you to view the contents of the database in real time, perform arbitrary SQLqueries and even edit data directly in the IDE interface, which significantly speeds up the search for errors.

How to reset the database during development?

The fastest way is to remove the application from the emulator device or use the `adb shell pm clear command com.your.package`, which will completely clear the application data.

Do you need to install SQLite separately on your computer?

No, for Android development you do not need to install a separate SQLite server. The Room library and Android emulator contain all the necessary components. However, to easily view `.db` files on your computer, you can download a free client, for example, DB Browser for SQLite.

Can I use Room with Java?

Yes, the Room library fully supports the Java language. The annotation syntax remains the same, but instead of coroutines for asynchrony in Java, `Executor` or `LiveData` with observation are more often used.

Where is the database file physically stored?

The file is located in the internal memory of the device along the path `/data/data/your_package/databases/`. Access to this directory on a real device without root access is prohibited, but through Android Studio (Device File Explorer) you can access the database files of the application running on the emulator or device being debugged.

โš ๏ธ Attention: Android Studio interfaces and library versions may be updated. If you don't find a menu or class, check the official Google documentation, as menu paths or artifact names may have changed.