肇鑫的技术博客

业精于勤,荒于嬉

SwiftUI view recreated may cause bugs what are hard to debug

Today, I encountered an issue that VideoPlayer turned to blank when iPhone screen was rotated.

The VideoPlayer was in a modal view that created with fullScreenCover. The sample code was like this:

import SwiftUI
import AVKit

struct TopView: View {
    private let player = AVQueuePlayer(playerItem: nil)
    
    var body: some View {
        VideoPlayer(player: player)
            .onAppear {
                if let videoURL = Bundle.main.url(forResource: "sample", withExtension: "mov") {
                    let playerItem = AVPlayerItem(url: videoURL)
                    player.replaceCurrentItem(with: playerItem)
                    
                    Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { timer in
                        if player.status == .readyToPlay {
                            player.play()
                            
                            if player.timeControlStatus == .playing {
                                timer.invalidate()
                            }
                        }
                    }
                }
            }
    }
}

Using Publisher for UIDevice.orientationDidChangeNotification

At first, I thought I should recreate the player when the screen was rotated.

import SwiftUI
import AVKit

struct TopView: View {
    @State private var playerItem:AVPlayerItem?
    
    private let player = AVQueuePlayer(playerItem: nil)
    private let publisher = NotificationCenter.default.publisher(for: UIDevice.orientationDidChangeNotification)
    
    var body: some View {
        if playerItem != nil {
            VideoPlayer(player: player)
                .onReceive(publisher, perform: { _ in
                    self.player.pause()
                    self.playerItem = nil
                })
        } else {
            ProgressView()
                .onAppear(perform: setPlayer)
        }
    }
    
    private func setPlayer() {
        if let videoURL = Bundle.main.url(forResource: "sample", withExtension: "mov") {
            self.playerItem = AVPlayerItem(url: videoURL)
            player.replaceCurrentItem(with: playerItem)
            
            Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { timer in
                if player.status == .readyToPlay {
                    player.play()
                    
                    if player.timeControlStatus == .playing {
                        timer.invalidate()
                    }
                }
            }
        }
    }
}

However, the new code didn't work. I added more debug point and finally found that TopView was recreated when the screen was rotated. Since the view was recreated, the player in onReceive was newly created, it couldn't stop the playing of the previously played item.

The issue was because, unlike other structs and objects, which could automatically released when container view was released. AVPlayer hold its owned reference when playing. This behavior caused the SwiftUI view was not released properly.

Use Binding from parent view

The solution was easy. Since I wanted the player to be constant, I should set it up in the parent view.

import SwiftUI
import AVKit

struct TopView: View {
    @Binding var player:AVQueuePlayer
    
    var body: some View {
        VideoPlayer(player: player)
            .onAppear {
                if let videoURL = Bundle.main.url(forResource: "sample", withExtension: "mov") {
                    let playerItem = AVPlayerItem(url: videoURL)
                    player.replaceCurrentItem(with: playerItem)
                    
                    Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { timer in
                        if player.status == .readyToPlay {
                            player.play()
                            
                            if player.timeControlStatus == .playing {
                                timer.invalidate()
                            }
                        }
                    }
                }
            }
    }
}

Now everything worked fine.

Final Thoughts

Some objects hold their own references as strong. Those objects may keep view from release and cause bugs. We should using Binding to create those objects in a higher view which is not recreated. Then the bugs are fixed.

VideoPlayer Turns Black Screen When Replace a Player Item

I was working on a project that shows videos from the Photo Library. However, each time I replaced the current video, the VideoPlayer turned black screen for a while.

I had no idea why this happened so I looked for what Photos app did for videos. It turned out that Photos showed a preview of the video first and then automatically played the video when the video data was loaded.

I did the same. First I fetched the preview image. When the preview was downloaded, I fetched the video.

The issue was the same. After some investigation, I found that the method of fetching video was called twice, which was not expected. Then I found that was because fetching preview may be called multiple times. Photo Library may provide a low quality image first then provide the full quality image. This issue was solved by read the info dictionary of the fetching preview result. But the black screen issue was still there.

Maybe the AVPlayer or PlayerItem was not ready?

if let video {
    player.replaceCurrentItem(with: video)
    
    Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { timer in
        if player.status == .readyToPlay {
            playerItem = video
            timer.invalidate()
        }
    }
}

The result was the same.

Maybe there was still some time between the player was ready and the player was really playing.

if let video {
    player.replaceCurrentItem(with: video)
    
    Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { timer in
        if player.status == .readyToPlay {
            player.play()
            
            if player.timeControlStatus == .playing {
                playerItem = video
                timer.invalidate()
            }
        }
    }
}

The issue solved.

Conclusion

  1. Do not create another AVPlayer each time. Just create an empty player and replace the replaceCurrentItem.
  2. There was time gap between player ready and player real playing. So you need to play the player first when player was ready and then updated the UI after the player was really playing.
  3. I was using a timer instead of a notification as the former was more simple to use.

SwiftUI with Preview

Some times, only the preview of SwiftUI view crashes. Both the simulator and the real device don't crash. So we need to do extra works or don't run some methods if the target is a preview.

We can't identify preview from debug, so we add a isPreview variable under debug, and that won't leave the variable in release version.

struct SomeView : View {
    #if DEBUG
    var isPreview = false
    #endif
    var body: some View {
        Text("Hello World!")
            .onAppear {
                #if DEBUG
                if isPreview {
                    return
                }
                #endif
                
                foo()
            }
    }
    
    private func foo() {
        ...
    }
}

struct SomeView_Previews: PreviewProvider {
    static var previews: some View {
        Group {
            SomeView(isPreview: true)
                .previewDevice(PreviewDevice(rawValue: "iPhone SE 2"))
                .previewDisplayName("iPhone SE 2")
            
            SomeView(isPreview: true)
                .previewDevice(PreviewDevice(rawValue: "iPhone 14 Pro"))
                .previewDisplayName("iPhone 14 Pro")
        }
    }
}