Local-first на Android в 2026 году: SQLite, Room и хранение пользовательских данных на устройстве
Руководство 2026 года по созданию local-first приложений для Android на Room и SQLite — проектирование схемы, миграции, WAL, экспорт и когда (и когда не стоит) добавлять синхронизацию.
Стандартная архитектура Android-приложения в 2026 году всё ещё подразумевает бэкенд. Аутентификация, синхронизация, пуши, сервер, к которому обращаешься, когда есть сеть. Для многих приложений это правильная форма. Для многих других — это просто архитектура, которую придётся поддерживать вечно, и поток данных, который придётся защищать в политике конфиденциальности.
Я строю противоположное: local-first приложения для Android, где устройство — источник истины. Granyn (бюджет), Hydrame (гидратация), Subly (подписки) — все работают именно так. Никаких аккаунтов, никакой облачной базы, хранящей ваши данные, никаких серверов, которые я должен поднимать в три часа ночи. Этот пост — практическая версия 2026 года о том, как я это делаю.
Что на самом деле означает «local-first»
Local-first — более сильное утверждение, чем «работает офлайн»:
- Устройство владеет данными. Чтение и запись идут сначала в локальную БД; UI никогда не блокируется на сетевом вызове.
- Приложение полностью функционально в режиме полёта. Включая функции, которые в cloud-first приложении молча отключились бы.
- Синхронизация, если она есть, — со сквозным шифрованием между собственными устройствами пользователя — сервер выступает ретранслятором, а не хранилищем записей.
- Экспорт — это функция, а не костыль. Ваши данные в формате, который вы можете забрать в другое место.
Для приложения продуктивности с одним устройством — трекера, дневника, бюджета — этого достаточно. Сервер — это бремя поддержки, которое вы решаете на себя не брать.
Local-first — это архитектура принципа «мы не можем потерять ваши данные, потому что они у нас никогда не были».
Room в 2026 году: скучный выбор, который хорошо состарился
Для локального хранилища на Android в 2026 году Room по-прежнему правильный ответ. Это тонкий слой над SQLite с проверкой SQL на этапе компиляции, интеграцией корутин + Flow и KSP (больше никакого ожидания обработчика аннотаций). Он был достаточно стабилен, чтобы посты, написанные годы назад, всё ещё применимы, а небольшие добавления с тех пор (авто-миграции, @MapColumn, более простая поддержка многопроцессности) — все в правильном направлении.
Минимальная настройка:
@Entity(tableName = "subscriptions")
data class SubscriptionEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val service: String,
val amountMinor: Long, // store cents, never floats
val currency: String, // "TRY", "EUR", "USD"
val cycle: BillingCycle,
val nextChargeDate: LocalDate,
val createdAt: Instant = Instant.now(),
)
@Dao
interface SubscriptionDao {
@Query("SELECT * FROM subscriptions ORDER BY nextChargeDate ASC")
fun observeAll(): Flow<List<SubscriptionEntity>>
@Query("SELECT SUM(amountMinor) FROM subscriptions WHERE currency = :currency")
fun totalForCurrency(currency: String): Flow<Long?>
@Insert
suspend fun insert(s: SubscriptionEntity): Long
@Delete
suspend fun delete(s: SubscriptionEntity)
}
Несколько вещей, которые я всегда делаю и за которые готов поручиться:
- Храните деньги как целочисленную минорную единицу.
amountMinor: LongвместоDouble. Числа с плавающей точкой и валюта несовместимы; одна ошибка округления обойдётся вам в неделю. - Используйте
InstantиLocalDateизkotlinx-datetimeсTypeConverter. Типы JDKjava.time.*тоже работают, но kotlinx-datetime дружелюбнее к мультиплатформенности, если вы когда-нибудь пойдёте по этому пути. Flowвезде из DAO. UI подписывается; приложение автоматически реагирует на записи, без ручного подключения слушателей.
Миграции: относитесь к БД как к публичному API
Самая частая причина, по которой инди-приложения теряют данные, — запоротая миграция. Относитесь к схеме как к публичному API: каждое изменение версионируется, тестируется и необратимо.
Room делает это почти простым:
@Database(
entities = [SubscriptionEntity::class],
version = 3,
autoMigrations = [
AutoMigration(from = 1, to = 2),
AutoMigration(from = 2, to = 3, spec = AddNotesField::class),
],
exportSchema = true,
)
@TypeConverters(Converters::class)
abstract class AppDatabase : RoomDatabase()
@RenameColumn(tableName = "subscriptions", fromColumnName = "note", toColumnName = "notes")
class AddNotesField : AutoMigrationSpec
Две настройки, которые нужно зафиксировать в первый же день:
exportSchema = true+ закоммитьте сгенерированные JSON-файлы вschemas/в git. Затем добавьтеRoomDatabaseSchemaTest, который загружает каждую историческую схему и мигрирует вперёд. Это самая дешёвая страховка, которую вы когда-либо купите.- Никогда не редактируйте опубликованную миграцию. Если вы нашли баг, напишите новую миграцию, которая его исправляет. Схема — это журнал только для движения вперёд.
Приём, который дважды меня спасал: выполняйте все разрушительные преобразования как MIGRATION с сырым SQL, а не как авто-миграцию, чтобы можно было скопировать старые строки во временную таблицу, перестроить и убедиться, что количество совпадает, прежде чем фиксировать миграцию.
WAL и другие маленькие победы SQLite
Room по умолчанию использует журналирование с упреждающей записью (WAL) на Android, но стоит понимать, что это значит: писатели не блокируют читателей, fsync дешевле, а приложение ощутимо шустрее на списках, которые перезапрашиваются по мере ввода. Несколько небольших настроек на уровне SQLite, которые всё ещё стоят одной строки:
val db = Room.databaseBuilder(context, AppDatabase::class.java, "app.db")
.setQueryExecutor(Dispatchers.IO.asExecutor())
.setTransactionExecutor(Dispatchers.IO.asExecutor())
.addCallback(object : RoomDatabase.Callback() {
override fun onOpen(db: SupportSQLiteDatabase) {
super.onOpen(db)
db.query("PRAGMA journal_mode=WAL;").use { it.moveToFirst() }
db.query("PRAGMA synchronous=NORMAL;").use { it.moveToFirst() }
}
})
.build()
synchronous=NORMAL с WAL — достаточно безопасное значение по умолчанию для приложения на устройстве, где вы не ведёте банковский реестр. Если ваши данные — это буквально банковский реестр, оставьте FULL.
Для списков индексируйте столбцы, по которым сортируете. Число инди-приложений, которые я видел с запросом WHERE created_at > и без индекса на created_at, слишком велико. Это бесплатная производительность, которую вы не берёте.
Экспорт — это функция, а не кнопка паники
Каждое local-first приложение должно поставляться с экспортом в одно касание. Три причины:
- Это укрепляет доверие. «Ваши данные принадлежат вам» перестаёт быть маркетингом в тот момент, когда у пользователя появляется CSV на телефоне.
- Это бесплатная переносимость. Больше никаких тикетов в поддержку «а как мне перейти в другое приложение?».
- Это полезная история резервного копирования без запуска сервиса бэкапов.
Минимальный экспорт в CSV из Flow-сущностей:
suspend fun exportCsv(uri: Uri, context: Context, dao: SubscriptionDao) {
val rows = dao.observeAll().first()
context.contentResolver.openOutputStream(uri)?.use { out ->
out.bufferedWriter().use { w ->
w.appendLine("service,amount,currency,cycle,nextDate")
rows.forEach {
w.appendLine("${it.service},${it.amountMinor / 100.0},${it.currency},${it.cycle},${it.nextChargeDate}")
}
}
}
}
Дополните это импортом — и пользователь сможет переезжать между устройствами, отправив CSV самому себе по почте. Не гламурно; работает идеально.
Когда синхронизация начинает действительно иметь значение
Local-first не означает отсутствие синхронизации. Он означает, что устройство — источник истины. Синхронизация, когда вы её добавляете, должна сохранять этот инвариант истинным.
Взрослые варианты в 2026 году:
- Синхронизация на основе CRDT (Yjs, Automerge) для совместных сценариев или множества устройств. Тяжеловесно для однопользовательского приложения.
- Синхронизация зашифрованных блобов со сквозным шифрованием для случая «два устройства, один пользователь». Шифруете локально, сервер хранит непрозрачные байты, расшифровываете локально на другом устройстве. Идентичность в стиле Tailscale, API iCloud / Drive в качестве транспорта или ваш собственный крошечный ретранслятор.
- Просто на основе файлов для многих случаев. Экспорт в Google Drive по расписанию, восстановление на новом устройстве. Не так гладко, как живая синхронизация, но нет серверов для запуска и нет примитивов E2EE, которые можно испортить.
Для Granyn и Hydrame я выпустил релиз без синхронизации. У большинства пользователей один телефон, и они держат его годами; синхронизация оказывается куда менее важной, чем предполагают на интервью по исследованию продукта. Я предпочту её не иметь, чем выпускать наполовину сломанную версию.
Как выглядит день в local-first
У каждой инди-мастерской свой вариант, но мой выглядит так:
- UI — это Compose, наблюдает за Flow из репозитория.
- Репозиторий оборачивает DAO с преобразованием в доменные типы и обратно.
- DAO — единственное, что касается Room.
- WorkManager выполняет периодические локальные задачи (планирование уведомлений, ежедневный пересчёт итогов) — никогда сеть.
- DataStore хранит настройки, но никогда пользовательские данные.
Есть Repository, единственный экземпляр database, а остальная часть приложения общается через корутины и StateFlow. Никакого LiveData в новом коде, никакого RxJava нигде. Граф зависимостей помещается на салфетке.
Что вы теряете, что получаете
Что вы теряете, выбирая local-first:
- Кросс-девайсную синхронизацию без инженерной работы
- Серверную аналитику по фактическому содержимому (и соблазны, которые с ней приходят)
- Простые функции «поделиться с другом»
- Маркетинговое модное словечко «ИИ по вашим данным», если ваш ИИ облачный
Что вы получаете:
- Политику конфиденциальности, которая практически пишет себя сама
- Нулевые серверные расходы навсегда
- Приложения, работающие в режиме полёта и ощущающиеся мгновенными
- Пользователей, которые вам доверяют, потому что архитектура это заслуживает
Для инди-приложения, выходящего в 2026 году, это правильный компромисс. Cloud-first стек впечатляет, но это ещё две работы, на которые вы не подписывались: SRE и поддержка клиентов при потере данных. Local-first убирает обе. Вы становитесь человеком, который выпускает приложения, а не человеком, который держит серверы и заодно выпускает приложения, когда есть время.
Если вы колебались по поводу инди-идеи для Android, потому что облачная часть кажется непосильной, я бы просто её пропустил. Начните локально. Добавьте синхронизацию позже, если она вам когда-нибудь действительно понадобится. Чаще всего — не понадобится.
// По теме
Ещё из журнала
Разработка Granyn: трекер бюджета без входа в банк, всего на трёх таблицах
Как Granyn отслеживает расходы в разных валютах и распознаёт повторяющиеся счета, не подключая ни одного банковского аккаунта — схема Room под капотом и компромиссы, которые она навязывает.
Как я делал Stocky: предсказать, что заканчивается на вашей кухне — прямо на устройстве
Как Stocky превращает скан штрихкода и немного истории использования в список покупок, который появляется прежде, чем у вас что-то закончится — и вся математика, и все данные остаются на вашем телефоне.
OCR с приоритетом приватности на Android: как Subly читает счета без облака
Практическое руководство 2026 года по OCR на устройстве под Android с ML Kit Text Recognition — настройка, код и приёмы извлечения полей, которые держат данные пользователей вне ваших серверов.