Lifetime of ObservedObject inside a UIHostingController

Okay, I have come across an interesting problem that stumps me a bit. First, some background info:

  • I have created a very small View to show a list of 5 tag buttons in SwiftUI. As the rest of my app is still written in UIKit this lives inside a UIHostingController, this is kind of important later on (I believe).
  • There are 5 fixed tags, whose data resides in CoreData, so each tag is represented by TaskTag, a subclass of NSManagedObject.
  • The tags have a to-many relationship to CoachingTask, which is also an NSManagedObject subclass (the inverse relationship is to-1, so currently each CoachingTask can have only one TaskTag, multiple tags will be supported later). The CoachingTask objects are the important ones here, as the UI is supposed to define which TaskTag is associated to a given CoachingTask.

Since I also need to provide a UI when no CoachingTask is currently displayed in a boring old UIView I was interested in haven a @StateObject or @ObservedObject that basically wraps an optional (so the property can be nil). For various reasons I wanted to use a wrapper like this:

class ObservableOptional<T: ObservableObject>: ObservableObject {

    var optional: T? {
        willSet {
            recreateSubscriber(with: newValue)
            objectWillChange.send()
        }
    }
    private var subscriber: AnyCancellable?

    init(optional: T?) {
        self.optional = optional
        recreateSubscriber(with: optional)
    }

    private func recreateSubscriber(with newOptional: T?) {
        subscriber = newOptional?.objectWillChange.sink(receiveValue: { [weak self] _ in
            self?.objectWillChange.send()
        })
    }
}

My SwiftUI view then looks like this:

struct TaskTagsList: View {

    @Namespace var namespace
    private let title = "TaskTagsList"
    @ObservedObject private var model: ObservableOptional<CoachingTask>
    private let fixedTags: [TaskTag]
    var coachingTask: CoachingTask? {
        get { model.optional }
        set { model.optional = newValue }
    }

    var tagButtonModels: [TagButton.ViewModel] { // TagButton.ViewModel is a DAO, a struct
        TagButton.ViewModel.tagButtonModels(for: coachingTask, and: fixedTags)
    }

    init(for coachingTask: CoachingTask?, and fixedTags: [TaskTag]) {
        self.fixedTags = fixedTags
        model = ObservableOptional(optional: coachingTask)
    }

    var body: some View {
        // next three lines are for debugging and illustrating the issue:
        let _ = print(self.title, self.namespace, terminator: " -- ")
        let _ = print("model address", Unmanaged.passUnretained(model).toOpaque(), terminator: " -- ")
        let _ = Self._printChanges()
        HStack {
            ForEach(tagButtonModels) { btnModel in
                // TagButton is omitted here for now, it's basically just a bubble
                TagButton(id: btnModel.id, title: btnModel.title,
                          iconColor: btnModel.color, active: btnModel.active,
                          tapped: btnModel.toggleTaskTagOnCoachingTask)
            }
            Spacer()
        }
    }
}

The containing ViewController that embeds the UIHostingController<TaskTagsList> is pretty standard, in its viewDidLoad I create and embed the hoster (which all layouts fine) and if the CoachingTask property changes I recreate the hoster's rootView with a new TaskTagsList view.

