Room или SQLite? Как не писать SQL запросы вручную на Android
Если ты разрабатываешь под 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 во всех новых проектах