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

Резервное копирование локальных данных в Android в 2026 году: экспорт через SAF, Auto Backup и восстановление, которому можно доверять

Как local-first приложения для Android резервируют данные пользователя без сервера: экспорт/импорт через SAF, Auto Backup for App Data и восстановление, которое проверяет данные перед перезаписью.

MFKAPPS 4 мин чтения

«Без сервера» — это и есть всё торговое предложение local-first приложения. Именно поэтому сброс к заводским настройкам, потерянный телефон или смена устройства могут стереть месяцы истории бюджета в Granyn, и восстанавливать её будет попросту не из чего. Если облака нет, резервное копирование не может быть чем-то второстепенным — оно должно быть полноценной функцией, сделанной с той же тщательностью, что и модель данных, которую оно защищает.

В Android есть два механизма, которые здесь имеют значение, и они решают разные задачи. Ни один не заменяет другой.

Auto Backup for App Data: автоматически, но непрозрачно

Android автоматически и бесплатно резервирует файлы вашего приложения на Google Диск пользователя, если вы включите эту опцию:

<!-- AndroidManifest.xml -->
<application
    android:allowBackup="true"
    android:fullBackupContent="@xml/backup_rules"
    ...>
<!-- res/xml/backup_rules.xml -->
<full-backup-content>
    <include domain="database" path="granyn.db" />
    <exclude domain="database" path="granyn.db-wal" />
    <exclude domain="sharedpref" path="device_keys.xml" />
</full-backup-content>

Это по-настоящему полезно — это та страховочная сетка, которая спасает того, кто никогда не открывает экран настроек. Но у неё есть реальные ограничения, с которыми я предпочитаю считаться, а не бороться:

  • Всего 25 МБ, с молчаливым усечением при превышении лимита. База данных Room с историей транзакций за несколько лет плюс скриншоты может упереться в этот предел быстрее, чем кажется.
  • Автоматически восстанавливается только во время первоначальной настройки нового устройства, вошедшего в тот же аккаунт Google. Пользователь, переустановивший приложение на том же телефоне, таким образом ничего не получит обратно.
  • Это непрозрачно. Внутри приложения нет никакого подтверждения того, что именно и когда было сохранено в резервной копии. Я не могу показать пользователю «последнее резервное копирование — 3 дня назад», потому что ОС мне об этом не сообщает.

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

Явный экспорт: то, что пользователь действительно контролирует

Механизм, которому я на самом деле доверяю, — это обычный экспорт/импорт, который запускает сам пользователь, записывая файл туда, куда он выберет, через Storage Access Framework:

@Serializable
data class GranynExport(
    val schemaVersion: Int = 2,
    val exportedAt: Long,
    val accounts: List<AccountDto>,
    val transactions: List<TransactionDto>,
)

suspend fun exportTo(context: Context, uri: Uri) {
    val export = GranynExport(
        exportedAt = clock.nowMillis(),
        accounts = accountDao.getAll().map { it.toDto() },
        transactions = transactionDao.getAll().map { it.toDto() },
    )
    context.contentResolver.openOutputStream(uri)?.use { out ->
        out.write(Json.encodeToString(export).toByteArray())
    }
}

Запускается через ACTION_CREATE_DOCUMENT, поэтому пункт назначения выбирает сам пользователь — память устройства, USB-накопитель, любая облачная папка, которая у него уже синхронизируется. Никаких разрешений сверх одноразового выбора файла, а сам файл — обычный, читаемый JSON. Этот момент значит больше, чем кажется на первый взгляд: пользователь, который может открыть резервную копию в текстовом редакторе и увидеть названия своих же счетов, доверяет ей так, как никогда не заслужит непрозрачный .bak-файл.

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

Восстановление должно заслужить право что-либо перезаписывать

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

  1. Разобрать и провалидировать файл до того, как трогать рабочую базу данных. Проверить schemaVersion, убедиться, что файл не пуст, проверить внутреннюю согласованность идентификаторов, на которые есть ссылки.
  2. Показать сводку перед применением изменений. «12 счетов, 340 транзакций, резервная копия от 24 июля» — цифры, которые пользователь может сверить с тем, что он помнит, прежде чем что-либо будет перезаписано.
  3. Обернуть запись в единую транзакцию Room. Либо весь импорт применяется целиком, либо не применяется вовсе; сбой посреди импорта не может оставить базу данных в «сшитом» наполовину состоянии.
suspend fun restoreFrom(export: GranynExport) = db.withTransaction {
    require(export.schemaVersion <= CURRENT_SCHEMA_VERSION) {
        "Backup was made with a newer app version"
    }
    accountDao.deleteAll()
    transactionDao.deleteAll()
    accountDao.insertAll(export.accounts.map { it.toEntity() })
    transactionDao.insertAll(export.transactions.map { it.toEntity() })
}

Именно db.withTransaction выполняет всю настоящую защитную работу — если insertAll выбрасывает исключение на середине, Room откатывает весь блок целиком, и существующие данные пользователя остаются нетронутыми. Без этого восстановление, упавшее на середине, хуже, чем полное отсутствие функции восстановления.

Относиться к этому как к ключевой функции, а не как к строчке в настройках

Соблазнительно выпустить экспорт/импорт как одну кнопку, спрятанную в настройках, и считать дело сделанным. Для local-first приложения эта кнопка и есть план восстановления после катастрофы — нет никакого серверного бэкапа, который тихо прикроет баг в ней. Я тестирую сценарий восстановления с той же серьёзностью, что и онбординг: реальные экспортированные файлы, заблокированный и перезагруженный телефон, чистая установка. Если кнопка экспорта сломана, никто не узнает об этом до того момента, когда она нужнее всего, — а это самый неподходящий момент, чтобы это обнаружить.

Ничего из этого не является сложной инженерией. Это та скучная её разновидность, которая окупается лишь в тот единственный день, когда она реально понадобится пользователю, — а для local-first приложения именно ради этого дня всё и затевалось.

// По теме

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

MFKAPPS 4 мин чтения

Room TypeConverters в 2026 году: как хранить enum'ы, даты и списки, не повреждая схему

Практическое руководство по Room TypeConverters на Android — enum'ы, Instant/LocalDate и списки — а также ошибки, которые превращают конвертер в незаметный баг повреждения данных.

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

R8 и ProGuard для Kotlin, Room и Compose: сбой, который случается только в релизе

Почему приложение Android на Room + Compose идеально работает в debug и падает в продакшене, и какие именно правила keep R8/ProGuard ловят это раньше пользователя.

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

Индексы базы данных Room в 2026 году: находим по-настоящему медленный запрос и исправляем его

Практическое руководство по индексированию базы Room/SQLite на Android — чтение EXPLAIN QUERY PLAN, добавление @Index без угадывания и ошибки, которые незаметно сводят индекс на нет.

#android #engineering #room