本文へ移動

ホーム / App / 第3部 アプリの設計

第3部 アプリの設計・第6章

端末のデータとオフライン

★★★ 必須10トピック・面接 5問

電波がなくても使えるアプリにするための、端末側のデータの扱いを押さえる。

別のページで開く →

DBラボを読み込んでいます…

6.1端末の DB

用語★★★ 必須
ひとことで

端末の中の SQLite(Room・Core Data・GRDB)。アプリの更新時にスキーマを移行する。

アプリの端末内の DB は、ほとんどが SQLite(ファイル1つで動く小さな DB)です。Android では Room、iOS では SwiftData・Core Data・GRDB などがその上に乗っています。サーバーの DB と違い、利用者の端末ごとにばらばらの版の DB があるのが難しいところです。

user_version とマイグレーション

SQLite には、DB ファイルの中に数字を1つ持てる PRAGMA user_version があります。これを「スキーマの版」として使い、起動時に今の版を見て、足りない分の変更を順番に流します。これがマイグレーションです。Room は @Database(version = N) の版をこの仕組みで管理し、Migration(1, 2) のように1段ずつ書きます。

どの版からでも、1段ずつ順番に最新へuser_version = 11→2user_version = 22→3user_version = 33→4user_version = 4何か月も更新しなかった端末(v1)は 3 段を順に流すv3 の端末は 1 段だけ
端末ごとに DB の版が違うので、どの版からでも順番に最新まで上げる
PRAGMA user_version;            -- 今の版(例:2)
BEGIN;
ALTER TABLE notes ADD COLUMN pinned INTEGER NOT NULL DEFAULT 0;
PRAGMA user_version = 3;
COMMIT;
注意点理由・対策
どの版からでも最新にできるようにする何か月も更新しなかった人は 1 → 5 のように飛んでくる。1段ずつの変更を全部残す
変更はトランザクションで包む途中でアプリが落ちても、中途半端な DB が残らない
「消して作り直す」で逃げないRoom の fallbackToDestructiveMigration は、送っていない下書きなど利用者のデータまで消える
古い版から更新するテストをするRoom は版ごとのスキーマを書き出して、マイグレーションを試すテストが書ける

(SRE)サーバー側のマイグレーション

面接ではこう答える

アプリの端末内の DB はほとんどが SQLite で、Android では Room、iOS では SwiftData や Core Data、GRDB などがその上に乗っています。サーバーの DB と違い、利用者の端末ごとにばらばらの版の DB があるので、PRAGMA user_version などでスキーマの版を持ち、起動時に足りない分の変更を順番に流すマイグレーションを書きます。どの版からでも最新にできるよう1段ずつの変更をすべて残し、トランザクションで包み、古い版から更新するテストをします。

別のページで開く →

DBラボを読み込んでいます…

ひとことで

WAL は変更を別のファイルに追記する SQLite の方式で、書き込み中でも読み込みが止まらない。

端末の DB は、画面の表示と裏の同期が同時に読み書きします。SQLite の書き込みの方式を WAL にすると、この同時アクセスが滑らかになります。

SQLite の既定(ロールバックジャーナル)では、書き込み中は読み込みも待たされます。PRAGMA journal_mode=WAL; にすると、変更をまず別のファイル(-wal)に追記するので、書き込み中でも読み込みが止まりません。裏で同期しながら画面で一覧を表示するアプリでは効果が大きく、Room は多くの端末で既定で WAL を使います。

書き込みは -wal に追記、読み込みは止まらない読み込み一覧の表示書き込み裏での同期app.db本体app.db-wal最近の変更読む-wal も読む追記チェックポイントで本体に書き戻すほかに -shm も作られる。DB を書き出すときは閉じるか、先に書き戻す
WAL では書き込みは -wal に追記され、読み込みは止まらない

落とし穴は、DB ファイルが .db・-wal・-shm の3つになることです。.db だけをコピーして書き出すと、まだ -wal にある最近の変更が抜けます。書き出す前は DB を閉じるか、チェックポイント(PRAGMA wal_checkpoint)で本体に書き戻します。

面接ではこう答える

