Резервное копирование локальных данных в Android в 2026 году: экспорт через SAF, Auto Backup и восстановление, которому можно доверять
Как local-first приложения для Android резервируют данные пользователя без сервера: экспорт/импорт через SAF, Auto Backup for App Data и восстановление, которое проверяет данные перед перезаписью.
«Без сервера» — это и есть всё торговое предложение 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 уже менялась один раз после запуска — при добавлении нового поля я поднимаю версию, и импортёр точно знает, какую структуру ожидать, вместо того чтобы угадывать по тому, что есть в файле.
Восстановление должно заслужить право что-либо перезаписывать
Импорт — это то место, где функция резервного копирования проходит настоящую проверку, и именно здесь стоит быть параноиком. Опасный сценарий — не «файл повреждён», а «файл валиден, но частичная запись оставляет у пользователя меньше данных, чем было изначально». Правила, которым я подчиняю восстановление:
- Разобрать и провалидировать файл до того, как трогать рабочую базу данных. Проверить
schemaVersion, убедиться, что файл не пуст, проверить внутреннюю согласованность идентификаторов, на которые есть ссылки. - Показать сводку перед применением изменений. «12 счетов, 340 транзакций, резервная копия от 24 июля» — цифры, которые пользователь может сверить с тем, что он помнит, прежде чем что-либо будет перезаписано.
- Обернуть запись в единую транзакцию 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 приложения именно ради этого дня всё и затевалось.
// По теме
Ещё из журнала
Room TypeConverters в 2026 году: как хранить enum'ы, даты и списки, не повреждая схему
Практическое руководство по Room TypeConverters на Android — enum'ы, Instant/LocalDate и списки — а также ошибки, которые превращают конвертер в незаметный баг повреждения данных.
R8 и ProGuard для Kotlin, Room и Compose: сбой, который случается только в релизе
Почему приложение Android на Room + Compose идеально работает в debug и падает в продакшене, и какие именно правила keep R8/ProGuard ловят это раньше пользователя.
Индексы базы данных Room в 2026 году: находим по-настоящему медленный запрос и исправляем его
Практическое руководство по индексированию базы Room/SQLite на Android — чтение EXPLAIN QUERY PLAN, добавление @Index без угадывания и ошибки, которые незаметно сводят индекс на нет.