You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何观测AVPlayerLooper循环次数?Swift/SwiftUI实现及Firestore同步

SwiftUI中AVPlayer循环次数统计与Firestore同步方案

核心结论:观测逻辑放在协调器(Coordinator)中

直接给结论:监听AVPlayer循环次数的逻辑必须放在UIRepresentable的Coordinator里,原因如下:

  • makeUIView仅在视图首次创建时执行一次,无法响应后续的播放状态变化
  • updateUIView会在SwiftUI视图状态更新时频繁调用,在这里注册监听会导致重复绑定,触发多次无效计数
  • Coordinator作为SwiftUI和UIKit的中间层,天然适配AVPlayer的生命周期,能安全管理监听事件,不会出现内存泄漏或逻辑混乱

完整实现代码

下面采用分层设计,把数据操作、UI渲染、播放监听拆解开,保证逻辑清晰且计数精准。

1. ViewModel:负责Firestore数据同步

把Firestore的读写逻辑抽离到ViewModel中,避免视图层代码臃肿,同时保证数据操作的可复用性:

import FirebaseFirestore

class VideoPlayerViewModel: ObservableObject {
    private let db = Firestore.firestore()
    private let videoId: String // 视频唯一标识,用于定位Firestore文档
    
    @Published var loopCount = 0
    
    init(videoId: String) {
        self.videoId = videoId
        // 可选:初始化时从Firestore拉取当前循环次数
        fetchCurrentLoopCount()
    }
    
    private func fetchCurrentLoopCount() {
        db.collection("videos").document(videoId).getDocument { [weak self] snapshot, error in
            guard let data = snapshot?.data(), let count = data["loopCount"] as? Int else { return }
            DispatchQueue.main.async {
                self?.loopCount = count
            }
        }
    }
    
    func updateLoopCountToFirestore() {
        // 用Firestore事务保证计数精准,解决多端并发更新的冲突问题
        db.runTransaction({ [weak self] transaction, errorPointer -> Any? in
            guard let self = self else { return nil }
            let videoRef = self.db.collection("videos").document(self.videoId)
            
            guard let snapshot = try? transaction.getDocument(videoRef) else {
                errorPointer?.pointee = NSError(domain: "FirestoreError", code: -1, userInfo: [NSLocalizedDescriptionKey: "无法获取视频文档"])
                return nil
            }
            
            let currentCount = snapshot.data()?["loopCount"] as? Int ?? 0
            let newCount = currentCount + 1
            
            transaction.updateData(["loopCount": newCount], forDocument: videoRef)
            return newCount
        }) { [weak self] result, error in
            if let error = error {
                print("更新循环次数失败: \(error.localizedDescription)")
            } else if let newCount = result as? Int {
                DispatchQueue.main.async {
                    self?.loopCount = newCount
                }
            }
        }
    }
}

2. UIRepresentable:AVPlayer的SwiftUI包装

负责渲染AVPlayer的UI,Coordinator中处理播放结束的监听:

import SwiftUI
import AVKit

struct AVPlayerView: UIViewRepresentable {
    @ObservedObject var viewModel: VideoPlayerViewModel
    let player: AVPlayer
    
    func makeUIView(context: Context) -> UIView {
        let view = UIView()
        let playerLayer = AVPlayerLayer(player: player)
        playerLayer.frame = view.bounds
        playerLayer.videoGravity = .resizeAspect
        view.layer.addSublayer(playerLayer)
        
        // 给协调器注册播放结束通知
        NotificationCenter.default.addObserver(context.coordinator,
                                               selector: #selector(Coordinator.handlePlaybackEnd),
                                               name: .AVPlayerItemDidPlayToEndTime,
                                               object: player.currentItem)
        
        return view
    }
    
    func updateUIView(_ uiView: UIView, context: Context) {
        // 仅在player实例变化时更新layer,避免无效操作
        if let playerLayer = uiView.layer.sublayers?.first as? AVPlayerLayer, playerLayer.player != player {
            playerLayer.player = player
        }
    }
    
    func makeCoordinator() -> Coordinator {
        Coordinator(viewModel: viewModel, player: player)
    }
    
    class Coordinator: NSObject {
        private let viewModel: VideoPlayerViewModel
        private let player: AVPlayer
        
        init(viewModel: VideoPlayerViewModel, player: AVPlayer) {
            self.viewModel = viewModel
            self.player = player
            super.init()
        }
        
        @objc func handlePlaybackEnd() {
            // 手动处理循环:如果actionAtItemEnd是none,说明需要自定义循环逻辑
            guard player.actionAtItemEnd == .none else { return }
            
            // 重置播放进度到开头并继续播放
            player.seek(to: .zero)
            player.play()
            
            // 触发Firestore更新
            viewModel.updateLoopCountToFirestore()
        }
        
        deinit {
            // 移除通知监听,防止内存泄漏
            NotificationCenter.default.removeObserver(self)
        }
    }
}

3. 主视图使用示例

在SwiftUI主视图中整合ViewModel和AVPlayerView:

struct ContentView: View {
    // 替换为你的实际视频ID和URL
    private let videoId = "your-video-document-id"
    private let videoURL = URL(string: "https://example.com/your-video.mp4")!
    
    @StateObject private var viewModel: VideoPlayerViewModel
    
    init() {
        _viewModel = StateObject(wrappedValue: VideoPlayerViewModel(videoId: videoId))
        // 初始化AVPlayer并设置循环模式
        let player = AVPlayer(url: videoURL)
        player.actionAtItemEnd = .none // 关闭系统默认的播放结束行为,由我们自己处理循环
        player.play()
    }
    
    var body: some View {
        VStack {
            AVPlayerView(viewModel: viewModel, player: AVPlayer(url: videoURL))
                .frame(height: 300)
            
            Text("已循环播放: \(viewModel.loopCount) 次")
                .padding()
                .font(.headline)
        }
    }
}

方案合理性说明

  1. 职责分离:ViewModel管数据,UIRepresentable管UI渲染,Coordinator管播放事件,各层逻辑清晰,易于维护和扩展
  2. 精准计数:使用Firestore事务更新,避免多端同时更新导致的计数偏差
  3. 生命周期安全:Coordinator在销毁时自动移除通知监听,不会出现内存泄漏;makeUIView中仅注册一次监听,不会重复触发计数
  4. 状态同步:ViewModel的@Published属性自动驱动SwiftUI视图更新,保证界面显示的循环次数和Firestore数据一致

内容的提问来源于stack exchange,提问作者Harrison Senesac

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.15 00:13:15