iOS の一覧でユーザーがフィルタ条件を素早く切り替えたとします。古い条件の2ページ目を取得中に、新しい条件の1ページ目が表示されました。数秒後に古い応答が届き、無関係な項目が一覧に混入します。この不具合は安定したネットワークでは再現しにくく、クラウド Mac 上で何度も手動スクロールしても、2つのリクエストが狙った順序で返るとは限りません。まずページを一覧に反映するルールを状態モデルとして切り出し、入力の順序を固定して検証してから、画面に接続するほうが確実です。
一覧の状態遷移を先に定義する
以下の例では、1から始まるページ番号と整数の業務 ID を使います。実際の API が返すフィールドは異なっていても、少なくとも現在のクエリ、応答待ちのページ、表示済みの ID、サーバーに次のページがあるかどうかは区別する必要があります。フィルタ条件の変更は、そのたびに新しいクエリとして扱います。新旧の条件が結果的に同じ項目を返しても、古いリクエストに新しい一覧を書き換えさせてはいけません。
クエリごとに増加する generation を割り当て、ページ番号とともにリクエストの識別情報に含めます。応答を一覧に反映できるのは、その識別情報が現在応答待ちのリクエストと完全に一致する場合だけです。リクエスト中に再び一覧の末尾へ到達しても、そのイベントは無視し、同じページ番号のリクエストを同時に2つ送らないようにします。
リクエストのキャンセルはネットワークと計算資源を節約するための操作であり、古いコールバックが実行されない保証にはなりません。一覧に反映する前に、必ずリクエストの識別情報を照合します。
実行可能なモデルで3つの実行順序を検証する
以下を Pager.swift として保存し、Xcode コマンドラインツールがインストールされた Mac で xcrun swift Pager.swift を実行します。コードはネットワークもタイマーも使わないため、実行するたびに同じ不具合につながる順序を検証できます。
struct Ticket: Equatable {
let generation: Int
let page: Int
}
struct Pager {
private(set) var generation = 0
private(set) var nextPage = 1
private(set) var ids: [Int] = []
private(set) var inFlight: Ticket?
private(set) var hasMore = true
mutating func start() -> Ticket? {
guard hasMore, inFlight == nil else { return nil }
let ticket = Ticket(generation: generation, page: nextPage)
inFlight = ticket
return ticket
}
mutating func reset() {
generation += 1
nextPage = 1
ids = []
inFlight = nil
hasMore = true
}
mutating func finish(
_ ticket: Ticket,
rows: [Int],
hasMore: Bool
) {
guard inFlight == ticket else { return }
var seen = Set(ids)
for id in rows where seen.insert(id).inserted {
ids.append(id)
}
nextPage += 1
self.hasMore = hasMore
inFlight = nil
}
}
var pager = Pager()
let page1 = pager.start()!
assert(pager.start() == nil)
pager.finish(page1, rows: [1, 2], hasMore: true)
let page2 = pager.start()!
pager.finish(page2, rows: [2, 3], hasMore: true)
pager.finish(page2, rows: [2, 3], hasMore: true)
assert(pager.ids == [1, 2, 3])
let oldPage = pager.start()!
pager.reset()
let newPage = pager.start()!
pager.finish(oldPage, rows: [4], hasMore: false)
assert(pager.ids.isEmpty)
pager.finish(newPage, rows: [7], hasMore: false)
assert(pager.ids == [7])
assert(pager.start() == nil)
最初のアサーションは、連続して末尾に到達しても読み込みが1回しか始まらないことを確認します。次の一連の処理は、ページをまたぐ ID の重複と、同じコールバックの再実行の両方を検証します。項目 2 は1回だけ表示され、処理済みのページは再度受け付けられません。最後はフィルタ条件の切り替えを再現します。古い世代のページが後から届いても、新しい一覧やその hasMore を変更できません。
実際のリクエストでも書き込みの境界を守る
画面側の役割は、「次のページを読み込む」または「クエリをリセットする」イベントを送ることだけです。リクエスト開始時に start() が返した識別情報を保持し、終了後に応答を finish へ渡してから、モデルの一覧を読み取ります。リセット時には古いネットワークタスクをキャンセルしても構いませんが、完了コールバックがモデルを迂回して画面側の配列へ直接追加しないようにします。
API に合わせて調整すべき点が2つあります。まず、この例は ID で重複を除きます。API に安定した ID がなければ、レコードの同一性を判定するルールを先に決めてください。配列内の位置で代用してはいけません。次に、この例はページ番号で進めます。カーソル方式の API では、リクエストに使用したカーソルを識別情報に含め、応答を受け付けてから応答で返された新しいカーソルを保存します。新旧のカーソルが同じなのに API が次のページもあると返した場合は、プロトコルエラーを記録して自動読み込みを止め、同じページへのリクエストを繰り返さないようにします。
エラーと再試行の扱い
この例が示すのは成功応答だけです。実装では失敗時にも識別情報を照合し、古いリクエストのエラーが新しいクエリの読み込み状態を上書きしないようにします。現在のページが失敗した場合は処理中の印を消しますが、ページ番号は進めません。ユーザーが再試行したら、同じページをリクエストします。読み込み中・失敗・再試行可能の状態を画面上で区別し、1回のタイムアウトで表示済みの項目を消さないようにします。
安定した実行環境でテストする
まず開発用 Mac で上記のモデルを実行して状態のルールを確認し、同じ実行順序をプロジェクトのテストに移します。テストダブルでは、短い遅延を使ってリクエストの前後関係を推測するのではなく、各ページの応答をいつ渡すかをテストコードで決めます。その後、iOS シミュレータで画面を確認します。連続して末尾に到達しても読み込み表示は1回だけ出ること、フィルタ条件の切り替え後に古い項目が一瞬表示されないこと、最終ページでは新たなリクエストが発生しないことを確かめます。
リモートデスクトップのネットワーク遅延は問題の観察には役立ちますが、テストが成立する条件にしてはいけません。クラウド Mac の利点は、チームが同じツールチェーンとシミュレータ環境でアサーションを繰り返し実行できることです。リクエストの順序は引き続きテスト側で制御します。一覧の実装を変更するたびに、「末尾への連続到達、ページ間の重複、古い応答の遅延到着、失敗後の再試行」の4組のテストケースを残してください。
マージ前に書き込み経路を点検する
最後に、呼び出し元をたどって配列への書き込み箇所をすべて探します。完了コールバック、プルして更新する処理、フィルタの変更、画面の復帰を確認し、いずれも同じ識別情報の照合を通るようにします。そうしなければ、モデルのテストが通っても、迂回したコールバックが一覧を汚染できます。さらに、重複排除によってサーバーが返した並び順が知らぬ間に変わらないこと、最終ページかどうかが最初に返ってきた任意のリクエストではなく、受け付けた応答に基づいて決まることを確認します。
ページングの競合は、低速な回線で偶然再現するのを待つ必要はありません。「どの応答に一覧を変更する資格があるか」を実行可能なルールにすれば、応答の順序が入れ替わるケースも、実環境のスクリーンショットにしか残らない偶発的な問題ではなく、通常の回帰テストで検証できます。
よくある質問
古いリクエストをキャンセルすれば十分ですか?
十分ではありません。一覧へ反映する直前に、応答の検索世代と現在待っているページを照合します。
複数ページに同じレコードが現れた場合は?
安定した業務 ID で重複を除き、ページ番号またはカーソルが前進しているかも別途確認します。
次回のリモート開発に備えてMacを用意
用途に合わせてモデルとロケーションを選び、画面共有、VNC、SSHで接続できます。価格と空き状況はコンソールでリアルタイムにご確認ください。