Kotlin + SQLite のオフラインファーストフォームにおける競合解決
オフラインファーストのフォームでの競合解決を学ぶ:明確なマージルール、Kotlin + SQLite のシンプルな同期フロー、編集競合の実用的な UX パターン。

2 台がオフラインで編集したときに実際に何が起きるか
オフラインファーストのフォームは、ネットワークが遅かったり使えないときでもデータを見たり編集したりできるようにします。サーバーを待つ代わりに、アプリはまずローカルの SQLite に変更を書き込み、後で同期します。
それは操作感としては即時ですが、現実は単純です:2 台のデバイスが互いの存在を知らないまま同じレコードを変更する可能性があります。
典型的な競合の例は次のとおりです:現場の技術者が地下で電波が届かないタブレットで作業指示を開き、status を「Done」にしてメモを追加します。同時に別の監督者が別の電話で同じ作業指示を更新し、担当を変更し、期限を編集します。双方が保存を押し、両方ともローカルでは成功します。誰も悪くありません。
同期が起きたとき、サーバーは「本当の」レコードを決めなければなりません。競合を明示的に扱わないと、通常次のどれかになります:
- 最後の書き込み勝ち(LWW):後で同期した方が前の変更を上書きして誰かのデータが失われる。
- 強制失敗:同期が一方の更新を拒否し、アプリが役に立たないエラーを表示する。
- 重複レコード:上書きを避けるために別のコピーが作られ、レポートがややこしくなる。
- 無自覚なマージ:システムが変更を合成するが、ユーザーの期待と違う形でフィールドを混ぜてしまう。
競合はバグではありません。オフラインで作業できるようにすること自体が競合を生む予測可能な結果だからです。
目的は二つあります:データを守ることと、アプリを使いやすく保つこと。多くの場合、フィールド単位の明確なマージルールと、本当に必要なときだけユーザーの操作を中断する UX が答えになります。異なるフィールドを編集しているなら静かにマージできますが、同じフィールドを別の値に変更しているなら、それを可視化して正しい結論を選ぶ手助けをするべきです。
データに合った競合戦略を選ぶ
競合はまず技術の問題ではなく、二人が同期前に同じレコードを変えたときに何が「正しい」と見なすかというプロダクト上の判断です。
オフラインアプリのほとんどは次の 3 つの戦略でカバーできます:
- 最後の書き込み勝ち(LWW):最新の編集を受け入れて古い方を上書きする。
- 手動レビュー:停止して人間にどちらを残すか選んでもらう。
- フィールド単位マージ:フィールドごとに変更を組み合わせ、同じフィールドが両方で触られているときだけ尋ねる。
LWW は速度が正確さより重要で、間違っても問題が小さいときに許容できます。内部メモや重要でないタグ、あとで再編集できる下書きなどが該当します。
手動レビューは、アプリが推測すべきでない高影響フィールドに対して安全な選択です:法的文書、コンプライアンス確認、給与や請求額、銀行情報、投薬指示など、誤りが責任問題を生む分野です。
フィールド単位マージは、役割ごとに異なる部分を更新するフォームのデフォルトとして通常最適です。サポート担当は住所を編集し、営業は契約更新日を変える――フィールド単位のマージは両方を残し、誰も邪魔しません。しかし両方が同じ 更新日 を編集しているなら、そのフィールドは決定を促すべきです。
何かを実装する前に、ビジネスにとって「正しい」とは何かを書き出してください。簡単なチェックリスト:
- どのフィールドが常に最新の実世界の値を反映すべきか(例:現在の
status)? - どのフィールドは履歴的で上書きされるべきではないか(例:提出日時)?
- 誰が各フィールドを変更できるか(役割、所有権、承認)?
- 値が不一致のときの真の参照元はどれか(デバイス、サーバー、マネージャー承認)?
- 間違った選択をしたら何が起きるか(些細な不便なのか、財務や法的影響か)?
これらのルールが明確になれば、同期コードの仕事はそれを強制することだけです。
画面単位ではなくフィールドごとにマージルールを定義する
競合が起きると、フォーム全体が均等に影響を受けることは稀です。あるユーザーは電話番号を更新し、別のユーザーはメモを追加するかもしれません。レコード全体をすべてか無かで扱うと、良い作業をやり直させてしまいます。
フィールド単位のマージは各フィールドが既知の振る舞いを持つので予測可能です。UX は落ち着き、速くなります。
始める簡単な方法はフィールドを「通常は自動マージして安全」と「通常は自動マージが危険」に分けることです。
通常自動マージして安全なもの:notes や内部コメント、tags、添付ファイル(多くは集合の和で扱う)、last contacted のようなタイムスタンプ(最新を保持することが多い)。
通常自動マージが危険なもの:status や状態、assignee/所有者、合計/価格、承認フラグ、在庫数など。
次にフィールドごとに優先ルールを選びます。一般的な選択肢はサーバー優先、クライアント優先、役割優先(例:マネージャーが代理人を上書き)、あるいは決定的なタイブレーカー(最新のサーバーバージョン)です。
両者が同じフィールドを変更したときにどうするか、各フィールドに対して次のいずれかを決めてください:
- 明確なルールで自動マージ(例:
tagsは集合の和) - 両方の値を保持(例:
notesは著者と時刻を付けて追記) - レビューのためにフラグを立てる(例:
statusやassigneeは選択を要求)
例:2 人のサポート担当が同じチケットをオフラインで編集。担当 A が status を「Open」から「Pending」に変え、担当 B が notes を編集して refund タグを追加した。同期時に notes と tags は安全にマージできるが、status は無自覚にマージすべきではない。status のみをプロンプトで解決し、他は既にマージされた状態にする。
後の議論を防ぐために、各フィールドのルールは一文で文書化してください:
- "
notes: 両方を保持し、最新を末尾に追加、著者と時刻を含める。" - "
tags: 和集合、両方で明示的に削除されない限り削除しない。" - "
status: 両側が変更したらユーザーに選択を依頼する。" - "
assignee: マネージャーが変更していればマネージャー優先、そうでなければサーバー優先。"
その一文が Kotlin コード、SQLite クエリ、競合 UI の真実のソースになります。
データモデルの基本:SQLite におけるバージョンと監査フィールド
競合を予測可能にしたければ、同期するすべてのテーブルに小さなメタデータ列を追加してください。それがないと、それが新しい編集なのか古いコピーなのか、あるいはマージが必要な 2 つの編集なのか判別できません。
サーバー同期される各レコードに対する実用的な最小セット:
id(安定した主キー):再利用しないことversion(整数):サーバーでの成功した書き込みごとにインクリメントするupdated_at(タイムスタンプ):レコードが最後に変更された時刻updated_by(テキストまたはユーザー ID):最後に変更した人
デバイス側には、サーバーで確認されていない変更を追うローカル専用フィールドを追加します:
dirty(0/1):ローカルの変更が存在するpending_sync(0/1):アップロードキューに入っているが確認されていないlast_synced_at(タイムスタンプ):この行が最後にサーバーと一致した時刻sync_error(テキスト、任意):UI に表示する最後の失敗理由
楽観的同時実行制御は、静かな上書きを防ぐ最も単純なルールです:各更新にあなたが編集していると思っているバージョン(expected_version)を含めます。サーバーのレコードがまだそのバージョンであれば更新は受け入れられ、サーバーは新しいバージョンを返します。そうでなければ競合です。
例:ユーザー A と B は両方 version = 7 をダウンロードしました。A が先に同期するとサーバーは 8 に上げます。B が expected_version = 7 で同期を試みると、サーバーは競合で拒否し、B のアプリは上書きするのではなくマージを行います。
良い競合画面のために、共有の出発点(ユーザーが元々編集したベース)を保存してください。一般的な方法:
- 最後に同期したレコードのスナップショットを保存する(1 列に JSON、または並行テーブル)。
- 変更ログを保存する(編集ごとに行、またはフィールドごとに行)。
スナップショットはシンプルでフォームには十分なことが多いです。変更ログは重くなりますが、何がどのフィールドで変わったかを正確に説明できます。
どちらにしても、UI は各フィールドに対して 3 つの値を表示できるべきです:ユーザーの編集値、サーバーの現在値、そして共有の出発点。
レコードスナップショット vs 変更ログ:どちらかを選ぶ
オフラインファーストのフォームを同期するとき、完全なレコード(スナップショット)をアップロードするか、操作のリスト(変更ログ)をアップロードするか選べます。Kotlin と SQLite の両方で動きますが、同じレコードを 2 人が編集したときの振る舞いは異なります。
オプション A: 全レコードのスナップショット
スナップショットでは、保存のたびに最新の全状態(すべてのフィールド)を書きます。同期時にはレコードとバージョン番号を送ります。サーバーがバージョンを古いと判断すると競合が発生します。
これは実装が単純で読み取りが速いですが、必要以上に大きな競合を生むことがあります。ユーザー A が電話番号を編集し、B が住所を編集した場合でも、スナップショット方式だと重なっていると見なされることがあります。
オプション B: 変更ログ(操作)
変更ログでは、何が変わったかを保存し、完全なレコードは保存しません。各ローカル編集がリプレイできる操作になります。
しばしばマージしやすい操作の例:
- フィールド値をセットする(
emailを新しい値に設定) - ノートを追記する(新しいノート項目を追加)
- タグを追加する(集合にタグを追加)
- タグを削除する(集合からタグを削除)
- チェックボックスを完了にする(
isDoneを true にしてタイムスタンプを付与)
操作ログは競合を減らすことができます。多くの操作は重ならないからです。ノートの追記は他人の別の追記とほとんど競合しません。タグの追加/削除は集合演算のようにマージできます。単一値フィールドについては、依然として競合する場合はフィールドごとのルールが必要です。
トレードオフは複雑さです:安定した操作 ID、順序(ローカルシーケンスとサーバー時刻)、可換でない操作に対するルールが必要になります。
クリーンアップ:同期成功後の圧縮
操作ログは増えるので縮小方法を計画してください。
一般的な方法はレコードごとの圧縮です:既知のサーバーバージョンまでの全ての操作が確認されたら、それらを新しいスナップショットに折り込み、古い操作を削除します。取り消し(undo)や監査、デバッグを容易にする必要がある場合は短い履歴は残します。
Kotlin + SQLite のためのステップバイステップ同期フロー
良い同期戦略は、送るものと受け入れるものに厳格であることがほとんどです。目標は単純:新しいデータを誤って上書きしないこと、そして安全にマージできないときは競合を明確にすることです。
実用的なフロー:
-
すべての編集をまず SQLite に書き込む。 ローカルトランザクションで変更を保存し、レコードを
pending_sync = 1にマークします。local_updated_atと最後に知っているserver_versionを保存します。 -
全レコードではなくパッチを送る。 接続が戻ったら、レコード ID と変更されたフィールドだけ、そして
expected_versionを送ります。 -
サーバーにバージョン不一致を拒否させる。 サーバーの現在のバージョンが
expected_versionと違えば、サーバーは競合ペイロード(サーバーのレコード、提案された変更、どのフィールドが異なるか)を返します。バージョンが一致すればパッチを適用し、バージョンをインクリメントして更新済みレコードを返します。 -
まず自動マージを適用し、必要ならユーザーに聞く。 フィールド単位のマージルールを実行します。
notesのような安全なフィールドは、statusやprice、assigneeのような敏感フィールドと区別して扱います。 -
最終結果をコミットして保留フラグをクリアする。 自動マージでも手動解決でも、最終レコードを SQLite に書き戻し、
server_versionを更新し、pending_sync = 0にして、後で何が起きたか説明できる監査データを記録します。
例:2 人の営業が同じ注文をオフラインで編集。A が配達日を変え、B が顧客電話番号を変えた。パッチを使えばサーバーは両方の変更をきれいに受け入れられます。両方が配達日を変えていたら、全体を再入力させる代わりに一つの明確な決定を提示します。
UI の約束を一貫させてください:「保存済み」はローカルに保存されたことを意味し、「同期済み」は別の明確な状態にしてください。
フォームでの競合解決の UX パターン
競合は例外であるべきで、通常のフローではないようにするべきです。安全に自動マージできるものをまずマージし、本当に必要なときだけユーザーに尋ねてください。
安全なデフォルトで競合を稀にする
もし 2 人が異なるフィールドを編集しているなら、モーダルを表示せずに自動マージしてください。両方の変更を保持し、小さな「同期後に更新されました」のメッセージを出します。
プロンプトは真の衝突にのみ予約してください:同じフィールドが両方で変えられている場合、またはある変更が別のフィールドに依存している場合(例:status とその理由)。
聞く必要があるときは素早く終わらせる
競合画面は二つのことに答えるべきです:何が変わったか、そして何が保存されるか。値を並べて比較できるように:"あなたの編集"、"相手の編集"、そして "保存される結果"。もし衝突しているのが 2 フィールドだけなら、フォーム全体を見せる必要はありません。直接そのフィールドにジャンプし、残りは読み取り専用にします。
選択肢は必要最小限に保ってください:
- 自分のを保持
- 相手のを保持
- 最終値を編集
- フィールドごとに確認(必要な場合のみ)
部分的なマージは UX が混乱しやすい箇所です。競合しているフィールドだけをハイライトし、ソースを明確にラベル付け("Yours" と "Theirs")してください。安全な選択肢を事前選択して、ユーザーが確認して先に進めるようにします。
放置した場合に何が起きるかを明示して期待値を設定してください:例えば「ローカル版は保持して後で再試行します」や「このレコードは選択があるまで Needs review のままになります」のように。一覧でその状態を見えるようにして、競合が見失われないようにします。
AppMaster でこのフローを作るときも同じアプローチが当てはまります:まず安全なフィールドを自動マージし、特定のフィールドが衝突したときだけ焦点を絞ったレビュー手順を表示します。
困難なケース:削除、重複、そして「行方不明」レコード
ランダムに見える同期問題の多くは次の 3 つから来ます:誰かが削除している間に誰かが編集している、2 台がオフラインで同じものを作成する(重複草稿)、あるいはレコードが消えてまた現れる。これらは明確なルールが必要です。LWW は驚きを生みやすいです。
削除 vs 編集:どちらが勝つか?
削除が編集より強いかどうかを決めてください。多くの業務アプリでは削除の方が勝つのが期待に合っています。
実用的なルール例:
- どこかのデバイスでレコードが削除されたら、古い編集があっても全体として削除扱いにする。
- 削除を元に戻せる必要があるなら、ハード削除の代わりにアーカイブ状態にする。
- 削除後に編集が届いた場合、その編集は監査履歴として保持するがレコードは復活させない。
オフライン作成の衝突と重複草稿
オフラインでは一時 ID(UUID など)を作ってからサーバーが最終 ID を割り当てることが多く、同じ実体に対して 2 つの草稿が作られてしまうことがあります。
安定した自然キー(領収書番号、バーコード、メール+日付)があるなら、それで衝突を検出してください。ない場合は重複が起きるのを受け入れ、後で簡単にマージできるオプションを提供してください。
実装のヒント:SQLite に local_id と server_id の両方を保持します。サーバーが応答したらマッピングを書き込み、キューにある変更がまだローカル ID を参照していないと確信できるまでそのマッピングを保持します。
同期後の「復活」を防ぐ
復活は Device A がレコードを削除したが、Device B がオフラインで古いコピーをアップロードして upsert し、レコードが再作成されてしまうときに起きます。
対策はトゥームストーンです。行をすぐ削除する代わりに deleted_at(しばしば deleted_by と delete_version も)でマークします。同期中にトゥームストーンは実際の変更として扱い、古い未削除状態を上書きできるようにします。
トゥームストーンをどれくらい保持するか決めてください。ユーザーが何週間もオフラインになる可能性があるなら、その期間より長く保持します。アクティブなデバイスが削除より後のバージョンまで同期したと確信できてからパージしてください。
取り消しをサポートする場合は、取り消しも別の変更として扱ってください:deleted_at をクリアし、バージョンを上げます。
データ損失やユーザー不満を招く一般的なミス
多くの同期失敗は、小さな前提が原因で良いデータを静かに上書きしてしまうことに起因します。
ミス 1:編集の順序付けにデバイス時刻を信用する
端末は誤った時計を使っていたり、タイムゾーンが違ったり、ユーザーが時刻を手動で変えることがあります。デバイス時刻で順序付けすると、やがて編集の適用順序を間違えます。
サーバー発行のバージョン(単調増加の serverVersion)を優先し、クライアント時刻は表示専用にしてください。どうしても時刻が必要なら安全策を入れてサーバー側で調整してください。
ミス 2:敏感なフィールドで誤って LWW を使う
LWW は簡単ですが、status、合計、承認、担当者のように誰が最後に同期したかで決めて良いフィールドでは致命的な問題を起こすことがあります。
高リスクフィールドの安全チェックリスト:
statusの遷移は状態機械として扱い、自由なテキスト編集にしない。- 合計は行アイテムから再計算する。合計値をそのままマージしない。
- カウンターは差分(デルタ)を適用してマージする。
- 所有者/担当の競合は確認を必須にする。
ミス 3:古いキャッシュデータで新しいサーバー値を上書く
これはクライアントが古いスナップショットを編集して全レコードをアップロードすると起きます。サーバーがそれを受け入れると、サーバー側の新しい変更が消えてしまいます。
送るデータの形を直してください:変更されたフィールドのみ(または変更ログ)とベースバージョンを送ります。ベースバージョンが遅れているなら、サーバーが拒否するかマージを強制するようにします。
ミス 4:誰が何を変えたかの履歴がない
競合が起きたとき、ユーザーは「自分は何を変えたか、相手は何を変えたか」を知りたがります。編集者の識別とフィールドごとの変更がなければ、競合画面は推測になってしまいます。
updatedBy、サーバー側の更新時刻、少なくとも軽量なフィールド単位の監査トレイルを保存してください。
ミス 5:レコード全体の比較を強いる競合 UI
ユーザーに全レコードを比較させるのは疲れます。ほとんどの競合は 1〜3 フィールドだけです。競合しているフィールドだけを表示し、安全な選択肢を事前選択して残りは自動で受け入れさせてください。
AppMaster のようなノーコードツールでフォームを作るときも同じです:フィールドレベルで解決して、ユーザーがスクロールしてフォーム全体を比較する代わりに一つの明確な選択をさせることを目指してください。
クイックチェックリストと次のステップ
オフライン編集を安全に感じさせるには、競合をエラーではなく通常の状態として扱ってください。最良の結果は、明確なルール、再現可能なテスト、そして平易な言葉で何が起きたかを説明する UX から来ます。
機能を追加する前にこれらの基本を固めてください:
- 各レコード型について、フィールドごとにマージルールを割り当てる(LWW、最大/最小保持、追記、和集合、常に確認など)。
- サーバー制御のバージョンと、あなたが管理する
updated_atを保存し、同期時に検証する。 - 同じレコードを 2 台のデバイスがオフラインで編集し、両順序(A 先、B 先)で同期する 2 台テストを実行する。結果が予測可能であることを確認する。
- ハード競合をテストする:削除 vs 編集、異なるフィールドの編集。
- 状態を明示する:Synced、Pending upload、Needs review を表示する。
実際のフローを 1 つの実用的なフォームでエンドツーエンドでプロトタイプしてください。現実的なシナリオを使います:現場技術者が電話でジョブノートを更新する一方で、ディスパッチャーがタブレットで同じジョブのタイトルを編集する。もし異なるフィールドに触れていれば自動マージし、小さな「他デバイスから更新」ヒントを表示します。同じフィールドを触れていれば、2 つの選択肢と明確なプレビューを備えた簡単なレビュー画面に誘導します。
フルモバイルアプリとバックエンド API を一緒に構築する準備ができたら、AppMaster (appmaster.io) が役立ちます。データをモデリングし、ビジネスロジックを定義し、Web とネイティブのモバイル UI を 1 つの場所で構築し、同期ルールが確かになったらデプロイするかソースコードをエクスポートできます。
よくある質問
オフラインの間に 2 台のデバイスが同じサーバー管理のレコードを別々に変更し、それぞれが古いバージョンを基にしているときに起きます。サーバーは後で両方の更新を見て、異なるフィールドごとに最終値を決めなければなりません。
ほとんどの業務フォームではデフォルトとして フィールド単位のマージ を使うのが良い出発点です。異なる役割が異なるフィールドを編集することが多く、両方の変更をそのまま残せる場合が多いからです。手動レビュー は金銭や承認、コンプライアンスのように誤判断が重大な影響を与えるフィールドにのみ使ってください。最後の書き込みが勝つ (LWW) は、古い編集を失っても問題ない低リスクのフィールド向けです。
異なるフィールドに対する編集であれば通常は自動でマージして UI を見せずに済ませられます。もし同じフィールドが別の値に変えられているなら、そのフィールドは決定を促すべきです。自動選択は利用者を驚かせる可能性があるためです。決定範囲は狭くして、競合しているフィールドのみを見せてください。
version をレコードの単調増加カウンタと見なし、クライアントは各更新で expected_version を送るようにします。もしサーバーの現在のバージョンが一致しなければ、上書きするのではなく競合応答で拒否します。この単一ルールで、デバイスが異なる順序で同期しても“静かなデータ損失”を防げます。
実用的な最小セットは安定した id、サーバー制御の version、サーバー制御の updated_at と updated_by です。これがないと何が変わったのか説明できず、競合検出も難しくなります。デバイス側では行がアップロード待ちかを示す pending_sync と、最後に同期したサーバーバージョンを追跡してください。
変更したフィールドだけ(パッチ)とベースの expected_version を送ってください。レコード全体を送ると、小さく重ならない編集でも余計な競合を生み、古いキャッシュで新しいサーバー値を上書きする危険が高まります。パッチはどのフィールドにマージルールが必要かも明確にします。
スナップショットは実装がシンプルで、最新の完全なレコードを保存して後で比較します。変更ログはより柔軟で、「フィールドを設定」や「ノートを追加」などの操作を保存し、それを最新のサーバー状態にリプレイします。ノートやタグの追加などは操作ログの方がマージしやすいことが多いです。実装の速さを優先するならスナップショット、頻繁にマージが発生し「誰が何を変えたか」を明確にしたいなら変更ログを選んでください。
削除が編集より強いのかを事前に決めておきましょう。多くの業務アプリでは削除が勝つ(つまり削除されたらどこでも削除されたままにする)方が期待に近いです。安全な方法は「トゥームストーン」アプローチ:行をすぐ消すのではなく deleted_at とバージョンでマークし、同期時に古いアップサートで復活しないようにします。復元可能にしたければ「アーカイブ」状態にするのが良いです。
端末が間違った時刻を使うことがあるので、編集の順序付けにデバイス時刻を信用しないでください。サーバー発行のバージョン(単調増加の serverVersion)を優先し、クライアントのタイムスタンプは表示用に留めてください。時刻で順序付けするなら安全策とサーバーでの再調整を入れてください。
「保存 = ローカルに保存」という約束を守り、「同期済み」は別の明確な状態にしてください。AppMaster で構築する場合も同じ構造を目指しましょう:フィールド単位のマージルールを製品ロジックとして定義し、安全なフィールドを自動マージし、本当に衝突しているフィールドだけを小さなレビューに回します。両順序(A→B、B→A)で 2 台のデバイスが同じレコードをオフラインで編集してから同期するテストを実行してください。


