Comparing lifecycle management for async sequences and publishers

Published on: April 12, 2022

In my previous post you learned about some different use cases where you might have to choose between an async sequence and Combine while also clearly seeing that async sequence are almost always better looking in the examples I’ve used, it’s time to take a more realistic look at how you might be using each mechanism in your apps.

The details on how the lifecycle of a Combine subscription or async for-loop should be handled will vary based on how you’re using them so I’ll be providing examples for two situations:

  • Managing your lifecycles in SwiftUI
  • Managing your lifecycles virtually anywhere else

We’ll start with SwiftUI since it’s by far the easiest situation to reason about.

Managing your lifecycles in SwiftUI

Apple has added a bunch of very convenient modifiers to SwiftUI that allow us to subscribe to publishers or launch an async task without worrying about the lifecycle of each too much. For the sake of having an example, let’s assume that we have an object that exists in our view that looks a bit like this:

class ExampleViewModel {
    func notificationCenterPublisher() -> AnyPublisher<UIDeviceOrientation, Never> {
        NotificationCenter.default.publisher(for: UIDevice.orientationDidChangeNotification)
            .map { _ in UIDevice.current.orientation }
            .eraseToAnyPublisher()
    }

    func notificationCenterSequence() async -> AsyncMapSequence<NotificationCenter.Notifications, UIDeviceOrientation> {
        await NotificationCenter.default.notifications(named: UIDevice.orientationDidChangeNotification)
            .map { _ in await UIDevice.current.orientation }
    }
} 

In the SwiftUI view we’ll call each of these two functions to subscribe to the publisher as well as iterate over the async sequence. Here’s what our SwiftUI view looks like:

struct ExampleView: View {
    @State var isPortraitFromPublisher = false
    @State var isPortraitFromSequence = false

    let viewModel = ExampleViewModel()

    var body: some View {
        VStack {
            Text("Portrait from publisher: \(isPortraitFromPublisher ? "yes" : "no")")
            Text("Portrait from sequence: \(isPortraitFromSequence ? "yes" : "no")")
        }
        .task {
            let sequence = await viewModel.notificationCenterSequence()
            for await orientation in sequence {
                isPortraitFromSequence = orientation == .portrait
            }
        }
        .onReceive(viewModel.notificationCenterPublisher()) { orientation in
            isPortraitFromPublisher = orientation == .portrait
        }
    }
}

In this example I’d argue that the publisher approach is easier to understand and use than the async sequence one. Building the publisher is virtually the same as it is for the async sequence with the major difference being the return type of our publisher vs. our sequence: AnyPublisher<UIDeviceOrientation, Never> vs. AsyncMapSequence<NotificationCenter.Notifications, UIDeviceOrientation>. The async sequence actually leaks its implementation details because we have to return an AsyncMapSequence instead of something like an AnyAsyncSequence<UIDeviceOrientation> which would allow us to hide the internal details of our async sequence.

At this time it doesn’t seem like the Swift team sees any benefit in adding something like eraseToAnyAsyncSequence() to the language so we’re expected to provide fully qualified return types in situations like ours.

Using the sequence is also a little bit harder in SwiftUI than it is to use the publisher. SwiftUI’s onReceive will handle subscribing to our publisher and it will provide the publisher’s output to our onReceive closure. For the async sequence we can use task to create a new async context, obtain the sequence, and iterate over it. Not a big deal but definitely a little more complex.

When this view goes out of scope, both the Task created by task as well as the subscription created by onReceive will be cancelled. This means that we don’t need to worry about the lifecycle of our for-loop and subscription.

If you want to iterate over multiple sequences, you might be tempted to write the following:

.task {
    let sequence = await viewModel.notificationCenterSequence()
    for await orientation in sequence {
        isPortraitFromSequence = orientation == .portrait
    }

    let secondSequence = await viewModel.anotherSequence()
    for await output in secondSequence {
        // handle ouput
    }
}