The interesting part is: When I go back and forth to/from the view controller (i.e. the hoster and everything else gets recreated several times) I can see that my ObservableOptional instances are not properly dealloced. Also, the actual "view nodes" (for lack of a better word, I don't mean the instances of my TaskTagsList structs, but the objects SwiftUI creates to render them) seem to not be thrown away. My debugging lines clearly show there are still "old" such nodes (with their own ID and so on).
At first I naturally thought it's because I use @ObservedObject, but that is not it (i.e. @StateObject results in the same, see below why I used @ObservedObject in the first place †).

I am really at a loss here... I understand that SwiftUI basically keeps my observed objects around somewhere (they're reference types for that reason after all) to determine what to redraw when, but I would think that once I dealloc the UIHostingController that all is gone. Furthermore I am unsure if that's just expected with my code (i.e. I am dumb) and logical that the runtime keeps instances for "previous views" around (and since they reference my ObservableOptionals they stay as well) or whether it's the other way round and I messed up in a way that keeps the ObservableOptionals around which in turn keeps the view nodes in memory somehow.
My hunch is the latter, because if I recreate the entire hierarchy for a different CoachingTask I don't see the same old "ghost nodes" identifiers.

Sorry for the long post, but if you made it this far, perhaps you have an idea or a pointer to a solution? :slight_smile:


†: If I simply use a @StateObject I run into an issue where the model I wrap in it is modified before the view is actually drawn, as the UIHostingController is created and configured in viewDidLoad. That gives a runtime warning, which is not the case for @ObservedObject. Since my TaskTagsList view does not require relying on one fixed instance of the model I was fine with using the second property wrapper and as said that alone does not result in the "ghost nodes".

I don't know if it will help, but I know Swift UI is very dependent on when the object will change messages arrive. Have you tried moving the recreateSubscriber(with:) into a didSet?

First off: Thanks for the suggestion!
I just tried that and the "leak" (if you want to call it that) is still there.
To clarify, you meant to just move the recreateSubscriber(with:) call into a didSet, correct? Because the objectWillChange.send() call must be in willSet, SwiftUI requires to inform about changes before they take place (I had looked that up before).

In general I am also suspicious of my ObservableOptional class, but then again I am pretty sure that's how you implement ObservableObject if you don't use @Published to wrap your properties (and I didn't do that since I must forward the coming changes from the optional, if it is not nil, to my own objectWillChange publisher...).

The resulting memory leak is tiny, so for now I can live with it, but obviously that's not a good, sustainable solution. Plus, I want to understand what causes this. Is there really a retain cycle somewhere? Shouldn't all of this be dealloced once I throw away the UIHostingController? This is driving me up the walls...

Okay, for completeness sake, I present you my walk of shame (well, bugfix of shame?). It's a classic case of "Hm, there's a bug and I used this new tech for the first time in earnest, I must be doing something wrong" and then actually making a dumb mistake with the stuff you've been (mostly correctly) using for years. I just didn't look where it mattered.
Or in other words: I am not insane and SwiftUI does indeed work as I thought and as it should. I am just stupid instead.

Naturally because of that I did not post the relevant code above either. I actually have a second UIHostingController to embed another SwiftUI view in my UIKit view controller. That other SwiftUI view, TimeInvestsList has some functionality that requires informing the embedding view controller about stuff (to update some classic UIKit views).

Gummy points if you can guess where this is going...

So when I create TimeInvestsList in my viewDidLoad I have to use the following initializer that includes two closures:

init(coachingTask: CoachingTask?, coreDataStack: CoreDataStack,
     heightUpdated: (@MainActor (CGFloat) -> Void)? = nil,
     deletionCallback: (@MainActor () -> Void)? = nil)

And how do I call this from my view controller? Well, like so:

TimeInvestsList(coachingTask: coachingTask, coreDataStack: cdStack,
                heightUpdated: timeInvestListHeightUpdated(_:),
                deletionCallback: updateUI)

Meaning I effin' capture my entire view controller by passing its functions as closures to the TimeInvestsList!

I am so mad at myself. It's been years since I built that kind of retain cycle and even longer since I failed to immediately smell it once some memory spikes (or unexpected logs) appeared...

It wasn't the SwiftUI runtime keeping my model (i.e. my ObservableOptional) around. I wasn't deallocating my entire classic view controller because I dumbly set up a retain cycle to something it should own itself... ARGH!
I replaced those closures with some thunks that contain a [weak self] capture and now everything gets deallocated and I have no "ghost nodes" anymore...

I am writing this on the off chance that someone else runs into a similar problem. Or ever wants to have an optional ObservableObject, the wrapper I posted above works perfectly fine.


In retrospect this entire thing makes me wonder whether there is a possible way to have the compiler show a warning if you use a function of a reference type to pass as a closure parameter directly... Or maybe not a compiler thing (because I assume there's no way it can determine whether it's a bad thing or intended), maybe this could be included in something like SwiftLint? As a general recommendation like "Hey, this one captures self, you're aware of that, right?" I'm probably not the first one wondering that, though, and after all, retain cycles are just a sucky thing that can happen... hm...

Congrats. It happens to all of us. I should have suggested this before but the last time I was tracking down a memory leak/cycle, I found that Xcode's somewhat new feature, that lets you view memory tree and cycles inside your running app to be very useful. Combined with Malloc Stack logging, it real sped up tracking them down. It was much faster than sending it through profiler.

1 Like

Yes, I like that tool a a lot, too. Naturally in this case I was blinded by expecting to have failed in a completely different area... :laughing:
Specifically since the Leak profiling tool in Instruments is not as useful anymore as in the past. These days you see tons of leaks reported by the system itself, which is very unfortunate. I can't remember when it was, but back in the days that wasn't the case and if you saw a leak it was almost certainly your fault. Well, that wouldn't have helped my in this case either, I guess, but still...