クラウドMacで検証するiOSの楽観的更新と古い応答の分離
お気に入りボタンを押すとすぐに色が変わるのに、通信に失敗すると誤った状態に戻ってしまう。ログアウトして再ログインした後、古いリクエストが新しいユーザーの画面を書き換えてしまう。こうした不具合を再現するために、低速な回線で偶然の発生を待つ必要はありません。クラウドMacのビルドジョブで、楽観的更新を独立した状態機械に切り出し、成功、失敗、セッション切り替えのイベントを決めた順序で与えれば、安定して再現できます。
まず、実行中の書き込みを1件に制限するルールを決める
この記事では、検証しやすい方針を採用します。同じレコードに対して、実行中の書き込みは最大1件とします。リクエスト中にもう一度タップされても新たなリクエストは送らず、画面には現在の仮の値と「保存中」の表示を残します。素早い連続切り替えはできなくなりますが、ロールバック先が明確になります。
状態は、確定値、現在の表示値、処理中のチケットの3つに分けます。操作開始時に変更するのは表示値だけです。成功時にはサーバーの返却値で確定値と表示値の両方を更新し、明確な失敗時には確定値に戻します。リスト内の各レコードがそれぞれ状態を持ち、全体で共有する真偽値のフラグは使いません。
楽観的更新は、ユーザーの意図を先に表示するだけであり、保存の成功を先に保証するものではありません。クライアント側のチケットも、サーバー側での書き込み順序の制御を代替するものではありません。
結果が不明な場合の扱いを明確にする
タイムアウトや接続の切断は、サーバーが処理を拒否したことを意味しません。この場合、仮の表示を取り消すことはできますが、状態は同期待ちにし、サーバーから再取得するまで操作を再開させないようにします。以下の失敗分岐が扱うのは、業務ルールによる拒否など、書き込みが確定していないと分かっている場合だけです。あらゆるネットワークエラーをそのまま処理するための分岐ではありません。
UIに依存しない状態機械を実装する
以下を optimistic-check.swift として保存します。チケットには個別の識別子を使い、セッションを切り替えても再利用しません。モデル自体はネットワーク処理を開始せず、UIフレームワークにも依存しないため、コマンドラインツールをインストールしたノードでそのまま実行できます。
import Foundation
struct FavoriteState {
private(set) var confirmed: Bool
private(set) var visible: Bool
private(set) var pending: UUID?
init(_ value: Bool) {
confirmed = value
visible = value
}
mutating func begin(_ desired: Bool) -> UUID? {
guard pending == nil else { return nil }
let ticket = UUID()
pending = ticket
visible = desired
return ticket
}
mutating func finish(_ ticket: UUID, saved: Bool?) {
guard pending == ticket else { return }
if let saved {
confirmed = saved
}
visible = confirmed
pending = nil
}
mutating func replaceSession(with value: Bool) {
pending = nil
confirmed = value
visible = value
}
}
finish のオプショナル値には明確な意味を持たせています。値があればサーバーが確認した最終状態を表し、nil は書き込みが確定していないと分かっていることを表します。サーバーは業務ルールに従って要求された値を別の値に変える場合があるため、成功時にユーザーがタップしたときの目標値をそのままコピーしてはいけません。
本番コードでは、状態機械をメインアクター上に置き、ネットワーク処理の完了後も同じアクターに戻ってから状態を変更します。2つのコールバックキューから同時にこれらのメソッドを呼び出さないでください。値型であっても、データ競合が自動的になくなるわけではありません。
実際の通信を待たず、イベントの順序で検証する
以下のアサーションを同じファイルの末尾に追加します。スリープも実際のリクエスト送信も必要ありません。チケットの値自体はランダムですが、検証では一致するかどうかだけを使うため、結果は決定的です。
var state = FavoriteState(false)
let first = state.begin(true)!
precondition(state.visible && !state.confirmed)
precondition(state.begin(false) == nil)
state.finish(first, saved: nil)
precondition(!state.visible && state.pending == nil)
let second = state.begin(true)!
state.finish(second, saved: true)
precondition(state.visible && state.confirmed)
state.finish(second, saved: false)
precondition(state.visible)
let oldSession = state.begin(false)!
state.replaceSession(with: false)
let current = state.begin(true)!
state.finish(oldSession, saved: false)
precondition(state.pending == current && state.visible)
state.finish(current, saved: false)
precondition(!state.visible && !state.confirmed)
実行方法は次のとおりです。このコンパイルでは、事前条件チェックを無効にする最適化オプションを追加しないでください。
set -eu
xcrun swiftc optimistic-check.swift -o optimistic-check
./optimistic-check
終了コードがゼロ以外なら、ジョブを失敗させます。最初のグループは失敗時のロールバックと重複タップ、2つ目はコールバックの重複、3つ目は古いセッションの応答と、サーバーの返却値がユーザーの意図と異なる場合を検証します。アカウント識別子やレスポンス本文全体を出力する必要はありません。
ネットワーク接続時もレコードの同一性を守る
実際のリクエストでは、レコード識別子、現在のセッション識別情報、チケットをキャプチャします。応答が返ったら、まず同じレコードに対するものかを確認し、その後で対応する状態機械に結果を渡します。リストのセルが再利用される場合、行番号だけで対象を探してはいけません。並べ替えや絞り込みによって行番号は変わります。
アカウントを切り替える際は、すべてのレコードの状態を置き換え、正とするデータを再取得します。例の replaceSession が示すのは、新しいセッションの初期値を取得した後のリセット処理です。ログアウト後も古い値を使い続けてよいという意味ではありません。
リクエストのキャンセルは無駄な処理を減らすだけで、チケットの照合を代替できません。キャンセルした時点でサーバー側の書き込みがすでに確定していたり、コールバックが実行待ちになっていたりする可能性があります。複数のデバイスから同じレコードを変更する場合は、サーバー側でもバージョンを条件とする書き込みや競合を示す結果を提供する必要があります。ローカルで実行中の書き込みを1件に制限するだけでは、デバイス間の競合には対応できません。
状態の検証をリリース前の必須チェックにする
コードをマージする前に、3種類のチェックをそろえます。業務ルールによる拒否の後でボタンを再操作できること、結果が不明な場合は追加の書き込みを禁止して同期を開始すること、アカウント切り替え後に古いコールバックが新しい状態を変更しないことです。状態機械の検証に加えて、UIテストで「保存中」の表示、アクセシビリティの状態、タップの抑止が実際に pending に連動していることも確認します。
ロールバックの不具合が見つかったら、まずイベントの順序、機密情報を除去したレコード識別子、結果の分類を記録し、古い行番号や別セッションのモデルを混用していないか調べます。待ち時間を延ばして問題を覆い隠してはいけません。最終的な合格基準は、ボタンが最後にたまたま正しく表示されることではなく、すべての状態変化に根拠となるイベントがあることです。
よくある質問
タイムアウトなら保存されていないと判断できますか?
できません。結果が不明なだけです。仮の表示を戻しても、接続回復後はサーバーの状態を読み直してから次の書き込みを許可します。
ローカルの識別子でサーバー上の競合も防げますか?
防げません。古い応答による画面更新を止める仕組みです。サーバー側には書き込みの直列化やバージョン条件が別途必要です。
ビルド用に物理Macを1台追加
Apple Silicon M4とM4 Proから選べる、1台を1社で専有する物理Macです。ビルド負荷に合わせて機種を選び、チームに合った課金プランをお選びください。
物理Macをレンタル