收藏按钮点下就变色,网络失败后却退回错误状态;退出后再登录,旧请求又把新用户的界面改掉。这类故障不需要靠慢网碰运气。在云端 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。
发现回滚错误时,先保留事件顺序、记录标识的脱敏值和结果类别,再检查是否混用了旧行号或跨会话模型。不要用延长等待时间掩盖问题。最终验收标准是每一次状态变化都有事件依据,而不是按钮最后恰好显示正确。
常见问题
请求超时后可以直接认为服务器没有保存吗?
不可以。超时只表示客户端未拿到明确结果;界面可以撤回临时反馈,但恢复连接后应重新读取权威状态,再允许后续写入。
本地请求票据能防止服务器上的乱序覆盖吗?
不能。票据只阻止旧回调修改当前界面。服务端写入仍需串行化、版本条件或其他明确的并发控制策略。