SQLite の既定の方式では、書き込み中は読み込みも待たされます。WAL モードにすると、変更をまず -wal という別のファイルに追記し、後でチェックポイントで本体に書き戻すので、書き込み中でも読み込みが止まりません。裏で同期しながら画面で一覧を表示するアプリでは効果が大きく、Room は多くの端末で既定で WAL を使います。注意点は DB が3つのファイルになることで、本体だけをコピーすると最近の変更が抜けます。

別のページで開く →

DBラボを読み込んでいます…

端末の DB を直接 SQLite の API で扱うことは少なく、ライブラリを通して使います。違いは、SQL を自分で書くか、オブジェクトとして扱うかです。

どのライブラリも、中は SQLiteRoomAndroid・SQL を書くSwiftDataiOS 17〜・マクロCore DataiOS・オブジェクトの図GRDBiOS・SQL を書くSQLite(端末の中のファイル1つの DB)
どれも中では SQLite を使っている。違うのは書き方と、どこまで自動でやってくれるか
ライブラリ書き方向いている・注意点
Room(Android)SQL を @Query に書き、コンパイル時に確かめる。Flow で変更を受け取れるAndroid の標準。マイグレーションのテストの仕組みもある
SwiftData(iOS 17〜)@Model を付けたクラスを保存する。SwiftUI と組み合わせやすい新しく、対応 OS が限られる。細かい問い合わせや大量のデータは確かめてから
Core Data(iOS)オブジェクトの図(モデル)を作って扱う。歴史が長い情報が多い。スレッドごとの扱い(context)に決まりがある
GRDB(iOS)SQL とレコードの型の両方で書けるSQLite をそのまま使いたいときに向く。サードパーティのライブラリ

どれを使っても、中は SQLite なので、インデックス・トランザクション・WAL の知識はそのまま使えます。選ぶときは、対応する最低の OS の版と、SQL をどれだけ自分で書きたいかで決めます。

端末の DB でも、件数が数万を超えると、問い合わせの書き方で速さが大きく変わります。基本はサーバーの DB と同じです。

困りごと原因対策
一覧の表示が遅い絞り込みや並べ替えの列にインデックスがないEXPLAIN QUERY PLAN で SCAN(全件読み)になっていないか見て、インデックスを足す
同期で大量に入れると遅い1件ずつ別のトランザクションで書いているまとめて1つのトランザクションで書く。数十倍速くなることもある
一覧の各行で別の問い合わせN+1(1回の一覧の後に、行ごとに1回ずつ)JOIN や IN でまとめて取る。Room の @Relation も中身を確かめる
メインスレッドで DB を読む小さい問い合わせでも積み重なると引っかかるRoom は既定でメインスレッドを禁止している。iOS も裏で読む
EXPLAIN QUERY PLAN
SELECT * FROM notes WHERE folder_id = 3 ORDER BY updated_at DESC;
-- SCAN notes なら全件読み。SEARCH notes USING INDEX ... ならインデックスを使っている

インデックスを増やすと書き込みが遅くなり、DB のファイルも大きくなるので、実際に遅い問い合わせに合わせて足します。

(SRE)インデックスの仕組み(B-Tree)

別のページで開く →

DBラボを読み込んでいます…

ひとことで

まず端末に保存して画面に反映し、つながったらサーバーと同期する。衝突の扱いを決めておく。

オフラインファーストは、画面は常に端末の DB を読んで表示し、サーバーとは裏で同期する設計です。電波がなくても見られて、編集もでき、つながったら送ります。表示が速くなる利点もあります。難しいのは、端末とサーバー(や別の端末)で同じデータが別々に変わったときの衝突です。

画面は DB を読み、送信は裏のキューに任せる画面同じトランザクションデータの表送信待ち(outbox)編集表示同期の処理WorkManager・BGTask古い順サーバー冪等キー付き409(版が違う)=衝突取り直して、まとめる/選んでもらう成功したら outbox から消す画面には「未送信」の印を出す
変更は DB と送信待ちのキューに同時に書き、つながったら古い順に送る

(SRE)冪等性の実装方法

