Utilisation du nouveau framework Combine dans iOS 13.
Supposons qu'un éditeur en amont envoie des valeurs à un rythme très irrégulier - parfois des secondes ou des minutes peuvent s'écouler sans aucune valeur, puis un flux de valeurs peut traverser en une seule fois. J'aimerais créer un éditeur personnalisé qui souscrit aux valeurs en amont, les met en mémoire tampon et les émet à une cadence régulière et connue à leur arrivée, mais ne publie rien s'ils ont tous été épuisés.
Pour un exemple concret:
Mon éditeur abonné à l'amont produirait des valeurs toutes les 1 seconde:
Aucun des éditeurs ou opérateurs existants de Combine ne semble tout à fait faire ce que je veux ici.
throttle
et debounce
échantillonneraient simplement les valeurs en amont à une certaine cadence et enlèveraient celles qui sont manquantes (par exemple, ne publieraient que "a "si la cadence était de 1000 ms)delay
ajouterait le même délai à chaque valeur, mais ne les espacerait pas (par exemple, si mon délai était de 1000 ms, il publierait "a" à 6001 ms, "b" à 6002 ms, "c" à 6003 ms)buffer
semble prometteur, mais je n'arrive pas à comprendre comment l'utiliser - comment le forcer à publier une valeur à partir du tampon à la demande. Lorsque j'ai raccordé un évier à buffer
, il a semblé publier instantanément toutes les valeurs, pas du tout en mémoire tampon.J'ai pensé à utiliser une sorte d'opérateur de combinaison comme Zip
ou merge
ou combineLatest
et à le combiner avec un éditeur Timer, et c'est probablement la bonne approche, mais je ne peux pas comprendre exactement comment le configurer pour donner le comportement que je veux.
Modifier
Voici un diagramme en marbre qui, espérons-le, illustre ce que je veux faire:
Upstream Publisher:
-A-B-C-------------------D-E-F--------|>
My Custom Operator:
-A----B----C-------------D----E----F--|>
Édition 2: Test unitaire
Voici un test unitaire qui devrait réussir si modulatedPublisher
(mon éditeur tampon souhaité) fonctionne comme vous le souhaitez. Ce n'est pas parfait, mais il stocke les événements (y compris l'heure reçue) tels qu'ils sont reçus, puis compare les intervalles de temps entre les événements, en s'assurant qu'ils ne sont pas plus petits que l'intervalle souhaité.
func testCustomPublisher() {
let expectation = XCTestExpectation(description: "async")
var events = [Event]()
let passthroughSubject = PassthroughSubject<Int, Never>()
let cancellable = passthroughSubject
.modulatedPublisher(interval: 1.0)
.sink { value in
events.append(Event(value: value, date: Date()))
print("value received: \(value) at \(self.dateFormatter.string(from:Date()))")
}
// WHEN I send 3 events, wait 6 seconds, and send 3 more events
passthroughSubject.send(1)
passthroughSubject.send(2)
passthroughSubject.send(3)
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(6000)) {
passthroughSubject.send(4)
passthroughSubject.send(5)
passthroughSubject.send(6)
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(4000)) {
// THEN I expect the stored events to be no closer together in time than the interval of 1.0s
for i in 1 ..< events.count {
let interval = events[i].date.timeIntervalSince(events[i-1].date)
print("Interval: \(interval)")
// There's some small error in the interval but it should be about 1 second since I'm using a 1s modulated publisher.
XCTAssertTrue(interval > 0.99)
}
expectation.fulfill()
}
}
wait(for: [expectation], timeout: 15)
}
Le plus proche que j'ai obtenu utilise Zip
, comme ceci:
public extension Publisher where Self.Failure == Never {
func modulatedPublisher(interval: TimeInterval) -> AnyPublisher<Output, Never> {
let timerBuffer = Timer
.publish(every: interval, on: .main, in: .common)
.autoconnect()
return timerBuffer
.Zip(self, { $1 }) // should emit one input element ($1) every timer tick
.eraseToAnyPublisher()
}
}
Cela ajuste correctement les trois premiers événements (1, 2 et 3), mais pas les trois derniers (4, 5 et 6). Le résultat:
value received: 1 at 3:54:07.0007
value received: 2 at 3:54:08.0008
value received: 3 at 3:54:09.0009
value received: 4 at 3:54:12.0012
value received: 5 at 3:54:12.0012
value received: 6 at 3:54:12.0012
Je crois que cela se produit parce que Zip
a une certaine capacité de mise en mémoire tampon interne. Les trois premiers événements en amont sont mis en mémoire tampon et émis sur la cadence du minuteur, mais pendant les 6 secondes d'attente, les événements du minuteur sont mis en mémoire tampon - et lorsque la deuxième configuration des événements en amont est déclenchée, il y a déjà des événements du minuteur en attente dans la file d'attente, de sorte qu'ils 'est jumelé et a tiré immédiatement.
Pourrait Publishers.CollectByTime
être utile ici quelque part?
Publishers.CollectByTime(upstream: upstreamPublisher.share(), strategy: Publishers.TimeGroupingStrategy.byTime(RunLoop.main, .seconds(1)), options: nil)
Je voulais juste mentionner que j'ai adapté la réponse de Rob plus tôt et l'ai convertie en un éditeur personnalisé, afin de permettre un seul pipeline ininterrompu (voir les commentaires ci-dessous sa solution). Mon adaptation est ci-dessous, mais tout le mérite lui revient. Il utilise également toujours l'opérateur step
et SteppingSubscriber
de Rob, car cet éditeur personnalisé les utilise en interne.
Edit: mis à jour avec tampon dans le cadre de l'opérateur modulated
, sinon il faudrait l'attacher pour mettre en tampon les événements en amont.
public extension Publisher {
func modulated<Context: Scheduler>(_ pace: Context.SchedulerTimeType.Stride, scheduler: Context) -> AnyPublisher<Output, Failure> {
let upstream = buffer(size: 1000, prefetch: .byRequest, whenFull: .dropNewest).eraseToAnyPublisher()
return PacePublisher<Context, AnyPublisher>(pace: pace, scheduler: scheduler, source: upstream).eraseToAnyPublisher()
}
}
final class PacePublisher<Context: Scheduler, Source: Publisher>: Publisher {
typealias Output = Source.Output
typealias Failure = Source.Failure
let subject: PassthroughSubject<Output, Failure>
let scheduler: Context
let pace: Context.SchedulerTimeType.Stride
lazy var internalSubscriber: SteppingSubscriber<Output, Failure> = SteppingSubscriber<Output, Failure>(stepper: stepper)
lazy var stepper: ((SteppingSubscriber<Output, Failure>.Event) -> ()) = {
switch $0 {
case .input(let input, let promise):
// Send the input from upstream now.
self.subject.send(input)
// Wait for the pace interval to elapse before requesting the
// next input from upstream.
self.scheduler.schedule(after: self.scheduler.now.advanced(by: self.pace)) {
promise(.more)
}
case .completion(let completion):
self.subject.send(completion: completion)
}
}
init(pace: Context.SchedulerTimeType.Stride, scheduler: Context, source: Source) {
self.scheduler = scheduler
self.pace = pace
self.subject = PassthroughSubject<Source.Output, Source.Failure>()
source.subscribe(internalSubscriber)
}
public func receive<S>(subscriber: S) where S : Subscriber, Failure == S.Failure, Output == S.Input {
subject.subscribe(subscriber)
subject.send(subscription: PaceSubscription(subscriber: subscriber))
}
}
public class PaceSubscription<S: Subscriber>: Subscription {
private var subscriber: S?
init(subscriber: S) {
self.subscriber = subscriber
}
public func request(_ demand: Subscribers.Demand) {
}
public func cancel() {
subscriber = nil
}
}