Monday, August 8, 2022
HomeiOS DevelopmentEvaluating lifecycle administration for async sequences and publishers – Donny Wals

Evaluating lifecycle administration for async sequences and publishers – Donny Wals


Printed on: April 12, 2022

In my earlier publish you discovered about some totally different use circumstances the place you may need to decide on between an async sequence and Mix whereas additionally clearly seeing that async sequence are virtually all the time higher wanting within the examples I’ve used, it’s time to take a extra practical have a look at the way you is perhaps utilizing every mechanism in your apps.

The small print on how the lifecycle of a Mix subscription or async for-loop ought to be dealt with will fluctuate primarily based on the way you’re utilizing them so I’ll be offering examples for 2 conditions:

  • Managing your lifecycles in SwiftUI
  • Managing your lifecycles nearly wherever else

We’ll begin with SwiftUI because it’s by far the simplest scenario to cause about.

Managing your lifecycles in SwiftUI

Apple has added a bunch of very handy modifiers to SwiftUI that permit us to subscribe to publishers or launch an async activity with out worrying in regards to the lifecycle of every an excessive amount of. For the sake of getting an instance, let’s assume that we’ve got an object that exists in our view that appears a bit like this:

class ExampleViewModel {
    func notificationCenterPublisher() -> AnyPublisher<UIDeviceOrientation, By no means> {
        NotificationCenter.default.writer(for: UIDevice.orientationDidChangeNotification)
            .map { _ in UIDevice.present.orientation }
            .eraseToAnyPublisher()
    }

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

Within the SwiftUI view we’ll name every of those two capabilities to subscribe to the writer in addition to iterate over the async sequence. Right here’s what our SwiftUI view appears to be like like:

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

    let viewModel = ExampleViewModel()

    var physique: some View {
        VStack {
            Textual content("Portrait from writer: (isPortraitFromPublisher ? "sure" : "no")")
            Textual content("Portrait from sequence: (isPortraitFromSequence ? "sure" : "no")")
        }
        .activity {
            let sequence = await viewModel.notificationCenterSequence()
            for await orientation in sequence {
                isPortraitFromSequence = orientation == .portrait
            }
        }
        .onReceive(viewModel.notificationCenterPublisher()) { orientation in
            isPortraitFromPublisher = orientation == .portrait
        }
    }
}

On this instance I’d argue that the writer method is simpler to know and use than the async sequence one. Constructing the writer is nearly the identical as it’s for the async sequence with the foremost distinction being the return sort of our writer vs. our sequence: AnyPublisher<UIDeviceOrientation, By no means> vs. AsyncMapSequence<NotificationCenter.Notifications, UIDeviceOrientation>. The async sequence truly leaks its implementation particulars as a result of we’ve got to return an AsyncMapSequence as a substitute of one thing like an AnyAsyncSequence<UIDeviceOrientation> which might permit us to cover the inner particulars of our async sequence.

Right now it doesn’t look like the Swift group sees any profit in including one thing like eraseToAnyAsyncSequence() to the language so we’re anticipated to supply totally certified return varieties in conditions like ours.

Utilizing the sequence can be a little bit bit tougher in SwiftUI than it’s to make use of the writer. SwiftUI’s onReceive will deal with subscribing to our writer and it’ll present the writer’s output to our onReceive closure. For the async sequence we will use activity to create a brand new async context, acquire the sequence, and iterate over it. Not a giant deal however positively a little bit extra complicated.

When this view goes out of scope, each the Job created by activity in addition to the subscription created by onReceive shall be cancelled. Because of this we don’t want to fret in regards to the lifecycle of our for-loop and subscription.

If you wish to iterate over a number of sequences, you is perhaps tempted to put in writing the next:

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

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

Sadly, this setup wouldn’t have the specified final result. The primary for-loop might want to end earlier than the second sequence is even created. This for-loop behaves identical to a daily for-loop the place the loop has to complete earlier than transferring on to the subsequent strains in your code. The truth that values are produced asynchronously doesn’t change this. To iterate over a number of async sequences in parallel, you want a number of duties:

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

In SwiftUI, that is al comparatively easy to make use of, and it’s comparatively onerous to make errors. However what occurs if we examine publishers and async sequences lifecycles outdoors of SwiftUI? That’s what you’ll discover out subsequent.

Managing your lifecycles outdoors of SwiftUI

Once you’re subscribing to publishers or iterating over async sequences outdoors of SwiftUI, issues change a little bit. You abruptly have to handle the lifecycles of every thing you do way more fastidiously, or extra particularly for Mix you have to be sure you retain your cancellables to keep away from having your subscriptions being torn down instantly. For async sequences you’ll wish to be sure you don’t have the duties that wrap your for-loops linger for longer than they need to.

Let’s have a look at an instance. I’m nonetheless utilizing SwiftUI, however all of the iterating and subscribing will occur in a view mannequin as a substitute of my view:

struct ContentView: View {
    @State var showExampleView = false