この章では、衝突の扱い・送信待ちのキュー・差分の同期をそれぞれ次のトピックで説明します。

面接ではこう答える

オフラインファーストは、画面は常に端末の DB を読んで表示し、サーバーとは裏で同期する設計です。電波がなくても見られて編集もでき、つながったら送るので、表示も速くなります。変更は DB と送信待ちのキューに同じトランザクションで書き、つながったら古い順に送ります。難しいのは端末とサーバーで同じデータが別々に変わったときの衝突で、データの種類ごとに後勝ちにするか、版の番号で検出するかを決めておきます。

別のページで開く →

DBラボを読み込んでいます…

6.6衝突の検出と解決

用語★★★ 必須
ひとことで

衝突は、各行の版の番号を送って食い違いをサーバーで見つける。消えても困らない値だけ後勝ちにする。

オフラインで編集できるようにすると、同じデータが端末とサーバー(や別の端末)で別々に変わる衝突が必ず起きます。データの種類ごとに、どう見つけてどう解決するかを決めておきます。

方法仕組み向いている・注意点
後勝ち(Last Write Wins)最後に届いた変更で上書きする設定のように消えても困らない値向け。端末の時計はずれるので、時刻はサーバーで付ける
バージョンでの検出各行に版の番号を持ち、送るときに「版3を元にした変更」と伝える。サーバーの版が違えば衝突として返す(HTTP の 409 や、If-Match と ETag なら 412)メモ・予定など、黙って消えると困るもの
項目ごとのマージ変わった項目だけを送り、別の項目の変更は両方残す衝突を減らせる。同じ項目どうしは結局どちらかを選ぶ

衝突したときは、サーバーの最新を取り直して「自動でまとめる」「利用者に選んでもらう」「別の版として両方残す」のどれかにします。削除は行を消さずに「削除済み」の印(墓標)を付けて同期しないと、別の端末から復活してしまいます。

同じ版を元にした変更が2つ届く端末 Aサーバー端末 B版3を元に編集版3を元に編集送る版4になるあとで送る版3 ≠ 版4 → 409 で衝突衝突したら最新を取り直し、自動でまとめる・利用者に選んでもらう・両方残す、のどれかにする
同じ版を元にした変更が2つ届くと、後から届いた方は衝突として返される

面接ではこう答える

オフラインの編集では、同じデータが別々に変わる衝突が起きます。設定のように消えても困らない値は、サーバーの時刻で後勝ちにします。メモや予定のように黙って消えると困るものは、各行に版の番号を持ち、どの版を元にした変更かを送り、サーバーの版と違えば 409 などで衝突として返します。衝突したら最新を取り直して、自動でまとめるか、利用者に選んでもらうか、両方を残します。削除は行を消さずに墓標を付けて同期します。

ひとことで

変更と同じトランザクションで送信待ちの表に1行足し、つながったら冪等キー付きで古い順に送る。

オフラインでの変更は、すぐ送れないので「送るべきもの」を DB に記録しておきます。これが送信待ちのキュー(outbox)です。

オフラインでの変更は、DB の更新と同じトランザクションで「送信待ち」の表に1行追加します。つながったら古い順に送り、成功したら消します。

  • 各操作に一意の ID(冪等キー)を付ける。送った後に応答が届かず再送しても、サーバーは二重に処理しない。
  • 同じ行への変更は順番を守って送る。
  • 送る処理は、Android は WorkManager(ネットワークがあるときの条件付き)、iOS はアプリを開いたときと BGTaskScheduler で動かす。
  • 何度送っても失敗する操作(400 など)は止めて、利用者に知らせる。キューの先頭で詰まり続けないようにする。

画面には「未送信」の印を出すと、利用者が安心できます。

送信待ち(outbox)の1行の一生送信待ちDB と同時に追加送信中完了(消す)待って再送失敗(止める)2xx回線・5xx4xx利用者に通知待って再送したものは、間をあけて送信中に戻る(点線)
成功したら消し、一時的な失敗は待って再送、何度も失敗するものは止めて知らせる

面接ではこう答える

