用户在 iOS 列表里快速切换筛选条件:旧条件的第二页还在路上,新条件的第一页已经显示。几秒后旧响应抵达,列表突然混入不相干的项目。这类故障在稳定网络下很难复现;换一台云端 Mac 反复手动滑动,也不能保证两次请求按需要的顺序返回。更可靠的做法是先把分页写入规则抽成状态模型,用固定顺序的输入验证,再接回界面。
先定义列表允许怎样变化
以下示例采用从 1 开始的页码和整数业务 ID。真实接口可以返回其他字段,但至少要区分当前查询、正在等待的页、已展示的 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,再从模型读取列表。重置时可以同时取消旧网络任务,但不要让网络完成回调绕过模型,直接追加到界面数组。
有两点需要按接口调整。其一,示例按 ID 去重;若接口没有稳定 ID,先明确一条记录的身份规则,不能用数组位置代替。其二,示例以页码推进;游标接口应把请求时使用的游标放入凭据,接受响应后再保存响应返回的新游标。如果新游标与旧游标相同而接口仍声称有下一页,应记录协议错误并停止自动加载,避免反复请求同一页。
错误与重试怎么放
示例只演示成功响应。实际实现中,失败处理也要核对凭据:旧请求的错误不能覆盖新查询的加载状态。当前页失败时清除它的进行中标记,但不要推进页码;用户重试应请求同一页。把加载、失败、可重试分别呈现在界面上,不要因一次超时把已有项目清空。
把测试放到稳定的执行环境
先在开发机上运行上述模型,确认状态规则;再把同样的时序移入工程测试。测试替身应由测试代码决定何时交付每页响应,而不是用短暂延时猜测请求先后。随后在 iOS 模拟器中检查界面:连续触底时加载提示只出现一次,切换筛选条件后旧项目不闪回,最后一页不再触发请求。
远程桌面的网络延迟可以帮助观察问题,却不应成为测试成立的条件。云端 Mac 的价值是让团队在同一工具链和模拟器环境下重复执行这些断言;请求顺序仍由测试控制。每次修改列表实现,都保留“重复触底、跨页重复、旧响应迟到、失败重试”四组用例。
合并前检查写入路径
最后沿代码调用链找一遍所有数组写入点。搜索完成回调、下拉刷新、筛选变更和页面恢复,确认它们都经过同一套凭据校验;否则模型测试通过,旁路回调仍能污染列表。再确认项目去重不会悄悄改变服务器给出的排序,末页状态来自被接受的响应,而不是任意一个最先返回的请求。
分页竞态不需要等弱网偶遇。只要把“哪个响应有资格改列表”写成可执行规则,乱序就能成为普通的回归测试,而不是线上截图里的偶发问题。
常见问题
取消旧请求后,还需要检查响应是否过期吗?
需要。取消不能代替状态校验;在写入列表前,仍应核对响应所属的查询代次和当前待处理页。
服务端返回重复记录时,客户端该怎样处理?
按稳定的业务 ID 去重,并另行检查分页游标是否前进;去重不应掩盖服务端持续返回同一页的问题。
给下一次远程开发准备一台 Mac
按用途选机型与节点,使用屏幕共享、VNC 或 SSH 连接。价格与实际可用性以控制台实时返回为准。