2026年のローカルファーストなAndroid: SQLite、Room、そしてユーザーデータを端末に残すこと
RoomとSQLiteでローカルファーストなAndroidアプリを作るための2026年版ガイド — スキーマ設計、マイグレーション、WAL、エクスポート、そして同期をいつ足すべきか(いつ足すべきでないか)。
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-datetimeのInstantとLocalDateをTypeConverterとともに使う。 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
初日に固めておくべき設定が二つある。
exportSchema = trueにして、生成されるschemas/内のJSONファイルをgitにコミットする。そのうえで、各過去バージョンのスキーマを読み込んで前方へマイグレートするRoomDatabaseSchemaTestを追加する。これは、あなたが買える中で最も安上がりな保険だ。- 公開済みのマイグレーションは決して編集しない。 バグを見つけたら、それを直す新しいマイグレーションを書く。スキーマは前方向にしか進まない台帳だ。
私を二度救ってくれたパターン: 破壊的な変換はすべて、自動マイグレーションではなく生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にインデックスがないインディーアプリを、私はあまりにも多く見てきた。それは、あなたが取り逃がしているタダのパフォーマンスだ。
エクスポートは機能であって、非常ボタンではない
すべてのローカルファーストなアプリは、ワンタップのエクスポートを備えて出荷されるべきだ。理由は三つ。
- 信頼を築く。 ユーザーの手元のスマホにCSVが一つある瞬間、「あなたのデータはあなたのもの」はマーケティングであることをやめる。
- タダの可搬性。 「でも別のアプリにどうやって移るの?」というサポートチケットがなくなる。
- 役に立つバックアップの物語になる —— しかもあなたがバックアップサービスを運用することなく。
エンティティの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は、同期なしで出荷した。ほとんどのユーザーはスマホを一台持っていて、それを何年も使い続ける。同期は、プロダクトディスカバリーのインタビューで人々が思い込むよりも、はるかに重要でないことがわかった。半分壊れたバージョンを出荷するくらいなら、私はそれを持たない方を選ぶ。
ローカルファーストな一日の形
インディーの現場はそれぞれ独自の変種を持つが、私のはこんな形だ。
- UIはComposeで、リポジトリからのFlowを監視する。
- RepositoryはDAOをラップし、ドメイン型との相互マッピングを行う。
- DAOが、Roomに触れる唯一のもの。
- WorkManagerは定期的なローカルジョブ(通知のスケジューリング、日次合計の再計算)を担う —— ネットワークは決して使わない。
- DataStoreは設定を保持し、ユーザーデータは決して持たない。
Repositoryが一つ、databaseインスタンスが一つあり、アプリの残りはコルーチンとStateFlowと話す。新しいコードにLiveDataはなく、どこにもRxJavaはない。依存グラフは紙ナプキン一枚に収まる。
何を手放し、何を得るか
ローカルファーストにすることで手放すもの。
- エンジニアリングなしのクロスデバイス同期
- 実際のコンテンツに対するサーバー側の分析(そしてそれに伴う誘惑)
- 手軽な「友達とシェア」機能
- あなたのAIがクラウドベースなら、「あなたのデータの上で動くAI」というマーケティング用語
得られるもの。
- ほとんど勝手に書き上がるプライバシーポリシー
- 永遠にゼロのサーバーコスト
- 機内モードでも動き、瞬時に感じられるアプリ
- アーキテクチャがそれに値するからこそ、あなたを信頼するユーザー
2026年に出荷するインディーアプリにとって、それは正しいトレードだ。クラウドファーストなスタックは見事だが、それはあなたが望んでいなかった二つの追加の仕事でもある。SREと、データ消失に対するカスタマーサポートだ。ローカルファーストはその両方を取り除く。あなたは、アプリを出荷する人になる。サーバーを運用しながら、余裕があるときにアプリも出荷する人ではなく。
もしあなたが、クラウドの部分が重たく感じられるという理由で、あるインディーAndroidのアイデアを迷っているのなら、私ならそこは飛ばす。ローカルから始めよう。同期は後で、本当に必要になったときに足せばいい。たいていの場合、必要にはならない。
// 関連記事
ジャーナルの他の記事
Granynを作る: 銀行ログインなしの家計簿を、3つのテーブルで
Granynが銀行口座を一つも連携せずに、複数通貨での支出を追跡し、定期的な請求を検知する仕組み — その裏にあるRoomスキーマと、そこで生じるトレードオフ。
Stocky を作る:キッチンで何が切れるかを予測する — しかも端末の上で
バーコードのスキャンとちょっとした使用履歴を、切らす前に現れる買い物リストへと Stocky がどう変えるのか。すべての計算も、すべてのデータも、あなたの電話の中にとどめたまま。
AndroidでのプライバシーファーストなOCR: Sublyがクラウドを使わず請求書を読み取る仕組み
ML Kit Text Recognitionを使ったAndroidのオンデバイスOCRに関する、2026年の実践的なガイド。セットアップ、コード、そしてユーザーのデータをサーバーに残さないためのフィールド抽出のコツを解説します。