İçeriğe geç
Tüm yazılar

2026'da local-first Android: SQLite, Room ve kullanıcı verisini cihazda tutmak

Room ve SQLite ile local-first Android uygulamaları geliştirmenin 2026 rehberi — şema tasarımı, migration'lar, WAL, dışa aktarma ve senkronizasyonu ne zaman eklemeli (ve ne zaman eklememeli).

MFKAPPS 6 dk okuma

2026’da varsayılan Android uygulama mimarisi hâlâ bir backend olduğunu varsayar. Kimlik doğrulama, senkronizasyon, push, ağ açık olduğunda çağrılacak bir sunucu. Pek çok uygulama için doğru biçim budur. Pek çok başkası içinse sonsuza dek bakımını üstlenmen gereken bir mimariden ibarettir — ve gizlilik politikanın savunmak zorunda kalacağı bir veri akışından.

Ben bunun tam tersini inşa ediyorum: cihazın tek doğruluk kaynağı olduğu local-first Android uygulamaları. Granyn (bütçe), Hydrame (su takibi), Subly (abonelikler) hepsi bu şekilde çalışıyor. Hesap yok, verini tutan bir bulut veritabanı yok, gece 3’te ayakta tutmam gereken sunucular yok. Bu yazı, bunu nasıl yaptığımın pratik 2026 sürümü.

”Local-first” aslında ne demek

Local-first, “çevrimdışı çalışır”dan daha güçlü bir iddiadır:

  • Veriye cihaz sahiptir. Okuma ve yazma işlemleri önce yerel veritabanına gider; arayüz asla bir ağ çağrısında bloke olmaz.
  • Uygulama uçak modu açıkken tümüyle çalışır. Bulut-öncelikli bir uygulamada sessizce devre dışı kalacak özellikler dahil.
  • Senkronizasyon varsa, kullanıcının kendi cihazları arasında uçtan uca şifrelidir — sunucu bir aktarıcıdır, bir kayıt yeri değil.
  • Dışa aktarma bir özelliktir, bir hile değil. Verin, başka bir yere taşıyabileceğin bir formatta.

Tek cihazlı bir üretkenlik uygulaması için — bir takipçi, bir günlük, bir bütçe — ihtiyacın olan tek şey budur. Sunucu, üstlenmemeyi seçtiğin bir bakım yüküdür.

Local-first, “verini kaybedemeyiz çünkü hiç sahip olmadık” mimarisidir.

2026’da Room: iyi yaşlanan sıkıcı seçim

2026’da Android’de yerel depolama için Room hâlâ doğru cevap. SQLite’ın üzerinde, derleme zamanında SQL denetimi, coroutine + Flow entegrasyonu ve KSP (artık annotation-processor bekleme süreleri yok) sunan ince bir katman. Yıllar önce yazılmış yazıların hâlâ geçerli olduğu kadar stabil oldu ve o zamandan beri gelen küçük eklemeler (auto-migration’lar, @MapColumn, daha kolay çoklu-süreç desteği) hep doğru yönde.

Minimal bir kurulum:

@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)
}

Her zaman yaptığım ve savunacağım birkaç şey:

  • Parayı tamsayı alt birim olarak sakla. Double yerine amountMinor: Long. Float’lar ve para birbirine karışmaz; tek bir yuvarlama hatası sana bir hafta kaybettirir.
  • kotlinx-datetime’dan Instant ve LocalDate kullan ve bir TypeConverter ekle. JDK’nın java.time.* tipleri de çalışır, ama kotlinx-datetime, ileride multiplatform’a geçersen daha dostçadır.
  • DAO’dan her yerde Flow. Arayüz abone olur; uygulama, sen dinleyici bağlamadan yazma işlemlerine otomatik tepki verir.

Migration’lar: veritabanını bir public API gibi düşün

Indie uygulamaların veri kaybetmesinin en yaygın nedeni, kötü giden bir migration’dır. Şemana bir public API gibi davran: her değişiklik versiyonlanır, test edilir ve geri döndürülemez.

Room bunu neredeyse kolaylaştırıyor:

@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

İlk günden sabitlemen gereken iki ayar:

  1. exportSchema = true + üretilen JSON dosyalarını schemas/ klasöründe git’e commit et. Sonra, her geçmiş şemayı yükleyip ileriye migrate eden bir RoomDatabaseSchemaTest ekle. Bu, alacağın en ucuz sigorta.
  2. Yayınlanmış bir migration’ı asla düzenleme. Bir hata bulursan, onu düzelten yeni bir migration yaz. Şema, yalnızca ileriye giden bir defterdir.

Beni iki kez kurtaran kalıp: tüm yıkıcı dönüşümleri auto-migration olarak değil, ham SQL ile bir MIGRATION olarak yaparım; böylece eski satırları geçici bir tabloya kopyalayabilir, yeniden inşa edebilir ve migration’ı commit etmeden önce sayıların eşleştiğini doğrulayabilirim.

WAL ve diğer küçük SQLite kazanımları

Room, Android’de varsayılan olarak write-ahead logging (WAL) kullanır, ama bunun ne anlama geldiğini bilmekte fayda var: yazanlar okuyanları bloke etmez, fsync daha ucuzdur ve kullanıcı yazdıkça yeniden sorgulanan listelerde uygulama gözle görülür şekilde daha akıcı hisseder. Hâlâ bir satıra değen birkaç küçük SQLite düzeyi ince ayar:

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()