Unfortunately, this setup wouldn’t have the desired outcome. The first for-loop will need to finish before the second sequence is even created. This for-loop behaves just like a regular for-loop where the loop has to finish before moving on to the next lines in your code. The fact that values are produced asynchronously does not change this. To iterate over multiple async sequences in parallel, you need multiple tasks:

.task {
    let sequence = await viewModel.notificationCenterSequence()
    for await orientation in sequence {
        isPortraitFromSequence = orientation == .portrait
    }
}
.task {
    let secondSequence = await viewModel.anotherSequence()
    for await output in secondSequence {
        // handle ouput
    }
}

In SwiftUI, this is al relatively simple to use, and it’s relatively hard to make mistakes. But what happens if we compare publishers and async sequences lifecycles outside of SwiftUI? That’s what you’ll find out next.

Managing your lifecycles outside of SwiftUI

When you’re subscribing to publishers or iterating over async sequences outside of SwiftUI, things change a little. You suddenly need to manage the lifecycles of everything you do much more carefully, or more specifically for Combine you need to make sure you retain your cancellables to avoid having your subscriptions being torn down immediately. For async sequences you’ll want to make sure you don’t have the tasks that wrap your for-loops linger for longer than they should.

Let’s look at an example. I’m still using SwiftUI, but all the iterating and subscribing will happen in a view model instead of my view:

struct ContentView: View {
    @State var showExampleView = false

    var body: some View {
        Button("Show example") {
            showExampleView = true
        }.sheet(isPresented: $showExampleView) {
            ExampleView(viewModel: ExampleViewModel())
        }
    }
}

struct ExampleView: View {
    @ObservedObject var viewModel: ExampleViewModel
    @Environment(\.dismiss) var dismiss

    var body: some View {
        VStack(spacing: 16) {
            VStack {
                Text("Portrait from publisher: \(viewModel.isPortraitFromPublisher ? "yes" : "no")")
                Text("Portrait from sequence: \(viewModel.isPortraitFromSequence ? "yes" : "no")")
            }

            Button("Dismiss") {
                dismiss()
            }
        }.onAppear {
            viewModel.setup()
        }
    }
}

This setup allows me to present an ExampleView and then dismiss it again. When the ExampleView is presented I want to be subscribed to my notification center publisher and iterate over the notification center async sequence. However, when the view is dismissed the ExampleView and ExampleViewModel should both be deallocated and I want my subscription and the task that wraps my for-loop to be cancelled.

Here’s what my non-optimized ExampleViewModel looks like:

@MainActor
class ExampleViewModel: ObservableObject {
    @Published var isPortraitFromPublisher = false
    @Published var isPortraitFromSequence = false

    private var cancellables = Set<AnyCancellable>()

    deinit {
        print("deinit!")
    }

    func setup() {
        notificationCenterPublisher()
            .map { $0 == .portrait }
            .assign(to: &$isPortraitFromPublisher)

        Task { [weak self] in
            guard let sequence = await self?.notificationCenterSequence() else {
                return
            }
            for await orientation in sequence {
                self?.isPortraitFromSequence = orientation == .portrait
            }
        }
    }

    func notificationCenterPublisher() -> AnyPublisher<UIDeviceOrientation, Never> {
        NotificationCenter.default.publisher(for: UIDevice.orientationDidChangeNotification)
            .map { _ in UIDevice.current.orientation }
            .eraseToAnyPublisher()
    }

    func notificationCenterSequence() -> AsyncMapSequence<NotificationCenter.Notifications, UIDeviceOrientation> {
        NotificationCenter.default.notifications(named: UIDevice.orientationDidChangeNotification)
            .map { _ in UIDevice.current.orientation }
    }
}

If you’d put the views in a project along with this view model, everything will look good on first sight. The view updates as expected and the ExampleViewModel’s deinit is called whenever we dismiss the ExampleView. Let’s make some changes to setup() to double check that both our Combine subscription and our Task are cancelled and no longer receiving values:

