База данных на Android: SQLite, Room и где здесь MySQL

База данных на Android: SQLite, Room и где здесь MySQL Полезное

База данных на Android — это чаще всего файл SQLite внутри приложения: движок SQLite встроен в систему, отдельный сервер не нужен, а данные лежат в приватной папке приложения. Работают с ним обычно через библиотеку Room из Android Jetpack — она превращает Kotlin-классы в таблицы и проверяет SQL-запросы при сборке. MySQL — серверная СУБД: приложение не подключается к ней напрямую, а обращается к своему серверу по HTTPS, и уже сервер читает и пишет MySQL.

Ниже — схема «локально или на сервере», рабочий SQL на SQLite, та же схема на Room, миграции без потери данных и типовые ошибки.

Три слоя, которые часто путают

  • SQLite — сам движок и формат файла базы. Встроен в Android, версия зависит от версии системы.
  • SQLiteOpenHelper — старый низкоуровневый API Android: SQL строками, курсоры, ручной onUpgrade. Работает, но много шаблонного кода.
  • Room — надстройка над SQLite: сущности (@Entity), DAO с запросами (@Dao, @Query), класс базы (@Database) и миграции. Внутри все равно SQLite.
  • MySQL — отдельный сервер, к которому подключаются по сети. На телефоне он не работает.

Где хранить данные: локально или на сервере

Задача Что взять Почему
Заметки, черновики, история, офлайн-кеш SQLite через Room Работает без сети, данные только этого устройства
Общие данные пользователей: заказы, чаты, каталог Сервер + MySQL/PostgreSQL, приложение ходит через API Данные видят все клиенты, права проверяет сервер
Настройки: тема, флажки, токен сессии DataStore Это пары ключ-значение, таблицы не нужны
И офлайн, и общие данные Room как кеш + синхронизация с API Экран читает локальную базу, сеть обновляет ее

Граница упрощенная: в реальных приложениях часто сочетают все три слоя. Но стартовое правило такое: локальное — в Room, общее — на сервере за API.

Почему MySQL не подключают из приложения напрямую

Технически JDBC-драйвер можно попытаться добавить в APK, но так не делают по нескольким причинам.

  1. Пароль к базе окажется в APK. Приложение можно распаковать и достать строку подключения. Дальше у злоумышленника прямой доступ ко всем данным, а не только к своим.
  2. Порт СУБД открыт в интернет. MySQL придется выставить наружу, это лишняя поверхность атаки.
  3. Нет прав на уровне пользователя. СУБД знает одну учетку приложения, а не конкретного человека. Проверку «этот заказ — твой» делает только сервер.
  4. Мобильная сеть нестабильна. Долгие соединения рвутся, а HTTP-запросы с таймаутами и повторами переживают смену Wi-Fi на мобильную сеть.

Правильная схема: приложение -> HTTPS-запрос с токеном -> сервер (аутентификация, проверка прав, параметризованный SQL) -> MySQL. На стороне приложения это клиент вроде Retrofit или Ktor, а локальная копия нужных данных лежит в Room.

Рабочий пример на SQLite: две таблицы и JOIN

Сначала сам SQL — его можно прогнать в консольном sqlite3 на компьютере, тот же движок работает внутри Android. Схема из исходной версии статьи: работодатели и сотрудники, у сотрудника внешний ключ на работодателя.

CREATE TABLE employer (
    id   INTEGER PRIMARY KEY AUTOINCREMENT,
    name TEXT NOT NULL
);
CREATE TABLE employee (
    id          INTEGER PRIMARY KEY AUTOINCREMENT,
    name        TEXT NOT NULL,
    employer_id INTEGER NOT NULL
        REFERENCES employer(id) ON DELETE CASCADE
);
CREATE INDEX index_employee_employer_id ON employee(employer_id);

INSERT INTO employer (name) VALUES ('Otus'), ('Acme');
INSERT INTO employee (name, employer_id) VALUES
    ('Anna', 1), ('Boris', 1), ('Vera', 2);

SELECT e.name AS employee, r.name AS employer
FROM employee e
JOIN employer r ON r.id = e.employer_id
WHERE r.name = 'Otus'
ORDER BY e.name;

Сохраните как schema.sql и выполните sqlite3 staff.db < schema.sql. Результат (проверено в sqlite3 3.51.0):

Anna|Otus
Boris|Otus

Индекс на employer_id добавлен не случайно: SQLite не создает индекс под внешний ключ сам, а без него JOIN и каскадное удаление просматривают всю таблицу сотрудников.

Ловушка: внешние ключи в SQLite выключены по умолчанию

Неверное ожидание: раз в схеме есть REFERENCES, сотрудника с несуществующим работодателем вставить нельзя. Проверка на той же базе:

