Перейти к основному содержимому
Мобилка32ГЛАВА 32Хранение на устройстве: DataStore и Roomпамять—дискпосле перезапуска

Хранение на устройстве: DataStore и Room

Прочитано0%
Квизы0/5
Тренажёрыне пройден0/1
Уровень 1 · Новичок

Зачем это нужно​

За последние главы мы дважды упирались в одно и то же ограничение. Выбранная вкладка нижней панели должна пережить перезапуск — а remember и ViewModel его не переживают. При отсутствии сети экран остаётся пустым — потому что загруженные раньше товары нигде не сохранились.

Оба вопроса решает хранение на устройстве. Это последняя ступень в цепочке, которую мы выстраивали:

ЧТО ДОКУДА ДОЖИВАЕТrememberрекомпозиция———rememberSaveableрекомпозицияповорот——ViewModelрекомпозицияповорот——DataStore / Roomрекомпозицияповоротсмерть процессаперезапуск«состояние сохраняется после перезапуска приложения» закрывается только нижней строкойперезапуск — новый процесс: в памяти не остаётся ничего
Каждый следующий способ переживает больше событий; перезапуск приложения переживает только запись на диск

Перезапуск — это новый процесс. В памяти не остаётся ничего, и единственное, что можно прочитать, — то, что было записано на диск.

В конкурсных заданиях это отдельные критерии, причём формулируются они одинаково из года в год: «данные входа и активная сессия должны быть сохранены после перезапуска приложения», «необходимо сохранять состояние экрана», «реализуйте кэширование главной страницы».

Два хранилища под разные задачи​

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таблицы записей· 500 товаров· история заказов· корзина· кэш каталогавыборка, сортировка, страницы — без чтения всеговопрос для выбора: это одно значение или набор однотипных записей?
DataStore хранит отдельные значения, Room — наборы однотипных записей с возможностью выборки

Вопрос для выбора: это одно значение или набор однотипных записей?

Токен, флаг тёмной темы, последняя открытая вкладка, признак «онбординг уже показан» — отдельные значения, их место в 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.

Отвечено вопросов: 0 из 2
Что выбрать для хранения токена авторизации?
Почему чтение из DataStore — это Flow, а не обычная функция?
Отвечено верно: 0 из 2

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("ни одного повторного запроса из экрана — база сама сообщила об изменениях") }
экран показывает: [молоко]
добавили запись:
экран показывает: [молоко, хлеб]
добавили ещё одну:
экран показывает: [молоко, хлеб, сыр]
удалили первую:
экран показывает: [хлеб, сыр]

ни одного повторного запроса из экрана — база сама сообщила об изменениях
ЗАПРОС, КОТОРЫЙ ПРОДОЛЖАЕТ ОТВЕЧАТЬRoom@Query → FlowпотокViewModelcollectЭкранзаписали в базу в одном месте — экран обновился сам, без повторного запросаэто тот самый Flow из главы про потоки, только источник — база, а не сеть
Запрос отдаёт поток: изменение таблицы в любом месте приложения доходит до экрана само

Практический смысл: экран корзины не нужно обновлять вручную после добавления товара с другого экрана. Он подписан на таблицу и получит новое содержимое сам. Это тот же 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")
}
}

На чемпионате в течение соревнования обычно хватает первого варианта. Но знать про второй нужно: вопрос про миграции — типовой на защите.

Отвечено вопросов: 0 из 2
Добавили колонку в @Entity и запустили приложение. Что произойдёт?
Зачем Entity отдельно от доменной модели?
Отвечено верно: 0 из 2

Где живёт база​

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, поэтому он ничего не докажет. Эксперт проверяет именно перезапуск.

Отвечено вопросов: 0 из 2
Как правильно проверить, что состояние переживает перезапуск?
Где хранить пароль пользователя?
Отвечено верно: 0 из 2

Что дальше​

Данные теперь можно сохранить, но приложение пока не умеет ими пользоваться при отсутствии сети: репозиторий по-прежнему ходит только в сеть и отдаёт ошибку, если её нет.

Соединить два источника — задача следующего шага: репозиторий читает из базы потоком, а сеть только обновляет базу. Тогда экран показывает сохранённое сразу, а обновление приходит следом; в самолётном режиме он остаётся заполненным. Разбор этой связки — впереди, а всё нужное для неё у вас уже есть: поток из @Query, отметка времени в сущности и разделение слоёв.

Отвечено вопросов: 0 из 1
Почему запрос @Query с Flow не помечают suspend?
Отвечено верно: 0 из 1

Углубиться​

  • @Relation и @Embedded — связи между таблицами: заказ и его позиции одним запросом.
  • @Transaction — несколько операций как одна: либо все, либо ни одной.
  • Database Inspector в Android Studio — смотреть содержимое таблиц на работающем приложении.
  • Proto DataStore — типизированное хранилище, когда настроек становится много.
  • Шифрование: Android Keystore для по-настоящему чувствительных данных.

Сводная проверка по всей главе: 7 вопросов, 5:00 на всё, без подсказок по ходу. Вопросы показываются по одному, ответ изменить нельзя. Если время выйдет — экзамен завершится с тем, что успел ответить. Пересдавать можно сколько угодно раз.

Куда дальше: проверенные ресурсы​

  • Room — developer.android.com — сущности, DAO, запросы и миграции.
  • DataStore — хранилище настроек и его отличия от SharedPreferences.
  • Room и Flow — асинхронные запросы и подписки на таблицы.
  • Миграции Room — как менять схему, не теряя данные.

Видео по теме

ВидеоThe FULL Beginner Guide for Room in Android | Local DatabasePhilipp Lackner
ВидеоPreferences DataStore in 10min (SharedPreferences deprecated)Philipp Lackner
ВидеоKotlin: Using Room Kotlin APIs - MAD SkillsAndroid Developers
ВидеоPreferences DataStore - MAD SkillsAndroid Developers
ВидеоHow to Save Complex Objects In a Room DBPhilipp Lackner

Комментарии

Обсуждение живёт в GitHub Discussions и грузится с чужого домена — поэтому только по нажатию.