意外的合并发布者行为

Unexpected Combine Publisher Behavior

我正在构建一个抵押贷款计算器作为学习练习 Combine。一切都进展顺利,直到我遇到这样一种情况,即当我对它进行单元测试时,我无法从我的 Publishers 之一获得确定性的已发布输出。我没有进行任何异步调用。这是有问题的 AnyPublisher:

public lazy var monthlyPayment: AnyPublisher<Double, Never> = {
    Publishers.CombineLatest3(financedAmount, monthlyRate, numberOfPayments)
        .print("montlyPayment", to: nil)
        .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()
}()

假设我将抵押类型从 30 年更改为 15 年,发生了一些事情:

  1. 由于抵押期限(长度)的变化numberOfPayments发生变化
  2. monthlyRate 由于抵押期限(长度)的变化

最终目标

我的最终目标是等待 financedAmountmonthlyRatenumberOfPayments 发布商完成他们的工作,当他们全部完成后,然后计算每月付款。 Merge 3 似乎在每个发布者中获取更改,并且对于每个更改,它都会计算并吐出我不想要的输出。

Repo with problematic class and associated unit tests

我尝试过的

我试过 MergeManyMerge3.collect(),但语法不正确。我用谷歌搜索了这个问题,并在 public GitHub 回购中寻找示例,但我想不出任何与我的情况密切相关的东西。我想弄清楚我搞砸了什么以及如何解决它。

支持声明

这些是我对 monthlyPayment 所依赖的其他出版商的声明:

@Published var principalAmount: Double
@Published var mortgageTerm: MortgageTerm = .thirtyYear
@Published var downPaymentAmount: Double = 0.0

// monthlyRate replies upon annualRate, so I'm including annualRate above
internal lazy var monthlyRate: AnyPublisher<Double, Never> = {
  annualRate
      .print("monthlyRate", to: nil)
      .map { rate in
          rate / 12
      }
      .eraseToAnyPublisher()
}()

public lazy var annualRate: AnyPublisher<Double, Never> = {
  $mortgageTerm
      .print("annualRate", to: nil)
      .map { value -> Double in
          switch value {
          case .tenYear:
              return self.rates.tenYearFix
          case .fifteenYear:
              return self.rates.fifteenYearFix
          case .twentyYear:
              return self.rates.twentyYearFix
          case .thirtyYear:
              return self.rates.thirtyYearFix
          }
      }
      .map { [=11=] * 0.01 }
      .eraseToAnyPublisher()
}()

public lazy var financedAmount: AnyPublisher<Double, Never> = {
  Publishers.CombineLatest($principalAmount, $downPaymentAmount)
      .map { principal, downPayment in
          principal - downPayment
      }
      .eraseToAnyPublisher()
}()

public lazy var numberOfPayments: AnyPublisher<Double, Never> = {
  $mortgageTerm
      .print("numberOfPayments: ", to: nil)
      .map {
          Double([=11=].rawValue * 12)
      }
      .eraseToAnyPublisher()
}()

更新

我试图将 Merge3.collect() 一起使用,但我的单元测试超时了。这是更新后的 monthlyPayment 声明:

 public lazy var monthlyPayment: AnyPublisher<Double, Never> = {
     Publishers.Merge3(financedAmount, monthlyRate, numberOfPayments)
         .collect()
         .map { mergedArgs in
             let numerator = mergedArgs[1] * pow((1 + mergedArgs[1]), mergedArgs[2])
             let denominator = pow((1 + mergedArgs[1]), mergedArgs[2]) - 1
             
             return mergedArgs[0] * (numerator / denominator)
         }
         .eraseToAnyPublisher()
 }()

