雲端 Mac 驗證 iOS 分頁競態與重複資料

雲端 Mac 驗證 iOS 分頁競態與重複資料

雲端 Mac 驗證 iOS 分頁競態與重複資料

使用者在 iOS 清單中快速切換篩選條件:舊條件的第二頁還在載入,新條件的第一頁卻已經顯示。幾秒後,舊回應抵達,不相關的項目突然混進清單。這類問題在穩定網路下很難重現;即使換一台雲端 Mac 反覆手動捲動,也無法保證兩個請求依預期順序返回。更可靠的做法,是先將分頁寫入規則抽成狀態模型,以固定順序的輸入驗證,再接回介面。

先定義清單可以如何變動

以下範例使用從 1 開始的頁碼與整數業務 ID。實際 API 可以回傳其他欄位,但至少要區分目前的查詢、等待中的頁面、已顯示的 ID,以及伺服器是否還有下一頁。每次篩選條件變更都視為一次新查詢;即使新舊條件最後回傳相同項目,也不能讓舊請求寫入新清單。

為每次查詢分配遞增的 generation,並將它與頁碼一同放進請求憑據。只有憑據與目前等待處理的請求完全一致,回應才能寫入。載入請求進行中若再次觸底,就直接忽略,不要同時發出兩個頁碼相同的請求。

取消請求是為了節省網路與運算資源,不能據此認定舊回呼不會執行。寫入清單前仍須核對請求憑據。

用可執行模型固定三種時序

將以下內容儲存為 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)

第一個斷言檢查連續觸底時只啟動一次載入。第二組同時涵蓋跨頁重複 ID 與同一回呼重複執行:項目 2 只會出現一次,已完成的頁面也不能再次被接受。最後一組模擬切換篩選條件;帶著舊代次抵達的頁面,不得改動新清單或其 hasMore。

接入實際請求時保留寫入邊界

介面層只負責發出「載入下一頁」或「重設查詢」事件。請求開始時儲存 start() 回傳的憑據;請求結束後,將回應交給 finish,再從模型讀取清單。重設時可以一併取消舊的網路任務,但不要讓網路完成回呼繞過模型,直接將項目附加到介面陣列。

有兩點需要依 API 調整。第一,範例按 ID 去重;若 API 沒有穩定 ID,應先定義記錄的識別規則,不能以陣列位置代替。第二,範例以頁碼推進;使用游標的 API 應將發出請求時使用的游標放進憑據,接受回應後再儲存回應回傳的新游標。如果新舊游標相同,API 卻仍宣稱有下一頁,應記錄協定錯誤並停止自動載入,避免反覆請求同一頁。

錯誤與重試該如何處理

範例只示範成功回應。實際實作中,處理失敗時也要核對憑據:舊請求的錯誤不能覆蓋新查詢的載入狀態。目前頁面失敗時,清除其進行中標記,但不要推進頁碼;使用者重試時應請求同一頁。介面上應分別呈現載入中、失敗與可重試狀態,不要因為一次逾時就清空既有項目。

在穩定的執行環境中測試

先在開發機上執行上述模型,確認狀態規則;再將相同時序移入專案測試。測試替身應由測試程式碼決定何時交付各頁回應,而不是靠短暫延遲猜測請求先後。接著在 iOS 模擬器中檢查介面:連續觸底時載入提示只出現一次,切換篩選條件後舊項目不會閃回,抵達最後一頁後不再發出請求。

遠端桌面的網路延遲有助於觀察問題,卻不應成為測試能否成立的條件。雲端 Mac 的價值,在於讓團隊能在相同的工具鏈與模擬器環境中反覆執行這些斷言;請求順序仍由測試控制。每次修改清單實作,都要保留「重複觸底、跨頁重複、舊回應遲到、失敗重試」四組測試案例。

合併前檢查寫入路徑

最後沿著程式碼呼叫鏈,找出所有寫入陣列的位置。檢查完成回呼、下拉重新整理、篩選條件變更與頁面還原,確認它們都經過同一套憑據驗證;否則,即使模型測試通過,繞過模型的回呼仍可能污染清單。也要確認項目去重不會悄悄改變伺服器提供的排序,而末頁狀態必須來自被接受的回應,不是任意一個最先返回的請求。

不必等到弱網環境偶然觸發分頁競態。只要把「哪個回應有資格修改清單」寫成可執行的規則,亂序就能成為一般的迴歸測試,而不是只出現在正式環境截圖中的偶發問題。

常見問題

取消舊請求後,還需要辨識逾期回應嗎?

需要。寫入清單前仍須核對回應所屬的查詢代次與目前等待的頁碼,不能只依賴取消操作。

跨頁出現相同資料時應如何處理?

以穩定的業務 ID 去重,同時確認頁碼或游標持續前進,避免重複頁造成無止境載入。

獨享實體主機

為下一次遠端開發準備一台 Mac

依照使用需求選擇機型與節點,透過螢幕共享、VNC 或 SSH 連線。價格與實際供應狀況以控制台即時顯示為準。

設定遠端 Mac