सामग्री पर जाएं
सभी पोस्ट

2026 में लोकल-फर्स्ट Android: SQLite, Room, और यूज़र डेटा को डिवाइस पर ही रखना

Room और SQLite के साथ लोकल-फर्स्ट Android ऐप बनाने की 2026 गाइड — स्कीमा डिज़ाइन, माइग्रेशन, WAL, एक्सपोर्ट, और सिंक कब जोड़ें (और कब नहीं)।

MFKAPPS 8 मिनट पढ़ना

2026 में भी डिफ़ॉल्ट Android ऐप आर्किटेक्चर एक बैकएंड मान कर चलता है। Auth, sync, push, और एक सर्वर जिसे नेटवर्क उपलब्ध होने पर कॉल किया जाए। बहुत सारे ऐप्स के लिए यही सही आकार है। पर बहुत सारे बाकी ऐप्स के लिए यह बस एक ऐसा आर्किटेक्चर है जिसे आपको हमेशा के लिए मेंटेन करना पड़ता है — और एक ऐसा डेटा फ़्लो जिसे आपकी प्राइवेसी पॉलिसी को बचाना पड़ता है।

मैं इसका उल्टा बनाता हूँ: लोकल-फर्स्ट Android ऐप्स, जहाँ डिवाइस ही सत्य का स्रोत होता है। Granyn (बजट), Hydrame (हाइड्रेशन), Subly (सब्सक्रिप्शन) — ये सब इसी तरह चलते हैं। कोई अकाउंट नहीं, कोई क्लाउड डेटाबेस नहीं जो आपका डेटा रखे, कोई सर्वर नहीं जिसे मुझे रात के 3 बजे चालू रखना पड़े। यह पोस्ट इसका व्यावहारिक 2026 वाला संस्करण है कि मैं यह कैसे करता हूँ।

“लोकल-फर्स्ट” का असल में क्या मतलब है

लोकल-फर्स्ट “ऑफ़लाइन काम करता है” से कहीं ज़्यादा मज़बूत दावा है:

  • डेटा का मालिक डिवाइस है। रीड और राइट पहले लोकल DB पर जाते हैं; UI कभी किसी नेटवर्क कॉल पर ब्लॉक नहीं होता।
  • ऐप एयरप्लेन मोड ऑन रहते हुए भी पूरी तरह काम करता है। उन फ़ीचर्स समेत जो एक क्लाउड-फर्स्ट ऐप में चुपचाप बंद हो जाते।
  • सिंक, अगर है भी, तो यूज़र के अपने डिवाइसों के बीच एंड-टू-एंड एन्क्रिप्टेड होता है — सर्वर एक रिले है, रिकॉर्ड नहीं।
  • एक्सपोर्ट एक फ़ीचर है, कोई जुगाड़ नहीं। आपका डेटा, ऐसे फ़ॉर्मैट में जिसे आप कहीं और ले जा सकें।

एक सिंगल-डिवाइस प्रोडक्टिविटी ऐप के लिए — कोई ट्रैकर, कोई जर्नल, कोई बजट — बस इतना ही चाहिए। सर्वर एक मेंटेनेंस का बोझ है जिसे न उठाने का चुनाव आप करते हैं।

लोकल-फर्स्ट “हम आपका डेटा खो नहीं सकते क्योंकि वह हमारे पास कभी था ही नहीं” का आर्किटेक्चर है।

2026 में Room: वह उबाऊ चुनाव जो अच्छे से टिका

2026 में Android पर लोकल स्टोरेज के लिए Room अब भी सही जवाब है। यह SQLite के ऊपर एक पतली परत है, जिसमें कंपाइल-टाइम SQL चेकिंग, coroutines + Flow इंटीग्रेशन, और KSP है (अब annotation-processor के इंतज़ार का झंझट नहीं)। यह इतना स्थिर रहा है कि सालों पहले लिखी गई पोस्ट्स आज भी लागू होती हैं, और तब से जो छोटे-छोटे इज़ाफ़े हुए (auto-migrations, @MapColumn, आसान multi-process सपोर्ट) वे सब सही दिशा में हैं।

एक न्यूनतम सेटअप:

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

कुछ चीज़ें जो मैं हमेशा करता हूँ, और जिनके पक्ष में तर्क दूँगा:

  • पैसे को एक इंटीजर माइनर यूनिट के रूप में स्टोर करें। Double की जगह amountMinor: Long। फ़्लोट और करेंसी का मेल नहीं बैठता; एक अकेली राउंडिंग बग आपका एक हफ़्ता खा जाएगी।
  • kotlinx-datetime से Instant और LocalDate का इस्तेमाल करें एक TypeConverter के साथ। JDK के java.time.* टाइप्स भी काम करते हैं, पर अगर आप कभी multiplatform की ओर जाएँ तो kotlinx-datetime ज़्यादा दोस्ताना है।
  • DAO से हर जगह Flow UI सब्सक्राइब करता है; ऐप बिना आपके लिसनर वायर किए राइट्स पर अपने-आप प्रतिक्रिया देता है।