オフラインでの変更は、DB の更新と同じトランザクションで送信待ちの表に1行追加します。こうすると、DB は変わったのに送る記録がない、という食い違いが起きません。つながったら古い順に送り、成功したら消します。各操作には冪等キーを付けて、応答が届かず再送してもサーバーで二重に処理されないようにし、400 のように何度送っても失敗するものは止めて利用者に知らせ、キューが詰まらないようにします。

ひとことで

前回どこまで取ったかを同期トークンや時刻で覚え、変わった分と削除の印だけを受け取る。

サーバーからの変更を取るたびに全件を取り直すと、件数が増えるにつれて通信量も時間も増えます。そこで前回どこまで取ったかを覚えておき、その後に変わった分だけを受け取ります。

前回の続きから、変わった分だけ受け取る端末保存したsync_token = t41サーバーt41 より後の変更を探すGET /changes?since=t41変更3件・削除1件・next = t44トークンが古すぎて差分を出せないと言われたら、全件を取り直す(フル同期)
前回もらった同期トークンを送り、その後に変わった分と削除の印だけを受け取る
やり方仕組み注意点
更新時刻で取るupdated_at > 前回の時刻 の行を返す同じ時刻の行の取りこぼしが起きやすい。時刻はサーバーのものを使う
同期トークンサーバーが「ここまで渡した」印を返し、次はそれを送る取りこぼしが起きにくい。トークンの期限切れにはフル同期で応える
プッシュで知らせる変わったことだけ通知し、端末が取りにいく通知は届かないことがあるので、開いたときの同期と組み合わせる

削除は「行がない」ことでは伝わらないので、サーバーは削除の印(墓標)を一定期間残して差分に含めます。墓標を消した後に古いトークンで来た端末には、フル同期をさせます。

面接ではこう答える

差分の同期は、前回どこまで取ったかを覚えておき、その後に変わった分だけを受け取る仕組みです。更新時刻で取る方法もありますが、同じ時刻の行の取りこぼしが起きやすいので、サーバーが返す同期トークンを次に送る方法が確実です。削除は墓標として差分に含め、トークンが古すぎて差分を出せないときはフル同期に切り替えます。

ひとことで

メモリとディスクの2段で持ち、上限を決めて長く使っていないものから消す(LRU)。

画像や API の結果を端末に取っておくと、表示が速くなり通信量も減ります。ただし端末の容量とメモリは限られているので、上限と消し方を決めておきます。

近い順に探す2段のキャッシュ表示したいメモリ速い・小さいディスク少し遅い・大きい通信遅い・通信量なければなければどちらも上限を決め、あふれたら長く使っていないものから消す(LRU)メモリが足りないと OS から言われたら、メモリのキャッシュを捨てる
まずメモリ、なければディスク、それもなければ通信。取ったものは両方に入れる
置き場所向いているもの注意点
メモリ今の画面と直前の画面の画像上限を決める。メモリ不足の通知で捨てる
ディスクのキャッシュのフォルダー画像・取り直せる API の結果iOS の Caches、Android の cacheDir は容量が減ると OS に消されることがある
DBオフラインで見せたいデータ古いデータを消す方針(件数・日数)を決める
アプリのファイルのフォルダー利用者が作ったもの・ダウンロードしたもの消えると困るので、容量を設定画面で見せて消せるようにする

画像の読み込みは、Android なら Coil や Glide、iOS なら Kingfisher や SDWebImage のようなライブラリが、2段のキャッシュと表示の大きさへの縮小をまとめて引き受けてくれます。自作するより、こうしたライブラリの設定(上限・縮小)を確かめるほうが確実です。

面接ではこう答える

アプリのキャッシュは、速いが小さいメモリと、少し遅いが大きいディスクの2段で持ちます。表示するときはメモリ、ディスク、通信の順に探し、取ったものは両方に入れます。どちらも上限を決め、あふれたら長く使っていないものから消す LRU にし、OS からメモリが足りないと言われたらメモリのキャッシュを捨てます。画像は Coil や Kingfisher などのライブラリに任せ、上限と縮小の設定を確かめます。

6.10安全な保存

用語★★☆ 重要
ひとことで

