云端 Mac 上验收 iOS Combine 订阅取消与生命周期

云端 Mac 上验收 iOS Combine 订阅取消与生命周期

搜索页退出后结果仍回写,或者反复进入页面后请求越来越多,常见原因不是网络慢,而是 Combine 订阅活得太久。在云端 Mac 上跑回归测试时,先把真实网络换成可控事件源:明确什么时候订阅、什么时候取消、哪个对象应该释放,再接回实际服务。这样失败能稳定复现,也不用等待远端响应。

先把取消契约拆开

一次取消至少涉及三个不同结论:下游不再接收值,上游收到取消信号,底层任务停止。前两项可以通过发布者管线测试;第三项必须检查具体任务实现,不能由订阅状态推导。

给页面定义四条验收项:取消后值不变;重复取消只清理一次;页面模型释放;旧请求不能覆盖新请求。正常完成另设用例,因为完成通知不等于取消通知。

cancel 不是回滚。取消前已经写入的状态、已经落盘的文件,以及已经提交的服务端操作,不会自动撤销。

先固定 Xcode 工具链,把测试放进现有 XCTest 单元测试目标。下面只依赖 Combine 与 XCTest,不需要启动模拟器界面,也不需要网络权限。

用同步事件源检查取消传播

把以下代码加入测试目标。这里的计数只用于测试,生产代码不要把全局计数器当作取消状态。测试先发送一个值,取消两次,再发送另一个值;三个断言分别检查接收、传播和取消后的隔离。

import Combine
import XCTest

final class SubscriptionLifetimeTests: XCTestCase {
    func testCancelStopsDeliveryAndPropagatesOnce() {
        let source = PassthroughSubject<Int, Never>()
        var received: [Int] = []
        var cancellations = 0

        let token = source
            .handleEvents(receiveCancel: {
                cancellations += 1
            })
            .sink { value in
                received.append(value)
            }

        source.send(1)
        XCTAssertEqual(received, [1])

        token.cancel()
        token.cancel()
        source.send(2)

        XCTAssertEqual(cancellations, 1)
        XCTAssertEqual(received, [1])
    }
}

PassthroughSubject 在这段没有调度切换的管线中同步发送,断言可以紧跟操作。不要加 sleep“让系统处理一下”;它只会隐藏事件顺序不明确的问题。

这项测试验证的是示例管线。接入自己的服务时,需要通过依赖注入把事件源传给业务模型,再从模型公开状态做相同断言,不能只测试一段与产品无关的管线。

检查持有关系,而不只检查 cancel

典型循环是模型持有 AnyCancellable,而 sink 闭包强持有模型。此时期待模型析构来释放订阅,会形成互相等待。下面用弱捕获切断循环,并用弱引用验收对象真的释放。

private final class ScreenModel {
    var value = 0
    private var token: AnyCancellable?

    init(source: PassthroughSubject<Int, Never>) {
        token = source.sink { [weak self] value in
            self?.value = value
        }
    }
}

extension SubscriptionLifetimeTests {
    func testModelReleaseCancelsSubscription() {
        let source = PassthroughSubject<Int, Never>()
        var cancellations = 0
        let publisher = source.handleEvents(receiveCancel: {
            cancellations += 1
        })
        var token: AnyCancellable?
        weak var weakModel: ScreenModel?

        do {
            let model = ScreenModel(source: source)
            weakModel = model
            token = publisher.sink { [weak model] value in
                model?.value = value
            }
        }

        XCTAssertNil(weakModel)
        token?.cancel()
        XCTAssertEqual(cancellations, 1)
    }
}

第二个用例分别观察模型释放和外部订阅取消,不把它们混为一个动作。模型内部订阅随模型释放而取消;外部 token 仍由测试持有,必须显式结束。迁入实际工程时,应增加内部订阅的取消观察点,分别断言两条订阅。

弱捕获也不是万能修复。如果任务本来必须跨页面继续,应由更长寿命的服务持有,再让页面只订阅进度,避免“页面释放就误停任务”。

给异步回写增加身份检查

取消搜索 A 后启动搜索 B,已经排入其他执行队列的工作仍可能尝试回写。不要把“调用过取消”当作状态安全屏障。

为每次请求分配递增代号,回写时核对代号,并把代号变更和状态写入限制在同一个 actor 或串行执行上下文。测试顺序固定为:启动 A、启动 B、返回 B、最后返回 A。最终状态必须仍属于 B。

另加退出场景:启动请求、退出页面、触发迟到响应,确认没有可见状态更新。若业务支持后台继续执行,则断言服务状态更新、页面回调停止,两者需要分开。

涉及 receive(on:) 时,先验证同步核心,再验证调度边界。使用可控调度器推进队列;没有这类基础设施时,用明确的执行完成信号等待,不能凭等待一段时间就宣称迟到事件不存在。

把测试变成可定位的回归检查

每个失败用例记录请求代号、事件顺序、取消次数和最终状态,不记录请求令牌或真实用户数据。一次只改变一个条件:先验证单次订阅,再覆盖快速重订阅、正常完成、失败结束和页面释放。

接入真实网络任务时,单独检查取消回调是否调用底层 cancel,以及任务最终状态;不要仅凭服务端没有返回结果判断成功。取消与完成竞态应允许符合契约的终止顺序,但不能允许终止后的业务回写。

最后做一次破坏性验证:去掉弱捕获,应能暴露持有循环;移除请求代号检查,应让乱序响应测试失败。能抓住人为注入的错误,才说明这些测试守住了实际边界,而不只是让流水线显示通过。

常见问题

调用 cancel 后,网络请求一定已经停止了吗?

不一定。cancel 会取消当前订阅,但底层任务是否停止取决于发布者实现。需要单独验证取消回调是否调用任务取消,并检查任务侧状态。

怎样避免生命周期测试依赖固定等待时间?

先用 PassthroughSubject 同步驱动事件与取消;涉及调度器时使用可控调度器或明确的完成信号。不要用固定 sleep 推断没有事件。

为下一次构建订一台云端 Mac

选好机型、城市和租期,使用独享物理节点运行自己的开发工具。可用状态以控制台实时返回为准。

把阅读变成下一次构建

先核对机型与租期,再安排项目。