雲端 Mac 上的 iOS 樂觀更新驗收:失敗還原與舊回應隔離

雲端 Mac 上的 iOS 樂觀更新驗收:失敗還原與舊回應隔離

收藏按鈕一按就變色,網路失敗後卻還原成錯誤的狀態;登出再登入後,舊請求又改動了新使用者的介面。這類問題不必靠慢速網路碰運氣。在雲端 Mac 的建置工作中,先將樂觀更新拆成獨立的狀態機,再依指定順序送入成功、失敗與工作階段切換事件,就能穩定重現。

先確立單一進行中寫入的契約

本文採用最容易驗收的一種策略:同一筆記錄最多只能有一個進行中的寫入請求。請求尚未完成時,再次點擊不會送出新請求;介面保留目前的暫存值,並顯示儲存中。這個做法犧牲了連續快速切換的能力,換來明確的還原目標。

狀態分成三個部分:已確認值、目前顯示值,以及進行中請求的票證。開始操作時只修改顯示值;成功時,以伺服器回傳值更新已確認值與顯示值;明確失敗時,則還原為已確認值。清單中的每筆記錄各自持有一份狀態,不使用全域布林旗標。

樂觀更新只是提早呈現使用者的意圖,不代表已經成功儲存。用戶端票證也無法取代伺服器端的寫入順序控制。

釐清結果不確定時的處理界線

逾時或連線中斷,不等於伺服器拒絕了請求。這時可以撤回暫時的回饋,但應進入待同步狀態,重新讀取伺服器資料後,才開放操作。下方的失敗分支只代表已知未提交的業務拒絕,不應直接用來處理所有網路錯誤。

實作不依賴介面的狀態機

將以下內容儲存為 optimistic-check.swift。票證使用獨立識別碼,切換工作階段時不會重複使用;模型本身不會發起網路請求,也不包含介面框架,因此可直接在已安裝命令列工具的節點上執行。

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 則代表已知未提交。伺服器可能依業務規則,將請求值改為另一個值,因此成功路徑不能直接複製使用者點擊時的目標值。

正式環境的程式碼應將狀態機放在主執行器上,網路請求完成後,也必須回到同一個執行器再修改狀態。不要從兩個回呼佇列同時呼叫這些方法;值型別不會自動消除資料競爭。

用事件順序取代實際的網路等待

將下列斷言接在同一個檔案的末尾。這些檢查不需要暫停等待,也不必真的發出請求;雖然票證內容是隨機的,檢查過程只依賴相等關係,因此結果仍然是確定的。

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

只要結束碼非零,就中止工作。第一組檢查失敗還原與重複點擊,第二組檢查重複回呼,第三組檢查舊工作階段的回應,以及伺服器回傳值與使用者意圖不同的情況。不需要輸出帳號識別碼或完整的回應本文。

串接網路時保留記錄身分

實際請求應擷取記錄識別碼、目前工作階段的身分,以及票證。收到回應後,先確認仍屬於同一筆記錄,再將結果交給對應的狀態機。清單儲存格被重複使用時,不能只靠列號尋找目標;排序與篩選都會改變列號。

切換帳號時,應替換所有記錄的狀態,並重新載入權威資料。範例中的 replaceSession 示範的是取得新工作階段初始值之後的重設動作,不代表登出後仍可繼續使用舊值。

取消請求只能減少無用的工作,不能取代票證檢查。取消時,伺服器可能已經提交資料,回呼也可能已在佇列中等待執行。若多部裝置同時修改同一筆記錄,還需要伺服器提供版本條件或衝突結果;用戶端的單一進行中寫入策略,無法處理跨裝置競爭。

將狀態檢查設為發布前的必要關卡

合併程式碼前,補齊三類檢查:業務拒絕後,按鈕可以再次操作;結果不明時,禁止繼續寫入並觸發同步;切換帳號後,舊回呼不會改動新狀態。除了狀態機檢查,也要透過介面測試,確認儲存中提示、輔助使用狀態與點擊攔截確實綁定至 pending。

發現還原錯誤時,先保留事件順序、去識別化的記錄識別碼與結果類別,再檢查是否混用了舊列號,或使用了跨工作階段的模型。不要靠延長等待時間掩蓋問題。最終驗收標準是每一次狀態變化都有對應的事件依據,而不是按鈕最後剛好顯示正確。

常見問題

請求逾時代表伺服器沒有儲存嗎?

不代表。逾時只表示結果不明;介面可以還原暫時狀態,但連線恢復後應重新讀取權威狀態,再允許後續寫入。

本機請求識別碼能避免伺服器寫入順序錯亂嗎?

不能。它只能阻止舊回呼更新目前介面。伺服器仍需要寫入序列化、版本條件或其他明確的並行控制。

為 CI/CD 流程添一台實體 Mac

可選 Apple Silicon M4 與 M4 Pro,整台主機由單一客戶獨享,並非虛擬機。依照建置工作負載挑選機型,再選擇適合團隊的計費週期。

租用實體節點

將下一次建置交給專屬節點

先確認硬體規格與計費週期,再為團隊租用獨享的實體 Mac。