トークンやパスワードは Keychain・Keystore に保存する。普通の設定ファイルには置かない。

端末に保存するものは、中身の大事さで保存先を分けます。普通の設定ファイルは、端末のバックアップや、改造した端末(脱獄・root)から読まれることがあるので、秘密の値は入れません。

保存先の使い分け

保存するものiOSAndroid
表示の設定・最後に開いたタブUserDefaultsDataStore(昔は SharedPreferences)
一覧・下書きなどのデータSQLite(SwiftData・Core Data など)Room(SQLite)
トークン・パスワードなどの秘密KeychainAndroid Keystore の鍵で暗号化して保存
作り直せるキャッシュ(画像など)Caches フォルダーcacheDir(容量が減ると消される)

iOS の Keychain は、いつ読めるかを指定できます(例:kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly は再起動後に一度ロックを解除すれば読め、別の端末へのバックアップの移行には含まれません)。また、アプリを消しても Keychain の項目が残る場合があるので、入れ直したときの扱いを決めておきます。

Android の Keystore は、鍵を端末の安全な領域に置き、鍵そのものを取り出せないようにする仕組みです。データはその鍵で暗号化してから保存します。以前よく使われた Jetpack Security(security-crypto)の EncryptedSharedPreferences は、2025年の 1.1.0 ですべての API が非推奨になり、Keystore を直接使うよう案内されているので、新しく作るなら Keystore の鍵で自分で暗号化する形を選びます。自動バックアップ(dataExtractionRules)からトークンのファイルを外す設定も忘れずに。

端末の暗号化

今の iPhone と Android 端末は、保存領域全体が暗号化されています。ただしこれは「端末を盗まれて分解されたとき」の守りで、ロックを解除した後はアプリから普通に読めます。iOS ではファイルごとに保護の強さを選べ(Data Protection)、既定は「再起動後に一度ロックを解除するまで読めない」です。特に大事なファイルは .complete(ロック中は読めない)にできますが、ロック中に裏で読めなくなるので、裏で使うファイルには向きません。

面接ではこう答える

端末に保存するものは、中身の大事さで保存先を分けます。表示の設定は UserDefaults や DataStore、一覧や下書きは SQLite、作り直せる画像はキャッシュのフォルダーに置き、トークンやパスワードのような秘密は iOS なら Keychain、Android なら Keystore の鍵で暗号化して保存します。普通の設定ファイルはバックアップや改造した端末から読まれることがあるからです。さらに Android では自動バックアップから秘密のファイルを外す設定をします。

この章のまとめ

🖨 まとめシート(印刷・PDF)
  • 端末の DB(SQLite・Room・Core Data)とマイグレーション
  • オフラインで使えるようにし、同期で衝突を扱う
  • 端末の DB:端末の中の SQLite(Room・Core Data・GRDB)。アプリの更新時にスキーマを移行する。
  • WAL モードと同時アクセス:WAL は変更を別のファイルに追記する SQLite の方式で、書き込み中でも読み込みが止まらない。
  • オフラインファーストと同期:まず端末に保存して画面に反映し、つながったらサーバーと同期する。衝突の扱いを決めておく。
  • 衝突の検出と解決:衝突は、各行の版の番号を送って食い違いをサーバーで見つける。消えても困らない値だけ後勝ちにする。
  • 送信待ちのキュー(outbox):変更と同じトランザクションで送信待ちの表に1行足し、つながったら冪等キー付きで古い順に送る。
  • 差分の同期(どこまで取ったか):前回どこまで取ったかを同期トークンや時刻で覚え、変わった分と削除の印だけを受け取る。
  • キャッシュと容量の管理:メモリとディスクの2段で持ち、上限を決めて長く使っていないものから消す(LRU)。
  • 安全な保存:トークンやパスワードは Keychain・Keystore に保存する。普通の設定ファイルには置かない。

この章のクイズ

面接ではこう聞かれる

まず答えを見ずに声に出して答え、そのあと開いて比べる。

Q1オフラインで編集したデータの同期はどう設計しますか?

模範回答

