База данных на Android — это чаще всего файл SQLite внутри приложения: движок SQLite встроен в систему, отдельный сервер не нужен, а данные лежат в приватной папке приложения. Работают с ним обычно через библиотеку Room из Android Jetpack — она превращает Kotlin-классы в таблицы и проверяет SQL-запросы при сборке. MySQL — серверная СУБД: приложение не подключается к ней напрямую, а обращается к своему серверу по HTTPS, и уже сервер читает и пишет MySQL.
Содержание
- Три слоя, которые часто путают
- Где хранить данные: локально или на сервере
- Почему MySQL не подключают из приложения напрямую
- Рабочий пример на SQLite: две таблицы и JOIN
- Та же схема на Room
- Миграции: почему нельзя «DROP и создать заново»
- Безопасность локальной базы
- Если не получилось
- Выводы
- Где применяется / связь с практикой
- FAQ
Ниже — схема «локально или на сервере», рабочий 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, но так не делают по нескольким причинам.
- Пароль к базе окажется в APK. Приложение можно распаковать и достать строку подключения. Дальше у злоумышленника прямой доступ ко всем данным, а не только к своим.
- Порт СУБД открыт в интернет. MySQL придется выставить наружу, это лишняя поверхность атаки.
- Нет прав на уровне пользователя. СУБД знает одну учетку приложения, а не конкретного человека. Проверку «этот заказ — твой» делает только сервер.
- Мобильная сеть нестабильна. Долгие соединения рвутся, а 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.



