本文へスキップ
すべての記事

2026年のローカルファーストなAndroid: SQLite、Room、そしてユーザーデータを端末に残すこと

RoomとSQLiteでローカルファーストなAndroidアプリを作るための2026年版ガイド — スキーマ設計、マイグレーション、WAL、エクスポート、そして同期をいつ足すべきか(いつ足すべきでないか)。

MFKAPPS 2 分で読めます

2026年になっても、Androidアプリの標準的なアーキテクチャは依然としてバックエンドを前提にしている。認証、同期、プッシュ、ネットワークが繋がっているときに呼び出すサーバー。多くのアプリにとって、それは正しい形だ。だが同じくらい多くのアプリにとって、それは永遠にメンテナンスし続けなければならないアーキテクチャでしかなく——そしてプライバシーポリシーが守らなければならないデータフローでしかない。

私が作るのはその逆だ。端末こそが信頼できる唯一の情報源(source of truth)である、ローカルファーストなAndroidアプリ。Granyn(家計簿)、Hydrame(水分補給)、Subly(サブスク管理)は、すべてその仕組みで動いている。アカウントなし、あなたのデータを抱え込むクラウドデータベースなし、午前3時に私が起きて守り続けなければならないサーバーもなし。この記事は、私がそれをどうやっているかの、実践的な2026年版だ。

「ローカルファースト」が本当に意味するもの

ローカルファーストは、「オフラインでも動く」よりも強い主張だ。

  • データは端末が所有する。 読み書きはまずローカルDBに向かい、UIがネットワーク呼び出しでブロックされることは決してない。
  • 機内モードのままでもアプリは完全に機能する。 クラウドファーストなアプリなら黙って無効化されるような機能まで含めて。
  • 同期が存在するとしても、それはユーザー自身の端末間でエンドツーエンド暗号化される —— サーバーは中継役であって、記録を持つ主体ではない。
  • エクスポートは機能であって、その場しのぎではない。 あなたのデータを、あなたが他所へ持ち出せる形式で。

シングルデバイスの生産性アプリ——トラッカー、ジャーナル、家計簿——にとっては、それで十分だ。サーバーは、あえて背負わないことを選べるメンテナンス負担なのだ。

ローカルファーストとは、「そもそも預かっていないのだから、あなたのデータを失いようがない」というアーキテクチャだ。

2026年のRoom: 地味だが、うまく歳を重ねた選択

2026年のAndroidにおけるローカルストレージとして、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)
}

私がいつもやっていて、かつ強く勧めたいことがいくつかある。

  • 金額は整数の最小単位で保存する。 DoubleではなくamountMinor: Longで。浮動小数点数と通貨は相容れない。丸め誤差のバグが一つあるだけで、あなたは一週間を失う。
  • kotlinx-datetimeInstantLocalDateTypeConverterとともに使う。 JDKのjava.time.*型でも動くが、もしいつかマルチプラットフォームへ進むなら、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 にして、生成されるschemas/内のJSONファイルをgitにコミットする。そのうえで、各過去バージョンのスキーマを読み込んで前方へマイグレートするRoomDatabaseSchemaTestを追加する。これは、あなたが買える中で最も安上がりな保険だ。
  2. 公開済みのマイグレーションは決して編集しない。 バグを見つけたら、それを直す新しいマイグレーションを書く。スキーマは前方向にしか進まない台帳だ。

私を二度救ってくれたパターン: 破壊的な変換はすべて、自動マイグレーションではなく生SQLのMIGRATIONとして行う。そうすれば、古い行を一時テーブルにコピーし、作り直し、件数が一致することを確認してから、マイグレーションをコミットできる。

WAL、そして他のSQLite上の小さな勝利

RoomはAndroid上でデフォルトでWAL(write-ahead logging)を使うが、それが何を意味するかは知っておく価値がある。書き込みが読み込みをブロックせず、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から作る、最小限の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}")
            }
        }
    }
}

これをインポートと組み合わせれば、ユーザーは自分宛にCSVをメールするだけで端末間を移動できる。華やかではないが、完璧に動く。

同期が本当に重要になり始めるとき

ローカルファーストは同期をしないという意味ではない。端末が信頼できる唯一の情報源である、という意味だ。同期を足すなら、その不変条件を真に保ち続けなければならない。

2026年の、大人向けの選択肢はこうだ。

  • CRDTベースの同期(Yjs、Automerge)は、共同編集や多端末のケース向け。シングルユーザーのアプリには重い。
  • エンドツーエンド暗号化されたblob同期は、「同じユーザーの二台の端末」というケース向け。ローカルで暗号化し、サーバーは不透明なバイト列を保存し、もう一方の端末でローカルに復号する。Tailscale風のアイデンティティ、トランスポートとしてのiCloud / Drive API、あるいは自前の小さな中継サーバー。
  • 多くのケースではただのファイルベースでよい。スケジュールに従ってGoogle Driveへエクスポートし、新しい端末で復元する。ライブ同期ほど滑らかではないが、運用するサーバーもなく、間違えると危険なE2EEのプリミティブもない。

GranynとHydrameは、同期なしで出荷した。ほとんどのユーザーはスマホを一台持っていて、それを何年も使い続ける。同期は、プロダクトディスカバリーのインタビューで人々が思い込むよりも、はるかに重要でないことがわかった。半分壊れたバージョンを出荷するくらいなら、私はそれを持たない方を選ぶ。

ローカルファーストな一日の形

インディーの現場はそれぞれ独自の変種を持つが、私のはこんな形だ。

  1. UIはComposeで、リポジトリからのFlowを監視する。
  2. RepositoryはDAOをラップし、ドメイン型との相互マッピングを行う。
  3. DAOが、Roomに触れる唯一のもの。
  4. WorkManagerは定期的なローカルジョブ(通知のスケジューリング、日次合計の再計算)を担う —— ネットワークは決して使わない。
  5. DataStoreは設定を保持し、ユーザーデータは決して持たない。

Repositoryが一つ、databaseインスタンスが一つあり、アプリの残りはコルーチンとStateFlowと話す。新しいコードにLiveDataはなく、どこにもRxJavaはない。依存グラフは紙ナプキン一枚に収まる。

何を手放し、何を得るか

ローカルファーストにすることで手放すもの。

  • エンジニアリングなしのクロスデバイス同期
  • 実際のコンテンツに対するサーバー側の分析(そしてそれに伴う誘惑)
  • 手軽な「友達とシェア」機能
  • あなたのAIがクラウドベースなら、「あなたのデータの上で動くAI」というマーケティング用語

得られるもの。

  • ほとんど勝手に書き上がるプライバシーポリシー
  • 永遠にゼロのサーバーコスト
  • 機内モードでも動き、瞬時に感じられるアプリ
  • アーキテクチャがそれに値するからこそ、あなたを信頼するユーザー

2026年に出荷するインディーアプリにとって、それは正しいトレードだ。クラウドファーストなスタックは見事だが、それはあなたが望んでいなかった二つの追加の仕事でもある。SREと、データ消失に対するカスタマーサポートだ。ローカルファーストはその両方を取り除く。あなたは、アプリを出荷する人になる。サーバーを運用しながら、余裕があるときにアプリも出荷する人ではなく。

もしあなたが、クラウドの部分が重たく感じられるという理由で、あるインディーAndroidのアイデアを迷っているのなら、私ならそこは飛ばす。ローカルから始めよう。同期は後で、本当に必要になったときに足せばいい。たいていの場合、必要にはならない。