PRAGMA foreign_keys;
INSERT INTO employee (name, employer_id) VALUES ('Ghost', 99);
SELECT count(*) FROM employee WHERE employer_id = 99;
0
1

PRAGMA foreign_keys вернул 0 — проверка выключена, и «сирота» с employer_id = 99 спокойно записался. Исправление — включать проверку для каждого соединения:

PRAGMA foreign_keys = ON;
DELETE FROM employee WHERE employer_id = 99;
INSERT INTO employee (name, employer_id) VALUES ('Ghost', 99);
Runtime error near line 3: FOREIGN KEY constraint failed (19)

Теперь вставка отклонена. И каскад заработал: удаляем работодателя — уходят его сотрудники.

PRAGMA foreign_keys = ON;
DELETE FROM employer WHERE name = 'Acme';
SELECT name FROM employee ORDER BY name;
Anna
Boris

В SQLiteOpenHelper для этого вызывают db.setForeignKeyConstraintsEnabled(true) в onConfigure(). Room при наличии foreignKeys в сущностях включает проверку сам.

Та же схема на Room

Room генерирует код при сборке через KSP. Пример ниже — на ветке Room 2.x (2.8.5, сентябрь 2026), ее API совпадает с большинством туториалов и рабочих проектов. Зависимости в build.gradle.kts модуля (актуальный номер версии берите со страницы релизов AndroidX):

plugins {
    id("com.google.devtools.ksp")
}

dependencies {
    val roomVersion = "2.8.5"
    implementation("androidx.room:room-runtime:$roomVersion")
    implementation("androidx.room:room-ktx:$roomVersion")
    ksp("androidx.room:room-compiler:$roomVersion")
}

Сущности — это таблицы. Внешний ключ и индекс описаны аннотациями, как в SQL выше:

@Entity(tableName = "employer")
data class Employer(
    @PrimaryKey(autoGenerate = true) val id: Long = 0,
    val name: String
)

@Entity(
    tableName = "employee",
    foreignKeys = [ForeignKey(
        entity = Employer::class,
        parentColumns = ["id"],
        childColumns = ["employer_id"],
        onDelete = ForeignKey.CASCADE
    )],
    indices = [Index("employer_id")]
)
data class Employee(
    @PrimaryKey(autoGenerate = true) val id: Long = 0,
    val name: String,
    @ColumnInfo(name = "employer_id") val employerId: Long,
    val email: String? = null
)

DAO — интерфейс с запросами. SQL в @Query Room проверяет при сборке: опечатка в имени столбца даст ошибку компиляции, а не падение у пользователя.

data class EmployeeRow(val employee: String, val employer: String)

@Dao
interface StaffDao {
    @Insert
    suspend fun insertEmployer(employer: Employer): Long

    @Insert
    suspend fun insertEmployee(employee: Employee): Long

    @Query(
        "SELECT e.name AS employee, r.name AS employer " +
        "FROM employee e JOIN employer r ON r.id = e.employer_id " +
        "WHERE r.name = :employer ORDER BY e.name"
    )
    fun observeByEmployer(employer: String): Flow<List<EmployeeRow>>
}

Параметр :employer Room передает как привязанный аргумент, а не склеивает в строку — это защищает от SQL-инъекций. Flow сам присылает новый список, когда таблица меняется, поэтому экран обновляется без ручного перечитывания.

Класс базы и миграция с версии 1 на 2 (добавили столбец email):

@Database(entities = [Employer::class, Employee::class], version = 2)
abstract class AppDatabase : RoomDatabase() {
    abstract fun staffDao(): StaffDao
}

val MIGRATION_1_2 = object : Migration(1, 2) {
    override fun migrate(db: SupportSQLiteDatabase) {
        db.execSQL("ALTER TABLE employee ADD COLUMN email TEXT")
    }
}

fun buildDatabase(context: Context): AppDatabase =
    Room.databaseBuilder(context, AppDatabase::class.java, "staff.db")
        .addMigrations(MIGRATION_1_2)
        .build()

С июля 2026 года стабилен и Room 3.0 — это отдельные артефакты androidx.room3:room3-* и пакет androidx.room3. В нем только Kotlin и KSP, методы DAO обязаны быть suspend или возвращать Flow, а миграция пишется как override suspend fun migrate(connection: SQLiteConnection) вместо SupportSQLiteDatabase. Схема сущностей и SQL в @Query остаются теми же, поэтому пример выше переносится почти без изменений.

Базу создают один раз на приложение (синглтон через DI или Application), а вызывают из корутины, например во ViewModel:

viewModelScope.launch {
    val otusId = dao.insertEmployer(Employer(name = "Otus"))
    dao.insertEmployee(Employee(name = "Anna", employerId = otusId))
}

