I have a publisher which would need re-evaluating on day change, but should continue to emit values at any other time.
As such, I thought I could use a NotificationCenter publisher for the UIApplication.significantTimeChangeNotification notification and combine it with my publisher such that the combine emission process would re-run on either on data change or day change and hence re-evaluate the map filter. See a rough outline of that code below.
The problem is that there is no published event by NotificationCenter at the point in time that this is setup and hence, none of the following map etc calls actually evaluate. merge(with:) won't work as the two publishers publish different types, but combineLatest(_:) and zip(_:) both won't emit events until both publishers have emitted a single event.
I can validate that my code operates as expected by adding NotificationCenter.default.post(name: UIApplication.significantTimeChangeNotification, object: nil) after this code, but that is undesirable due to it potentially signalling other areas of the app that an actual time change has occurred when it hasn't
private func todaysDate() -> String {
let formatter = DateFormatter()
formatter.dateFormat = "YYYY-MM-dd"
return formatter.string(from: Date())
}
#Published var entities: [MyEntity]
let dayChangePublisher = NotificationCenter.default
.publisher(for: UIApplication.significantTimeChangeNotification)
$entities.combineLatest(dayChangePublisher)
.map(\.0) // Only pass on the entity for further operations
.map { entities -> MyEntity? in
let today = todaysDate()
return entities?.first(where: { $0.id == today })
}
...remainder of combine code
Can this combination of publishers and evaluation of events occur with the current Swift combine framework? Like the behaviour I'd expect from merge(with:) but where the publishers emit two different types.
edit:
I found one solution where I map the notification publisher to a nil array
let dayChangePublisher = NotificationCenter.default
.publisher(for: UIApplication.significantTimeChangeNotification)
.map { _ ➝ [MyEntity]? in
return nil
}
And then use merge and compactMap to avoid passing any nil values on
let mergedPub = repo.$entities
.merge(with: dayChangePublisher)
.compactMap { entity -> MyEntity? in
let today = todaysDate()
return entities?.first { $0.id == today }
}
.share()
It works, but maybe a bit cumbersome if anyone has a better solution?
If I understood your question, you need a combineLatest that is not blocked by not having an initial value from one of the publishers.
You can achieve that with .prepend(value) operator. In this case, since you don't care about the actual value, map first to Void, then prepend a Void. It would work like so:
let dayChangePublisher = NotificationCenter.default
.publisher(for: UIApplication.significantTimeChangeNotification)
$entities.combineLatest(
dayChangePublisher
.map { _ in }
.prepend(()) // make sure to prepend a () value
)
.map(\.0) // Only pass on the entity for further operations
.map { entities -> MyEntity? in
let today = todaysDate()
return entities?.first(where: { $0.id == today })
}
//...
Related
I am trying organize and design a Combine-based API framework for communicating and reacting to OBS Studio (live-streaming software), using obs-websocket. I've written my own Combine Subject-wrapped Publisher for WebSocket communication, and am now using that to talk with OBS. I've created Publishers for sending request messages to OBS, as well as listening for event messages emitted by OBS. I've also been developing this alongside an actual SwiftUI app that uses the new framework.
While developing the app, I found that there are some complex combinations of requests and event listeners that I would need to use around the app. So, as part of the framework, I built out a few Publishers that merge results from an initial request and event listeners for changes to those properties. Testing these out, they worked great, so I committed to git and attempted to implement them in my app code. Instead, they didn't work consistently. I realized that by using the same Publishers in multiple places of the app, it was creating duplicate Publishers (because almost all Publishers are structs/value-types).
So, I found an article about implementing the .share() operator (https://www.swiftbysundell.com/articles/using-combines-share-operator-to-avoid-duplicate-work/) and I tried it out. Specifically, I set up a system for storing the different publishers that could be recalled while still active. Some of them are keyed by relevant values (like keying by the URL in the article), but others are just single values, as there wouldn't be more than one of that publisher at a time. That worked fine.
class PublisherStore {
typealias ResponsePublisher = AnyPublisher<OBSRequestResponse, Error>
var responsePublishers = [String: ResponsePublisher]()
typealias BatchResponsePublisher = AnyPublisher<OpDataTypes.RequestBatchResponse, Error>
var batchResponsePublishers = [String: BatchResponsePublisher]()
typealias EventPublisher = AnyPublisher<OBSEvent, Error>
var eventPublishers = [OBSEvents.AllTypes: EventPublisher]()
var eventGroupPublishers = [String: EventPublisher]()
var anyOpCode: AnyPublisher<UntypedMessage, Error>? = nil
var anyOpCodeData: AnyPublisher<OBSOpData, Error>? = nil
var allMessagesOfType = [OBSEnums.OpCode: AnyPublisher<OBSOpData, Error>]()
var studioModeState: AnyPublisher<Bool, Error>? = nil
var currentSceneNamePair: AnyPublisher<SceneNamePair, Error>? = nil
var sceneList: AnyPublisher<[OBSRequests.Subtypes.Scene], Error>? = nil
var sceneItemList = [String: AnyPublisher<[OBSRequests.Subtypes.SceneItem], Error>]()
var activeSceneItemList: AnyPublisher<[OBSRequests.Subtypes.SceneItem], Error>? = nil
var sceneItemState = [String: AnyPublisher<SceneItemStatePair, Error>]()
}
Where I started running into issues is attempting to implement the final part of the article: adding a custom DispatchQueue. What's been confusing me is the placement of subscribe(on:)/receive(on:) operators, and which ones should be to DispatchQueue.main vs. my internal custom queue. Here's what I have in my primary chain that calls one of the custom merged Publishers:
try connectToOBS()
.handleEvents(receiveOutput: { _ in print("Main thread outside before?:", Thread.isMainThread) })
// <1>
.tryFlatMap { _ in try studioModeStatePublisher() } // <- custom Publisher
// <2>
.handleEvents(receiveOutput: { _ in print("Main thread outside after?:", Thread.isMainThread) })
.output(in: 0..<4)
.sink(receiveCompletion: { print("Sink completion:", $0); expectation1.fulfill() },
receiveValue: { _ in })
.store(in: &observers)
I have .receive(on: DispatchQueue.main)
Should I be placing .receive(on: DispatchQueue.main) at <1> or <2>? When I put it at <2> or leave it out, I don't get any print outs past the custom publisher. If I put it at <1>, it works, but is that the right way to do it? Here is the code for the custom publisher (sorry for it being a bit messy):
public func getStudioModeStateOnce() throws -> AnyPublisher<Bool, Error> {
return try sendRequest(OBSRequests.GetStudioModeEnabled())
.map(\.studioModeEnabled)
// If error is thrown because studio mode is not active, replace that error with false
.catch { error -> AnyPublisher<Bool, Error> in
guard case Errors.requestResponseNotSuccess(let status) = error,
status.code == .studioModeNotActive else { return Fail(error: error).eraseToAnyPublisher() }
return Just(false)
.setFailureType(to: Failure.self)
.eraseToAnyPublisher()
}
.eraseToAnyPublisher()
}
public func studioModeStatePublisher() throws -> AnyPublisher<Bool, Error> {
// <3>
if let pub = publishers.studioModeState {
return pub
}
// Get initial value
let pub = try getStudioModeStateOnce()
// Merge with listener for future values
.merge(with: try listenForEvent(OBSEvents.StudioModeStateChanged.self, firstOnly: false)
.map(\.studioModeEnabled))
.removeDuplicates()
.receive(on: sessionQueue) // <- this being what Sundell's article suggested.
.handleEvents(receiveCompletion: { [weak self] _ in
self?.publishers.studioModeState = nil
})
.share()
.eraseToAnyPublisher()
// <4>
publishers.studioModeState = pub
return pub
}
Calls at <3> and <4> probably need to be done on the sessionQueue. What would be the best practice for that? The deepest level of publisher that this relies on looks like this:
try self.wsPublisher.send(msg, encodingMode: self.encodingProtocol)
// <5>
.eraseToAnyPublisher()
Should I put .receive(on: sessionQueue) at <5>? Even when the tests work, I'm not sure if I'm doing it right. Sorry for such a long thing, but I tried to add as much detail as possible and tried to bold my questions. Any and all help would be welcomed, and I'd be happy to provide any extra details if needed. Thanks!
Edit: I've realized that it was actually subscribing to the stored Publisher and would receive values if the value changed in OBS post-subscription. But the issue of receiving the most recent value is the issue. I replaced .share() with the .shareReplay idea from this article: (https://www.onswiftwings.com/posts/share-replay-operator/). Again, it works in my testing (including delays in subscription), but still doesn't receive the most recent value when used in my SwiftUI app. Anyone have any ideas?
I want my app to periodically fetch new records and stores them in Core Data. I have a label on my UI that should display the number of elements for a particular record and I want that number to be updated as more records are added into the database. As an exercise, I want to use Combine to accomplish it.
I'm able to display the number of elements in the database when the app launches, but the number doesn't get updated when new data enters into the database (I verified that new data was being added by implementing a button that would manual refresh the UI).
Here's the code that displays the correct number of elements on launch but doesn't update when new records are added:
let replayRecordFetchRequest: NSFetchRequest<ReplayRecord> = ReplayRecord.fetchRequest()
_ = try? persistentContainer.viewContext.fetch(replayRecordFetchRequest).publisher.count().map { String(format: Constants.Strings.playsText, $0) }.assign(to: \.text, on: self.playsLabel)
Here's a code snippet from the WWDC 2019 Session 230 talk that I adapted but this doesn't work at all (the subscriber is never fired):
let replayRecordFetchRequest: NSFetchRequest<ReplayRecord> = ReplayRecord.fetchRequest()
if let replayRecords = try? replayRecordFetchRequest.execute() {
_ = replayRecords.publisher.count().map { String(format: Constants.Strings.playsText, $0) }.assign(to: \.text, on: self.playsLabel)
}
So, I didn't know this until now, but not all publishers are infinitely alive.
And the problem was that the NSFetchRequest.publisher is not a long-living publisher. It simply provides a way to iterate through the sequence of elements in the fetch request. As a result, the subscriber will cancel after the elements are iterated. In my case, I was counting the elements published until cancellation then assigning that value onto the UI.
Instead, I should be subscribing to changes to the managed object context and assigning that pipeline to my UI. Here's some example code:
extension NotificationCenter.Publisher {
func context<T>(fetchRequest: NSFetchRequest<T>) -> Publishers.CompactMap<NotificationCenter.Publisher, [T]> {
return compactMap { notification -> [T]? in
let context = notification.object as! NSManagedObjectContext
var results: [T]?
context.performAndWait {
results = try? context.fetch(fetchRequest)
}
return results
}
}
}
let playFetchRequest: NSFetchRequest<ReplayRecord> = ReplayRecord.fetchRequest()
let replayVideoFetchRequest: NSFetchRequest<ReplayVideo> = ReplayVideo.fetchRequest()
let playsPublisher = contextDidSavePublisher.context(fetchRequest: playFetchRequest).map(\.count)
let replayVideoPublisher = contextDidSavePublisher.context(fetchRequest: replayVideoFetchRequest).map(\.count)
playsSubscription = playsPublisher.zip(replayVideoPublisher).map {
String(format: Constants.Strings.playsText, $0, $1)
}.receive(on: RunLoop.main).assign(to: \.text, on: self.playsLabel)
I need to call a sequences of function to get all the information I need for a notification. First subscribe which opens up the session, then queryNotification to listen on all the incoming notifications, and once a notification is received, need to call getNotificationAttrs with the notificationId returned in queryNotification, then call getAppAttributes with appIdentifier returned in getNotificationAttrs and I need the combined result of queryNotification, getNotificationAttrs and getAppAttributes. How the functions look like are below:
func subscribeNotification() -> Single<Info>
func queryNotification() -> Observable<Notification>
func getNotificationAttrs(uid: UInt32, attributes: [Attribute]) -> Single<NotificationAttributes>
func getAppAttributes(appIdentifier: String, attributes: [AppAttribute]) -> Single<NotificationAppAttributes>
The tricky part is that queryNotification returns Observable and both getNotificationAttrs and getAppAttributes return Single. What I have in mind of chaining them together is like:
subscribeNotification()
.subscribe(onSuccess: { info in
queryNotification()
.flatMap({ notification in
return getNotificationAttributes(uid: notification.uid, attributes: [.appIdentifier, .content])
})
.flatMap({ notifAttrs
return getAppAttributes(appIdentifier: notifAttrs.appIdentifier, attributes: [.displayName])
})
.subscribe {
// have all the result from last two calls
}
})
Is this doable? Any direction is appreciated! Thanks!
The most obvious and IMHO correct solution is to promote your Single into an Observable. Also, I'm not a fan of the first subscribe where it is. You end up with an indentation pyramid.
I'm following your comments about needing the values from all of queryNotification(), getNotificationAttrs(did:attributes:) and getAppAttributes(appIdentifier:attributes:)...
let query = subscribeNotification()
.asObservable()
.flatMap { _ in queryNotification() }
.share(replay: 1)
let attributes = query
.flatMap { getNotificationAttrs(uid: $0.uid, attributes: [.appIdentifier, .content]) }
.share(replay: 1)
let appAttributes = attributes
.flatMap { getAppAttributes(appIdentifier: $0.appIdentifier, attributes: [.displayName]) }
Observable.zip(query, attributes, appAttributes)
.subscribe(onNext: { (query, attributes, appAttributes) in
})
The above will follow the steps you outlined and the subscribe will get called every time a new notification is emitted.
Also notice how the above reads quite a bit like synchronous code would (just with some extra wrapping.)
I need to make multiple calls.
1. Delete Document Upload
2. Image 1 & server returns URL
3. Upload Image 2 & server returns URL
4. Create Document API contains both URLs & extra
parameters.
The code which I tried to write is in RxSwift,& MVVM.
let resultOfDocumentUpdateWithDelete =
donepressed
.filter{ $0 }
.withLatestFrom(self.existingDocumentIDChangedProperty)
.flatMapLatest {id in
let deleted_document = apiClient.deleteDocument(id).asObservable().materialize()
let upload_frontImage = deleted_document
.withLatestFrom(self.frontImageNameChangedProperty)
.flatMapLatest {image in
apiClient.uploadImage(image: image!).asObservable().materialize()
}
let upload_backImage = upload_frontImage
.withLatestFrom(self.backImageChangedProperty)
.flatMapLatest {image in
apiClient.uploadImage(image: image!).asObservable().materialize()
}
let upload_document = upload_backImage
.withLatestFrom(self.parametersChangedProperty)
.flatMapLatest {parameters in
apiClient.uploadDocument(parameters: parameters)
}
return upload_document.materialize()
}
.share(replay: 1)
Make sure, two responses of server are input in last API, so all of these will be called in a sequence.
how to do in RxSwift.
This was an interesting one! The take-away here is that when you are in doubt, go ahead and make your own operator. If it turns out that you later figure out how to do the job using the built-in operators, then you can replace yours. The only thing with making your own is that they require a lot more testing.
Note, to use the below, you will have to combineLatest of your observables and then flatMap and pass their values into this function.
// all possible results from this job.
enum ProcessResult {
case success
case deleteFailure(Error)
case imageFailue(Error)
case backImageFailure(Error)
case documentFailure(Error)
}
func uploadContent(apiClient: APIClient, documentID: Int, frontImage: UIImage, backImage: UIImage, parameters: Parameters) -> Single<ProcessResult> {
// instead of trying to deal with all the materializes, I decided to turn it into a single process.
return Single.create { observer in
// each api call happens in turn. Note that there are no roll-back semantics included! You are dealing with a very poorly written server.
let deleted = apiClient.deleteDocument(id: documentID)
.asObservable()
.share()
let imagesUploaded = deleted
.flatMap { _ in Observable.zip(apiClient.uploadImage(image: frontImage).asObservable(), apiClient.uploadImage(image: backImage).asObservable()) }
.share()
let documentUploaded = imagesUploaded
.flatMap { arg -> Single<Void> in
let (frontURL, backURL) = arg
var updatedParams = parameters
// add frontURL and backURL to parameters
return apiClient.uploadDocument(parameters: updatedParams)
}
.share()
let disposable = deleted
.subscribe(onError: { observer(.success(ProcessResult.deleteFailure($0))) })
let disposable1 = imagesUploaded
.subscribe(onError: { observer(.success(ProcessResult.imageFailue($0))) })
let disposable2 = documentUploaded
.subscribe(
onNext: { observer(.success(ProcessResult.success)) },
onError: { observer(.success(ProcessResult.documentFailure($0))) }
)
return Disposables.create([disposable, disposable1, disposable2])
}
}
I'm combining a viewDidAppear and filter Drivers with RxSwift. And they work great. But when I introduce a third Driver, it stops calling flatMapLatest on the latest combine.
In my View Controller, I have these Drivers:
let filter: Driver<String>
let viewDidAppear: Driver<Void>
let refresh: Driver<Void>
And in my view model:
// On viewDidAppear, I download a list of portfolios
let viewDidAppearPortfolios = viewDidAppear
.flatMapLatest({ (_) -> Driver<Result<[PortfolioModel]>> in
return networkService.request(Router.portfolios)!
.responseCollections()
.trackActivity(fetching)
.asDriver(onErrorJustReturn: .failure(NSError()))
})
.flatMapLatest({ (result: Result<[PortfolioModel]>) -> Driver<[PortfolioModel]> in
switch result {
case .success(let value): return Driver.just(value)
case .failure(_): return Driver.just([])
}
})
// Then I combine with a filter from my search bar.
self.portfolios = Driver.combineLatest(viewDidAppearPortfolios, filter)
.flatMapLatest { (portfolios: [PortfolioModel], filter: String) -> Driver<[PortfolioModel]> in
if filter.isEmpty {
return Driver.just(portfolios)
}
return Driver.just(portfolios.filter({ (portfolio) -> Bool in
portfolio.portfolio.localizedCaseInsensitiveContains(filter)
}))
}
The above works!
The network requests a list of portfolios, and I'm able to filter those results as I type, client side.
However, I'd like for the user to pull to refresh, and trigger the network request again! And so, I combine with my refresh driver.
And this:
Driver.combineLatest(viewDidAppearPortfolios, filter)
Becomes this:
Driver.combineLatest(viewDidAppearPortfolios, filter, refresh)
Problem!
After combining with refresh the flatMapLatest is no longer called on viewDidAppear! Only if I manually pullToRefresh.
Driver.combineLatest(viewDidAppearPortfolios, filter, refresh).flatMapLatest { _,_,_ in
// No longer get's called on viewDidAppear after combining with refresh
}
The viewDidAppearPortfolios still executes, so the network request
is getting called!
Only if I manually pull to refresh do I get the
list of portfolios that I previously requested...
Any idea why?
Thank you!
It looks like your refresh didn't emit a single event yet and so the combineLatest is not computed.
I tried this code to test:
let one = Driver.just(1)
let two = Driver.just(2)
let three: Driver<Int> = .just(3)
let result = Driver.combineLatest(one, two, three)
.flatMapLatest {
return Driver.just($0 + $1 + $2)
}
result.drive(onNext: {
print($0)
})
This prints 6 but if you use let three: Driver<Int> = .empty() this is not printing anything. So I guess you need a way to set an initial value to refresh stream.