    var physique: some View {
        Button("Present instance") {
            showExampleView = true
        }.sheet(isPresented: $showExampleView) {
            ExampleView(viewModel: ExampleViewModel())
        }
    }
}

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

    var physique: some View {
        VStack(spacing: 16) {
            VStack {
                Textual content("Portrait from writer: (viewModel.isPortraitFromPublisher ? "sure" : "no")")
                Textual content("Portrait from sequence: (viewModel.isPortraitFromSequence ? "sure" : "no")")
            }

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

This setup permits me to current an ExampleView after which dismiss it once more. When the ExampleView is offered I wish to be subscribed to my notification heart writer and iterate over the notification heart async sequence. Nevertheless, when the view is dismissed the ExampleView and ExampleViewModel ought to each be deallocated and I would like my subscription and the duty that wraps my for-loop to be cancelled.

Right here’s what my non-optimized ExampleViewModel appears to be like like:

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

    non-public var cancellables = Set<AnyCancellable>()

    deinit {
        print("deinit!")
    }

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

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

    func notificationCenterPublisher() -> AnyPublisher<UIDeviceOrientation, By no means> {
        NotificationCenter.default.writer(for: UIDevice.orientationDidChangeNotification)
            .map { _ in UIDevice.present.orientation }
            .eraseToAnyPublisher()
    }

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

Should you’d put the views in a undertaking together with this view mannequin, every thing will look good on first sight. The view updates as anticipated and the ExampleViewModel’s deinit is known as at any time when we dismiss the ExampleView. Let’s make some modifications to setup() to double verify that each our Mix subscription and our Job are cancelled and not receiving values:

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

    Job { [weak self] in
        guard let sequence = self?.notificationCenterSequence() else {
            return
        }
        for await orientation in sequence {
            print("sequence acquired worth")
            self?.isPortraitFromSequence = orientation == .portrait
        }
    }.retailer(in: &cancellables)
}

Should you run the app now you’ll discover that you just’ll see the next output while you rotate your gadget or simulator after dismissing the ExampleView:

// current ExampleView and rotate
subscription acquired worth
sequence acquired worth
// rotate once more
subscription acquired worth
sequence acquired worth
// dismiss
deinit!
// rotate once more
sequence acquired worth

You’ll be able to see that the ExampleViewModel is deallocated and that the subscription not receives values after that. Sadly, our Job remains to be lively and it’s nonetheless iterating over our async sequence. Should you current the ExampleView once more, you’ll discover that you just now have a number of lively iterators. It is a downside as a result of we wish to cancel our Job at any time when the item that comprises it’s deallocated, mainly what Mix does with its AnyCancellable.

Fortunately, we will add a easy extension on Job to piggy-back on the mechanism that makes AnyCancellable work:

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

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

Mix’s AnyCancellable is created with a closure that’s run at any time when the AnyCancellable itself shall be deallocated. On this closure, the duty can cancel itself which may also cancel the duty that’s producing values for our for-loop. This could finish the iteration so long as the duty that produces values respects Swift Concurrency’s activity cancellation guidelines.

Now you can use this extension as follows:

Job { [weak self] in
    guard let sequence = self?.notificationCenterSequence() else {
        return
    }
    for await orientation in sequence {
        print("sequence acquired worth")
        self?.isPortraitFromSequence = orientation == .portrait
    }
}.retailer(in: &cancellables)

Should you run the app once more, you’ll discover that you just’re not left with extraneous for-loops being lively which is nice.

Identical to earlier than, iterating over a second async sequence requires you to create a second activity to carry the second iteration.

In case the duty that’s producing your async values doesn’t respect activity cancellation, you might replace your for-loop as follows:

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

    if Job.isCancelled { break }
}

This merely checks whether or not the duty we’re at the moment in is cancelled, and whether it is we get away of the loop. You shouldn’t want this so long as the worth producing activity was applied appropriately so I wouldn’t advocate including this to each async for-loop you write.

Abstract

On this publish you discovered quite a bit about how the lifecycle of a Mix subscription compares to that of a activity that iterates over an async sequence. You noticed that utilizing both in a SwiftUI view modifier was fairly easy, and SwiftUI makes managing lifecycles simple; you don’t want to fret about it.

Nevertheless, you additionally discovered that as quickly as we transfer our iterations and subscriptions outdoors of SwiftUI issues get messier. You noticed that Mix has good built-in mechanisms to handle lifecycles by its AnyCancellable and even its assign(to:) operator. Duties sadly lack an identical mechanism which implies that it’s very simple to finish up with extra iterators than you’re comfy with. Fortunately, we will add an extension to Job to handle this by piggy-backing on Mix’s AnyCancellable to cancel our Job objects as quickly s the item that owns the duty is deallocated.

All in all, Mix merely gives extra handy lifecycle administration out of the field after we’re utilizing it outdoors of SwiftUI views. That doesn’t imply that Mix is robotically higher, however it does imply that async sequences aren’t fairly in a spot the place they’re as simple to make use of as Mix. With a easy extension we will enhance the ergonomics of iterating over an async sequence by quite a bit, however I hope that the Swift group will deal with binding activity lifecycles to the lifecycle of one other object like Mix does in some unspecified time in the future sooner or later.

RELATED ARTICLES

LEAVE A REPLY

Please enter your comment!
Please enter your name here

Most Popular