1. 项目背景与核心需求拆解
1.1 为什么音视频录制项目绕不开播放能力
做过iOS音视频开发的人都有一个共识:录制和播放从来不是孤立的两件事。你做一个录制App,用户拍完视频总得能回看吧?你做一个视频编辑工具,素材导入后总得能预览吧?你做一个在线教育类应用,课程视频既要能播本地的缓存文件,又要能流畅播放在线流媒体。这些场景背后都指向同一个技术组件——AVPlayer。
我接触过不少刚入行的iOS开发者,他们一开始会觉得播放视频嘛,用AVPlayerViewController几行代码就搞定了。确实,如果只是播一个本地mp4文件,系统封装的播放器控制器几乎零配置就能跑起来。但一旦需求变得稍微复杂一点——比如需要在播放器上叠加自定义UI、需要精确控制播放进度、需要处理播放失败的重试逻辑、需要在录制和播放之间做无缝切换——AVPlayerViewController就不够用了,你必须下沉到AVPlayer这一层来做精细控制。
这个项目的核心就是解决一个问题:在iOS音视频录制类应用中,如何用AVPlayer同时搞定本地视频和在线视频的播放需求。听起来简单,但实际操作中涉及的知识点相当密集:AVPlayer的基本用法、AVPlayerItem的状态管理、本地文件路径的处理、在线URL的加载策略、播放进度的监听、缓冲状态的处理、以及各种异常情况的兜底。
1.2 本地视频与在线视频的本质差异
很多人觉得本地视频和在线视频不就是URL不一样吗?一个传file://路径,一个传https://地址,AVPlayer都能播。从API调用的角度看确实如此,但底层机制差别很大。
本地视频的读取走的是文件系统IO,数据就在设备存储上,AVPlayer可以几乎瞬间拿到完整的媒体信息,seek操作也是毫秒级响应。而在线视频走的是网络请求,AVPlayer需要通过HTTP协议与服务器通信,先拿到媒体头信息(比如moov box),再根据播放进度分段请求数据。这就带来了几个本地播放不会遇到的问题:网络延迟导致的起播慢、缓冲不足导致的卡顿、服务器不支持Range请求导致的seek失败、以及网络中断后的恢复播放。
我在实际项目中遇到过这样一个案例:同一个视频文件,放在Bundle里播放一切正常,放到CDN上就频繁出现播放到中间卡住的情况。排查后发现是CDN节点对Range请求的支持不完整,AVPlayer请求某个字节区间的数据时返回了200而不是206,导致播放器无法正确拼接数据。这类问题在本地播放中根本不会出现,但在线播放就是家常便饭。
1.3 适用人群与前置知识
这篇内容适合哪些人看?如果你正在做iOS音视频相关的开发,尤其是涉及录制后回放、在线视频播放、或者混合播放场景,那这篇内容会对你有直接帮助。如果你是完全没接触过AVFoundation的新手,建议先了解一下AVAsset、AVPlayerItem、AVPlayer这三个核心类的基本关系,不然看后面的实操部分可能会有点吃力。
前置知识方面,你需要熟悉Swift或Objective-C的基本语法,了解iOS的文件系统结构(Bundle、Documents、Caches目录的区别),对HTTP协议有基本的认知(状态码、Range请求头)。这些不需要精通,但至少看到206 Partial Content的时候知道是什么意思。
2. AVPlayer核心架构与关键类解析
2.1 AVPlayer、AVPlayerItem、AVAsset三者的关系
理解AVPlayer的第一步是搞清楚这三个类的关系。我用一个生活化的类比来解释:把播放视频想象成看书。AVAsset就是这本书本身,它包含了书的所有内容(视频轨道、音频轨道、时长、元数据等),但它只是一个抽象的描述,你还没开始读。AVPlayerItem相当于你当前要读的那一页,它管理着阅读进度、书签位置、以及当前页面的状态。AVPlayer则是你的眼睛和大脑,负责实际的“阅读”动作——解码、渲染、输出到屏幕。
代码层面的关系是这样的:你先创建一个AVAsset(或者它的子类AVURLAsset),然后用这个asset创建AVPlayerItem,最后用playerItem创建AVPlayer。播放本地视频和在线视频的区别就在第一步——AVURLAsset的URL不同。
// 本地视频 let localURL = Bundle.main.url(forResource: "demo", withExtension: "mp4")! let localAsset = AVURLAsset(url: localURL) let localItem = AVPlayerItem(asset: localAsset) let localPlayer = AVPlayer(playerItem: localItem) // 在线视频 let remoteURL = URL(string: "https://example.com/video.mp4")! let remoteAsset = AVURLAsset(url: remoteURL) let remoteItem = AVPlayerItem(asset: remoteAsset) let remotePlayer = AVPlayer(playerItem: remoteItem)看起来几乎一样对吧?但实际运行中,localItem创建后立刻就是readyToPlay状态,而remoteItem需要等待网络请求完成才能进入可播放状态。这个差异决定了你在处理在线视频时必须监听AVPlayerItem的status属性变化,而不能创建完就直接调play()。
2.2 AVPlayerItem的状态机与KVO监听
AVPlayerItem有三个核心状态:unknown、readyToPlay、failed。创建之初是unknown,当AVFoundation完成了媒体信息的解析后变为readyToPlay,如果解析失败(比如URL无效、格式不支持、网络不可达)则变为failed。
这里有一个新手常踩的坑:不要用同步的方式去读取status。因为状态变化是异步的,你在创建playerItem之后立刻读item.status大概率还是unknown。正确的做法是用KVO(Key-Value Observing)来监听状态变化。
observation = playerItem.observe(\.status, options: [.new, .initial]) { item, _ in switch item.status { case .readyToPlay: print("可以播放了,时长:\(CMTimeGetSeconds(item.duration))") self.player.play() case .failed: print("播放失败:\(String(describing: item.error))") self.handlePlaybackError(item.error) case .unknown: print("还在加载中...") @unknown default: break } }注意:KVO的observer需要在合适的时机移除,否则可能造成崩溃。在Swift中推荐把observation token保存为属性,在deinit或页面销毁时调用
observation?.invalidate()。
除了status,还有几个属性值得监听:isPlaybackLikelyToKeepUp(是否大概率能流畅播放)、isPlaybackBufferEmpty(缓冲区是否为空)、isPlaybackBufferFull(缓冲区是否已满)。这三个属性组合起来可以帮你判断当前的缓冲状态,从而决定是否显示loading指示器。
2.3 播放进度监听与时间观察
播放进度的监听有两种方式:一种是定期轮询player.currentTime(),另一种是使用addPeriodicTimeObserver。前者简单粗暴但精度和性能都不好,后者是官方推荐的做法。
timeObserver = player.addPeriodicTimeObserver( forInterval: CMTime(seconds: 0.5, preferredTimescale: 600), queue: .main ) { [weak self] time in guard let self = self else { return } let currentSeconds = CMTimeGetSeconds(time) let totalSeconds = CMTimeGetSeconds(self.playerItem.duration) self.progressSlider.value = Float(currentSeconds / totalSeconds) self.timeLabel.text = self.formatTime(currentSeconds) }forInterval决定了回调的频率,设成0.5秒意味着每半秒回调一次。这个值不要设得太小,否则频繁的主线程回调会影响UI流畅度。我一般用0.5到1秒之间,具体看UI刷新需求。如果进度条需要更平滑的动画,可以在回调里用UIView动画来插值,而不是把interval设成0.1。
提示:
addPeriodicTimeObserver返回的token必须在合适的时候用removeTimeObserver移除,否则会造成内存泄漏。而且移除的时机要在player释放之前,顺序反了会崩溃。
3. 本地视频播放的完整实操流程
3.1 本地视频的来源与路径处理
iOS应用中的本地视频通常来自几个地方:App Bundle内置、Documents目录(用户下载或录制的)、Caches目录(临时缓存)、以及相册中的PHAsset。不同来源的路径处理方式不同,但最终都需要转换成一个AVPlayer能识别的URL。
Bundle内置的视频最简单,用Bundle.main.url(forResource:withExtension:)就能拿到URL。但要注意,Bundle中的资源是只读的,你不能对其进行写操作。Documents目录的视频需要拼接路径:
let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! let videoURL = documentsPath.appendingPathComponent("recorded_video.mp4") // 检查文件是否存在 guard FileManager.default.fileExists(atPath: videoURL.path) else { print("文件不存在") return }相册中的视频需要通过PHAsset和PHImageManager来获取。这里有一个重要的细节:从iCloud同步的照片/视频,本地可能只有缩略图,需要先请求完整数据。用PHImageManager.default().requestAVAsset可以拿到AVAsset,但如果是iCloud上的资源,这个过程可能很慢,需要给用户一个加载提示。
let options = PHVideoRequestOptions() options.isNetworkAccessAllowed = true // 允许从iCloud下载 options.deliveryMode = .automatic PHImageManager.default().requestAVAsset(forVideo: asset, options: options) { avAsset, _, info in DispatchQueue.main.async { if let avAsset = avAsset { let item = AVPlayerItem(asset: avAsset) self.player.replaceCurrentItem(with: item) self.player.play() } } }3.2 本地播放器的初始化与UI搭建
虽然AVPlayerViewController用起来方便,但做录制类应用通常需要自定义播放UI。我的做法是用AVPlayerLayer来承载视频画面,然后在上面叠加自定义的控制层。
class VideoPlayerView: UIView { override class var layerClass: AnyClass { return AVPlayerLayer.self } var playerLayer: AVPlayerLayer { return layer as! AVPlayerLayer } var player: AVPlayer? { get { playerLayer.player } set { playerLayer.player = newValue } } override init(frame: CGRect) { super.init(frame: frame) playerLayer.videoGravity = .resizeAspect playerLayer.backgroundColor = UIColor.black.cgColor } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } }用layerClass重写的方式是最优雅的,这样VideoPlayerView的根layer直接就是AVPlayerLayer,不需要手动管理layer的frame。videoGravity设为.resizeAspect可以保持视频宽高比,黑边填充剩余空间,这是最常用的显示模式。
控制层我一般包含这几个元素:播放/暂停按钮、进度条(UISlider)、当前时间/总时长标签、全屏切换按钮。进度条的交互需要处理touchDown、touchUpInside、valueChanged三个事件——按下时暂停进度更新,拖动时seek到对应位置,松开后恢复播放。
3.3 本地视频播放的完整代码实现
把上面的内容串起来,一个完整的本地视频播放器实现大概长这样:
import AVFoundation import UIKit class LocalVideoPlayerController: UIViewController { private var player: AVPlayer? private var playerItem: AVPlayerItem? private var timeObserver: Any? private var statusObservation: NSKeyValueObservation? private var playerView: VideoPlayerView! private var playPauseButton: UIButton! private var progressSlider: UISlider! private var timeLabel: UILabel! private var isDragging = false override func viewDidLoad() { super.viewDidLoad() setupUI() setupPlayer() } private func setupUI() { view.backgroundColor = .black playerView = VideoPlayerView(frame: view.bounds) playerView.autoresizingMask = [.flexibleWidth, .flexibleHeight] view.addSubview(playerView) // 控制层布局省略,核心是按钮和进度条 playPauseButton = UIButton(type: .system) playPauseButton.setTitle("播放", for: .normal) playPauseButton.addTarget(self, action: #selector(togglePlayPause), for: .touchUpInside) // ... 布局代码 progressSlider = UISlider() progressSlider.minimumValue = 0 progressSlider.maximumValue = 1 progressSlider.addTarget(self, action: #selector(sliderTouchDown), for: .touchDown) progressSlider.addTarget(self, action: #selector(sliderValueChanged), for: .valueChanged) progressSlider.addTarget(self, action: #selector(sliderTouchUp), for: [.touchUpInside, .touchUpOutside]) // ... 布局代码 } private func setupPlayer() { guard let url = Bundle.main.url(forResource: "demo", withExtension: "mp4") else { print("视频文件不存在") return } let asset = AVURLAsset(url: url) playerItem = AVPlayerItem(asset: asset) player = AVPlayer(playerItem: playerItem) playerView.player = player // 监听状态 statusObservation = playerItem?.observe(\.status, options: [.new, .initial]) { [weak self] item, _ in DispatchQueue.main.async { if item.status == .readyToPlay { self?.updateDurationLabel() } } } // 监听播放进度 timeObserver = player?.addPeriodicTimeObserver( forInterval: CMTime(seconds: 0.5, preferredTimescale: 600), queue: .main ) { [weak self] time in guard let self = self, !self.isDragging else { return } let current = CMTimeGetSeconds(time) let total = CMTimeGetSeconds(self.playerItem?.duration ?? .zero) if total > 0 { self.progressSlider.value = Float(current / total) self.timeLabel.text = "\(self.formatTime(current)) / \(self.formatTime(total))" } } // 监听播放结束 NotificationCenter.default.addObserver( self, selector: #selector(playbackDidEnd), name: .AVPlayerItemDidPlayToEndTime, object: playerItem ) } @objc private func togglePlayPause() { guard let player = player else { return } if player.rate > 0 { player.pause() playPauseButton.setTitle("播放", for: .normal) } else { player.play() playPauseButton.setTitle("暂停", for: .normal) } } @objc private func sliderTouchDown() { isDragging = true } @objc private func sliderValueChanged() { guard let duration = playerItem?.duration else { return } let targetTime = CMTimeGetSeconds(duration) * Double(progressSlider.value) timeLabel.text = "\(formatTime(targetTime)) / \(formatTime(CMTimeGetSeconds(duration)))" } @objc private func sliderTouchUp() { guard let duration = playerItem?.duration else { return } let targetTime = CMTimeGetSeconds(duration) * Double(progressSlider.value) player?.seek(to: CMTime(seconds: targetTime, preferredTimescale: 600), toleranceBefore: .zero, toleranceAfter: .zero) { [weak self] _ in self?.isDragging = false } } @objc private func playbackDidEnd() { player?.seek(to: .zero) playPauseButton.setTitle("播放", for: .normal) } private func formatTime(_ seconds: Double) -> String { guard !seconds.isNaN && seconds >= 0 else { return "00:00" } let mins = Int(seconds) / 60 let secs = Int(seconds) % 60 return String(format: "%02d:%02d", mins, secs) } deinit { if let observer = timeObserver { player?.removeTimeObserver(observer) } statusObservation?.invalidate() NotificationCenter.default.removeObserver(self) } }这段代码涵盖了本地视频播放的核心流程。有几个细节值得注意:seek的时候用了toleranceBefore: .zero和toleranceAfter: .zero,这是精确seek,适合进度条拖拽场景。如果是普通播放中的seek,可以用默认的容差,性能更好。另外playbackDidEnd里把播放头seek回起点,这样用户再点播放就能从头开始。
4. 在线视频播放的关键技术与实战
4.1 在线视频URL的加载策略
在线视频播放的第一步是确定URL。这听起来是废话,但实际操作中有很多讲究。首先,URL必须是AVPlayer支持的协议和格式。AVPlayer原生支持HTTP/HTTPS下的HLS(m3u8)和渐进式下载(mp4、mov等),但对于RTMP、RTSP这类流媒体协议就不支持了。如果你需要播RTSP流,得用第三方库或者自己基于FFmpeg做解码。
其次,URL的构造要注意编码问题。如果视频地址包含中文、空格或特殊字符,必须做percent encoding,否则AVPlayer会直接报错。
let rawURLString = "https://example.com/videos/我的视频.mp4" guard let encodedString = rawURLString.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed), let url = URL(string: encodedString) else { print("URL构造失败") return }还有一个容易被忽略的点:ATS(App Transport Security)。iOS默认只允许HTTPS请求,如果你的视频服务器只有HTTP,需要在Info.plist中配置例外。不过从安全角度考虑,我强烈建议所有在线视频都走HTTPS,不仅是为了过审,也是为了用户的数据安全。
4.2 缓冲状态监控与加载指示器
在线视频播放最影响用户体验的就是缓冲。用户点了播放,结果黑屏等了好几秒,如果没有loading提示,用户会以为App卡死了。所以监听缓冲状态并给出视觉反馈是必须的。
前面提到的三个属性——isPlaybackLikelyToKeepUp、isPlaybackBufferEmpty、isPlaybackBufferFull——需要组合使用。我的经验是:
| 属性组合 | 含义 | UI表现 |
|---|---|---|
| bufferEmpty = true | 缓冲区空了,正在等待数据 | 显示loading,暂停播放 |
| likelyToKeepUp = false | 数据不足,可能卡顿 | 显示loading,但不必暂停 |
| likelyToKeepUp = true | 数据充足,可以流畅播放 | 隐藏loading |
| bufferFull = true | 缓冲区已满 | 隐藏loading,可考虑降低请求频率 |
bufferObservation = playerItem?.observe(\.isPlaybackBufferEmpty, options: [.new]) { [weak self] item, _ in DispatchQueue.main.async { if item.isPlaybackBufferEmpty { self?.showLoadingIndicator() } } } keepUpObservation = playerItem?.observe(\.isPlaybackLikelyToKeepUp, options: [.new]) { [weak self] item, _ in DispatchQueue.main.async { if item.isPlaybackLikelyToKeepUp { self?.hideLoadingIndicator() } } }注意:这些KVO回调不一定在主线程触发,更新UI前必须切回主线程。我见过不少崩溃就是因为直接在KVO回调里操作了UIButton。
4.3 在线播放的错误处理与重试机制
在线视频播放的失败率远高于本地播放。常见的失败原因包括:网络不可达、服务器返回4xx/5xx、视频格式不支持、DNS解析失败、连接超时等。AVPlayer在失败时会把playerItem.status设为failed,并在error属性中给出具体错误信息。
处理错误的第一步是区分错误类型。AVPlayerItem的error通常是AVError,可以通过code来判断具体原因。比如AVError.mediaServicesWereReset表示媒体服务重启了(通常发生在系统资源紧张时),这种情况需要重建player。AVError.fileFormatNotRecognized表示格式不支持,换源或者提示用户。
private func handlePlaybackError(_ error: Error?) { guard let error = error as? AVError else { showAlert(message: "未知播放错误") return } switch error.code { case .mediaServicesWereReset: // 重建player rebuildPlayer() case .fileFormatNotRecognized: showAlert(message: "视频格式不支持") case .networkError: // 网络错误,启动重试 retryPlayback() default: showAlert(message: "播放失败:\(error.localizedDescription)") } }重试机制我一般做成指数退避:第一次失败后等1秒重试,第二次等2秒,第三次等4秒,最多重试3次。超过3次就提示用户检查网络。重试的时候不要直接调player.play(),而是重新创建AVPlayerItem,因为失败的item状态已经不可恢复了。
private var retryCount = 0 private let maxRetryCount = 3 private func retryPlayback() { guard retryCount < maxRetryCount else { showAlert(message: "多次重试失败,请检查网络后重试") return } retryCount += 1 let delay = pow(2.0, Double(retryCount - 1)) // 1, 2, 4秒 DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in guard let self = self, let url = self.currentVideoURL else { return } let newItem = AVPlayerItem(url: url) self.player?.replaceCurrentItem(with: newItem) self.playerItem = newItem self.setupObservers(for: newItem) self.player?.play() } }4.4 本地与在线播放的统一封装
在实际项目中,本地视频和在线视频往往需要在同一个播放器里切换。与其写两套逻辑,不如做一个统一的封装,把差异点抽象出来。
enum VideoSource { case local(URL) case remote(URL) var url: URL { switch self { case .local(let url): return url case .remote(let url): return url } } var isRemote: Bool { if case .remote = self { return true } return false } } class UnifiedVideoPlayer { private let player = AVPlayer() private var currentItem: AVPlayerItem? private var observations: [NSKeyValueObservation] = [] func load(source: VideoSource, completion: @escaping (Result<Void, Error>) -> Void) { let asset = AVURLAsset(url: source.url) let item = AVPlayerItem(asset: asset) currentItem = item player.replaceCurrentItem(with: item) // 统一的状态监听 let statusObs = item.observe(\.status, options: [.new, .initial]) { item, _ in switch item.status { case .readyToPlay: completion(.success(())) case .failed: completion(.failure(item.error ?? NSError(domain: "Player", code: -1))) default: break } } observations.append(statusObs) // 只有在线视频才需要监听缓冲状态 if source.isRemote { setupBufferObservers(for: item) } } private func setupBufferObservers(for item: AVPlayerItem) { let emptyObs = item.observe(\.isPlaybackBufferEmpty, options: [.new]) { item, _ in // 通知UI显示loading } let keepUpObs = item.observe(\.isPlaybackLikelyToKeepUp, options: [.new]) { item, _ in // 通知UI隐藏loading } observations.append(contentsOf: [emptyObs, keepUpObs]) } func cleanup() { observations.forEach { $0.invalidate() } observations.removeAll() player.replaceCurrentItem(with: nil) } }这个封装的好处是调用方不需要关心视频来源,统一用load(source:completion:)就行。内部根据isRemote决定是否启用缓冲监听,逻辑清晰,维护起来也方便。
5. 常见问题排查与避坑经验实录
5.1 播放器黑屏但有声音
这是最常见的问题之一。原因通常是AVPlayerLayer的frame不对,或者layer没有正确添加到视图层级上。排查步骤:先确认playerLayer.frame是否为零,再确认playerLayer是否被其他视图遮挡,最后检查videoGravity设置是否合理。
还有一种情况是视频轨道本身有问题——比如视频编码格式是AVPlayer不支持的(某些H.265变种),音频能解码但视频不能,就会出现有声音没画面的情况。这种只能换源或者转码。
5.2 seek不准确或seek后画面卡住
seek不准确通常是因为没有设置容差。默认情况下AVPlayer会做“容差seek”,找一个最近的关键帧,这样速度快但位置不精确。如果需要精确seek,必须把toleranceBefore和toleranceAfter都设为.zero。
seek后画面卡住则可能是在线视频的服务器不支持Range请求。AVPlayer seek到某个位置时需要请求该位置对应的数据片段,如果服务器总是返回整个文件,播放器就无法正确跳转。解决办法是让服务端支持Accept-Ranges: bytes和206 Partial Content响应。
5.3 播放结束后通知不触发
AVPlayerItemDidPlayToEndTime通知的object参数必须传对。如果你在addObserver时传的是nil,那所有playerItem的播放结束都会触发这个通知,可能造成误处理。正确的做法是传具体的playerItem实例。
另外,如果你在播放结束后调用了replaceCurrentItem,那原来的item就被释放了,通知自然也不会再触发。所以要在替换item之前处理好播放结束的逻辑。
5.4 内存泄漏与崩溃问题
AVPlayer相关的内存问题主要有两个来源:KVO observation没有invalidate,以及timeObserver没有remove。这两个都会导致player无法释放,进而造成内存泄漏。更严重的是,如果player已经释放了但timeObserver还在,下次回调时会直接崩溃。
我的习惯是在deinit里统一清理:
deinit { observations.forEach { $0.invalidate() } observations.removeAll() if let observer = timeObserver { player.removeTimeObserver(observer) timeObserver = nil } NotificationCenter.default.removeObserver(self) player.replaceCurrentItem(with: nil) }提示:
removeTimeObserver必须在player释放之前调用,否则会crash。如果你把player设为nil了再调removeTimeObserver,必崩。
5.5 常见问题速查表
| 问题现象 | 可能原因 | 排查方向 | 解决方案 |
|---|---|---|---|
| 黑屏有声音 | layer frame为零/视频编码不支持 | 检查layer布局和视频编码 | 修正frame或转码 |
| 起播慢 | 网络延迟/服务器响应慢 | 抓包看首字节时间 | 预加载/换CDN |
| 播放卡顿 | 缓冲不足/码率过高 | 监控缓冲状态 | 降低码率/增大缓冲 |
| seek不准 | 未设置容差 | 检查seek参数 | 设tolerance为零 |
| 播放结束无通知 | observer object传nil | 检查通知注册 | 传具体item实例 |
| 内存泄漏 | observation未清理 | Instruments检测 | deinit统一清理 |
| 在线播放失败 | 网络/格式/服务器 | 看error code | 重试/换源/提示 |
| 后台播放无声 | 未配置AudioSession | 检查session类别 | 设为playback |
6. 播放器性能优化与进阶技巧
6.1 预加载与缓存策略
在线视频的起播速度直接影响用户体验。AVPlayer本身没有提供预加载API,但你可以通过提前创建AVPlayerItem并让它开始加载数据来实现类似效果。具体做法是在用户可能点击播放之前(比如列表滚动到可见区域时),就创建好AVPlayerItem,等用户真正点击时直接替换,这样能省去媒体信息解析的时间。
对于需要反复播放的视频,可以考虑自己实现缓存层。思路是用AVAssetResourceLoaderDelegate拦截AVPlayer的数据请求,先从本地缓存查找,命中则直接返回,未命中则转发给服务器并顺便缓存。这个方案实现起来比较复杂,但效果很好,适合对播放体验要求高的场景。
6.2 后台播放与音频会话配置
录制类应用经常需要支持后台播放,比如用户锁屏后音频继续播放。这需要配置AVAudioSession:
do { try AVAudioSession.sharedInstance().setCategory(.playback, mode: .moviePlayback) try AVAudioSession.sharedInstance().setActive(true) } catch { print("AudioSession配置失败:\(error)") }同时要在Target的Signing & Capabilities中开启Background Modes的Audio选项。注意,.playback类别会中断其他App的音频,如果只是偶尔需要后台播放,可以考虑.playback配合.mixWithOthers选项。
6.3 播放器复用与多实例管理
在列表页中,如果有多个视频需要播放(比如短视频流),不要为每个视频创建一个AVPlayer。正确的做法是维护一个player池,或者只用一个player,在切换视频时替换item。AVPlayer的创建成本不低,频繁创建销毁会造成性能问题。
我的做法是维护一个最多3个player的池子:当前播放的、预加载的、以及一个备用的。当用户滑动切换视频时,从池子里取player替换item,而不是新建。这样既能保证切换速度,又不会占用过多内存。
6.4 视频渲染的进阶选项
AVPlayerLayer是最常用的渲染方式,但它有一些限制:不支持HDR显示、不支持自定义着色器。如果你的应用需要这些高级特性,可以考虑用AVPlayerItemVideoOutput配合Metal或OpenGL来自定义渲染管线。这个方案复杂度高很多,但灵活性也大很多,适合对画质有极致要求的场景。
对于普通应用来说,AVPlayerLayer完全够用。我建议先把基础播放做稳定,再考虑是否需要进阶渲染。很多团队一上来就追求自定义渲染,结果基础播放的稳定性都没做好,得不偿失。
7. 录制与播放的协同处理
7.1 录制完成后立即播放的衔接
录制类应用的一个典型流程是:用户录制一段视频,停止录制后立即进入预览播放。这个衔接过程有几个坑要注意。首先,录制完成后文件可能还在写入中,直接播放会失败。需要确保AVAssetWriter的finishWriting回调完成后再创建playerItem。
其次,录制时的AVCaptureSession和播放时的AVAudioSession可能冲突。录制时session类别通常是.playAndRecord,播放时需要切到.playback。切换session的时机很重要,太早会中断录制,太晚会导致播放无声。
// 录制完成回调 func didFinishRecording(to outputURL: URL) { // 先停止capture session captureSession.stopRunning() // 切换audio session do { try AVAudioSession.sharedInstance().setCategory(.playback, mode: .moviePlayback) try AVAudioSession.sharedInstance().setActive(true) } catch { print("Session切换失败:\(error)") } // 创建播放器 let asset = AVURLAsset(url: outputURL) let item = AVPlayerItem(asset: asset) player.replaceCurrentItem(with: item) player.play() }7.2 录制中的实时预览与回放切换
有些场景需要在录制过程中支持实时预览(比如直播类应用),录制结束后又需要回放。这两种模式对AVFoundation的使用方式完全不同。实时预览用的是AVCaptureVideoPreviewLayer,回放用的是AVPlayerLayer。切换时要注意layer的添加和移除顺序,避免出现画面闪烁或黑屏。
我的做法是用一个容器View,里面根据当前模式添加不同的layer。切换时先添加新layer,再移除旧layer,这样能保证画面平滑过渡。
7.3 视频编辑后的播放验证
如果应用支持视频编辑(裁剪、滤镜、变速等),编辑完成后需要播放验证效果。编辑后的视频通常是通过AVAssetExportSession导出的新文件,播放时要注意导出是否真正完成。AVAssetExportSession的exportAsynchronously回调里,只有status == .completed才能保证文件可用。
另外,编辑后的视频可能时长很短(比如裁剪后只有1秒),播放结束后通知会很快触发,UI上要做好处理,避免进度条跳变或者按钮状态错乱。
8. 实际项目中的经验总结
做iOS音视频播放这些年,踩过的坑确实不少。最大的体会是:AVPlayer的文档看起来简单,但实际行为受很多因素影响——网络环境、服务器配置、视频编码、系统版本、设备性能,任何一个环节出问题都可能导致播放异常。所以不要指望写一遍代码就万事大吉,充分的测试和兜底逻辑是必须的。
另一个体会是状态管理要集中。播放器有太多状态:加载中、可播放、播放中、暂停、缓冲、失败、结束。如果这些状态散落在各个地方管理,很快就会乱成一团。我的做法是定义一个PlayerState枚举,所有状态变化都通过一个统一的入口来更新,UI根据当前状态来渲染。这样逻辑清晰,排查问题也方便。
enum PlayerState { case idle case loading case ready case playing case paused case buffering case failed(Error) case ended }最后分享一个实用技巧:在开发阶段,给playerItem的error属性加一个详细的日志输出,把error.localizedDescription、error.code、以及playerItem.errorLog()的内容都打出来。errorLog()里包含了HTTP请求的详细记录,对于排查在线播放问题非常有帮助。很多问题看日志一眼就能定位,比盲目猜测效率高得多。
if let errorLog = playerItem.errorLog() { for event in errorLog.events { print("错误事件:\(event.errorStatusCode) - \(event.errorComment ?? "无描述")") print("URI:\(event.uri ?? "未知")") } }这套日志机制帮我在多个项目中快速定位了CDN配置错误、证书问题、以及编码不兼容等问题。建议你在项目初期就把这个加上,后面会省很多事。