Loading a file is a common operation in iOS and macOS apps often performed asynchronously on a background thread. Combine is a great tool for developers to work with asynchronous events. This post presents a complete example for using Combine to read a file on any thread in Swift:
- Create A File Subscription
- Create A File Publisher
- Load A File Using Combine
- Returning A Future Is Not A Great Approach
Create A File Subscription
The first step is to create a Combine subscription, FileSubscription
, that adheres to the Subscription
protocol. A FileSubscription
is responsible for loading the file at fileURL
when there is demand from subscribers.
An important note: no DispatchQueue
or threading logic is required in FileSubscription
.
class FileSubscription<S: Subscriber>: Subscription
where S.Input == Data, S.Failure == Error {
// fileURL is the url of the file to read
private let fileURL: URL
private var subscriber: S?
init(fileURL: URL, subscriber: S) {
self.fileURL = fileURL
self.subscriber = subscriber
}
func request(_ demand: Subscribers.Demand) {
// Load the file at fileURL only when demand is
// greater than 0, meaning subscribers were added
// to this subscription and demand values
if demand > 0 {
do {
// Success case, data is loaded and this
// subscription finishes
let data = try Data(contentsOf: fileURL)
subscriber?.receive(data)
subscriber?.receive(completion: .finished)
}
catch let error {
// Failure case, this subscription finishes
// and propagates the error
subscriber?.receive(
completion: .failure(error)
)
}
}
}
// Set the subscriber reference to nil, cancelling
// the subscription
func cancel() {
subscriber = nil
}
}
Create A File Publisher
The next step is to create a Combine publisher, FilePublisher
, adhering to the Publisher
protocol. A FilePublisher
is responsible for creating a new FileSubscription
when a subscriber subscribes to the publisher:
struct FilePublisher: Publisher {
// The output type of FilePublisher publisher
// is Data, which will be the Data of the read file
typealias Output = Data
typealias Failure = Error
// fileURL is the url of the file to read
let fileURL: URL
func receive<S>(subscriber: S) where S : Subscriber,
Failure == S.Failure, Output == S.Input {
// Create a FileSubscription for the new subscriber
// and set the file to be loaded to fileURL
let subscription = FileSubscription(
fileURL: fileURL,
subscriber: subscriber
)
subscriber.receive(subscription: subscription)
}
}
Load A File Using Combine
The final step is to use Combine methods to configure the FilePublisher
subscriber. In this example, the chain is configured using:
subscribe(on: DispatchQueue.global(qos: .background))
, meaning the file will be loaded asynchronouslyreceive(on: DispatchQueue.main)
, meaning the file data or file read error will be handled on the main threadsink(receiveCompletion:, receiveValue:)
, handling the success and failure cases of loading file data
let fileURL = // URL of the file to read
// The cancellables set is used to retain a reference
// to the subscription, otherwise the subscription or
// publisher may release before file logic is executed
var cancellables = Set<AnyCancellable>()
// Create a FilePublisher for the specified fileURL
FilePublisher(fileURL: fileURL)
// Subscribe on a background thread, meaning the
// FileSubscription logic for reading a file will
// run on a background thread
.subscribe(on: DispatchQueue.global(qos: .background))
// Receive on the main thread, meaning the sink
// callbacks will run on the main thread
.receive(on: DispatchQueue.main)
// Handle the success and failure cases
.sink(receiveCompletion: { error in
// Handle error
},
receiveValue: { fileData in
// Handle loaded file data
}
)
// Retain a reference to the subscription to prevent
// early deallocation before the subscription finishes
.store(in: &cancellables)
Returning A Future Is Not A Great Approach
Another potential implementation using Combine is to return a Future
that loads a file in its callback. Returning a Future
is not ideal, as the Future
callback will execute immediately. This means subscribe(on:)
has no impact on which thread the file is loaded on:
// Poor Implementation
func read(fileURL: URL) -> Future<Data, Error> {
return Future<Data, Error> { promise in
let data = // Read file data
promise(.success(data))
}
}
A common iteration is to wrap the file load logic in a DispatchQueue
to ensure the file load happens on a background thread. However, the problem is still the same. The subscribe(on:)
method has no impact on which thread the file is loaded on, meaning a subscriber cannot control which thread is used.
Adding a queue
argument to read(fileURL:)
could allow the Future
to load data on a specified thread. This still would not enable subscribe(on:)
to determine which thread a file is loaded on.
// Poor Implementation
func read(fileURL: URL) -> Future<Data, Error> {
return Future<Data, Error> { promise in
DispatchQueue.global().async {
let data = // Read file data
promise(.success(data))
}
}
}
Reading File Data On Any Thread Using Combine In Swift
That’s it! By using the Subscriber
and Publisher
protocols, and subscribe(on:)
and receive(on:)
methods, you can load files on any thread using Combine in Swift.