Sunday, October 15, 2023
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 completely different use instances the place you might need to decide on between an async sequence and Mix whereas additionally clearly seeing that async sequence are nearly at all times higher trying within the examples I’ve used, it’s time to take a extra lifelike take 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 needs 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 just about wherever else

We’ll begin with SwiftUI because it’s by far the best state of affairs to purpose 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 job 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 now have 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 seems to be 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")")
        }
        .job {
            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 strategy is simpler to grasp and use than the async sequence one. Constructing the writer is just about 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 now have to return an AsyncMapSequence as an alternative of one thing like an AnyAsyncSequence<UIDeviceOrientation> which might permit us to cover the inner particulars of our async sequence.

Presently it doesn’t appear to be the Swift staff sees any profit in including one thing like eraseToAnyAsyncSequence() to the language so we’re anticipated to offer totally certified return sorts in conditions like ours.

Utilizing the sequence can also be slightly 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 job to create a brand new async context, receive the sequence, and iterate over it. Not an enormous deal however undoubtedly slightly extra complicated.

When this view goes out of scope, each the Activity created by job in addition to the subscription created by onReceive might 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 write down the next:

.job {
    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 end 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:

.job {
    let sequence = await viewModel.notificationCenterSequence()
    for await orientation in sequence {
        isPortraitFromSequence = orientation == .portrait
    }
}
.job {
    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 evaluate publishers and async sequences lifecycles exterior of SwiftUI? That’s what you’ll discover out subsequent.

Managing your lifecycles exterior of SwiftUI

While you’re subscribing to publishers or iterating over async sequences exterior of SwiftUI, issues change slightly. You all of a sudden must handle the lifecycles of every little thing you do far more rigorously, or extra particularly for Mix it’s essential be sure to retain your cancellables to keep away from having your subscriptions being torn down instantly. For async sequences you’ll wish to be sure to don’t have the duties that wrap your for-loops linger for longer than they need to.

Let’s take a look at an instance. I’m nonetheless utilizing SwiftUI, however all of the iterating and subscribing will occur in a view mannequin as an alternative 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
    @Atmosphere(.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 middle writer and iterate over the notification middle 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 seems to be like:

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

    personal var cancellables = Set<AnyCancellable>()

    deinit {
        print("deinit!")
    }

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

        Activity { [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 }
    }
}

When you’d put the views in a undertaking together with this view mannequin, every little 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 examine that each our Mix subscription and our Activity are cancelled and not receiving values:

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

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

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

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

You may see that the ExampleViewModel is deallocated and that the subscription not receives values after that. Sadly, our Activity continues to be lively and it’s nonetheless iterating over our async sequence. When 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 Activity at any time when the thing that incorporates it’s deallocated, principally what Mix does with its AnyCancellable.

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

extension Activity {
    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 might be deallocated. On this closure, the duty can cancel itself which may even 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 job cancellation guidelines.

Now you can use this extension as follows:

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

When 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 job to carry the second iteration.

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

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

    if Activity.isCancelled { break }
}

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

Abstract

On this publish you discovered lots about how the lifecycle of a Mix subscription compares to that of a job 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 exterior of SwiftUI issues get messier. You noticed that Mix has good built-in mechanisms to handle lifecycles via 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 snug with. Fortunately, we will add an extension to Activity to handle this by piggy-backing on Mix’s AnyCancellable to cancel our Activity objects as quickly s the thing that owns the duty is deallocated.

All in all, Mix merely gives extra handy lifecycle administration out of the field once we’re utilizing it exterior of SwiftUI views. That doesn’t imply that Mix is robotically higher, but it surely 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 lots, however I hope that the Swift staff will deal with binding job lifecycles to the lifecycle of one other object like Mix does sooner or later sooner or later.



Supply hyperlink

RELATED ARTICLES

LEAVE A REPLY

Please enter your comment!
Please enter your name here

- Advertisment -
Google search engine

Most Popular

Recent Comments