Search code examples
iosswiftswiftuiavfoundationavplayer

SwiftUI infinite loop issue with @Environment size vars


I see SwiftUI body being repeatedly called in an infinite loop in the presence of Environment variables like horizontalSizeClass or verticalSizeClass. This happens after device is rotated from portrait to landscape and then back to portrait mode. The deinit method of TestPlayerVM is repeatedly called. Minimally reproducible sample code is pasted below.

The infinite loop is not seen if I remove size class environment references, OR, if I skip addPlayerObservers call in the TestPlayerVM initialiser.

import AVKit
import Combine

struct InfiniteLoopView: View {
       @Environment(\.verticalSizeClass) var verticalSizeClass
       @Environment(\.horizontalSizeClass) var horizontalSizeClass
    
       @State private var openPlayer = false
       @State var playerURL: URL = URL(fileURLWithPath: Bundle.main.path(forResource: "Test_Video", ofType: ".mov")!)
    
        var body: some View {
           PlayerView(playerURL: playerURL)
              .ignoresSafeArea()
        }
     }

struct PlayerView: View {
    @Environment(\.dismiss) var dismiss
    
    var playerURL:URL
    @State var playerVM = TestPlayerVM()
    
    var body: some View {
        VideoPlayer(player: playerVM.player)
            .ignoresSafeArea()
            .background {
                Color.black
            }
            .task {
                let playerItem = AVPlayerItem(url: playerURL)
                playerVM.playerItem = playerItem
            }
    }
  }

@Observable
class TestPlayerVM {
     private(set) public var player: AVPlayer = AVPlayer()

     var playerItem:AVPlayerItem? {
        didSet {
            player.replaceCurrentItem(with: playerItem)
        }
    }
    
    private var cancellable = Set<AnyCancellable>()
    
    init() {
        addPlayerObservers()
    }
    
    deinit {
        print("Deinit Video player manager")
        removeAllObservers()
    }
    
    private func removeAllObservers() {
        cancellable.removeAll()
    }
    
  
    private func addPlayerObservers() {
        
        player.publisher(for: \.timeControlStatus, options: [.initial, .new])
            .receive(on: DispatchQueue.main)
            .sink { timeControlStatus in
                print("Player time control status \(timeControlStatus)")
            }
            .store(in: &cancellable)
        
    }
}


Solution

  • This is caused by a combination of multiple things in your code:

    • @State with @Observable will call TestPlayerVM.init every time PlayerView.init is called. Normally this new instance will be deinitialised immediately, as the view only needs one instance, but SwiftUI leaks one instance sometimes. This isa well known bug.
    • cancellable is tracked by @Observable
    • you modify cancellable in init
    • you modify cancellable in deinit

    If any of these 4 things didn't happen, you wouldn't end up in an infinite loop.

    At the very beginning, PlayerView.init is called and this creates the first instance of TestPlayerVM.

    Rotating the screen causes the @Environments to change, so SwiftUI performs a view update, calling InfiniteLoopView.init, where you call PlayerView.init. This creates a second instance of TestPlayerVM. This second instance is leaked, i.e. erroneously retained in memory by SwiftUI and doesn't get deinitialised.

    This is why the infinite loop doesn't start until the second rotation of the screen. If SwiftUI doesn't leak the second instance, its deinit would have been called after the first rotation, kicking off the infinite loop.

    When you rotate the screen again, InfiniteLoopView.body gets called again, so PlayerView.init is also called again, creating the third instance of TestPlayerVM. Here's where the chain reaction starts.

    In TestPlayerVM.init, cancellable is modified as you insert a new element into it. Since cancellable is tracked by @Observable, SwiftUI now thinks that cancellable is a dependency of InfiniteLoopView. InfiniteLoopView will be updated whenever cancellable changes. I should stress that this is InfiniteLoopView, not PlayerView. We are still in the middle of evaluating InfiniteLoopView.body after all, so SwiftUI is finding the dependencies for InfiniteLoopView.

    Unlike the second instance, this third instance is (correctly) deinitialised immediately. But what happens in deinit? You modify cancellable by removing all its elements. SwiftUI observes this change, and because it determined that cancellable is a dependency of InfiniteLoopView earlier, it updates the view. InfiniteLoopView.body gets called, so PlayerView.init gets called, and therefore another instance of TestPlayerVM is created, and the loop continues.

    You can do any of these (or all of these) to break the loop:

    • Make the @State optional and initialise it in .task.
    • Put @ObservationIgnored on cancellable.
    • Call addPlayerObservers outside of init - in onAppear for example.
    • Don't cancellable.removeAll(). Let ARC do its work. The AnyCancellable will be automatically deinitialised just after TestPlayerVM.deinit anyway, assuming nothing else is holding a reference to them.