WAL ile synchronous=NORMAL, bir banka defteri işletmediğin, cihaz üzerinde çalışan bir uygulama için yeterince güvenli bir varsayılandır. Verin gerçekten bir banka defteriyse, FULL olarak bırak.

Listeler için, sıraladığın sütunları indeksle. WHERE created_at > sorgusu olup created_at üzerinde indeksi olmayan indie uygulama sayısı fazlaca yüksek. Bu, almadığın bedava performans.

Dışa aktarma bir özelliktir, panik butonu değil

Her local-first uygulama, tek dokunuşluk bir dışa aktarma ile gelmeli. Üç neden:

  1. Güven inşa eder. “Verin senindir”, bir kullanıcının telefonunda bir CSV olduğu an pazarlama olmaktan çıkar.
  2. Bedava taşınabilirliktir. Artık “peki başka bir uygulamaya nasıl geçerim?” destek talepleri yok.
  3. Faydalı bir yedekleme hikâyesidir, sen bir yedekleme servisi işletmeden.

Bir Flow-of-entities’ten minimal bir CSV dışa aktarma:

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}")
            }
        }
    }
}

Bunu içe aktarma ile eşleştir; kullanıcı kendisine bir CSV e-postalayarak cihazlar arasında geçebilir. Şaşaalı değil; kusursuz çalışır.

Senkronizasyon gerçekten ne zaman önem kazanmaya başlar

Local-first, senkronizasyon yok demek değildir. Cihazın tek doğruluk kaynağı olduğu anlamına gelir. Senkronizasyon, eklediğinde, bu değişmezi doğru tutmak zorundadır.

2026’daki olgun seçenekler:

  • CRDT tabanlı senkronizasyon (Yjs, Automerge), işbirlikçi veya çok-cihazlı durumlar için. Tek kullanıcılı bir uygulama için ağır.
  • Uçtan uca şifreli blob senkronizasyonu, “iki cihaz, aynı kullanıcı” durumu için. Yerelde şifrele, sunucu opak baytları saklar, diğer cihazda yerelde şifreyi çöz. Tailscale tarzı kimlik, taşıma olarak iCloud / Drive API’leri ya da kendi minik aktarıcın.
  • Sadece dosya tabanlı, pek çok durum için. Belirli bir programda Google Drive’a dışa aktar, yeni cihazda geri yükle. Canlı senkronizasyon kadar pürüzsüz değil, ama işletilecek sunucu yok, yanlış yapılacak E2EE ilkelleri yok.

Granyn ve Hydrame’i senkronizasyon olmadan yayına aldım. Çoğu kullanıcının tek telefonu var ve onu yıllarca tutuyor; senkronizasyon, ürün keşif görüşmelerinde insanların sandığından çok daha az önemli çıkıyor. Yarı kırık bir sürüm yayınlamaktansa hiç olmamasını tercih ederim.

Local-first bir günün biçimi

Her indie dükkânının kendi varyantı var, ama benimki şöyle görünüyor:

  1. Arayüz Compose’dur, repository’den Flow’ları gözlemler.
  2. Repository, DAO’yu domain tiplerine dönüştürme ile sarmalar.
  3. DAO, Room’a dokunan tek şeydir.
  4. WorkManager, periyodik yerel işleri yapar (bildirim zamanlaması, günlük toplam yeniden hesaplama) — asla ağ değil.
  5. DataStore, ayarları tutar, asla kullanıcı verisini değil.

Bir Repository, tek bir database örneği var ve uygulamanın geri kalanı coroutine’ler ve StateFlow ile konuşuyor. Yeni kodda LiveData yok, hiçbir yerde RxJava yok. Bağımlılık grafiği bir peçeteye sığıyor.

Neyden vazgeçiyorsun, ne kazanıyorsun

Local-first’e geçerek vazgeçtiğin şeyler:

  • Mühendislik işi olmadan cihazlar arası senkronizasyon
  • Gerçek içerik üzerinde sunucu tarafı analitiği (ve onunla gelen ayartmalar)
  • Kolay “bir arkadaşla paylaş” özellikleri
  • Yapay zekân bulut tabanlıysa “verin üzerinde yapay zeka” pazarlama sloganı

Kazandığın şeyler:

  • Neredeyse kendi kendini yazan bir gizlilik politikası
  • Sonsuza dek sıfır sunucu maliyeti
  • Uçak modunda çalışan ve anlık hisseden uygulamalar
  • Sana güvenen kullanıcılar, çünkü mimari bu güveni hak ediyor

2026’da yayına giren bir indie uygulama için bu, doğru takas. Bulut-öncelikli yığın etkileyicidir, ama aynı zamanda kaydolmadığın iki ekstra iştir: SRE ve veri kaybı için müşteri desteği. Local-first ikisini de ortadan kaldırır. Sunucu işleten ve fırsat buldukça uygulama da yayınlayan bir insan değil, uygulama yayınlayan bir insan olursun.

Bulut kısmı bunaltıcı geldiği için bir indie Android fikri konusunda kararsız kaldıysan, ben onu atlardım. Yerelde başla. Senkronizasyonu sonra ekle, eğer gerçekten ihtiyacın olursa. Çoğu zaman, olmayacak.