sheet 中的 AVPlayer:“CALayer 位置包含 NaN:[nan nan]”(包括 MRE)

AVPlayer in sheet: “CALayer position contains NaN: [nan nan]” (includes MRE)

最后,我设法创建了一个适合 1 个文件的最小可重现示例。这个异常要了我的命,整个周末都在试图找到原因。我无法用断点调试它,因为它在它的线程开始时被调用(xcode window 截图:https://d.pr/i/OPmq8Z )

MRE(最小可重现示例)的作用:

2021-04-11 22:38:17.251165+0200 DetailedTesting[44173:3531565] *** Terminating app due to uncaught exception 'CALayerInvalidGeometry', reason: 'CALayer position contains NaN: [nan nan]. Layer: <CALayer:0x2811a80a0; position = CGPoint (2 8); bounds = CGRect (0 0; 455 4); delegate = <UIView: 0x11bf18f60; frame = (2 8; 455 4); anchorPoint = (0, 0); autoresizesSubviews = NO; layer = <CALayer: 0x2811a80a0>>; opaque = YES; allowsGroupOpacity = YES; anchorPoint = CGPoint (0 0); _swiftUI_displayListID = 11; backgroundColor = <CGColor 0x2835f2d60> [<CGColorSpace 0x2835e09c0> (kCGColorSpaceICCBased; kCGColorSpaceModelRGB; sRGB IEC61966-2.1; extended range)] ( 1 1 1 0.232 )>'
*** First throw call stack:
(0x19e95d86c 0x1b3976c50 0x19e8564a4 0x1a1d85db4 0x1a1d85ce4 0x1a55f5378 0x1a58f1e38 0x1a58efb78 0x1a58eea8c 0x1a56585f0 0x1a53b09d0 0x1a5658158 0x1a5657f0c 0x1a565793c 0x1a5657f2c 0x1a565793c 0x1a56576d0 0x1a5657580 0x1a57167f4 0x1a52ca0ac 0x1a5868434 0x1a585e2c8 0x1a59e8f90 0x1a59e8fc4 0x1a185fec4 0x1a1d7a644 0x1a1d7ab18 0x1a1d8f30c 0x1a1cd4640 0x1a1cffb08 0x1a1d00e98 0x19e8d8358 0x19e8d25c4 0x19e8d2b74 0x19e8d221c 0x1b649c784 0x1a1312ee8 0x1a131875c 0x1a58a2210 0x1a58a219c 0x1a53daf90 0x100b17b48 0x100b17be8 0x19e5926b0)
libc++abi.dylib: terminating with uncaught exception of type NSException
*** Terminating app due to uncaught exception 'CALayerInvalidGeometry', reason: 'CALayer position contains NaN: [nan nan]. Layer: <CALayer:0x2811a80a0; position = CGPoint (2 8); bounds = CGRect (0 0; 455 4); delegate = <UIView: 0x11bf18f60; frame = (2 8; 455 4); anchorPoint = (0, 0); autoresizesSubviews = NO; layer = <CALayer: 0x2811a80a0>>; opaque = YES; allowsGroupOpacity = YES; anchorPoint = CGPoint (0 0); _swiftUI_displayListID = 11; backgroundColor = <CGColor 0x2835f2d60> [<CGColorSpace 0x2835e09c0> (kCGColorSpaceICCBased; kCGColorSpaceModelRGB; sRGB IEC61966-2.1; extended range)] ( 1 1 1 0.232 )>'
terminating with uncaught exception of type NSException

Sheet有视频播放器(出现异常时):

我的想法:

  1. 我发现如果我评论“VideoPlayerControlsView()”,错误不会再次发生,所以问题可能是在我的 CustomSlider 对象位于 VideoPlayerControlsView 视图内。

  2. 我觉得可能是加载远程视频导致的,因为一开始视频没有加载,app不知道size/bounds 的 AVPlayer 对象,因此无法创建某些父视图(可能是 CustomerSlider),或者计算负数 width/height..

代码(MRE):

import SwiftUI
import AVFoundation
import Foundation

struct ContentView: View {
    @State var hVideoURL: String?
    @State var isPaused: Bool = false

    var body: some View {
        Button("Let's Go!") {
            DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
                print("settings isPaused to TRUE")

                self.hVideoURL = "https://firebasestorage.googleapis.com/v0/b/fitma-e3043.appspot.com/o/flamelink%2Fmedia%2F1-horizontal.mov?alt=media&token=8f7dfc0f-0261-4a78-9eb0-6154ce1d9dfe"
                print("[debug] hVideoURL = \(String(describing: hVideoURL))")
                
                self.isPaused = true
            }
        }
        .sheet(item: self.$hVideoURL, onDismiss: {
            self.isPaused = false
            print("resume playing main video")
            
        }) { hVideoURLItem in
            detailedVideoView(url: hVideoURLItem)
        }
    }

    @ViewBuilder
    func detailedVideoView(url: String) -> some View {
        
        DetailedVideo(url: URL(string: url)!, isPaused: self.$isPaused)
            .onAppear {
                AppDelegate.orientationLock = UIInterfaceOrientationMask.landscapeLeft
                UIDevice.current.setValue(UIInterfaceOrientation.landscapeLeft.rawValue, forKey: "orientation")
                UINavigationController.attemptRotationToDeviceOrientation()
            }
            .onDisappear {
                DispatchQueue.main.async {
                    AppDelegate.orientationLock = UIInterfaceOrientationMask.portrait
                    UIDevice.current.setValue(UIInterfaceOrientation.portrait.rawValue, forKey: "orientation")
                    UINavigationController.attemptRotationToDeviceOrientation()
                }
            }

    }
}