测试现在因超时而失败,并且从未调用 .sink 代码:

 func testMonthlyPayment() {
     // sut is initialized w/ principalAmount of 0,000 & downPaymentAmount of ,000
     let sut = calculator
     
     let expectation = expectation(description: #function)
     
     let expectedPayments = [339.62, 433.97, 542.46]
     
     sut.monthlyPayment
         .collect(3)
         .sink { actualMonthlyPayment in
             XCTAssertEqual(actualMonthlyPayment.map { [=13=].roundTo(places: 2) }, expectedPayments)
             expectation.fulfill()
         }
         .store(in: &subscriptions)
     
     // Initialized with 30 year fix with 20% down
     // Change term to 20 years
     sut.mortgageType = .twentyYear
     
     // Change the financedAmount
     sut.downPaymentAmount.value = 0.0
     
     waitForExpectations(timeout: 5, handler: nil)     
}

问题是由于 numberOfPaymentsmonthlyRate 发布者是相互依赖的,并且都跟随 mortgageTerm 发布者。因此,当 $mortgageTerm 发出一个事件时,您最终会收到由关注者发布者发出的另外两个独立事件,这会打断您的流程。

这也表明您使用了太多的发布者来处理可以通过计算属性轻松解决的事情,但我假设您想尝试发布者,所以,让我们试试吧。

一种解决方案是对两条有问题的信息仅使用一个发布者,发布者发布元组,并利用一些辅助函数来计算要发布的数据。这样,应该同时发出的两条信息,嗯,同时发出了:).

func annualRate(mortgageTerm: MortgageTerm) -> Double {
    switch mortgageTerm {
    case .tenYear:
        return rates.tenYearFix
    case .fifteenYear:
        return rates.fifteenYearFix
    case .twentyYear:
        return rates.twentyYearFix
    case .thirtyYear:
        return rates.thirtyYearFix
    }
}
    
func monthlyRate(mortgageTerm: MortgageTerm) -> Double {
    annualRate(mortgageTerm: mortgageTerm) / 12
}
    
func numberOfPayments(mortgageTerm: MortgageTerm) -> Double {
    Double(mortgageTerm.rawValue * 12)
}

lazy var monthlyDetails: AnyPublisher<(monthlyRate: Double, numberOfPayments: Double), Never> = {
    $mortgageTerm
        .map { (monthlyRate: self.monthlyRate(mortgageTerm: [=10=]), numberOfPayments: self.numberOfPayments(mortgageTerm: [=10=])) }
        .eraseToAnyPublisher()
}()

完成上述设置后,您可以使用您首先尝试的 combineLatest

func monthlyPayment(financedAmount: Double, monthlyRate: Double, numberOfPayments: Double) -> Double {
    let numerator = monthlyRate * pow((1 + monthlyRate), Double(numberOfPayments))
    let denominator = pow((1 + monthlyRate), Double(numberOfPayments)) - 1
    
    return financedAmount * (numerator / denominator)
}
    
lazy var monthlyPayment: AnyPublisher<Double, Never> = {
    financedAmount.combineLatest(monthlyDetails) { financedAmount, monthlyDetails in
        let (monthlyRate, numberOfPayments) = monthlyDetails
        return self.monthlyPayment(financedAmount: financedAmount,
                                   monthlyRate: monthlyRate,
                                   numberOfPayments: numberOfPayments)
    }
    .eraseToAnyPublisher()
}()

函数是 Swift(以及任何其他语言)中的强大工具,因为明确定义和专门的函数有助于:

  • 代码结构
  • 可红性
  • 单元测试

在您的特定示例中,我会更进一步,并定义它:

func monthlyPayment(principalAmount: Double, downPaymentAmount: Double, mortgageTerm: MortgageTerm) -> Double {
    let financedAmount = principalAmount - downPaymentAmount
    let monthlyRate = self.monthlyRate(mortgageTerm: mortgageTerm)
    let numberOfPayments = self.numberOfPayments(mortgageTerm: mortgageTerm)
    let numerator = monthlyRate * pow((1 + monthlyRate), Double(numberOfPayments))
    let denominator = pow((1 + monthlyRate), Double(numberOfPayments)) - 1

    return financedAmount * (numerator / denominator)
}

上述函数清楚地描述了您屏幕的问题域,因为它的主要功能是根据三个输入计算每月付款。有了这个功能,您可以将整组发布者恢复到只有一个:

lazy var monthlyPayment = $principalAmount
    .combineLatest($downPaymentAmount, $mortgageTerm, self.monthlyPayment)

您将获得相同的功能,但代码量更少且可测试性更高。