Миграции: почему нельзя «DROP и создать заново»

В старых туториалах, включая прежнюю версию этой статьи, onUpgrade удаляет таблицу и создает ее снова. Что это значит для пользователя, видно на копии базы с двумя сотрудниками:

PRAGMA user_version = 1;
DROP TABLE IF EXISTS employee;
CREATE TABLE employee (
    id          INTEGER PRIMARY KEY AUTOINCREMENT,
    name        TEXT NOT NULL,
    employer_id INTEGER NOT NULL REFERENCES employer(id) ON DELETE CASCADE,
    email       TEXT
);
PRAGMA user_version = 2;
SELECT count(*) FROM employee;
0

После обновления приложения сотрудников ноль — данные пользователя потеряны. Исправление — изменить схему, сохранив строки, в одной транзакции:

PRAGMA user_version = 1;
BEGIN;
ALTER TABLE employee ADD COLUMN email TEXT;
PRAGMA user_version = 2;
COMMIT;
PRAGMA user_version;
SELECT name, email FROM employee ORDER BY name;
2
Anna|
Boris|

Строки на месте, новый столбец пустой. user_version — это номер схемы, который хранит сам файл SQLite; по нему SQLiteOpenHelper и Room понимают, какую миграцию запускать. ALTER TABLE ... ADD COLUMN покрывает простые случаи; переименование с изменением типа или ограничений в SQLite обычно делают через новую таблицу, копирование и замену.

Безопасность локальной базы

  • Файл базы лежит в приватном каталоге приложения, другие приложения его не читают. Но он не зашифрован: на устройстве с root или из резервной копии его можно открыть.
  • Пароли и токены в открытом виде в базу не кладите. Ключи шифрования хранит Android Keystore, а чувствительные таблицы шифруют отдельной библиотекой (например, SQLCipher).
  • Автоматический бэкап Android может включить файл базы в копию. Что попадает в бэкап, задают правила в манифесте: dataExtractionRules для Android 12 и новее, fullBackupContent для Android 11 и старше — при minSdk ниже 31 нужны оба.
  • Данные с сервера в локальной базе — только кеш: права все равно проверяет сервер.

Если не получилось

Симптом Причина Что делать
IllegalStateException про доступ к базе из главного потока Запрос Room вызван на UI-потоке Сделать метод DAO suspend или вернуть Flow, вызывать из корутины
Падение при запуске после обновления: миграция не найдена Подняли version, но не добавили Migration Написать миграцию и передать в addMigrations
После обновления пропали данные Разрушающая миграция (DROP или fallbackToDestructiveMigration(...)) Заменить на ALTER TABLE или копирование в новую таблицу
FOREIGN KEY constraint failed Вставка ссылается на несуществующую строку или порядок вставок нарушен Сначала вставить родителя, взять его id
Не видно, что лежит в базе — Android Studio: App Inspection -> Database Inspector на запущенном приложении

Выводы

  • База данных на Android по умолчанию — это SQLite: встроенный движок и файл в приватной папке приложения, без сервера.
  • Room — рекомендуемый способ работы с SQLite: сущности, DAO с проверкой SQL при сборке, Flow и явные миграции.
  • В SQLite внешние ключи работают только после PRAGMA foreign_keys = ON; индекс под внешний ключ нужно создать самому.
  • Миграция через DROP стирает данные пользователя; схему меняют через ALTER TABLE или перенос в новую таблицу.
  • MySQL живет на сервере: приложение ходит к нему через HTTPS API, пароль от СУБД в APK не кладут.

Где применяется / связь с практикой

Освойте тему на практике

Локальная база — часть почти любого приложения: офлайн-режим, кеш ленты, черновики, история. Room, корутины, ViewModel и работа с сетевым API разбираются в курсе «Android-разработчик. Базовый уровень»: там приложение собирается целиком, от экранов до хранения данных. Чтобы оценить формат, можно начать с открытых уроков Otus.

Смежная тема: знакомство с SQLite и ее особенности как СУБД.

FAQ

Можно ли установить MySQL прямо на Android-телефон?
Для приложения, которое публикуется в магазине, — нет смысла: пользователю не поставить сервер СУБД. Для локального хранения есть встроенный SQLite.

Room или SQLiteOpenHelper для нового проекта?
Room: меньше шаблонного кода, проверка запросов при сборке и готовая поддержка корутин. SQLiteOpenHelper встречается в старом коде, его полезно уметь читать.

Где физически лежит файл базы?
В приватном каталоге приложения, в папке databases. Путь можно получить через context.getDatabasePath("staff.db"), а содержимое смотреть в Database Inspector.

OTUS Журнал
Скидка 5% 14-20 сентября на курсы (popup)