Если ты разрабатываешь под Android и нужно сохранять информацию на телефоне, без базы данных не обойтись. В системе есть встроенная SQLite — бесплатно и надёжно, но есть минус: чтобы с ней работать, приходится писать SQL-запросы вручную, в коде разбирать объект Cursor и не забывать закрывать соединения. Я сам сталкивался с тем, что из-за такой возни появляются баги и тратится много времени.

Google предложил библиотеку Room. Она работает как прослойка над SQLite и использует подход ORM (Object-Relational Mapping). В этой статье мы на конкретном простом примере сравним, как выглядят операции добавления и чтения данных на чистом SQLite и на Room. Вы увидите, как Room избавляет от шаблонного кода и делает работу с базой данных понятной и безопасной.

Основная часть

1. Что такое SQLite и как с ним работают вручную

SQLite относится к реляционным БД и уже есть в каждой прошивке Android. Ей не нужен внешний сервер — все данные лежат в одном файле на устройстве. Для подключения к этой БД из кода используют специальный класс-помощник SQLiteOpenHelper. От него надо унаследовать свой класс и переопределить пару методов.

SQLiteOpenHelper — это специальный вспомогательный (helper) класс в Android, который упрощает работу с базой данных SQLite. 

Его главные задачи:

  1. Создание БД при первом обращении к ней. Вызывается метод onCreate().

  2. Обновление БД при изменении версии. Вызывается onUpgrade(), например, когда ты добавил новый столбец в таблицу.

  3. Благодаря SQLiteOpenHelper вам не нужно каждый раз проверять, существует ли файл базы. Вы просто наследуетесь и реализуете пару методов — остальное он делает сам.

То есть вместо того чтобы каждый раз вручную проверять, существует ли файл БД, и писать код для создания таблиц, ты наследуешься от SQLiteOpenHelper, реализуешь пару методов — остальное он берёт на себя.

Теперь перейдём к примеру. У нас будет таблица users с полями id, name, age. Напишем код для добавления пользователя и получения списка всех записей.

class UserDbHelper(context: Context) : SQLiteOpenHelper(context, "users.db", null, 1) {

    override fun onCreate(db: SQLiteDatabase) {
        db.execSQL("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, age INTEGER)")
    }

    override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
        db.execSQL("DROP TABLE IF EXISTS users")
        onCreate(db)
    }
    fun addUser(name: String, age: Int) {
        val db = writableDatabase
        db.execSQL("INSERT INTO users (name, age) VALUES ('$name', $age)")
        db.close()
    }

    fun getAllUsers(): List<User> {
        val db = readableDatabase
        val cursor = db.rawQuery("SELECT * FROM users", null)
        val users = mutableListOf<User>()
        while (cursor.moveToNext()) {
            val id = cursor.getInt(0)
            val name = cursor.getString(1)
            val age = cursor.getInt(2)
            users.add(User(id, name, age))
        }
        cursor.close()
        db.close()
        return users
    }
}

data class User(val id: Int, val name: String, val age: Int)

Посмотрите на код: видите, как много рутины? Каждый SQL-запрос — это строка, если опечататься, приложение упадёт только во время работы. Честно говоря, ещё меня бесило, что после каждого запроса надо писать cursor.close() и db.close() — забудешь, и память течёт, а чтобы достать данные, нужно знать индексы столбцов: getInt(0)getString(1) — а это неудобно и непонятно новичку.

На что обратить внимание:

  • SQL-запросы передаются как строки — легко опечататься, и ошибка проявится только во время выполнения.

  • После каждого запроса нужно вручную закрывать Cursor и SQLiteDatabase, иначе возможна утечка памяти.

  • Результат запроса — Cursor, из которого данные приходится доставать по индексу столбцов или по имени, что не всегда быстро.

Пояснение к коду:

  • Метод onCreate срабатывает, когда приложение впервые обращается к БД. Здесь мы создаём таблицу через CREATE TABLE

  • onUpgrade — удаляет старую таблицу и создаёт новую при смене версии БД.

  • addUser — вставляет нового пользователя в таблицу. В конце закрывает базу.

  • getAllUsers — читает всех пользователей: через Cursor перебирает строки, создаёт объекты User, затем закрывает Cursor и базу.

  • data class User — хранит данные одного пользователя.

2. Что такое ORM и как Room решает эти проблемы

Есть такая штука — ORM (объектно-реляционное отображение). Она даёт возможность обращаться к таблицам в базе как к обычным классам в коде. Забудьте про SQL-строчки: вы просто дёргаете методы типа insert() или getAll(), а библиотека под капотом сама сочиняет нужный запрос. Ну красота, не правда ли?

Room состоит из трёх главных компонентов:

  • Entity — класс, который описывает таблицу. Каждое поле класса — столбец в таблице.

  • DAO (Data Access Object) — интерфейс с методами для доступа к данным (вставка, выборка, удаление). Аннотации @Insert, @Query и другие указывают, что должен делать метод.

  • Database — абстрактный класс, наследующий RoomDatabase. Он связывает Entity и DAO, предоставляя экземпляр базы данных.

3. Тот же пример, но на Room

Подключение Room к проекту

В файле build.gradle.kts (модуль app) в раздел dependencies добавьте:

implementation("androidx.room:room-runtime:2.6.0")
ksp("androidx.room:room-compiler:2.6.0")    // ksp — это генератор кода


