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)
}
}
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
cancellable
in init
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 @Environment
s 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:
@State
optional and initialise it in .task
.@ObservationIgnored
on cancellable
.addPlayerObservers
outside of init
- in onAppear
for example.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.