オフライン優先モバイルアプリのバックグラウンド同期:競合、リトライ、UX
ネイティブKotlinやSwiftUIアプリ向けに、明確な競合ルール、リトライロジック、シンプルな保留中の変更UXを備えたオフライン優先のバックグラウンド同期を設計する方法。

問題:ユーザーはオフラインで編集するが現実は変わる
誰かが良好な接続で作業を始め、エレベーターの中や倉庫の奥、地下鉄のトンネルに入ります。アプリは動き続けるので、その人は作業を続けます。保存をタップしたり、メモを追加したり、ステータスを変えたり、新しいレコードを作るかもしれません。画面はすぐに更新されるので、すべて問題ないように見えます。
後で接続が復活すると、アプリはバックグラウンドで追いつこうとします。ここでバックグラウンド同期が驚きを生むことがあります。
アプリが注意深くないと、同じ操作が二重に送信されたり(重複)、サーバー上の新しい変更がユーザーの編集を上書きしてしまったり(編集が失われる)します。時には「保存済み」と「保存されていない」が同時に表示されたり、レコードが同期の後に現れたり消えたり再出現したりすることがあります。
競合は単純です:アプリが調整する機会を得る前に、同じ対象に対して別々の変更が行われたときに起きます。例えば、サポート担当者がオフラインでチケットの優先度を「高」に変えた一方で、同僚はオンラインでチケットをクローズした場合、再接続時に両方の変更をきれいに適用するにはルールが必要です。
目標はオフラインを完璧に見せることではありません。予測可能にすることが目標です:
- ユーザーが作業を失う不安なく続けられること。
- 同期が後で行われても不明な重複が発生しないこと。
- 注意が必要な場合は、アプリが何が起きたかと次に何をすべきかを明確に伝えること。
これはKotlin/SwiftUIで手書きする場合でも、AppMasterのようなノーコードプラットフォームでネイティブアプリを作る場合でも同じです。難しいのはUIウィジェットではなく、ユーザーがオフラインの間に世界が変わったときにアプリがどう振る舞うかを決めることです。
簡単なオフライン優先モデル(専門用語なし)
オフライン優先アプリは、端末が時々ネットワークを失うことを前提にしますが、それでもアプリが使いやすく感じられるべきです。サーバーに到達できないときでも画面は読み込み、ボタンは動くべきです。
重要な用語は四つで足ります:
- ローカルキャッシュ:端末に保存されたデータで、画面を即座に表示するためのもの。
- 同期キュー:ユーザーがオフライン(あるいはネットワークが不安定)な間に行った操作の一覧。
- サーバーの真実:最終的にみんなが共有するバックエンド上の版。
- 競合:キューに入れたユーザーの変更が、サーバー側の変更によってそのまま適用できなくなること。
読み取りと書き込みを分けて考えるのが役に立ちます。
読み取りは普通は単純です:利用可能なベストなデータ(多くはローカルキャッシュ)を表示し、ネットワークが戻ったら静かに更新します。
書き込みは異なります。レコード全体を一度に“保存する”ことに頼らないでください。オフラインになるとそれは破綻します。
代わりに、ユーザーがしたことを変更ログの小さなエントリとして記録します。例:「ステータスを承認に設定」「コメントXを追加」「数量を2から3に変更」。各エントリはタイムスタンプとIDとともに同期キューに入り、バックグラウンド同期がそれを配信しようとします。
ユーザーは作業を続けられ、変更は保留中から同期済みへと移行します。
AppMasterのようなノーコードプラットフォームを使う場合でも、同じ基本ブロックが必要です:高速表示のためのキャッシュされた読み取りと、再試行、マージ、競合発生時のフラグ付けができる明確なユーザー操作のキューです。
本当にオフライン対応が必要なものを決める
「オフラインファースト」は「接続なしで全部動く」と聞こえがちですが、その約束が多くのアプリを難しくします。本当にオフライン対応が有益な部分を選び、残りは明確にオンライン専用にしてください。
ユーザーの意図で考えます:地下室、飛行機、電波の届きにくい倉庫で人々は何をする必要があるか?良いデフォルトは、日常業務を作成・更新する操作はサポートし、最新の真実が重要な操作はブロックすることです。
現実的にオフライン向きの操作には、主要レコード(メモ、タスク、点検、チケット)の作成・編集、コメントの下書き、写真の添付(ローカルに保存して後でアップロード)などが含まれます。削除はソフトデリートにして、サーバーの確認が取れるまで元に戻せるウィンドウを設けるのが安全です。
リアルタイムである必要があるものも決めてください。支払い、権限変更、承認、機密データに関わるものは通常接続を要求すべきです。サーバー確認なしに操作が有効かどうか分からない場合はオフラインで許可しないで、あいまいなエラーではなく「接続が必要です」と明確に表示しましょう。
鮮度の期待値を設定してください。「オフライン」は二値ではありません。データがどの程度古くても許されるか(分、時間、次回アプリ起動時など)を定義し、それをUIに「最終更新 2時間前」「オンライン時に同期中」などの平易な言葉で示します。
最後に高競合データは早めにフラグを立てます。在庫数、共有タスク、チームメッセージは複数人が短時間で編集しやすく競合が発生しやすいです。そうしたものではオフライン編集をドラフトに限定したり、単一の値を上書きするのではなく変更をイベントとして記録することを検討してください。
AppMasterで構築するなら、この決定ステップでデータとビジネスルールをモデリングし、危険な操作はオンライン専用、オフラインでは安全なドラフトを保存するようにします。
同期キューの設計:各変更で何を保存するか
ユーザーがオフラインで作業するときは「データベース全体を同期しよう」としないで、ユーザーの操作を同期してください。明確な操作キューはバックグラウンド同期のバックボーンであり、問題が起きたときに理解しやすく保てます。
操作はユーザーが実際に行ったことに沿って小さく、人に判る形に保ちます:
- レコードの作成
- 特定フィールドの更新
- ステータス変更(提出、承認、アーカイブ)
- 削除(可能ならサーバー確認までソフトデリート)
小さな操作はデバッグが簡単です。サポートがユーザーを助けるときに「Draft → Submitted にステータスを変更した」と読む方が、巨大なJSON差分を調べるよりずっと楽です。
キューに入れる各操作について、安全に再生し競合を検出するための十分なメタデータを保存してください:
- レコード識別子(新規レコードなら一時的なローカルID)
- 操作のタイムスタンプとデバイス識別子
- レコードの期待バージョン(または最後に確認した更新時刻)
- ペイロード(どのフィールドを変更したか、可能なら古い値も)
- 冪等性キー(再試行で重複を作らないための一意の操作ID)
この「期待バージョン」が正直な競合処理の鍵です。サーバー側のバージョンが進んでいるなら、一旦保留にして決定をユーザーに委ねることができます。
ユーザーが一つのステップだと感じる操作は一緒に適用される必要があります。例えば「注文を作成」してから「3つの明細を追加する」は一体で成功または失敗すべきです。グループID(トランザクションID)を保存して、同期エンジンがまとめて送信し、すべてコミットするかすべて保留にするようにします。
手作りでもAppMasterでも目標は同じです:すべての変更を一度だけ記録し、安全に再生し、不一致があれば説明できるようにすることです。
ユーザーに説明できる競合解決ルール
競合は普通に起きます。目標はそれを不可能にすることではなく、稀で安全、そして発生したときに説明が簡単であることです。
競合が発生する瞬間を名前で示してください:アプリが変更を送信し、サーバーが「そのレコードはあなたが編集を始めたバージョンではありません」と返すときです。だからバージョン管理が重要になります。
各レコードに二つの値を持たせましょう:
- サーバーバージョン(サーバー上の現在の版)
- 期待バージョン(端末が編集中だと思っていた版)
期待バージョンが一致すれば更新を受け入れてサーバーバージョンを上げます。一致しなければ競合ルールを適用します。
データ型ごとにルールを選ぶ(全体に一つのルールを使わない)
データの種類によって適切なルールは異なります。ステータスフィールドは長いメモと同じではありません。
ユーザーが理解しやすいルールの例:
- 最新書き込みが勝つ(Last write wins):ビュー設定などリスクの低いフィールド向け。
- フィールドごとにマージ:ステータスとメモのように独立したフィールドがある場合に有効。
- ユーザーに問い合わせる:価格や権限、合計などのリスクが高い編集はユーザー判断。
- サーバー優先+コピー保存:サーバーの値を維持しつつ、ユーザー編集をドラフトとして保存して後で再適用できるようにする。
AppMasterでは、これらのルールは視覚的ロジックにうまくマッピングできます:バージョンをチェックし、フィールドを比較して、進む経路を選ぶという流れです。
削除の挙動を決める(さもないとデータを失う)
削除はやっかいです。レコードをすぐに削除するのではなくトゥームストーン(deletedマーカー)を使ってください。その上で、どこかで削除されたレコードを誰かが編集した場合にどうするかを決めます。
明快なルールの一例:「削除が勝つが、復元できる」。例:営業担当がオフラインで顧客メモを編集している間に管理者がその顧客を削除したら、同期時にアプリは「顧客は削除されました。復元すると編集を適用しますか?」と表示します。これにより沈黙のうちにデータが失われるのを防ぎ、ユーザーに選択権を与えます。
リトライと失敗状態:予測可能に保つ
同期が失敗したとき、ほとんどのユーザーは理由には興味がありません。彼らが気にするのは自分の作業が安全かどうか、次に何が起きるかです。予測可能な状態モデルはパニックやサポート対応を減らします。
まずは小さく目に見えるステータスモデルを用意し、画面全体で一貫して使いましょう:
- Queued(キュー済み):端末に保存され、ネットワーク待ち
- Syncing(同期中):送信中
- Sent(送信済み):サーバー確認済み
- Failed(失敗):送信できず、再試行するか注意が必要
- Needs review(要確認):送信はしたがサーバーが拒否またはフラグを立てた
リトライはバッテリーとデータに優しくあるべきです。最初は短い間隔で素早く再試行し(短時間の通信切断に対応)、その後は間隔を延ばします。1分、5分、15分、その後は時間単位といったシンプルなバックオフは分かりやすいです。また、無意味な再試行は避けます(無効な変更を延々と再送しない)。
エラーごとに扱いを分けてください。次に何をすべきかが変わるためです:
- オフライン/ネットワーク無し:キューに留め、オンライン時に再試行
- タイムアウト/サーバー不可:失敗としてマークし、バックオフ付きで自動再試行
- 認証期限切れ:同期を一時停止し、ユーザーに再サインインを促す
- バリデーション失敗(入力不正):要確認、修正すべき点を表示
- 競合(レコードが変更された):要確認、競合ルールへ誘導
冪等性は再試行を安全にする要です。すべての変更には一意の操作ID(通常はUUID)を付けて送ります。アプリが同じ変更を再送しても、サーバーがそのIDを認識して同じ結果を返し、重複を作らないようにします。
例:技術者が作業完了をオフラインで保存し、エレベーターに入ったとします。アップデートを送信してタイムアウトし、後で再試行します。操作IDがあれば二度目の送信は無害です。なければ「完了」イベントが重複して作られるかもしれません。
AppMasterでは、これらの状態とルールを同期プロセスの第一級フィールドとロジックとして扱い、KotlinやSwiftUIのアプリがどこでも同じように振る舞うようにします。
保留中の変更のUX:ユーザーが見るものとできること
人々はオフラインでアプリを使うとき、安全だと感じたいものです。良い「保留中の変更」UXは落ち着いて予測可能で、作業が端末に保存されていることを認め、次のステップを分かりやすく示します。
目立ちすぎないインジケーターが警告バナーより有効です。例えばヘッダーに小さな「同期中」アイコンを表示したり、編集画面に控えめに「3件保留中」とラベル付けするなど。危険を示す色は本当に危険なとき(例:サインアウトでアップロード不可)にだけ使います。
ユーザーが状況を理解できる場所を一つ用意します。シンプルな送信箱(Outbox)や保留中の変更画面に「チケット104にコメントを追加」や「プロフィール写真を更新」といった平易な文で項目を並べるだけで、不安は減りサポートの負担も下がります。
ユーザーができること
多くの人はほんの数種類の操作だけで十分です。それらはアプリ全体で一貫しているべきです:
- 今すぐ再試行
- もう一度編集(新しい変更が作られる)
- ローカル変更を破棄
- 詳細をコピー(問題報告時に便利)
ステータスラベルはシンプルに:保留中、同期中、失敗。失敗時は人間が話すように説明します:「アップロードできません。インターネット接続がありません。」や「他の人がこのレコードを変更したため拒否されました。」エラーコードは避けます。
アプリ全体をブロックしない
Stripeでの支払い、ユーザー招待など、本当にオンラインが必要な操作だけをブロックします。それ以外は閲覧やドラフト作成を含めて動くべきです。
現実的なフロー:現場の技術者が地下で作業報告を編集します。アプリは「1件保留中」と表示し、作業を続けさせます。後で「同期中」となり、正常なら自動でクリアされます。失敗した場合、作業報告は「失敗」と表示され、単一の「今すぐ再試行」ボタンがあれば十分です。
AppMasterで構築するなら、各レコードに(保留中、失敗、同期済み)といった状態をモデル化しておくと、UIはあちこちで特別扱いをせずに状態を反映できます。
認証、権限、オフラインでの安全性
オフラインモードはセキュリティモデルを変えます。ユーザーは接続がないときに操作を行えますが、サーバーが最終的な真実です。キューに入った変更は「要求されたもの」であって「承認済み」ではないと扱ってください。
オフライン中のログイン期限切れ
トークンは期限切れになります。その場合でもユーザーが編集を作り続けられるようにし、それらは保留として保存してください。支払いや管理者承認のようにサーバー確認が必要な操作は完了したと示してはいけません。同期が成功するまで保留として示します。
オンラインに戻ったらまずサイレントリフレッシュを試みます。どうしても再サインインが必要なら一度だけ促し、その後は自動で同期を再開します。
再ログイン後は、送信前にキュー項目を再検証してください。共有デバイスではユーザーIDが変わっている可能性があり、古い編集が誤ったアカウントで同期されないようにします。
権限変更と禁止された操作
権限はオフライン中に変わることがあります。昨日は許可されていた編集が今日は禁止されていることがあります。これを明示的に扱ってください:
- キュー内の各操作についてサーバー側で権限を再確認する
- 禁止された場合はその項目を停止し、明確な理由を表示する
- ユーザーのローカル編集は保持してコピーしたりアクセスをリクエストできるようにする
- 「禁止エラー」に対する無限再試行は避ける
例:サポート担当が飛行機で顧客メモをオフライン編集している間に役割が剥奪されたとします。同期時にサーバーが更新を拒否したら、アプリは「アップロードできません:アクセス権がありません」と表示し、メモをローカルドラフトとして保持します。
オフラインに保存する機密データ
画面表示とキュー再生に必要な最小限を保存してください。オフラインストレージは暗号化し、秘密情報のキャッシュは避け、ログアウト時のルールを明確にします(例:ローカルデータを消す、またはユーザーの明示的同意があればドラフトだけ保持する)。AppMasterで構築する場合は認証モジュールから始め、同期前に有効なセッションを必ず待つようにキューを設計してください。
作業を失ったり重複レコードができる原因となる落とし穴
ほとんどのオフラインバグは目新しいものではありません。完璧なWi‑Fiでテストしているときは無害に見える小さな決定が、実際の現場では壊れてしまいます。
よくある失敗の一つは無言の上書きです。アプリが古い版をアップロードしてサーバーがチェックせずに受け入れると、他人の新しい編集を消してしまい、気づかれるのは手遅れになることがあります。バージョン番号(またはlast updatedスタンプ)で同期し、サーバー側が進んでいる場合は上書きを拒否してユーザーに選択を提示してください。
もう一つはリトライの嵐です。弱い接続に戻ったときに端末が毎秒バックエンドを叩き続けると、バッテリーを消耗し重複書き込みが発生します。再試行は落ち着いて行い、失敗ごとに間隔を延ばし、多数の端末が同時に再試行しないように少しランダム性を入れます。
作業が失われたり重複を生む原因になりやすい間違い:
- すべての失敗を「ネットワーク」として扱う:恒久的エラー(無効なデータ、権限不足)と一時的エラー(タイムアウト)を分ける
- 同期失敗を隠す:何が失敗したか見えなければ人は作業をやり直し、二重に作る
- 保護なしに同じ変更を二度送る:常に一意のリクエストIDを付ける
- 重要なテキストフィールドを自動マージして誰にも知らせない:自動マージするならレビューを可能にする
- オフラインで安定したIDなしにレコードを作る:一時ローカルIDを使い、アップロード後にサーバーIDへマップする
短い例:現場技術者がオフラインで新しい「訪問記録」を作り、その後二度編集してから再接続したとします。作成コールが再試行で二重にレコードを作ると、後の編集が間違ったレコードに付与される恐れがあります。安定したIDとサーバー側の重複排除でこれを防げます。
AppMasterで作る際もルールは変わりません。違いはそれらをどこに実装するかです:同期ロジック、データモデル、そして「失敗」や「送信済み」を示す画面です。
事例:二人が同じレコードを編集したら
フィールド技術者のMayaは電波のない地下で「Job #1842」を更新しています。ステータスを「作業中」から「完了」に変更し、「バルブを交換、テストOK」とメモを追加しました。アプリは即座に保存し、それが保留中として表示されます。
上階では同僚のLeoがオンラインで同じ作業を同時に編集しており、スケジュール時間を変更し別の技術者に割り当てました。Mayaが再び電波を得るとバックグラウンド同期が静かに始まります。予測可能でユーザーフレンドリーな流れは次の通りです:
- Mayaの変更はまだ同期キューに残っている(ジョブID、変更フィールド、タイムスタンプ、最後に見たレコードのバージョン)。
- アプリがアップロードを試みると、サーバーが「このジョブはあなたが見ていた版から更新されています」と返す(競合)。
- 競合ルールが走る:ステータスとメモはマージできるが、割り当てはサーバー側で後に行われた変更が勝つ。
- サーバーはマージした結果を受け入れる:ステータス=「完了」(Maya)、メモ追加(Maya)、担当者=Leoの選択(Leo)。
- Mayaのアプリは「同期されました。割り当てがオフライン中に変更されました。」という明確なバナーで再表示され、細目の「レビュー」アクションで何が変わったか見られる。
ここに失敗が一つ加わるとします:Mayaのログイントークンがオフライン中に期限切れになっていた。最初の同期試行は「サインインが必要」と失敗します。アプリは編集を保持し、「一時停止」と表示して簡単なプロンプトを出します。サインイン後、同期は自動で再開され、何も打ち直す必要はありません。
バリデーション問題があれば(例:「完了」には写真が必須)、アプリは推測しないでください。「要対応」と表示し、追加すべきものを正確に示してから再送させます。
AppMasterのようなプラットフォームはここで助けになります。キュー、競合ルール、保留状態のUXを視覚的に設計でき、ネイティブのKotlinやSwiftUIアプリを生成できます。
早見チェックリストと次のステップ
オフライン同期をワラワラした修正の山ではなく、エンドツーエンドの機能として扱ってください。目標はシンプル:ユーザーが自分の作業が保存されたかどうか疑問に思わないこと、アプリが驚くべき重複を作らないこと。
基礎がしっかりしているか確認する短いチェックリスト:
- 同期キューは端末に保存され、すべての変更に安定したローカルIDと、利用可能ならサーバーIDがある。
- 明確なステータスが存在する(queued, syncing, sent, failed, needs review)し、一貫して使われている。
- リクエストは冪等(再試行しても安全)で、各操作に冪等性キーが含まれている。
- レコードにバージョン管理(updatedAt、リビジョン番号、ETagなど)があり競合検出ができる。
- 競合ルールは平易な言葉で書かれている(何が勝つか、何がマージされるか、いつユーザーに訊くか)。
基盤が整ったら、体験がデータモデルと同じくらい強固であるか検証してください。ユーザーは保留中のものを見て、失敗した理由を理解し、作業を失う恐れなく対処できるべきです。
現実に即したシナリオでテストします:
- 機内モード編集:作成、更新、削除してから再接続。
- 不安定なネットワーク:同期の途中で接続が切れても再試行で重複しない。
- アプリ強制終了:送信中に強制終了して再起動してもキューが復元される。
- 時計のずれ:端末時刻がずれていても競合検出が機能するか。
- ダブルタップ:ユーザーが保存を二度押ししてもサーバー側では一つの変更になるか。
UIを磨く前にフルフローのプロトタイプを作ってください。1画面、1レコードタイプ、1つの競合ケース(同一フィールドへの二人の編集)で始めます。簡単な同期ステータス領域、失敗用の再試行ボタン、1つの明確な競合画面を用意して、それが動いたら他の画面にも展開します。
コード不要で作る場合、AppMaster (appmaster.io) はバックエンドとネイティブKotlinおよびSwiftUIアプリを生成できるので、キュー、バージョンチェック、ユーザー向け状態表示に集中でき、配線作業を手作業でやる必要が減ります。


