Хранение на устройстве: DataStore и Room
- Зачем это нужно
- Два хранилища под разные задачи
- DataStore: значения, которые надо запомнить
- Room: таблица из data class
- Запрос, который продолжает отвечать
- Ещё одна модель данных
- Миграции: почему приложение падает после обновления
- Где живёт база
- Типичные ошибки
- Слова главы
- Тренажёр: набрать вслепую
- На чемпионате
- Что дальше
- Углубиться
- Куда дальше: проверенные ресурсы
Зачем это нужно
За последние главы мы дважды упирались в одно и то же ограничение. Выбранная вкладка нижней панели должна пережить перезапуск — а remember и ViewModel его не переживают. При отсутствии сети экран остаётся пустым — потому что загруженные раньше товары нигде не сохранились.
Оба вопроса решает хранение на устройстве. Это последняя ступень в цепочке, которую мы выстраивали:
Перезапуск — это новый процесс. В памяти не остаётся ничего, и единственное, что можно прочитать, — то, что было записано на диск.
В конкурсных заданиях это отдельные критерии, причём формулируются они одинаково из года в год: «данные входа и активная сессия должны быть сохранены после перезапуска приложения», «необходимо сохранять состояние экрана», «реализуйте кэширование главной страницы».
Два хранилища под разные задачи
Android предлагает два основных способа, и выбор между ними прост, если задать правильный вопрос.
data class Product(val id: Int, val title: String, val price: Double)
class Prefs { private val map = mutableMapOf<String, String>()
fun put(k: String, v: String) { map[k] = v }
fun get(k: String) = map[k]
fun dump() = map.toString() }
class Table { private val rows = mutableListOf<Product>()
fun insertAll(items: List<Product>) { rows.clear(); rows.addAll(items) }
fun query(min: Double) = rows.filter { it.price >= min }
fun count() = rows.size }
fun main() {
val prefs = Prefs()
prefs.put("token", "abc123"); prefs.put("darkTheme", "true"); prefs.put("lastTab", "cart")
println("хранилище настроек: ${prefs.dump()}")
println(" ↑ пары ключ-значение, читаются целиком, искать по ним нельзя")
println()
val table = Table()
table.insertAll(List(500) { Product(it, "товар $it", it * 1.5) })
println("таблица: ${table.count()} записей")
println(" выборка «дороже 700»: ${table.query(700.0).size} записей")
println(" ↑ можно искать, сортировать, брать частями — не читая всё в память")
}
хранилище настроек: {token=abc123, darkTheme=true, lastTab=cart}
↑ пары ключ-значение, читаются целиком, искать по ним нельзя
таблица: 500 записей
выборка «дороже 700»: 33 записей
↑ можно искать, сортировать, брать частями — не читая всё в память
Вопрос для выбора: это одно значение или набор однотипных записей?
Токен, флаг тёмной темы, последняя открытая вкладка, признак «онбординг уже показан» — отдельные значения, их место в DataStore.
Пятьсот товаров, история заказов, содержимое корзины — записи, по которым нужно искать и сортировать. Это Room.
DataStore: значения, которые надо запомнить
private val Context.settings by preferencesDataStore(name = "settings")
class SettingsStorage(private val context: Context) {
private val tokenKey = stringPreferencesKey("auth_token")
private val lastTabKey = stringPreferencesKey("last_tab")
val token: Flow<String?> = context.settings.data.map { it[tokenKey] }
suspend fun saveToken(value: String) {
context.settings.edit { it[tokenKey] = value }
}
suspend fun clear() {
context.settings.edit { it.clear() }
}
}
Возвращаясь к висящему вопросу из главы про Scaffold: сохранение выбранной вкладки — это ровно две строки. Записали при переключении, прочитали при запуске.
DataStore пришёл на смену SharedPreferences, и разница не косметическая: старый способ умел читать с диска в главном потоке, из-за чего интерфейс подвисал в самый неожиданный момент. DataStore этого не позволяет вовсе — запись только suspend, чтение только потоком. Если встретите SharedPreferences в чужом коде, это не ошибка, но в новом проекте берите DataStore.
Room: таблица из data class
Room — надстройка над SQLite, которая избавляет от ручного написания запросов и, что важнее, проверяет их на этапе компиляции. Опечатка в SQL станет ошибкой сборки, а не падением у пользователя.
Три части: сущность, объект доступа и сама база.
@Entity(tableName = "products")
data class ProductEntity(
@PrimaryKey val id: Int,
val title: String,
val price: Double,
val updatedAt: Long,
)
@Dao
interface ProductDao {
@Query("SELECT * FROM products ORDER BY title")
fun observeAll(): Flow<List<ProductEntity>>
@Query("SELECT * FROM products WHERE title LIKE '%' || :query || '%'")
fun search(query: String): Flow<List<ProductEntity>>
@Upsert
suspend fun upsertAll(items: List<ProductEntity>)
@Query("DELETE FROM products")
suspend fun clear()
}
@Database(entities = [ProductEntity::class], version = 1)
abstract class AppDatabase : RoomDatabase() {
abstract fun productDao(): ProductDao
}
Запрос, который продолжает отвечать
Самая ценная особенность Room — запросы, возвращающие Flow. Проследим, что это даёт:
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
data class Note(val id: Int, val text: String)
class Dao {
private val state = MutableStateFlow(listOf(Note(1, "молоко")))
fun observeAll(): Flow<List<Note>> = state
suspend fun insert(n: Note) { state.value = state.value + n }
suspend fun delete(id: Int) { state.value = state.value.filterNot { it.id == id } }
}
fun main() = runBlocking {
val dao = Dao()
val job = launch {
dao.observeAll().collect { list -> println(" экран показывает: ${list.map { it.text }}") }
}
delay(30)
println("добавили запись:"); dao.insert(Note(2, "хлеб")); delay(30)
println("добавили ещё одну:"); dao.insert(Note(3, "сыр")); delay(30)
println("удалили первую:"); dao.delete(1); delay(30)
job.cancel()
println()
println("ни одного повторного запроса из экрана — база сама сообщила об изменениях")
}
экран показывает: [молоко]
добавили запись:
экран показывает: [молоко, хлеб]
добавили ещё одну:
экран показывает: [молоко, хлеб, сыр]
удалили первую:
экран показывает: [хлеб, сыр]
ни одного повторного запроса из экрана — база сама сообщила об изменениях
Практический смысл: экран корзины не нужно обновлять вручную после добавления товара с другого экрана. Он подписан на таблицу и получит новое содержимое сам. Это тот же Flow из главы про потоки, только источником стала база.
Ещё одна модель данных
В главе про слои у нас было три модели: DTO с сервера, доменная и модель экрана. Room добавляет четвёртую — Entity.
Соблазн обойтись одним классом велик, но у сущности базы свои требования: первичный ключ, только простые типы в колонках, аннотации Room. Тащить это в доменную модель — значит связать предметную область со способом хранения.
fun ProductEntity.toDomain() = Product(id = id, title = title, priceRub = price)
fun Product.toEntity(now: Long) = ProductEntity(id = id, title = title, price = priceRub, updatedAt = now)
Мапперы те же, что и для сети, и живут там же — в слое data.
Room умеет хранить только простые типы: числа, строки, логические значения. Для даты, списка или вложенного объекта нужен TypeConverter — функция, которая переводит тип в строку и обратно. Практичнее хранить дату числом (Long), а списки выносить в отдельную таблицу со связью: так по ним можно будет искать, а не только читать целиком.
Миграции: почему приложение падает после обновления
version = 1 в аннотации базы — не формальность. Как только вы добавите колонку и не тронете версию, приложение упадёт при запуске с сообщением о несовпадении схемы.
Два пути.
Во время разработки проще всего разрешить пересоздание базы:
Room.databaseBuilder(context, AppDatabase::class.java, "shop.db")
.fallbackToDestructiveMigration(dropAllTables = true)
.build()
Данные при этом теряются, но во время разработки их и не жалко.
В настоящем приложении так нельзя: пользователь потеряет корзину и историю. Там пишут миграцию — инструкцию, как превратить старую схему в новую:
val MIGRATION_1_2 = object : Migration(1, 2) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE products ADD COLUMN discount INTEGER NOT NULL DEFAULT 0")
}
}
На чемпионате в течение соревнования обычно хватает первого варианта. Но знать про второй нужно: вопрос про миграции — типовой на защите.
Где живёт база
AppDatabase создаётся один раз на всё приложение. Создание — дорогая операция, а два открытых экземпляра одного файла приводят к блокировкам.
object DatabaseProvider {
@Volatile private var instance: AppDatabase? = null
fun get(context: Context): AppDatabase =
instance ?: synchronized(this) {
instance ?: Room.databaseBuilder(
context.applicationContext,
AppDatabase::class.java,
"shop.db",
).build().also { instance = it }
}
}
Обратите внимание на applicationContext: база живёт столько же, сколько процесс, и держать в ней ссылку на конкретную Activity — та же утечка памяти, о которой говорилось в главе про ViewModel.
Эта конструкция — двойная проверка с блокировкой — типовая для одиночек. Когда объектов такого рода становится много, их создание передают библиотеке внедрения зависимостей. Пока их два-три, ручной вариант честнее и понятнее.
Типичные ошибки
Два экземпляра DataStore на один файл. Исключение при запуске. Делегат объявляют один раз на уровне файла, а не внутри класса.
Запрос базы в главном потоке. Room этого не позволяет и бросает исключение — и правильно делает. Все операции идут из корутин.
Схема поменялась, версия нет. Падение при запуске после обновления.
Context активности в базе. Утечка. Нужен applicationContext.
Пароль в DataStore открытым текстом. Хранить можно токен, но не пароль. Токен протухает и отзывается, пароль — нет.
Кэш без отметки времени. Данные лежат вечно, и приложение показывает прошлогодние цены. Поле вроде updatedAt стоит завести сразу.
Entity уехал во ViewModel. Слой представления получил модель со всеми аннотациями базы.
Слова главы
DataStore— хранилище пар ключ-значение; чтение потоком, записьsuspend.stringPreferencesKey— типизированный ключ настройки.Room— надстройка над SQLite с проверкой запросов при компиляции.@Entity— класс, ставший таблицей.@PrimaryKey— уникальный идентификатор записи.@Dao— интерфейс операций с таблицей.@Query— SQL, проверяемый на этапе сборки.@Upsert— вставить или обновить по ключу.- Миграция — инструкция превращения старой схемы в новую.
TypeConverter— перевод сложного типа в простой и обратно.
Тренажёр: набрать вслепую
Цель: скорость ≥ 100 зн/мин, точность ≥ 88%
private val Context.settings by preferencesDataStore(name = "settings")
На чемпионате
Модуль хранения весит немного, но требования в нём конкретные и повторяются из года в год:
- «Данные входа и активная сессия сохранены после перезапуска» — токен в
DataStore, проверка при запуске. - «Сохранять состояние последнего экрана» и «состояние Bottom Bar» — тоже
DataStore, по значению на каждое. - «Реализуйте кэширование главной страницы» —
Roomплюс отметка времени. - «Все медиа ресурсы должны кэшироваться» — этим занимается библиотека загрузки изображений, отдельного кода обычно не требует.
Практический совет: проверяйте не поворотом экрана, а полным закрытием приложения — смахиванием из списка недавних. Поворот переживает и ViewModel, поэтому он ничего не докажет. Эксперт проверяет именно перезапуск.
Что дальше
Данные теперь можно сохранить, но приложение пока не умеет ими пользоваться при отсутствии сети: репозиторий по-прежнему ходит только в сеть и отдаёт ошибку, если её нет.
Соединить два источника — задача следующего шага: репозиторий читает из базы потоком, а сеть только обновляет базу. Тогда экран показывает сохранённое сразу, а обновление приходит следом; в самолётном режиме он остаётся заполненным. Разбор этой связки — впереди, а всё нужное для неё у вас уже есть: поток из @Query, отметка времени в сущности и разделение слоёв.
Углубиться
@Relationи@Embedded— связи между таблицами: заказ и его позиции одним запросом.@Transaction— несколько операций как одна: либо все, либо ни одной.- Database Inspector в Android Studio — смотреть содержимое таблиц на работающем приложении.
- Proto DataStore — типизированное хранилище, когда настроек становится много.
- Шифрование: Android Keystore для по-настоящему чувствительных данных.
Сводная проверка по всей главе: 7 вопросов, 5:00 на всё, без подсказок по ходу. Вопросы показываются по одному, ответ изменить нельзя. Если время выйдет — экзамен завершится с тем, что успел ответить. Пересдавать можно сколько угодно раз.
Куда дальше: проверенные ресурсы
- Room — developer.android.com — сущности, DAO, запросы и миграции.
- DataStore — хранилище настроек и его отличия от
SharedPreferences. - Room и Flow — асинхронные запросы и подписки на таблицы.
- Миграции Room — как менять схему, не теряя данные.
Комментарии
Обсуждение живёт в GitHub Discussions и грузится с чужого домена — поэтому только по нажатию.