【发布时间】:2021-02-01 21:14:11
【问题描述】:
我遇到了一个组合问题,我找不到合适的解决方案。
我的目标是监控一个队列,并处理队列中的项目直到它为空。如果然后有人在队列中添加更多项目,我会继续处理。物品需要一件一件处理,我不想丢失任何物品。
我在下面写了一个非常简化的队列来重现这个问题。为了简单起见,我的项目再次建模为字符串。
鉴于上述限制:
- 我在队列中使用
changePublisher来监控更改。 - 一个按钮让我可以向队列中添加一个新项目
-
flatMap运算符依赖于maxPublishers参数,只允许一个进行中的处理。 -
buffer运算符可防止在flatMap忙时丢失项目。
此外,我使用combineLatest 运算符仅在某些条件下触发管道。为简单起见,我在这里使用Just(true) 发布者。
问题
如果我点击按钮,第一个项目会进入管道并被处理。 changePublisher 触发是因为队列被修改(项目被删除),并且管道在compactMap 处停止,因为peek() 返回nil。到现在为止还挺好。不过,之后,如果我再次点击该按钮,则会在管道中发送一个值,但 永远不会通过 buffer。
解决方案?
我注意到删除 combineLatest 可以防止问题发生,但我不明白为什么。
代码
import Combine
import UIKit
class PersistentQueue {
let changePublisher = PassthroughSubject<Void, Never>()
var strings = [String]()
func add(_ s: String) {
strings.append(s)
changePublisher.send()
}
func peek() -> String? {
strings.first
}
func removeFirst() {
strings.removeFirst()
changePublisher.send()
}
}
class ViewController: UIViewController {
private let queue = PersistentQueue()
private var cancellables: Set<AnyCancellable> = []
override func viewDidLoad() {
super.viewDidLoad()
start()
}
@IBAction func tap(_ sender: Any) {
queue.add(UUID().uuidString)
}
/*
Listen to changes in the queue, and process them one at a time. Once processed, remove the item from the queue.
Keep doing this until there are no more items in the queue. The pipeline should also be triggered if new items are
added to the queue (see `tap` above)
*/
func start() {
queue.changePublisher
.print("Change")
.buffer(size: Int.max, prefetch: .keepFull, whenFull: .dropNewest)
.print("Buffer")
// NOTE: If I remove this combineLatest (and the filter below, to make it compile), I don't have the issue anymore.
.combineLatest(
Just(true)
)
.print("Combine")
.filter { _, enabled in return enabled }
.print("Filter")
.compactMap { _ in
self.queue.peek()
}
.print("Compact")
// maxPublishers lets us process one page at a time
.flatMap(maxPublishers: .max(1)) { reference in
return self.process(reference)
}
.sink { reference in
print("Sink for \(reference)")
// Remove the processed item from the queue. This will also trigger the queue's changePublisher,
// which re-run this pipeline in case
self.queue.removeFirst()
}
.store(in: &cancellables)
}
func process(_ value: String) -> AnyPublisher<String, Never> {
return Future<String, Never> { promise in
print("Starting processing of \(value)")
DispatchQueue.global(qos: .userInitiated).asyncAfter(deadline: .now() + 2) {
promise(.success(value))
}
}.eraseToAnyPublisher()
}
}
输出
如果您点击按钮两次,以下是流水线的运行示例:
Change: receive subscription: (PassthroughSubject)
Change: request max: (9223372036854775807)
Buffer: receive subscription: (Buffer)
Combine: receive subscription: (CombineLatest)
Filter: receive subscription: (Print)
Compact: receive subscription: (Print)
Compact: request max: (1)
Filter: request max: (1)
Combine: request max: (1)
Buffer: request max: (1)
Change: receive value: (())
Buffer: receive value: (())
Combine: receive value: (((), true))
Filter: receive value: (((), true))
Compact: receive value: (3999C98D-4A86-42FD-A10C-7724541E774D)
Starting processing of 3999C98D-4A86-42FD-A10C-7724541E774D
Change: request max: (1) (synchronous)
Sink for 3999C98D-4A86-42FD-A10C-7724541E774D // First item went through pipeline
Change: receive value: (())
Compact: request max: (1)
Filter: request max: (1)
Combine: request max: (1)
Buffer: request max: (1)
Buffer: receive value: (())
Combine: receive value: (((), true))
Filter: receive value: (((), true))
// Second time compactMap is hit, value is nil -> doesn't forward any value downstream.
Filter: request max: (1) (synchronous)
Combine: request max: (1) (synchronous)
Change: request max: (1)
// Tap on button
Change: receive value: (())
// ... Nothing happens
[编辑] 这是一个更受限制的示例,它可以在 Playgrounds 中运行并且也演示了问题:
import Combine
import Foundation
import PlaygroundSupport
PlaygroundPage.current.needsIndefiniteExecution = true
func process(_ value: String) -> AnyPublisher<String, Never> {
return Future<String, Never> { promise in
print("Starting processing of \(value)")
DispatchQueue.global(qos: .userInitiated).asyncAfter(deadline: .now() + 0.1) {
promise(.success(value))
}
}.eraseToAnyPublisher()
}
var count = 3
let s = PassthroughSubject<Void, Never>()
var cancellables = Set<AnyCancellable>([])
// This reproduces the problem. Switching buffer and combineLatest fix the problem…
s
.print()
.buffer(size: Int.max, prefetch: .keepFull, whenFull: .dropNewest)
.combineLatest(Just("a"))
.filter { _ in count > 0 }
.flatMap(maxPublishers: .max(1)) { _, a in process("\(count)") }
.sink {
print($0)
count -= 1
s.send()
}
.store(in: &cancellables)
s.send()
Thread.sleep(forTimeInterval: 3)
count = 1
s.send()
切换组合和缓冲区可以解决问题。
【问题讨论】:
-
我无法重现这个 - 删除一些 UIKit 的东西并用
add代替按钮点击,输出所有添加的值。此外,如果感觉有点过于复杂。为什么要在删除时发送值?为什么你有.combineLatest和Just(true)?我认为,一个主题 + 缓冲区 + flatMap 就是实现队列所需的全部内容。我认为那里甚至不需要一个数组。 -
正如我所提到的,“我使用 combineLatest 运算符仅在某些条件下触发管道。为简单起见,我在这里使用 Just(true) 发布者。”我这里不是实现队列,队列是给我的,但是我用Combine来实现对队列中元素的连续处理。
-
难道你不能让发布者在队列的头部发布值并使用
receive(on:)和串行调度队列来确保你一次只处理一个元素。 -
如果平面图 (
process()) 中的操作是同步的,这将起作用,但这里它是异步的,可以在任何地方(而不是串行队列)调度,因此串行队列将只有将 调用 序列化为process()的效果 -
我最初的例子非常复杂,所以我对其进行了简化并提出了一个新问题以避免这里的 cmets 过时:stackoverflow.com/questions/66007062/…
标签: ios swift rx-swift combine