extension String: Identifiable {
    public var id: String { self }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

struct DetailedVideo: View {
    var url: URL
    @Binding var isPaused: Bool
    
    var body: some View {
        ZStack {
            DetailedPlayerView(hVideoURL: url)
        }
    }
}

struct DetailedPlayerView: View {

    // The progress through the video, as a percentage (from 0 to 1)
    @State private var videoPos: Double = 0
    // The duration of the video in seconds
    @State private var videoDuration: Double = 0
    // Whether we're currently interacting with the seek bar or doing a seek
    @State private var seeking = false

    private var player: AVPlayer = AVPlayer()
        
    init(hVideoURL: URL?) {

        if hVideoURL != nil {
            player = AVPlayer(url: hVideoURL!)
            player.isMuted = true
            player.play()
        } else {
            print("[debug] hVideoURL is nil")
        }
    }

    var body: some View {
        ZStack {
            VideoPlayerView(videoPos: $videoPos,
                            videoDuration: $videoDuration,
                            seeking: $seeking,
                            player: player)
                .ignoresSafeArea(.all)
                .frame(width: UIScreen.screenHeight, height: UIScreen.screenWidth)
                
            VStack {
                
                Spacer()

                VideoPlayerControlsView(videoPos: $videoPos,
                                        videoDuration: $videoDuration,
                                        seeking: $seeking,
                                        player: player)
                    .frame(width: UIScreen.screenHeight - 2*70, height: 20)
                    .padding(.bottom, 20)
            }
                
        }
        .onDisappear {
            // When this View isn't being shown anymore stop the player
            self.player.replaceCurrentItem(with: nil)
        }
    }
}

// This is the SwiftUI view which wraps the UIKit-based PlayerUIView above
struct VideoPlayerView: UIViewRepresentable {
    @Binding private(set) var videoPos: Double
    @Binding private(set) var videoDuration: Double
    @Binding private(set) var seeking: Bool
    
    let player: AVPlayer
    
    func updateUIView(_ uiView: UIView, context: UIViewRepresentableContext<VideoPlayerView>) {
        // This function gets called if the bindings change, which could be useful if
        // you need to respond to external changes, but we don't in this example
    }
    
