云端 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。

发现回滚错误时,先保留事件顺序、记录标识的脱敏值和结果类别,再检查是否混用了旧行号或跨会话模型。不要用延长等待时间掩盖问题。最终验收标准是每一次状态变化都有事件依据,而不是按钮最后恰好显示正确。

常见问题

请求超时后可以直接认为服务器没有保存吗?

不可以。超时只表示客户端未拿到明确结果;界面可以撤回临时反馈,但恢复连接后应重新读取权威状态,再允许后续写入。

本地请求票据能防止服务器上的乱序覆盖吗?

不能。票据只阻止旧回调修改当前界面。服务端写入仍需串行化、版本条件或其他明确的并发控制策略。

给流水线加一台物理 Mac

Apple Silicon M4 与 M4 Pro 可选,整机只交给一个客户,非虚拟机。按构建负载选择机型,再选择适合团队的计费周期。

租用物理节点

把下一次构建交给专属节点

先确认硬件规格与计费周期,再为团队接入独享物理 Mac。