func setup() {
    notificationCenterPublisher()
        .map { $0 == .portrait }
        .handleEvents(receiveOutput: { _ in print("subscription received value") })
        .assign(to: &$isPortraitFromPublisher)

    Task { [weak self] in
        guard let sequence = self?.notificationCenterSequence() else {
            return
        }
        for await orientation in sequence {
            print("sequence received value")
            self?.isPortraitFromSequence = orientation == .portrait
        }
    }.store(in: &cancellables)
}

If you run the app now you’ll find that you’ll see the following output when you rotate your device or simulator after dismissing the ExampleView:

// present ExampleView and rotate
subscription received value
sequence received value
// rotate again
subscription received value
sequence received value
// dismiss
deinit!
// rotate again
sequence received value

You can see that the ExampleViewModel is deallocated and that the subscription no longer receives values after that. Unfortunately, our Task is still active and it’s still iterating over our async sequence. If you present the ExampleView again, you’ll find that you now have multiple active iterators. This is a problem because we want to cancel our Task whenever the object that contains it is deallocated, basically what Combine does with its AnyCancellable.

Luckily, we can add a simple extension on Task to piggy-back on the mechanism that makes AnyCancellable work:

extension Task {
    func store(in cancellables: inout Set<AnyCancellable>) {
        asCancellable().store(in: &cancellables)
    }

    func asCancellable() -> AnyCancellable {
        .init { self.cancel() }
    }
}

Combine’s AnyCancellable is created with a closure that’s run whenever the AnyCancellable itself will be deallocated. In this closure, the task can cancel itself which will also cancel the task that’s producing values for our for-loop. This should end the iteration as long as the task that produces values respects Swift Concurrency’s task cancellation rules.

You can now use this extension as follows:

Task { [weak self] in
    guard let sequence = self?.notificationCenterSequence() else {
        return
    }
    for await orientation in sequence {
        print("sequence received value")
        self?.isPortraitFromSequence = orientation == .portrait
    }
}.store(in: &cancellables)

If you run the app again, you’ll find that you’re no longer left with extraneous for-loops being active which is great.

Just like before, iterating over a second async sequence requires you to create a second task to hold the second iteration.

In case the task that’s producing your async values doesn’t respect task cancellation, you could update your for-loop as follows:

for await orientation in sequence {
    print("sequence received value")
    self?.isPortraitFromSequence = orientation == .portrait

    if Task.isCancelled { break }
}

This simply checks whether the task we’re currently in is cancelled, and if it is we break out of the loop. You shouldn’t need this as long as the value producing task was implemented correctly so I wouldn’t recommend adding this to every async for-loop you write.

Summary

In this post you learned a lot about how the lifecycle of a Combine subscription compares to that of a task that iterates over an async sequence. You saw that using either in a SwiftUI view modifier was pretty straightforward, and SwiftUI makes managing lifecycles easy; you don’t need to worry about it.

However, you also learned that as soon as we move our iterations and subscriptions outside of SwiftUI things get messier. You saw that Combine has good built-in mechanisms to manage lifecycles through its AnyCancellable and even its assign(to:) operator. Tasks unfortunately lack a similar mechanism which means that it’s very easy to end up with more iterators than you’re comfortable with. Luckily, we can add an extension to Task to take care of this by piggy-backing on Combine’s AnyCancellable to cancel our Task objects as soon s the object that owns the task is deallocated.

All in all, Combine simply provides more convenient lifecycle management out of the box when we’re using it outside of SwiftUI views. That doesn’t mean that Combine is automatically better, but it does mean that async sequences aren’t quite in a spot where they are as easy to use as Combine. With a simple extension we can improve the ergonomics of iterating over an async sequence by a lot, but I hope that the Swift team will address binding task lifecycles to the lifecycle of another object like Combine does at some point in the future.