introduction
In previous articles, we learned the basics of Combine: how Publisher, Subscriber, and Subscription work and how these parts work, and how to use Operator to operate Publisher and handle its events.
This article uses Combine for specific use cases and is closer to practical application development. We will learn how to use Combine for network tasks, how to debug Combine Publisher, how to use Timer, observe objects, and learn about resource management in Combine.
network
Combine provides APIs to help developers perform common tasks declaratively. These APIs revolve around two key features:
useURLSession
Perform network requests.
useCodable
The protocol encodes and decodes JSON data.
URLSession Extension
URLSession
It is a standard way to perform network-related tasks under the Apple platform, which can help us complete a variety of operations. For example:
- Data transfer tasks used to retrieve content of URLs;
- Data download task used to obtain the content of the URL;
- Upload tasks used to upload data or files to URLs;
- Streaming tasks that transfer data between two parties;
- A Websocket task that connects to a Websocket.
Among them, only the data transmission task exposes a Combine Publisher. Combine handles these tasks using a single API with two variants. The entry parameter isURLRequest
orURL
:
func dataTaskPublisher(for url: URL) -> func dataTaskPublisher(for request: URLRequest) ->
Let's see how to use this API:
import Combine import Foundation import PlaygroundSupport = true func example(_ desc: String, _ action:() -> Void) { print("--- \(desc) ---") action() } var subscriptions = Set<AnyCancellable>() example("URLSession") { guard let url = URL(string: "/api/v2/appliances") else { return } .dataTaskPublisher(for: url) .sink(receiveCompletion: { completion in if case .failure(let err) = completion { print("Retrieving data failed with error \(err)") } }, receiveValue: { data, response in print("Retrieved data of size \(), response = \(response)") }) .store(in: &subscriptions) }
After some basic code, enter ourexample
function. We useURL
As parameterdataTaskPublisher(for:)
. Make sure we handled the error. The request result is includedData
andURLResponse
tuple of . Combine in
Publisher is provided instead of closures. Finally, the Subscription is reserved, no request will be canceled immediately and the request will never be executed.
Codable
The Codable protocol is the encoding and decoding mechanism of Swift that we should definitely understand. Foundation byJSONEncoder
andJSONDecoder
Encoding and decoding JSON. We can also usePropertyListEncoder
andPropertyListDecoder
, but these are not very useful in the context of network requests.
In the previous example, we got some JSON. We can decode it using JSONDecoder:
example("URLSession") { guard let url = URL(string: "/api/v2/appliances") else { return } .dataTaskPublisher(for: url) .tryMap({ data, response in try JSONDecoder().decode([String:String].self, from: data) }) .sink(receiveCompletion: { completion in if case .failure(let err) = completion { print("Retrieving data failed with error \(err)") } }, receiveValue: { data in print("Retrieved data: \(data)") }) .store(in: &subscriptions) }
We're intryMap
Decode JSON in Operator, which works, but Combine provides a more suitable operator in this scenario to help reduce code:decode(type:decoder:)
:
.dataTaskPublisher(for: url) .map(\.data) .decode(type: [String:String].self, decoder: JSONDecoder()) .sink(receiveCompletion: { completion in if case .failure(let err) = completion { print("Retrieving data failed with error \(err)") } }, receiveValue: { data in print("Retrieved data: \(data)") }) .store(in: &subscriptions)
But becausedataTaskPublisher(for:)
Issue a tuple, we cannot use it directlydecode(type:decoder:)
, requiredmap(_:)
Process only part of the data. Other advantages include that we only instantiate when setting PublisherJSONDecoder
Once, not every timetryMap(_:)
Create it in the closure.
Publish network data to multiple Subscribers
Every time you subscribe to Publisher, it starts working. In the case of network requests, if multiple Subscribers require results, the same request is sent multiple times.
Combine does not have an operator that can achieve this as easily as other frameworks. We can useshare()
Operator, but this requires setting all Subscriptions before the result returns.
There is another solution:multicast()
Operator, it returns aConnectablePublisher
, the Publisher creates a separate Subject for each Subscriber. It allows us to subscribe multiple timesSubject
, and then when we are ready, call Publisher'sconnect()
method:
example("connect") { guard let url = URL(string: "/api/v2/appliances") else { return } let publisher = .dataTaskPublisher(for: url) .map(\.data) .multicast { PassthroughSubject<Data, URLError>() } let subscription1 = publisher .sink(receiveCompletion: { completion in if case .failure(let err) = completion { print("Sink1 Retrieving data failed with error \(err)") } }, receiveValue: { object in print("Sink1 Retrieved object \(object)") }) .store(in: &subscriptions) let subscription2 = publisher .sink(receiveCompletion: { completion in if case .failure(let err) = completion { print("Sink2 Retrieving data failed with error \(err)") } }, receiveValue: { object in print("Sink2 Retrieved object \(object)") }) .store(in: &subscriptions) let subscription = () }
In the above code, createDataTaskPublisher
After map data, then usemulticast
. Pass tomulticast
The closure of the closure must return a Subject of the appropriate type. We'll learn about it latermulticast
More information about. First subscription to Publisher, as it is aConnectablePublisher
, it won't start working immediately. Use it after you are ready()
It will start working and push values to all Subscribers.
With the above code, we can request and share the results with two Subscribers at once. This process is still a bit complicated, because Combine doesn't provide operators for such scenarios like other responsive frameworks. In the following articles we will explore how to design a better solution.
debug
Understanding event streams in asynchronous code has always been a challenge. This is especially true in the context of Combine, because the Operator chain in Publisher may not issue events immediately. For example,throttle(for:scheduler:latest:)
Such operators do not emit all the events they receive, so we need to understand what is going on. Combine provides some operators to help us debug.
Print Events
print(_:to:)
Operator is the first Operator we should use when we are not sure if anything is passed. It returns aPassthroughPublisher
, can print a lot of information about what is happening:
Even a simple case like this:
let subscription = (1...3).publisher .print("publisher") .sink { _ in }
The output is very detailed:
publisher: receive subscription: (1...3) publisher: request unlimited publisher: receive value: (1) publisher: receive value: (2) publisher: receive value: (3) publisher: receive finished
we will seeprint(_:to:)
Operator shows a lot of information:
- Print and display a description of its upstream Publisher when the subscription is received;
- Print the demand request of Subscriber so that we can see the number of requested values.
- Prints each value emitted by the upstream Publisher.
- Finally, print the event.
print
There is an extra parameter to accept oneTextOutputStream
Object. We can use it to redirect strings to print into custom loggers. We can also add additional information in the log, such as the current date and time, etc.
We can create a simple logger to display the time interval between each string to understand how quickly the publisher issues values:
example("print") { class TimeLogger: TextOutputStream { private var previous = Date() private let formatter = NumberFormatter() init() { = 5 = 5 } func write(_ string: String) { let trimmed = (in: .whitespacesAndNewlines) guard ! else { return } let now = Date() print("+\((for: (previous))!)s: \(string)") previous = now } } let subscription = (1...3).publisher .print("publisher", to: TimeLogger()) .sink { _ in } }
The results show the time between each print line:
--- print ---
+0.00064s: publisher: receive subscription: (1...3)
+0.00145s: publisher: request unlimited
+0.00035s: publisher: receive value: (1)
+0.00026s: publisher: receive value: (2)
+0.00028s: publisher: receive value: (3)
+0.00026s: publisher: receive finished
Execution side effects
In addition to printing information, performing operations on specific events is often useful, and we call this an execution side effect: additional operations do not directly affect other Publishers downstream, but can have an effect similar to modifying external variables.
handleEvents(receiveSubscription:receiveOutput:receiveCompletion:receiveCancel:receiveRequest:)
Let us intercept all events in the Publisher lifecycle and then perform additional operations in each step.
Consider this code:
example("handleEvents", { guard let url = URL(string: "/api/v2/appliances") else { return } .dataTaskPublisher(for: url) .map(\.data) .decode(type: [String:String].self, decoder: JSONDecoder()) .sink(receiveCompletion: { completion in print("\(completion)") }, receiveValue: { data in print("\(data)") }) })
We ran it and never saw any prints. We use handleEvents to track what is happening. You canpublisher
andsink
Insert this Operator between:
.handleEvents(receiveSubscription: { _ in print("Network request will start") }, receiveOutput: { _ in print("Network request data received") }, receiveCancel: { print("Network request cancelled") })
Run the code again, this time we will see some debug output:
--- handleEvents ---
Network request will start
Network request cancelled
We forgot to keep AnyCancellable. So Subscription starts but is canceled immediately.
Using Debugger Operator
The Debugger operator is the operator we really need to use when we have no choice.
The first simple operator isbreakpointOnError()
. As the name implies, when we use this Operator, if any upstream Publisher issues an error, Xcode will break in the debugger.
A more complete variant isbreakpoint(receiveSubscription:receiveOutput:receiveCompletion:)
. It allows you to intercept all events and decide whether to pause based on the circumstances.
For example, it will only break if some values pass:
.breakpoint(receiveOutput: { value in return value > 0 && value < 5 })
Assuming the upstream Publisher emits integer values, but values 1 to 5 will never be emitted, we can configure the breakpoint to break only in this case.
Timer
Timer is often used when encoding. In addition to executing code asynchronously, it may also require controlling the time and frequency of tasks that should be repeated.
Before the Dispatch framework became available, developers relied on RunLoop to perform tasks asynchronously and achieve concurrency. All of the above methods can create Timers, but not all Timers are the same in Combine.
Using RunLoop
Threads can have their own RunLoop, just call them from the current thread. Note that unless we understand how RunLoop runs—especially, it is better to just use RunLoop from the main thread.
Note: An important note and warning in Apple documentation is the RunLoop classNot thread-safe. We should only use the RunLoop method for the RunLoop of the current thread.
RunLoop implements what we will learn about in subsequent articlesScheduler
protocol. It defines several relatively low-level methods and is the only way that allows you to create a cancelable Timer:
example("Timer RunLoop") { let runLoop = let subscription = ( after: , interval: .seconds(1), tolerance: .milliseconds(100) ) { print("Timer fired") } .store(in: &subscriptions) }
This Timer does not pass any value, nor creates a Publisher. It's fromafter:
The date specified in the parameter starts with the specified intervalinterval
and tolerancetolerance
. The only use it has to do with Combine is what it returnsCancelable
Let's stop it after a while:
example("Timer RunLoop") { let runLoop = let subscription = ( after: , interval: .seconds(1), tolerance: .milliseconds(100) ) { print("Timer fired") } (after: .init(Date(timeIntervalSinceNow: 3.0))) { () } }
Taking all factors into account,RunLoop
Not the best way to create a Timer, it would be better to use the Timer class.
Use the Timer class
Timer is the oldest timer available in Mac OS X. It has been difficult to use due to its delegate mode and its close relationship with RunLoop. Combine brings a modern variant that we can use directly as Publisher:
let publisher = (every: 1.0, on: .main, in: .common)
on
andin
Two parameters are determined:
- Which RunLoop is attached to Timer? Here is the RunLoop of the main thread.
- In which RunLoop mode Timer runs, here is the default RunLoop mode.
RunLoop is the basic mechanism for asynchronous event handling in macOS, but their API is a bit cumbersome. We can callGet the RunLoop for any thread we create ourselves or get from Foundation, so we can also write the following code:
let publisher = (every: 1.0, on: .current, in: .common)
Note: Running this code on a Dispatch queue other than , may result in unpredictable results. The Dispatch framework does not use RunLoop to manage its threads. Since RunLoop needs to call its methods to handle events, we will never see the Timer fire on any queue except the main queue. Setting Timer to is the easiest and safest option.
The publisher returned by the timer isConnectablePublisher
, in which we explicitly call itconnect()
Before the method, it does not start firing when Subscription is used. We can also useautoconnect()
Operator, which automatically connects when the first Subscriber subscription is subscribed.
Therefore, the best way to create a Publisher that will start Timer when subscribed is to write:
let publisher = Timer .publish(every: 1.0, on: .main, in: .common) .autoconnect()
Timer Publisher Publisher Publishes the current date, whichType is
Date
. We can usescan
Make a timer that emits incremental values:
example("Timer Timer") { let subscription = Timer .publish(every: 1.0, on: .main, in: .common) .autoconnect() .scan(0) { counter, _ in counter + 1 } .sink { counter in print("Counter is \(counter)") } .store(in: &subscriptions) }
There is another one we don't use here()
Parameters: Tolerance. It specifies acceptable deviations in the form of TimeInterval. But please note that using a lower than RunLoopminimumTolerance
Values of values may produce results that are not in line with expectations.
Using DispatchQueue
We can use DispatchQueue to generate Timer. Although the Dispatch framework has aDispatchTimerSource
, but Combine does not provide a Timer interface for it. Instead, we will use another method to generate the Timer event:
example("Timer DispatchQueue") { let queue = let source = PassthroughSubject<Int, Never>() var counter = 0 let cancellable = ( after: , interval: .seconds(1) ) { (counter) counter += 1 } .store(in: &subscriptions) let subscription = { print("Timer emitted \($0)") } .store(in: &subscriptions) }
This code is not beautiful. Let's create asubject
source
, we will send itcounter
value. Each time the timing is triggered,counter
It will be added. Schedule a repeat operation on the selected queue every second, which will start immediately. subscriptionsource
Getcounter
value.
KVO
Dealing with changes is at the heart of Combine. Publisher Let us subscribe to them to handle asynchronous events. We've got itassign(to:on:)
, which allows us to update the value of the object property every time the Publisher emits a new value.
In addition, Combine also provides a mechanism for observing changes in a single variable:
- It provides a Publisher for any attribute of an object that complies with KVO (Key-Value Observing).
-
ObservableObject
The protocol handles situations where multiple variables may change.
publisher(for:options:)
KVO has always been an important part of Objective-C. A large number of properties of Foundation, UIKit, and AppKit classes all meet the requirements of KVO. We can use KVO to observe their changes.
Here is a question for OperationQueueoperationCount
Example of attribute KVO:
let queue = OperationQueue() let subscription = (for: \.operationCount) .sink { print("Outstanding operations in queue: \($0)") }
Every time a new Operation is added to the queue, its operationCount increases, and ourssink
A new count value will be received. When the queue consumes an Operation, the count will also decrease accordingly, and oursink
The updated count value will be received again.
There are many other framework classes that expose KVO-compliant properties. Just putpublisher(for:)
Together with KVO-compatible properties, we will get a Publisher that emits value changes.
Custom KVO Compatible Properties
We can also use Key-Value Observing in our own code, provided that:
- The object is a NSObject subclass;
- use
@objc dynamic
Mark attributes.
After this is done, the objects and properties we tag will be compatible with KVO and can use Combine.
Note: While the Swift language does not directly support KVO, marking the attribute as @objc dynamic forces the compiler to generate methods that trigger the KVO mechanism, which relies on specific methods in the NSObject protocol.
Try an example on Playground:
example("KVO") { class TestObject: NSObject { @objc dynamic var value: Int = 0 } let obj = TestObject() let subscription = (for: \.value) .sink { print("value changes to \($0)") } = 100 = 200 }
In the above code, we created aTestObject
Class, inherited fromNSObject
This is necessary for KVO. Mark the attribute we want to make it observable as@objc dynamic
. Create and subscribeobj
ofvalue
Publisher of attributes. How many times have the attributes been updated:
--- KVO --- value changes to 0 value changes to 100 value changes to 200
We noticed that in TestObject we are using the Swift typeInt
, and KVO as an Objective-C feature is still valid? KVO works properly with any Objective-C type and any Swift type bridged to Objective-C. This includes all native Swift types as well as arrays and dictionaries, as long as their values can be bridged to Objective-C.
Observation options
publisher(for:options:)
ofoptions
The parameter is a set of options with four values:.initial
、.prior
、.old
and.new
. The default value is[.initial]
, which is why we see Publisher emits the initial value before any changes are issued. Here is a breakdown of options:
.initial
emitting initial value.
.prior
emitting the previous value and the new value when a change occurs.
.old
and.new
Not used in this Publisher, they do nothing (just let the new value pass).
If we don't want the initial value, you can simply write:
(for: \.value, options: [])
If we specify.prior
, then you will get two separate values each time you change. Modify integerProperty example:
let subscription = (for: \.value, options: [.prior])
You will now see the following in the debug console with your integerProperty subscription:
--- KVO --- value changes to 0 value changes to 100 value changes to 100 value changes to 200
The property is first changed from 0 to 100, so we get two values: 0 and 100. Then, it changes from 100 to 200, so we get two values again: 100 and 200.
ObservableObject
Combine'sObservableObject
The protocol is not only for derived fromNSObject
Objects, and are suitable for Swift objects. It's with@Published
Property wrappers cooperate to help us generate using compilerobjectWillChange
Publisher Creates a class.
It saves us from writing a lot of duplicate code and allows creating objects that can self-monitorize properties and notify when any of them changes.
Here is an example:
example("ObservableObject") { class MonitorObject: ObservableObject { @Published var someProperty = false @Published var someOtherProperty = "" } let object = MonitorObject() let subscription = { print("object will change") } = true }
--- ObservableObject --- object will change
ObservableObject
Protocol enables compiler to generate automaticallyobjectWillChange
property. It's aObservableObjectPublisher
, it emits Void values and never fails.
It will fire every time one of the @Published variables of the object changesobjectWillChange
. Unfortunately, we have no way of knowing which property is actually changed. This is designed to work well with SwiftUI, which merges events to simplify screen updates.
Resource Management
In the previous content, we found that sometimes we want to share resources such as network requests, image processing, and file decoding instead of doing duplicate work. In other words, we want to share the result of a single resource between multiple subscribers—the value emitted by Publisher, rather than copying that result.
Combine provides two operators to manage resources:share()
Operator andmulticast(_:)
Operator。
share()
The purpose of this Operator is to let us get the Publisher by reference rather than by value. Publisher is usually a structure: Swift copies it multiple times when we pass Publisher to a function or store it in multiple properties. When we subscribe to each replica, Publisher can only do one thing: start its work and deliver the value.
share()
Operator ReturnAn instance of the class. Usually, Publisher is implemented as a structure, but in
share()
In the case of , the Operator gets a reference to the Publisher instead of using value semantics, which allows it to share the underlying Publisher.
This new Publisher "shares" upstream Publisher. It will subscribe to the upstream Publisher once with the first incoming Subscriber. It then forwards the value received from the upstream Publisher to this Subscriber and all Subscribers that are subscribed after it.
Note: The new Subscriber will only receive values sent by the upstream Publisher after a subscription. No buffering or replay is involved. If Subscriber subscribes after upstream Publisher is completedshare
Publisher, then the new Subscriber will only receive completion events.
Suppose we are executing a network request and you want multiple Subscribers to receive results without multiple requests:
example("share") { let shared = .dataTaskPublisher(for: URL(string: "/api/v2/appliances")!) .map(\.data) .print("shared") .share() print("subscribing first") let subscription1 = ( receiveCompletion: { _ in }, receiveValue: { print("subscription1 received: '\($0)'") } ) .store(in: &subscriptions) print("subscribing second") let subscription2 = ( receiveCompletion: { _ in }, receiveValue: { print("subscription2 received: '\($0)'") } ) .store(in: &subscriptions) }
The first Subscriber triggersshare()
upstream Publisher work (executes network requests). The second Subscriber will simply "connect" to it and receive values at the same time as the first Subscriber.
Run this code in Playground:
--- share ---
subscribing first
shared: receive subscription: (DataTaskPublisher)
shared: request unlimited
subscribing second
shared: receive value: (91 bytes)
subscription2 received: '91 bytes'
subscription1 received: '91 bytes'
shared: receive finished
We can see that the first Subscription triggers the pairDataTaskPublisher
Subscription. The second Subscription has nothing to change: Publisher continues to run, no second request is issued. When the request is completed, the Publisher sends the result data to two Subscribers and then completes.
To verify that the request is sent only once, we can comment outshare()
, the output will be similar to the following:
--- share ---
subscribing first
shared: receive subscription: (DataTaskPublisher)
shared: request unlimited
subscribing second
shared: receive subscription: (DataTaskPublisher)
shared: request unlimited
shared: receive value: (109 bytes)
subscription1 received: '109 bytes'
shared: receive finished
shared: receive value: (94 bytes)
subscription2 received: '94 bytes'
shared: receive finished
It can be clearly seen whenDataTaskPublisher
When not sharing, it received two Subscriptions! In this case, the request will run twice.
But there is a question: What if the second subscriber comes after the sharing request is completed? We can simulate this by delaying the second subscription:
example("share") { let shared = .dataTaskPublisher(for: URL(string: "/api/v2/appliances")!) .map(\.data) .print("shared") .share() print("subscribing first") let subscription1 = ( receiveCompletion: { _ in }, receiveValue: { print("subscription1 received: '\($0)'") } ) .store(in: &subscriptions) (deadline: .now() + 5) { print("subscribing second") let subscription2 = ( receiveCompletion: { print("subscription2 completion \($0)") }, receiveValue: { print("subscription2 received: '\($0)'") } ) .store(in: &subscriptions) } }
Run Playground and we'll seesubscription2
No value was received:
--- share ---
subscribing first
shared: receive subscription: (DataTaskPublisher)
shared: request unlimited
shared: receive value: (102 bytes)
subscription1 received: '102 bytes'
shared: receive finished
subscribing second
subscription2 completion finished
Creatingsubscription2
When the request has been completed and the result data has been issued. How to make sure both Subscriptions receive the request result?
multicast(_:)
After the upstream Publisher is finished, to share a single Subscription with the Publisher and replay the value to the new Subscriber, we need something likeshareReplay()
Operator. Unfortunately, this Operator is not part of Combine. We will create one in a subsequent article.
In the "network", we usemulticast(_:)
. This Operator is based onshare()
Build and publish the value to Subscriber using our selected Subject.multicast(_:)
What's unique about it is that the Publisher it returns is aConnectablePublisher
. This means it won't subscribe to the upstream Publisher until we call itconnect()
method. This gives you enough time to set up all the Subscribers we need before having it connect to the upstream Publisher and start working.
To adjust the previous example to usemulticast(_:)
, we can write:
example("multicast") { let subject = PassthroughSubject<Data, URLError>() let multicasted = .dataTaskPublisher(for: URL(string: "/api/v2/appliances")!) .map(\.data) .print("multicast") .multicast(subject: subject) let subscription1 = multicasted .sink( receiveCompletion: { _ in }, receiveValue: { print("subscription1 received: '\($0)'") } ) .store(in: &subscriptions) let subscription2 = multicasted .sink( receiveCompletion: { _ in }, receiveValue: { print("subscription2 received: '\($0)'") } ) .store(in: &subscriptions) let cancellable = () .store(in: &subscriptions) }
We'll prepare onesubject
, which passes the value emitted by the upstream Publisher and the completion event. Use the abovesubject
Prepare for multicast Publisher.
Run Playground, and the result output:
--- multicast ---
multicast: receive subscription: (DataTaskPublisher)
multicast: request unlimited
multicast: receive value: (116 bytes)
subscription1 received: '116 bytes'
subscription2 received: '116 bytes'
multicast: receive finished
A multicast Publisher, and allConnectablePublisher
Likewise, it also provides aautoconnect()
method, which makes it likeshare()
Works the same: When you subscribe to it for the first time, it connects to the upstream Publisher and starts working immediately.
Future
Althoughshare()
andmulticast(_:)
With a mature Publisher for you, Combine also provides another way to let us share the calculation results:Future
:
example("future") { func performSomeWork() throws -> Int { print("Performing some work and returning a result") return 5 } let future = Future<Int, Error> { fulfill in do { let result = try performSomeWork() fulfill(.success(result)) } catch { fulfill(.failure(error)) } } print("Subscribing to future...") let subscription1 = future .sink( receiveCompletion: { _ in print("subscription1 completed") }, receiveValue: { print("subscription1 received: '\($0)'") } ) .store(in: &subscriptions) (deadline: .now() + 3) { let subscription2 = future .sink( receiveCompletion: { _ in print("subscription2 completed") }, receiveValue: { print("subscription2 received: '\($0)'") } ) .store(in: &subscriptions) } }
Run will output:
--- future ---
Performing some work and returning a result
Subscribing to future...
subscription1 received: '5'
subscription1 completed
subscription2 received: '5'
subscription2 completed
In the code, we provide a simulationFuture
The work performed. Create newFuture
, Work starts immediately without waiting for Subscriber.
If successful, provide a value to the Promise. If it fails, pass an error to the Promise. Subscription Once shows that we have received the results. The second Subscription shows that we also received the results and did not perform two jobs.
From a resource perspective:
-
Future
is a class, not a structure. - Once created, it immediately calls the closure to start calculating the result.
- It stores the results of the Promise and delivers it to the current and future Subscriber.
In practice, this means that Future is a convenient way to start performing certain tasks immediately, while performing only once and delivering the results to any number of Subscribers. But it does the work and returns a single result, not a result stream, so there are fewer scenarios to use than a mature Subscriber. It is a great choice when we need to share a single result produced by a network request!
Content reference
- Combine | Apple Developer Documentation;
- FromKodecobook "Combine: Asynchronous Programming with Swift";
- For the aboveKodecoSelf-translated version of the book《Combine: Asynchronous Programming with Swift》Organize and supplement.
The above is the detailed explanation of the comprehensive use of Combine under specific use cases. For more information about Combine specific use cases, please pay attention to my other related articles!