    func makeUIView(context: UIViewRepresentableContext<VideoPlayerView>) -> UIView {
        let uiView = VideoPlayerUIView(player: player,
                                       videoPos: $videoPos,
                                       videoDuration: $videoDuration,
                                       seeking: $seeking
        )
        return uiView
    }
    
    static func dismantleUIView(_ uiView: UIView, coordinator: ()) {
        guard let playerUIView = uiView as? VideoPlayerUIView else {
            return
        }
        
        playerUIView.cleanUp()
    }
}

class VideoPlayerUIView: UIView {
    private let player: AVPlayer
    private let playerLayer = AVPlayerLayer()
    private let videoPos: Binding<Double>
    private let videoDuration: Binding<Double>
    private let seeking: Binding<Bool>
    private var durationObservation: NSKeyValueObservation?
    private var timeObservation: Any?
    
    init(player: AVPlayer, videoPos: Binding<Double>, videoDuration: Binding<Double>, seeking: Binding<Bool>) {
        self.player = player
        self.videoDuration = videoDuration
        self.videoPos = videoPos
        self.seeking = seeking
        
        super.init(frame: .zero)
        
        backgroundColor = .lightGray
        playerLayer.player = player
        layer.addSublayer(playerLayer)
        
        // Observe the duration of the player's item so we can display it
        // and use it for updating the seek bar's position
        durationObservation = player.currentItem?.observe(\.duration, changeHandler: { [weak self] item, change in
            guard let self = self else { return }
            self.videoDuration.wrappedValue = item.duration.seconds
        })
        
        // Observe the player's time periodically so we can update the seek bar's
        // position as we progress through playback
        timeObservation = player.addPeriodicTimeObserver(forInterval: CMTime(seconds: 0.5, preferredTimescale: 600), queue: nil) { [weak self] time in
            guard let self = self else { return }
            // If we're not seeking currently (don't want to override the slider
            // position if the user is interacting)
            guard !self.seeking.wrappedValue else {
                return
            }
            
            // update videoPos with the new video time (as a percentage)
            self.videoPos.wrappedValue = time.seconds / self.videoDuration.wrappedValue

        }
    }
    
    required init?(coder: NSCoder) {
        fatalError("[debug] init(coder:) has not been implemented")
    }
    
    override func layoutSubviews() {
        super.layoutSubviews()

        playerLayer.frame = bounds
    }
    
    func cleanUp() {
        // Remove observers we setup in init
        durationObservation?.invalidate()
        durationObservation = nil
        
        if let observation = timeObservation {
            player.removeTimeObserver(observation)
            timeObservation = nil
        }
    }
    
}

// This is the SwiftUI view that contains the controls for the player
struct VideoPlayerControlsView : View {
    @Binding private(set) var videoPos: Double
    @Binding private(set) var videoDuration: Double
    @Binding private(set) var seeking: Bool

    @State var shouldStopPlayer: Bool = false
    
    @State var player: AVPlayer
    
    @State private var playerPaused = false
    
