ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

iOS音视频开发:AVPlayer本地与在线视频播放实战指南

iOS音视频开发:AVPlayer本地与在线视频播放实战指南 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。正确的做法是用KVOKey-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下的HLSm3u8和渐进式下载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 }还有一个容易被忽略的点ATSApp Transport Security。iOS默认只允许HTTPS请求如果你的视频服务器只有HTTP需要在Info.plist中配置例外。不过从安全角度考虑我强烈建议所有在线视频都走HTTPS不仅是为了过审也是为了用户的数据安全。4.2 缓冲状态监控与加载指示器在线视频播放最影响用户体验的就是缓冲。用户点了播放结果黑屏等了好几秒如果没有loading提示用户会以为App卡死了。所以监听缓冲状态并给出视觉反馈是必须的。前面提到的三个属性——isPlaybackLikelyToKeepUp、isPlaybackBufferEmpty、isPlaybackBufferFull——需要组合使用。我的经验是属性组合含义UI表现bufferEmpty true缓冲区空了正在等待数据显示loading暂停播放likelyToKeepUp false数据不足可能卡顿显示loading但不必暂停likelyToKeepUp true数据充足可以流畅播放隐藏loadingbufferFull 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 (ResultVoid, 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类别设为playback6. 播放器性能优化与进阶技巧6.1 预加载与缓存策略在线视频的起播速度直接影响用户体验。AVPlayer本身没有提供预加载API但你可以通过提前创建AVPlayerItem并让它开始加载数据来实现类似效果。具体做法是在用户可能点击播放之前比如列表滚动到可见区域时就创建好AVPlayerItem等用户真正点击时直接替换这样能省去媒体信息解析的时间。对于需要反复播放的视频可以考虑自己实现缓存层。思路是用AVAssetResourceLoaderDelegate拦截AVPlayer的数据请求先从本地缓存查找命中则直接返回未命中则转发给服务器并顺便缓存。这个方案实现起来比较复杂但效果很好适合对播放体验要求高的场景。6.2 后台播放与音频会话配置录制类应用经常需要支持后台播放比如用户锁屏后音频继续播放。这需要配置AVAudioSessiondo { 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配置错误、证书问题、以及编码不兼容等问题。建议你在项目初期就把这个加上后面会省很多事。
返回列表