使用Combine开发房贷计算器遇Publisher非确定性输出问题
问题原因
- 最初用
CombineLatest3出现多输出、结果不确定的原因:当你修改贷款期限时,monthlyRate和numberOfPayments两个Publisher都会同步更新,两者的传播顺序由Combine内部调度决定,CombineLatest3只要收到任意一个上游的新值就会触发计算,所以会先输出一次「一个新值+一个旧值」组合的无效结果,再输出一次两个都是新值的正确结果,自然会出现多余输出和结果顺序不确定的问题。 - 后来用
Merge3+无参数collect()超时的原因:无参数的collect()操作符会等待上游Publisher发送完所有元素(也就是收到finished事件)才会把收集到的数组输出,而你所有的上游Publisher都是由@Published驱动的无限流,不会主动发送结束事件,所以collect()会一直等待,自然会导致测试超时,下游的sink也永远不会执行。
修复方案
方案1:加防抖操作符(简单易用)
对于同源的同步变更,加一个极短的防抖时间就可以过滤掉中间的无效输出,等所有变更传播完成后再触发计算,用户完全感知不到延迟:
public lazy var monthlyPayment: AnyPublisher<Double, Never> = { Publishers.CombineLatest3(financedAmount, monthlyRate, numberOfPayments) // 10毫秒防抖,足够过滤同步触发的多次变更 .debounce(for: .milliseconds(10), scheduler: DispatchQueue.main) .map { financedAmount, monthlyRate, numberOfPayments in let numerator = monthlyRate * pow((1 + monthlyRate), Double(numberOfPayments)) let denominator = pow((1 + monthlyRate), Double(numberOfPayments)) - 1 return financedAmount * (numerator / denominator) } .eraseToAnyPublisher() }()
方案2:合并同源变更(最优响应式实现)
你的monthlyRate和numberOfPayments都来源于$mortgageTerm,完全可以先把这两个依赖项合并成一个Publisher,从根源上避免同一个数据源触发多次更新:
首先定义合并后的期限关联值Publisher:
private lazy var termDependentValues: AnyPublisher<(monthlyRate: Double, numberOfPayments: Double), Never> = { $mortgageTerm .map { term -> (Double, Double) in // 单次处理同时计算利率和还款期数 let annualRate: Double switch term { case .tenYear: annualRate = self.rates.tenYearFix case .fifteenYear: annualRate = self.rates.fifteenYearFix case .twentyYear: annualRate = self.rates.twentyYearFix case .thirtyYear: annualRate = self.rates.thirtyYearFix } let monthlyRate = annualRate * 0.01 / 12 let numberOfPayments = Double(term.rawValue * 12) return (monthlyRate, numberOfPayments) } .eraseToAnyPublisher() }()
再和贷款金额组合计算月供:
public lazy var monthlyPayment: AnyPublisher<Double, Never> = { Publishers.CombineLatest(financedAmount, termDependentValues) .map { financedAmount, termValues in let numerator = termValues.monthlyRate * pow((1 + termValues.monthlyRate), termValues.numberOfPayments) let denominator = pow((1 + termValues.monthlyRate), termValues.numberOfPayments) - 1 return financedAmount * (numerator / denominator) } .eraseToAnyPublisher() }()
这个方案没有任何额外延迟,也完全不会产生无效输出,每次贷款期限变更只会触发一次月供计算,是最符合响应式设计的实现。
测试注意事项
- 不要使用无参数的
collect(),只有当你明确知道上游会发送finished事件时才用它,测试时如果要收集固定次数的输出,使用collect(n)指定收集的数量即可。 - 如果使用防抖方案做测试,可以把防抖时间调整到更小,或者使用XCTest的测试调度器来模拟时间流逝,避免测试超时。
内容的提问来源于stack exchange,提问作者Adrian
相关产品推荐
相关产品推荐