    var body: some View {
        HStack {
            // Play/pause button
            Button(action: togglePlayPause) {
                Image(systemName: playerPaused ? "arrowtriangle.right.fill" : "pause.fill")
                    .foregroundColor(Color.red)
                    .contentShape(Rectangle())

                    .padding(.trailing, 10)
            }
            
            // Current video time
            if videoPos.isFinite && videoPos.isCanonical && videoDuration.isFinite && videoDuration.isCanonical {
            Text(Utility.formatSecondsToHMS(videoPos * videoDuration))
                .foregroundColor(Color.red)
            }
            
            // Slider for seeking / showing video progress
            CustomSlider(value: $videoPos, shouldStopPlayer: self.$shouldStopPlayer, range: (0, 1), knobWidth: 4) { modifiers in
              ZStack {
                Group {

                    Color(#colorLiteral(red: 1, green: 1, blue: 1, alpha: 0.5799999833106995))//Color((red: 0.4, green: 0.3, blue: 1)
                        .opacity(0.4)
                        .frame(height: 4)
                        .modifier(modifiers.barRight)

                    Color.red//Color(red: 0.4, green: 0.3, blue: 1)
                        .frame(height: 4)
                        .modifier(modifiers.barLeft)

                }
                .cornerRadius(5)

                VStack {
                    Image(systemName: "arrowtriangle.down.fill") // SF Symbol
                    .foregroundColor(Color.red)
                    .offset(y: -3)
                }
                .frame(width: 20, height: 20)
                .contentShape(Rectangle())
                .modifier(modifiers.knob)
              }
            }
            .onChange(of: shouldStopPlayer) { _ in
                if shouldStopPlayer == false {
                    print("[debug] shouldStopPlayer == false")
                    sliderEditingChanged(editingStarted: false)
                } else {
                    if seeking == false {
                        print("[debug] shouldStopPlayer == true")
                        sliderEditingChanged(editingStarted: true)
                    }
                }
            }
            .frame(height: 20)

            // Video duration
            if videoDuration.isCanonical && videoDuration.isFinite {
            Text(Utility.formatSecondsToHMS(videoDuration))
                .foregroundColor(Color.red)
            }
        }
        .padding(.leading, 40)
        .padding(.trailing, 40)
    }
    
    private func togglePlayPause() {
        pausePlayer(!playerPaused)
    }
    
    private func pausePlayer(_ pause: Bool) {
        playerPaused = pause
        
        if playerPaused {
            player.pause()
        }
        else {
           player.play()
        }
    }
    
    private func sliderEditingChanged(editingStarted: Bool) {
        if editingStarted {
            // Set a flag stating that we're seeking so the slider doesn't
            // get updated by the periodic time observer on the player
            seeking = true
            pausePlayer(true)
        }
        
        // Do the seek if we're finished
        if !editingStarted {
            let targetTime = CMTime(seconds: videoPos * videoDuration,
                                    preferredTimescale: 600)
            player.seek(to: targetTime) { _ in
                // Now the seek is finished, resume normal operation
                self.seeking = false
                self.pausePlayer(false)
            }
        }
    }
}

class Utility: NSObject {
    
    private static var timeHMSFormatter: DateComponentsFormatter = {
        let formatter = DateComponentsFormatter()
        formatter.unitsStyle = .positional
        formatter.allowedUnits = [.minute, .second]
        formatter.zeroFormattingBehavior = [.pad]
        return formatter
    }()
    
    static func formatSecondsToHMS(_ seconds: Double) -> String {
        return timeHMSFormatter.string(from: seconds) ?? "00:00"
    }
    
}

extension UIScreen {
    static let screenWidth = UIScreen.main.bounds.size.width
    static let screenHeight = UIScreen.main.bounds.size.height
    static let screenSize = UIScreen.main.bounds.size
}

struct CustomSlider<Component: View>: View {

    @Binding var value: Double
    var range: (Double, Double)
    var knobWidth: CGFloat?
    let viewBuilder: (CustomSliderComponents) -> Component
    @Binding var shouldStopPlayer: Bool
    
    init(value: Binding<Double>, shouldStopPlayer: Binding<Bool>, range: (Double, Double), knobWidth: CGFloat? = nil, _ viewBuilder: @escaping (CustomSliderComponents) -> Component
    ) {
        _value = value
        _shouldStopPlayer = shouldStopPlayer
        self.range = range
        self.viewBuilder = viewBuilder
        self.knobWidth = knobWidth
    }

    var body: some View {
      return GeometryReader { geometry in
        self.view(geometry: geometry) // function below
      }
    }

