【问题标题】:Unexpected Combine Publisher Behavior意外的合并发布者行为
【发布时间】:2021-09-22 03:50:40
【问题描述】:

我正在构建一个抵押贷款计算器作为学习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(),但我无法正确使用语法。我已经用谷歌搜索了这个鼻涕,并在公共 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 { $0 * 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($0.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 $100,000 & downPaymentAmount of $20,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 { $0.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)     
}

【问题讨论】:

  • 我没有看到单元测试。让我们确保您知道如何对 Combine 进行单元测试;这不是微不足道的。 “我没有进行任何异步调用”错误。关于 Combine 的一切都是异步的,只有异步测试才能测试它。因此我的怀疑。您可能会发现我的 stackoverflow.com/questions/66036397/… 教育...
  • 你也可以试试zip()
  • @matt 谢谢。我会检查你的 cmets。
  • @Cristik 谢谢。你会把zip()放在哪里?
  • 问题是你正在组合的三个信号都在它们自己的“时间轴”上。您需要的是一个表明“该值已准备好计算”的信号。所有源值是否每次都更改?然后你需要一个发布者来计算有多少值发生了变化。但是,如果只有一个或两个值可能发生变化......那么您将需要一些其他信号来表示“好的......这些值已经稳定,现在可以计算每月付款。

标签: swift combine publisher


【解决方案1】:

问题是由于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: $0), numberOfPayments: self.numberOfPayments(mortgageTerm: $0)) }
        .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)

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

【讨论】:

  • 我喜欢这个方向。我在monthlyPayment 发布者中的mergeLatest 上遇到编译器错误。merge(with:) 是一个选项,所以我同意了。此外,Cannot convert value of type 'AnyPublisher&lt;(monthlyRate: Double, numberOfPayments: Double), Never&gt;' to expected argument type 'AnyPublisher&lt;Double, Never&gt;'Contextual closure type '(Publishers.MergeMany&lt;AnyPublisher&lt;Double, Never&gt;&gt;.Output) -&gt; Double' (aka '(Double) -&gt; Double') expects 1 argument, but 2 were used in closure body
  • @Adrian 更新了答案,现在应该编译
  • 再次感谢。我在这方面学到了一点。我得到了一把新锤子,在我最初的实现中,一切看起来都像钉子。哈哈。我认为我第一次尝试将无聊的东西作为计算变量,但我从未尝试过使用函数。将提交我拥有的东西并转移到其他东西上,稍后会弄清楚为什么那不起作用。再次感谢:)
  • @Adrian 是的,函数是一个强大的工具,小而专业的函数对代码结构、可读性和单元测试有很大帮助。更不用说它使发布者管道保持清洁,因为所有内容都被转发到函数,而不是在管道中编写大量计算代码。我会尝试用这样一个例子来更新我的答案。
  • @Adrian 谢谢,我一直想添加一个关于测试的章节,但我还没有开始。 :)
猜你喜欢
  • 2019-11-30
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多