А в самом верху файла добавим плагин

Плагин — это как доп. программа, которая подключается к Android Studio и учит её понимать аннотации Room и генерировать по ним код.

plugins { 
    id("com.google.devtools.ksp") version "1.9.0-1.0.13"  
}                                 // версию подбери под свой Kotlin

Для понимания материала вам не обязательно это запускать — просто запомните, что Room сам напишет за вас много скучной работы.

Теперь напишем три простые части (можно в трёх файлах, но можно и в одном).

Шаг 1. Сущность (Entity) — нужно заменить наш data class User на такой:

import androidx.room.Entity
import androidx.room.PrimaryKey

@Entity(tableName = "users")     // говорим Room: эта таблица называется "users"
data class User(
    @PrimaryKey(autoGenerate = true) val id: Int = 0,    // ключ, автоинкремент
    val name: String,
    val age: Int
)

Объяснение:

Раньше мы сами писали CREATE TABLE. Теперь Room по аннотациям создаст таблицу. @PrimaryKey(autoGenerate = true) — аналог INTEGER PRIMARY KEY AUTOINCREMENT.

Шаг 2. DAO (Data Access Object)

Создадим интерфейс UserDao:

import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query


@Dao 
interface UserDao {
    @Insert   // Room сам напишет INSERT
    suspend fun insert(user: User)


    @Query("SELECT * FROM users")   // Room выполнит этот SQL
    suspend fun getAll(): List<User>
}

Объяснение:

  • Раньше мы писали addUser с execSQL("INSERT...") и getAllUsers с rawQuery и разбором Cursor. Теперь просто @Insert — Room сам составит правильный INSERT.

  • @Query выполняет ваш SQL и сразу возвращает List<User>

  • Ключевое слово suspend позволяет вызывать эти методы из корутин, не блокируя главный поток.

Шаг 3. База данных (Database) — абстрактный класс, который связывает всё вместе.

import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase

@Database(entities = [User::class], version = 1)   // указываем, какие сущности есть
abstract class AppDatabase : RoomDatabase() {
    abstract fun userDao(): UserDao   // абстрактный метод, Room его реализует

    companion object {
        @Volatile
        private var INSTANCE: AppDatabase? = null
        fun getInstance(context: Context): AppDatabase {
            return INSTANCE ?: synchronized(this) {
                val instance = Room.databaseBuilder(
                    context.applicationContext,
                    AppDatabase::class.java,
                    "users.db"          // имя файла базы данных
                ).build()
                INSTANCE = instance
                instance
            }
        }
    }
}

Объяснение:

  • Этот класс говорит Room: "У меня есть таблица User, версия 1".

  • abstract fun userDao(): UserDao — Room сам напишет реализацию, которая вернёт объект DAO.

  • companion object с getInstance — это синглтон (один экземпляр базы данных на всё приложение), стандартный паттерн.

Шаг 4. Как пользоваться в коде (в Activity или фрагменте).

Теперь вместо:

val helper = UserDbHelper(this)
helper.addUser("Анна", 25)
val users = helper.getAllUsers()

Пишем:

val db = AppDatabase.getInstance(applicationContext)
lifecycleScope.launch {
    db.userDao().insert(User(name = "Анна", age = 25))
    val users = db.userDao().getAll()
    // users уже List<User>, можно показывать в UI
}

Что важно:

  • lifecycleScope.launch нужен, потому что методы DAO помечены suspend (они не должны выполняться в главном потоке).

  • Никакого закрытия курсоров и баз данных — Room всё делает сам.

Повторюсь, никакого SQL, никаких Cursor, никакого закрытия соединений — Room управляет всем автоматически!

Схема 1. Как работают SQLite и Room
Схема 1. Как работают SQLite и Room

4. Сравнение: SQLite + OpenHelper против Room

Критерий

SQLite + SQLiteOpenHelper

Room

Написание SQL

Вручную, в виде строк

Только внутри аннотации @Query (или вообще не нужно для простых операций)

Проверка запросов

Только во время выполнения

На этапе компиляции (неправильный SQL не даст собрать проект)

Преобразование данных

Через Cursor и индексы столбцов

Автоматически из @Entity в объект

Управление ресурсами

Нужно закрывать db и cursor

Room делает это сам

Поддержка корутин / Flow

Нет, требуется ручная работа с потоками

Есть из коробки (suspend, Flow, LiveData)

Шаблонный код

Очень много

Минимум

Когда всё же стоит использовать чистый SQLite?

Несмотря на все плюсы Room, есть случаи, где прямой SQLite может быть предпочтительнее:

  • Вы подключаете уже готовую базу данных со сложными представлениями (views) или триггерами, которые Room не понимает.

  • Вам нужна поддержка кастомных агрегатных функций или расширений SQLite, не реализованных в Room.

  • Приложение очень маленькое, и вы не хотите подключать дополнительную библиотеку.

Заключение

Мы рассмотрели один и тот же пример работы с базой данных — добавление и чтение пользователей — на двух подходах: классический SQLite через SQLiteOpenHelper и современный Room. Результат очевиден: Room резко сокращает количество кода, устраняет целый класс ошибок (например, забыли закрыть курсор) и даёт приятные возможности вроде проверки SQL на этапе компиляции.

~ Рекомендую использовать Room во всех новых проектах