    private func view(geometry: GeometryProxy) -> some View {
      let frame = geometry.frame(in: .global)
      let drag = DragGesture(minimumDistance: 0)
        .onChanged { drag in
                    shouldStopPlayer = true
                    self.onDragChange(drag, frame)
        }
        .onEnded { drag in
            shouldStopPlayer = false
            //self.updatedValue = value
            print("[debug] slider drag gesture ended, value = \(value)")
        }
      let offsetX = self.getOffsetX(frame: frame)

      let knobSize = CGSize(width: knobWidth ?? frame.height, height: frame.height)
      let barLeftSize = CGSize(width: CGFloat(offsetX + knobSize.width * 0.5), height:  frame.height)
      let barRightSize = CGSize(width: frame.width - barLeftSize.width, height: frame.height)

      let modifiers = CustomSliderComponents(
          barLeft: CustomSliderModifier(name: .barLeft, size: barLeftSize, offset: 0),
          barRight: CustomSliderModifier(name: .barRight, size: barRightSize, offset: barLeftSize.width),
          knob: CustomSliderModifier(name: .knob, size: knobSize, offset: offsetX))

      return ZStack { viewBuilder(modifiers).gesture(drag) }
    }
    
    private func onDragChange(_ drag: DragGesture.Value,_ frame: CGRect) {
        let width = (knob: Double(knobWidth ?? frame.size.height), view: Double(frame.size.width))
        let xrange = (min: Double(0), max: Double(width.view - width.knob))
        var value = Double(drag.startLocation.x + drag.translation.width) // knob center x
        value -= 0.5*width.knob // offset from center to leading edge of knob
        value = value > xrange.max ? xrange.max : value // limit to leading edge
        value = value < xrange.min ? xrange.min : value // limit to trailing edge
        value = value.convert(fromRange: (xrange.min, xrange.max), toRange: range)
        //print("[debug] slider drag gesture detected, value = \(value)")
        self.value = value
    }
    
    private func getOffsetX(frame: CGRect) -> CGFloat {
        let width = (knob: knobWidth ?? frame.size.height, view: frame.size.width)
        let xrange: (Double, Double) = (0, Double(width.view - width.knob))

        let result = self.value.convert(fromRange: range, toRange: xrange)
        return CGFloat(result)
    }
  
}

extension Double {
    func convert(fromRange: (Double, Double), toRange: (Double, Double)) -> Double {
        // Example: if self = 1, fromRange = (0,2), toRange = (10,12) -> solution = 11
        var value = self
        value -= fromRange.0
        value /= Double(fromRange.1 - fromRange.0)
        value *= toRange.1 - toRange.0
        value += toRange.0
        return value
    }
}

struct CustomSliderComponents {
    let barLeft: CustomSliderModifier
    let barRight: CustomSliderModifier
    let knob: CustomSliderModifier
}

struct CustomSliderModifier: ViewModifier {
    enum Name {
        case barLeft
        case barRight
        case knob
    }
    let name: Name
    let size: CGSize
    let offset: CGFloat

    func body(content: Content) -> some View {
        content
            .frame(width: (size.width >= 0) ? size.width : 0)
            .position(x: size.width*0.5, y: size.height*0.5)
            .offset(x: offset)
    }
}

class AppDelegate: UIResponder, UIApplicationDelegate {
    
    static var orientationLock = UIInterfaceOrientationMask.portrait
    
    func application(_ application: UIApplication, supportedInterfaceOrientationsFor window: UIWindow?) -> UIInterfaceOrientationMask {
        return AppDelegate.orientationLock
    }
}

非常感谢thoughts/ideas!

当遇到nan问题时,我的第一步总是寻找除法运算。

在您的代码中,这看起来像您的 convert 函数(您怀疑的滑块的一部分)。在进行计算之前,我会检查有效值的范围:

func convert(fromRange: (Double, Double), toRange: (Double, Double)) -> Double {
  
  //put a guard statement here to check that fromRange.0 isn't equal to fromRange.1 and that you won't get a 0 result. Return a default value otherwise.

  var value = self
  value -= fromRange.0
  value /= Double(fromRange.1 - fromRange.0)
  value *= toRange.1 - toRange.0
  value += toRange.0
  return value
}