माइग्रेशन: DB को एक पब्लिक 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. किसी पब्लिश हो चुके माइग्रेशन को कभी एडिट न करें। अगर कोई बग मिले, तो एक नया माइग्रेशन लिखें जो उसे ठीक करे। स्कीमा एक forward-only बहीखाता है।

वह पैटर्न जिसने मुझे दो बार बचाया: सारे विध्वंसक ट्रांसफ़ॉर्मेशन एक MIGRATION के रूप में raw SQL से करें, auto-migration से नहीं, ताकि मैं पुरानी पंक्तियों को एक temp टेबल में कॉपी कर सकूँ, फिर से बना सकूँ, और माइग्रेशन कमिट करने से पहले पुष्टि कर लूँ कि गिनतियाँ मेल खाती हैं।

WAL और अन्य छोटी-छोटी SQLite जीतें

Room Android पर डिफ़ॉल्ट रूप से write-ahead logging (WAL) पर होता है, पर यह जानना ज़रूरी है कि इसका क्या मतलब है: राइटर्स रीडर्स को ब्लॉक नहीं करते, 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()

WAL के साथ synchronous=NORMAL एक ऑन-डिवाइस ऐप के लिए काफ़ी सुरक्षित डिफ़ॉल्ट है, जहाँ आप कोई बैंक बहीखाता नहीं चला रहे। अगर आपका डेटा सचमुच एक बैंक बहीखाता है, तो इसे FULL पर ही रहने दें।

लिस्ट्स के लिए, जिन कॉलम्स से आप सॉर्ट करते हैं उन्हें इंडेक्स करें। मैंने ऐसे कितने ही इंडी ऐप्स देखे हैं जिनमें WHERE created_at > वाली क्वेरी है पर created_at पर कोई इंडेक्स नहीं। यह मुफ़्त परफ़ॉर्मेंस है जिसे आप नहीं ले रहे।

एक्सपोर्ट एक फ़ीचर है, घबराहट का बटन नहीं

हर लोकल-फर्स्ट ऐप को एक वन-टैप एक्सपोर्ट के साथ शिप होना चाहिए। तीन वजहें:

  1. यह भरोसा बनाता है। “आपका डेटा आपका है” उसी पल मार्केटिंग होना बंद कर देता है जब यूज़र के फ़ोन में एक CSV आ जाती है।
  2. यह मुफ़्त पोर्टेबिलिटी है। फिर कोई “पर मैं दूसरे ऐप पर कैसे जाऊँ?” वाले सपोर्ट टिकट नहीं।
  3. यह एक उपयोगी बैकअप कहानी है बिना आपके कोई बैकअप सर्विस चलाए।

एक Flow-of-entities से एक न्यूनतम CSV एक्सपोर्ट:

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

इसे import के साथ जोड़ें और यूज़र खुद को एक CSV ईमेल करके डिवाइसों के बीच जा सकता है। भड़कीला नहीं; पर बिल्कुल सही काम करता है।

सिंक कब वाकई मायने रखने लगता है

लोकल-फर्स्ट का मतलब कोई सिंक नहीं नहीं है। इसका मतलब है डिवाइस ही सत्य का स्रोत है। सिंक, जब आप उसे जोड़ें, तो उसे यह अपरिवर्तनीय सच बनाए रखना चाहिए।

2026 के परिपक्व विकल्प:

  • CRDT-आधारित सिंक (Yjs, Automerge) सहयोगी या कई-डिवाइस वाले मामलों के लिए। एक सिंगल-यूज़र ऐप के लिए भारी।
  • एंड-टू-एंड एन्क्रिप्टेड ब्लॉब सिंक “दो डिवाइस, वही यूज़र” वाले मामले के लिए। लोकली एन्क्रिप्ट करें, सर्वर अपारदर्शी बाइट्स स्टोर करता है, दूसरे डिवाइस पर लोकली डिक्रिप्ट करें। Tailscale-शैली की पहचान, iCloud / Drive APIs को ट्रांसपोर्ट के रूप में, या आपका अपना छोटा-सा रिले।
  • बस फ़ाइल-आधारित कई मामलों के लिए। एक शेड्यूल पर Google Drive पर एक्सपोर्ट करें, नए डिवाइस पर रिस्टोर करें। लाइव सिंक जितना सहज नहीं, पर न कोई सर्वर चलाना, न कोई E2EE आदिम गलत करने का डर।

Granyn और Hydrame के लिए मैंने बिना सिंक के शिप किया। ज़्यादातर यूज़र के पास एक ही फ़ोन होता है और वे उसे सालों रखते हैं; प्रोडक्ट डिस्कवरी इंटरव्यूज़ में लोग जितना मानते हैं, सिंक उससे कहीं कम अहम निकलता है। मैं एक आधे-टूटे संस्करण को शिप करने की बजाय उसे न रखना ज़्यादा पसंद करूँगा।