画面は端末の DB を正しい値として読み、変更は DB の更新と同じトランザクションで送信待ちのキューに積みます。つながったら古い順に送り、各操作には一意の ID を付けて、再送されてもサーバーで二重に処理されないようにします。

衝突は、各データに版の番号を持たせて検出します。送るときに元にした版を伝え、サーバーの版と違えば 409 などで衝突として返してもらいます。解決の仕方はデータの性質で決め、設定のような値なら後勝ち、メモのように黙って消えると困るものは、別の項目の変更なら自動でまとめ、同じ項目なら利用者に選んでもらうか両方残します。削除は墓標を付けて同期し、端末の時計はずれるので時刻はサーバーで付けます。画面に未送信の印を出し、何度送っても失敗するものは止めて知らせるようにします。

深掘りされたら

  • 複数の端末で同時に編集される場合はどうしますか?
  • 送信待ちのキューの順番が崩れるとどうなりますか?

見られているポイント衝突の検出と解決

Q2端末の DB のマイグレーションで気をつけることは?

模範回答

端末ごとに DB の版がばらばらなので、どの版からでも最新まで上げられることが一番大事です。1段ずつの変更をすべて残し、何か月も更新していない人が古い版から飛んできても順番に流れるようにします。変更はトランザクションで包み、途中で落ちても中途半端な状態が残らないようにします。

また、Room の fallbackToDestructiveMigration のように「消して作り直す」で逃げると、送っていない下書きなど利用者のデータまで消えます。Room なら版ごとのスキーマを書き出して、古い版から上げるテストを書いておきます。

深掘りされたら

  • 列の名前を変えたいときはどうしますか?
  • マイグレーションに時間がかかる場合は?

見られているポイントどの版からでも上げられること・データを消さないこと

Q3SQLite の WAL モードとは何ですか? なぜ使うのですか?

模範回答

WAL は、変更をまず別のファイル(-wal)に追記し、あとでまとめて本体に書き戻す方式です。既定の方式では書き込み中に読み込みが待たされますが、WAL なら書き込み中でも読み込みが止まりません。裏で同期しながら画面に一覧を出すアプリでは効果が大きく、Room も多くの端末で既定で WAL を使います。

注意点は、DB が本体・-wal・-shm の3つのファイルになることです。本体だけをコピーして書き出すと、-wal にある最近の変更が抜けます。書き出す前は DB を閉じるか、チェックポイントで本体に書き戻します。

深掘りされたら

  • チェックポイントとは何ですか?
  • 書き込みが同時に2つ来たらどうなりますか?

見られているポイント読み込みが止まらない理由と、ファイルが増える落とし穴

Q4オフラインで削除したデータが、別の端末から復活してしまいます。原因と対策は?

模範回答

原因は、削除を「行を消す」ことで表していることです。消した側には行がないので、同期のときに「消した」という情報をサーバーに伝えられず、まだ持っている別の端末から送られると、サーバーは新しいデータとして受け入れてしまいます。

対策は、行を消さずに「削除済み」の印と時刻(墓標)を付け、それも変更として同期することです。サーバーは墓標のあるデータを送られても復活させず、ほかの端末にも削除を伝えます。墓標は、全部の端末に伝わったと考えられる期間が過ぎてから片付けます。

深掘りされたら

  • 墓標はいつ消してよいですか?
  • 削除と編集が同時に起きたらどちらを優先しますか?

見られているポイント削除も同期の対象だと理解しているか

Q5端末のデータまわりの不具合を調べた経験を教えてください。

模範回答

例えば、新しい版を出した直後から、一部の利用者だけ起動直後に落ちるという報告があったとします。クラッシュの記録を見ると DB を開くところで落ちていて、対象は半年以上更新していなかった人に偏っていました。

古い版を入れてデータを作り、そこから新しい版に更新して再現させると、途中の版のマイグレーションが抜けていたことが分かりました。抜けていた段を足した修正版を急いで出し、それ以来、版ごとのスキーマを残して古い版から上げるテストを CI で流すようにしました。

深掘りされたら

  • 再現にはどんな環境を使いましたか?
  • 利用者のデータは守れましたか?

見られているポイント古い版からの更新という端末特有の原因に気づけるか