Если ты разрабатываешь под Android и нужно сохранять информацию на телефоне, без базы данных не обойтись. В системе есть встроенная SQLite — бесплатно и надёжно, но есть минус: чтобы с ней работать, приходится писать SQL-запросы вручную, в коде разбирать объект Cursor и не забывать закрывать соединения. Я сам сталкивался с тем, что из-за такой возни появляются баги и тратится много времени.
Google предложил библиотеку Room. Она работает как прослойка над SQLite и использует подход ORM (Object-Relational Mapping). В этой статье мы на конкретном простом примере сравним, как выглядят операции добавления и чтения данных на чистом SQLite и на Room. Вы увидите, как Room избавляет от шаблонного кода и делает работу с базой данных понятной и безопасной.
Основная часть
1. Что такое SQLite и как с ним работают вручную
SQLite относится к реляционным БД и уже есть в каждой прошивке Android. Ей не нужен внешний сервер — все данные лежат в одном файле на устройстве. Для подключения к этой БД из кода используют специальный класс-помощник SQLiteOpenHelper. От него надо унаследовать свой класс и переопределить пару методов.
SQLiteOpenHelper — это специальный вспомогательный (helper) класс в Android, который упрощает работу с базой данных SQLite.
Его главные задачи:
Создание БД при первом обращении к ней. Вызывается метод onCreate().
Обновление БД при изменении версии. Вызывается onUpgrade(), например, когда ты добавил новый столбец в таблицу.
Благодаря 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 TABLEonUpgrade— удаляет старую таблицу и создаёт новую при смене версии БД.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 управляет всем автоматически!

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 во всех новых проектах
