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

Local-first на Android в 2026 году: SQLite, Room и хранение пользовательских данных на устройстве

Руководство 2026 года по созданию local-first приложений для Android на Room и SQLite — проектирование схемы, миграции, WAL, экспорт и когда (и когда не стоит) добавлять синхронизацию.

MFKAPPS 7 мин чтения

Стандартная архитектура 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. Типы JDK java.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

Две настройки, которые нужно зафиксировать в первый же день:

  1. exportSchema = true + закоммитьте сгенерированные JSON-файлы в schemas/ в git. Затем добавьте RoomDatabaseSchemaTest, который загружает каждую историческую схему и мигрирует вперёд. Это самая дешёвая страховка, которую вы когда-либо купите.
  2. Никогда не редактируйте опубликованную миграцию. Если вы нашли баг, напишите новую миграцию, которая его исправляет. Схема — это журнал только для движения вперёд.

Приём, который дважды меня спасал: выполняйте все разрушительные преобразования как 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 приложение должно поставляться с экспортом в одно касание. Три причины:

  1. Это укрепляет доверие. «Ваши данные принадлежат вам» перестаёт быть маркетингом в тот момент, когда у пользователя появляется CSV на телефоне.
  2. Это бесплатная переносимость. Больше никаких тикетов в поддержку «а как мне перейти в другое приложение?».
  3. Это полезная история резервного копирования без запуска сервиса бэкапов.

Минимальный экспорт в 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

У каждой инди-мастерской свой вариант, но мой выглядит так:

  1. UI — это Compose, наблюдает за Flow из репозитория.
  2. Репозиторий оборачивает DAO с преобразованием в доменные типы и обратно.
  3. DAO — единственное, что касается Room.
  4. WorkManager выполняет периодические локальные задачи (планирование уведомлений, ежедневный пересчёт итогов) — никогда сеть.
  5. DataStore хранит настройки, но никогда пользовательские данные.

Есть Repository, единственный экземпляр database, а остальная часть приложения общается через корутины и StateFlow. Никакого LiveData в новом коде, никакого RxJava нигде. Граф зависимостей помещается на салфетке.

Что вы теряете, что получаете

Что вы теряете, выбирая local-first:

  • Кросс-девайсную синхронизацию без инженерной работы
  • Серверную аналитику по фактическому содержимому (и соблазны, которые с ней приходят)
  • Простые функции «поделиться с другом»
  • Маркетинговое модное словечко «ИИ по вашим данным», если ваш ИИ облачный

Что вы получаете:

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

Для инди-приложения, выходящего в 2026 году, это правильный компромисс. Cloud-first стек впечатляет, но это ещё две работы, на которые вы не подписывались: SRE и поддержка клиентов при потере данных. Local-first убирает обе. Вы становитесь человеком, который выпускает приложения, а не человеком, который держит серверы и заодно выпускает приложения, когда есть время.

Если вы колебались по поводу инди-идеи для Android, потому что облачная часть кажется непосильной, я бы просто её пропустил. Начните локально. Добавьте синхронизацию позже, если она вам когда-нибудь действительно понадобится. Чаще всего — не понадобится.

// По теме

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

MFKAPPS 4 мин чтения

Разработка Granyn: трекер бюджета без входа в банк, всего на трёх таблицах

Как Granyn отслеживает расходы в разных валютах и распознаёт повторяющиеся счета, не подключая ни одного банковского аккаунта — схема Room под капотом и компромиссы, которые она навязывает.

#android #engineering #privacy
MFKAPPS 3 мин чтения

Как я делал Stocky: предсказать, что заканчивается на вашей кухне — прямо на устройстве

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

#android #engineering #privacy
MFKAPPS 7 мин чтения

OCR с приоритетом приватности на Android: как Subly читает счета без облака

Практическое руководство 2026 года по OCR на устройстве под Android с ML Kit Text Recognition — настройка, код и приёмы извлечения полей, которые держат данные пользователей вне ваших серверов.

#android #engineering #privacy