How to clean up resources when a Combine publisher is cancelled

As I mentioned in an earlier post, the Shareup iOS app makes heavy use of Apple’s Combine framework, which is Apple’s recommended approach to handling asynchronous programming. Combine makes it easy to process and transform asynchronous events over time.

We use Combine everywhere in our app. One interesting use case we have for Combine is creating an “import pipeline” that handles importing external files into our app via mechanisms like NSItemProvider. Since importing external files can take a long time, we provide a way for our users to cancel the import. If that happens, we sometimes need to do something like display a message to the user or clean up resources, but Combine doesn’t provide an obvious way to do so.

Cancelling a Combine publisher is usually done by calling cancel() on the AnyCancellable instance returned from Publisher.sink(receiveCompletion:receiveValue:).

let subscription = [0, 1, 2]
  .publisher
  .sink(
    receiveCompletion: { completion in print(completion) },
    receiveValue: { value in print(value) }
  )
...
subscription.cancel()

cancel() can also be implicitely called when deallocating the parent object of the publisher.

class Parent {
  var cancellables = Set<AnyCancellable>()

  func subscribeToPublisher() {
    [0, 1, 2]
      .publisher
      .sink(
        receiveCompletion: { completion in print(completion) },
        receiveValue: { value in print(value) }
      )
      .store(in: &cancellables)
  }
}

Depending on how your code is organized, it may not be convenient or even possible to run additional code when cancel() is called. Thankfully, we can solve this problem by writing a small extension on Cancellable that wraps the original AnyCancellable in another one that calls our custom block.

public extension Cancellable {
	func onCancel(_ block: @escaping () -> Void) -> AnyCancellable {
		AnyCancellable {
			self.cancel()
			block()
		}
	}
}

Using the extension is simple regardless of how you’ve chosen to manage your publisher’s lifetime.

let subscription = [0, 1, 2]
  .publisher
  .sink(
    receiveCompletion: { completion in print(completion) },
    receiveValue: { value in print(value) }
  )
  .onCancel { print("cancelled") }
...
subscription.cancel()
class Parent {
  var cancellables = Set<AnyCancellable>()

  func subscribeToPublisher() {
    [0, 1, 2]
      .publisher
      .sink(
        receiveCompletion: { completion in print(completion) },
        receiveValue: { value in print(value) }
      )
      .onCancel { print("cancelled") }
      .store(in: &cancellables)
  }
}

Be sure to let me know if you have any thoughts.