एक लोकल-फर्स्ट दिन का आकार

हर इंडी शॉप का अपना रूप होता है, पर मेरा ऐसा दिखता है:

  1. UI Compose है, रिपॉज़िटरी से Flows को observe करता है।
  2. Repository DAO को डोमेन टाइप्स से/की ओर मैपिंग के साथ रैप करती है।
  3. DAO ही एकमात्र चीज़ है जो Room को छूती है।
  4. WorkManager समय-समय पर लोकल जॉब्स करता है (notification शेड्यूलिंग, रोज़ाना का कुल फिर से गणना) — कभी नेटवर्क नहीं।
  5. DataStore सेटिंग्स रखता है, कभी यूज़र डेटा नहीं।

एक Repository है, एक अकेला database इंस्टेंस है, और बाकी ऐप coroutines और StateFlow से बात करता है। नए कोड में कोई LiveData नहीं, कहीं कोई RxJava नहीं। डिपेंडेंसी ग्राफ़ एक नैपकिन पर आ जाता है।

आप क्या छोड़ते हैं, क्या पाते हैं

लोकल-फर्स्ट जाकर आप जो चीज़ें छोड़ते हैं:

  • बिना इंजीनियरिंग काम के क्रॉस-डिवाइस सिंक
  • असल कंटेंट पर सर्वर-साइड एनालिटिक्स (और उसके साथ आने वाले प्रलोभन)
  • आसान “किसी दोस्त के साथ शेयर करें” फ़ीचर्स
  • “आपके डेटा पर AI” का मार्केटिंग बज़वर्ड, अगर आपका AI क्लाउड-आधारित है

जो चीज़ें आप पाते हैं:

  • एक प्राइवेसी पॉलिसी जो लगभग खुद ही लिख जाती है
  • हमेशा के लिए शून्य सर्वर लागत
  • ऐसे ऐप्स जो एयरप्लेन मोड में काम करते हैं और तुरंत लगते हैं
  • ऐसे यूज़र जो आप पर भरोसा करते हैं क्योंकि आर्किटेक्चर उस भरोसे को कमाता है

2026 में शिप होने वाले एक इंडी ऐप के लिए, यही सही सौदा है। क्लाउड-फर्स्ट स्टैक प्रभावशाली है, पर यह दो अतिरिक्त काम भी हैं जिनके लिए आपने साइन-अप नहीं किया था: SRE और डेटा खोने के लिए कस्टमर सपोर्ट। लोकल-फर्स्ट दोनों को हटा देता है। आप वह इंसान बन जाते हैं जो ऐप्स शिप करता है, न कि वह इंसान जो सर्वर चलाता है और वक़्त मिलने पर ऐप्स भी शिप करता है।

अगर आप किसी इंडी Android आइडिया को लेकर इसलिए हिचकिचा रहे हैं क्योंकि क्लाउड वाला हिस्सा भारी लगता है, तो मैं बस उसे छोड़ दूँगा। लोकल से शुरू करें। सिंक बाद में जोड़ें, अगर आपको सचमुच कभी उसकी ज़रूरत पड़े। ज़्यादातर वक़्त, नहीं पड़ेगी।

// संबंधित पठन

जर्नल से और भी

MFKAPPS 5 मिनट पढ़ना

Granyn बनाना: बिना बैंक लॉगिन वाला बजट ट्रैकर, बस तीन टेबल में

Granyn एक भी बैंक अकाउंट जोड़े बिना कई मुद्राओं में खर्च कैसे ट्रैक करता है और आवर्ती बिलों को कैसे पकड़ता है — इसके पीछे का Room स्कीमा, और इससे जुड़े समझौते।

#android #engineering #privacy
MFKAPPS 4 मिनट पढ़ना

Stocky बनाना: आपकी रसोई में क्या खत्म होने वाला है, इसका पूर्वानुमान — वह भी डिवाइस पर ही

Stocky एक बारकोड स्कैन और थोड़े-से उपयोग इतिहास को ऐसी शॉपिंग लिस्ट में कैसे बदल देता है जो आपके खत्म होने से पहले ही सामने आ जाती है — और सारा गणित, सारा डेटा, आपके फोन पर ही रहता है।

#android #engineering #privacy
MFKAPPS 8 मिनट पढ़ना

Android पर प्राइवेसी-फर्स्ट OCR: Subly बिना क्लाउड के बिल कैसे पढ़ता है

2026 के लिए Android पर ऑन-डिवाइस OCR की व्यावहारिक गाइड, ML Kit Text Recognition के साथ — सेटअप, कोड, और वे फ़ील्ड-एक्सट्रैक्शन तरकीबें जो यूज़र के डेटा को आपके सर्वर से दूर रखती हैं।

#android #engineering #privacy