Read A File Using Combine In Swift

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:

  1. Create A File Subscription
  2. Create A File Publisher
  3. Load A File Using Combine
  4. 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:

  1. subscribe(on: DispatchQueue.global(qos: .background)), meaning the file will be loaded asynchronously
  2. receive(on: DispatchQueue.main), meaning the file data or file read error will be handled on the main thread
  3. sink(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.