Перейти к содержимому
Все записи

Jetpack DataStore в 2026 году: переход с SharedPreferences без потери ни одной настройки

Практическое руководство по переходу с SharedPreferences на Jetpack DataStore на Android — асинхронные ловушки, путь миграции, сохраняющий существующие значения, и как это протестировать.

MFKAPPS 4 мин чтения

SharedPreferences всё ещё работает. Но при первом обращении он делает синхронное чтение с диска, не даёт типовой безопасности на этапе компиляции и не предлагает способа наблюдать за изменением значения без ручной настройки листенера. Jetpack DataStore решает все три проблемы, и в 2026 году это стандартный выбор, который Google рекомендует для всего, что выходит за рамки одного одноразового флага. Большинство миграций застревают не на изучении нового API — а на переносе существующих пользователей без сброса их настроек при следующем обновлении.

Что на самом деле не так с SharedPreferences

getSharedPreferences().getString(...) выглядит синхронным и дешёвым, но при первом обращении в процессе может заблокировать вызывающий поток реальным чтением файла — часто это главный поток во время запуска приложения. Встроенного способа собирать изменения в виде потока нет; приходится регистрировать OnSharedPreferenceChangeListener и самостоятельно управлять его жизненным циклом. И каждое чтение типизировано строками: опечатка в имени ключа компилируется без проблем и молча падает во время выполнения, возвращая значение по умолчанию.

Ничего из этого не является экзотическим краевым случаем. Это обычное поведение SharedPreferences, и Preferences DataStore был создан специально для того, чтобы исправить это, не сильно меняя ментальную модель.

Preferences DataStore: та же форма, но безопасно

Экземпляр Preferences DataStore создаётся один раз, обычно как extension-свойство над Context:

val Context.settingsDataStore: DataStore<Preferences> by preferencesDataStore(
    name = "settings"
)

Чтения возвращаются в виде Flow, так что уведомления об изменениях вы получаете бесплатно, вместо того чтобы строить их самому:

val TIMER_MINUTES = intPreferencesKey("timer_minutes")

val timerMinutes: Flow<Int> = context.settingsDataStore.data
    .map { prefs -> prefs[TIMER_MINUTES] ?: 25 }

Записи проходят через suspend-функцию, поэтому их никогда не вызовут случайно из главного потока:

suspend fun setTimerMinutes(context: Context, minutes: Int) {
    context.settingsDataStore.edit { prefs ->
        prefs[TIMER_MINUTES] = minutes
    }
}

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

Часть, которую все пропускают: миграция существующих значений

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

val Context.settingsDataStore: DataStore<Preferences> by preferencesDataStore(
    name = "settings",
    produceMigrations = { context ->
        listOf(SharedPreferencesMigration(context, "settings_prefs"))
    }
)

Миграция выполняется один раз — при первом открытии DataStore после выхода этого кода: она читает старый файл SharedPreferences, копирует каждую запись в новое хранилище Preferences и оставляет старый файл на месте (не удаляет его — это отдельное решение, которое стоит принять самостоятельно, когда вы убедитесь, что миграция прошла везде). Если тип какого-то ключа не переносится чисто — например, Set<String>, который теперь нужен как List — вы либо отфильтровываете его в shouldRunMigration миграции, либо явно преобразуете, вместо того чтобы допустить исключение из-за несовпадения типов при чтении.

Ошибиться с именем старого файла настроек — частая ошибка, если он был создан с кастомным именем, а не именем по умолчанию для пакета, — приводит к тому, что миграции нечего копировать, и каждый пользователь сбрасывается к настройкам по умолчанию при обновлении. Перед выпуском проверьте точное имя по уже существующим в коде вызовам context.getSharedPreferences("name", MODE_PRIVATE), а не угадывайте его.

Когда Preferences DataStore недостаточно

Preferences DataStore хранит плоское, типизированное строками пространство ключей — он решил проблемы с потоками и наблюдаемостью, но не с типовой безопасностью. Proto DataStore заменяет мешок ключ-значение схемой, которую вы определяете один раз в файле .proto, так что объект настроек либо соответствует схеме, либо не компилируется — никакого null из-за опечатки в ключе во время выполнения. Дополнительная настройка окупается, как только экран настроек вырастает за пределы горстки флагов, или как только вложенные объекты (настройка уведомлений со своими полями звука, вибрации и тихих часов) начинают появляться в пространстве ключей как три-четыре отдельно именованных ключа вместо одного структурированного значения. В Mintly настройки звука, вибрации и автоперезапуска таймера были перенесены в небольшое сообщение Proto DataStore именно по этой причине — как только связанные настройки нужно читать и записывать вместе, плоское хранилище ключ-значение начинает работать против вас.

Тестировать миграцию, а не только новый API

Новый код чтения/записи достаточно прост, чтобы захотелось пропустить тестирование. Миграция — нет: это единственная часть этого изменения, которая выполняется ровно один раз, молча, на реальных пользовательских данных, без шанса повторить попытку, если что-то пошло не так. Минимальный тест создаёт настоящий файл SharedPreferences, открывает DataStore с подключённой миграцией и проверяет, что значения выжили:

@Test
fun migration_preservesExistingTimerSetting() = runTest {
    val prefs = context.getSharedPreferences("settings_prefs", Context.MODE_PRIVATE)
    prefs.edit().putInt("timer_minutes", 45).commit()

    val dataStore = PreferenceDataStoreFactory.create(
        migrations = listOf(SharedPreferencesMigration(context, "settings_prefs")),
        produceFile = { File(context.filesDir, "test_settings.preferences_pb") }
    )

    val minutes = dataStore.data.first()[intPreferencesKey("timer_minutes")]
    assertEquals(45, minutes)
}

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

Чек-лист

Перед слиянием миграции с SharedPreferences на DataStore: имя старого файла настроек подтверждено по реальному вызову getSharedPreferences() в коде, SharedPreferencesMigration подключена для каждого используемого сейчас ключа, тест создаёт старый формат и проверяет, что каждое значение выживает, а старый файл остаётся нетронутым, пока телеметрия не подтвердит, что миграция прошла на установленной базе. Это немного дополнительной аккуратности ради изменения, которое пользователи вообще не должны заметить — а это как раз и есть цель миграции настроек.

// По теме

Ещё из журнала

MFKAPPS 4 мин чтения

Baseline Profiles в Android: что на самом деле влияет на время холодного запуска в 2026 году

Практическое руководство по Android Baseline Profiles — генерация через Macrobenchmark, подключение в Gradle, честное измерение выигрыша и ещё три вещи, которые сильнее влияют на холодный запуск.

#android #engineering #kotlin
MFKAPPS 4 мин чтения

Разработка Mintly: как удержать точность таймера фокуса, когда Android хочет убить процесс

У работающего таймера Pomodoro проблема с надёжностью сложнее, чем у одноразового напоминания. Вот как Mintly переживает режим Doze, гибель процесса и дрейф при выключенном экране благодаря foreground-сервису и времени окончания по системным часам.

#android #engineering #mobile
MFKAPPS 5 мин чтения

Сканирование штрихкодов с ML Kit на Android в 2026 году: как Stocky добавляет товар в кладовую меньше чем за секунду

Практическое руководство 2026 года по сканированию штрихкодов на устройстве с ML Kit и CameraX: настройка форматов, офлайн-поиск товара и математика частичного расхода в приложении для кладовой.

#android #engineering #kotlin