
1. 从能响就行到丝滑播放AVPlayer 到底解决了什么问题很多人第一次接触 iOS 音频播放脑子里想的都是不就是放个声音吗。真动手写的时候才发现事情远没有想象中简单本地文件播放、网络流媒体加载、后台播放、锁屏控制、耳机插拔、来电打断、缓冲卡顿……每一个环节都能让一个看似简单的播放器变成一堆 bug 的集合体。AVPlayer 就是苹果给开发者准备的那把瑞士军刀。它属于 AVFoundation 框架专门用来处理音视频播放既能播本地文件也能直接拉取网络音频流。和它同门的还有 AVAudioPlayer但 AVAudioPlayer 只能处理本地音频文件面对在线播放这种场景就力不从心了。所以只要你的需求里出现了在线两个字AVPlayer 基本就是绕不开的选择。这篇文章适合谁看如果你正在做一个音乐类 App、播客客户端、有声书阅读器或者只是想在项目里加一个能播网络音频的小功能那这篇内容应该能帮你少走不少弯路。我会从 AVPlayer 的基本用法讲起逐步深入到在线播放场景下的缓冲处理、播放状态监听、后台播放配置、锁屏信息展示这些实战细节最后再聊聊那些文档里不会写、只有踩过坑才知道的经验。需要提前说明的是AVPlayer 本身是一个相当成熟的 API但它的成熟也意味着接口设计偏向底层很多东西需要开发者自己组装。比如播放进度更新、缓冲状态判断、播放结束处理这些都需要你手动去监听和实现。这不是苹果偷懒而是因为播放场景的差异太大框架层面很难给出一个通用的完美方案。理解了这一点后面的很多设计选择就顺理成章了。2. AVPlayer 的核心工作机制与在线播放的底层逻辑2.1 AVPlayer、AVPlayerItem、AVAsset 三者的关系刚接触 AVPlayer 的人很容易被这三个类搞晕。我用一个生活化的类比来解释把播放这件事想象成去餐厅吃饭。AVAsset就像是菜单它描述的是有什么菜也就是媒体资源的信息——时长、格式、轨道信息等。它本身不负责播放只负责描述。对于在线音频来说AVAsset 通常是一个 URL 指向的网络资源。AVPlayerItem像是你点的那份菜它基于 AVAsset 创建代表一个具体的、可播放的媒体项。它管理着播放状态、缓冲进度、时间信息等。一个 AVPlayerItem 对应一个媒体资源如果你想切换歌曲通常是替换 AVPlayerItem 而不是重建 AVPlayer。AVPlayer则是整个餐厅的服务员它负责协调播放行为——播放、暂停、跳转、变速等。一个 AVPlayer 可以在不同的 AVPlayerItem 之间切换就像服务员可以给你换菜一样。理解这三层关系非常重要因为在实际开发中很多问题都出在搞混了它们的职责。比如你想获取播放进度应该去问 AVPlayerItem 而不是 AVPlayer你想切换播放源应该替换 AVPlayerItem 而不是重新创建 AVPlayer。2.2 在线播放时 AVPlayer 内部发生了什么当你把一个网络 URL 交给 AVPlayer 时它背后做的事情比想象中复杂得多。简单来说整个过程大致分为这几个阶段第一步是资源探测。AVPlayer 会先向服务器发送请求获取媒体文件的头部信息了解这个文件的格式、时长、码率等元数据。这一步决定了后续能否正常播放。第二步是缓冲策略制定。AVPlayer 会根据网络状况和媒体文件信息决定预缓冲多少数据。这个策略是动态调整的网络好的时候多缓冲一些网络差的时候少缓冲一些以保证快速起播。第三步是解码与渲染。缓冲到足够数据后AVPlayer 开始解码音频数据并通过音频会话输出到扬声器或耳机。这里有一个关键点AVPlayer 的缓冲是分段进行的它不是把整个文件下载完再播放而是边下边播。这就解释了为什么在线播放时会出现缓冲中的状态也是为什么网络波动会直接影响播放体验。2.3 在线播放与本地播放的本质差异很多人觉得在线播放和本地播放的区别只是数据来源不同实际上差异远不止于此。本地文件播放时数据读取速度是稳定的、可预期的AVPlayer 可以快速完成缓冲并进入稳定播放状态。但在线播放面对的是一个充满不确定性的网络环境服务器响应速度、网络带宽波动、DNS 解析延迟、CDN 节点切换……任何一个环节出问题都会影响播放。这就导致在线播放场景下你必须额外关注几件事缓冲状态的实时监控、播放失败的重试机制、网络切换时的播放恢复、长时间缓冲的用户提示。这些在本地播放时几乎不需要考虑的问题在在线播放中都是必须处理的。另外在线播放还涉及到一个容易被忽略的点AVPlayer 默认不会自动处理 HTTP 重定向和某些认证场景。如果你的音频资源需要鉴权或者经过了重定向可能需要额外配置 AVURLAsset 的 options 参数。3. 从零搭建一个可用的在线音乐播放器3.1 音频会话配置别让静音键杀死你的播放在写任何播放代码之前有一件事必须优先处理配置 AVAudioSession。这是很多新手最容易忽略的一步结果就是代码没问题但就是没声音。iOS 系统默认的音频会话类别是ambient这个类别下当用户拨动静音开关时你的 App 也会跟着静音。对于音乐播放类 App 来说这显然不是想要的行为。你需要把类别设置为playbackimport AVFoundation do { let session AVAudioSession.sharedInstance() try session.setCategory(.playback, mode: .default, options: []) try session.setActive(true) } catch { print(音频会话配置失败: \(error)) }playback类别的好处是不受静音开关影响、支持后台播放、支持 AirPlay 等外部设备输出。如果你还需要在播放时显示锁屏控制信息那options里可以加上.allowAirPlay等选项。注意setActive(true)会中断其他 App 的音频播放。如果你的 App 只是偶尔播放一段提示音那应该用ambient类别并配合setActive(false)在播放结束后释放。但对于音乐播放器来说playback是标准选择。还有一个细节音频会话的激活时机。我建议在用户真正点击播放按钮时再激活会话而不是 App 启动时就激活。因为激活会话会抢占音频焦点如果用户打开 App 但没打算播放却把正在听的其他 App 音乐打断了体验会很差。3.2 创建 AVPlayer 并加载在线音频配置好音频会话后就可以创建播放器了。对于在线音频最直接的方式是guard let url URL(string: https://example.com/music/song.mp3) else { return } let playerItem AVPlayerItem(url: url) let player AVPlayer(playerItem: playerItem) player.play()看起来很简单对吧但这段代码在实际项目中几乎一定会出问题。原因在于网络请求是异步的player.play()调用时AVPlayerItem 的状态很可能还是unknown此时播放不会立即开始而是会等待缓冲。如果你没有监听状态变化用户就会觉得点了播放没反应。正确的做法是监听 AVPlayerItem 的status属性playerItem.publisher(for: \.status) .sink { status in switch status { case .readyToPlay: print(准备就绪可以播放) case .failed: print(加载失败: \(String(describing: playerItem.error))) case .unknown: print(状态未知正在加载) unknown default: break } } .store(in: cancellables)这里用了 Combine 框架来监听 KVO如果你不用 Combine也可以用传统的observe(\.status)方式。关键是要在readyToPlay之后再真正开始播放或者至少在 UI 上给用户一个加载中的反馈。3.3 播放进度与缓冲进度的实时获取在线播放场景下用户最关心的两个信息是当前播放到哪里了以及缓冲了多少。这两个信息分别对应 AVPlayerItem 的currentTime()和loadedTimeRanges。获取当前播放时间比较简单let currentTime player.currentTime() let seconds CMTimeGetSeconds(currentTime)但要注意currentTime()返回的是 CMTime 类型需要用CMTimeGetSeconds转换。另外如果播放器还没准备好这个值可能是 NaN使用前最好做一下判断。缓冲进度稍微复杂一些需要通过loadedTimeRanges来计算guard let timeRange playerItem.loadedTimeRanges.first?.timeRangeValue else { return } let start CMTimeGetSeconds(timeRange.start) let duration CMTimeGetSeconds(timeRange.duration) let bufferedEnd start durationloadedTimeRanges是一个数组因为 AVPlayer 可能分段缓冲。对于大多数在线音频场景取第一个 range 就够用了。但如果你做的是长音频比如有声书可能需要把所有 range 合并起来计算总缓冲量。进度更新需要一个定时器来驱动。我一般用addPeriodicTimeObserverlet interval CMTime(seconds: 0.5, preferredTimescale: CMTimeScale(NSEC_PER_SEC)) timeObserver player.addPeriodicTimeObserver(forInterval: interval, queue: .main) { [weak self] time in let seconds CMTimeGetSeconds(time) self?.updateProgressUI(seconds) }0.5 秒的间隔对于进度条更新来说足够了再密集一些会浪费性能再稀疏一些进度条会显得卡顿。记得在不需要的时候调用removeTimeObserver否则会造成内存泄漏。4. 在线播放绕不开的那些坑与应对策略4.1 缓冲卡顿用户最不能忍的体验问题在线播放最怕的就是卡顿。用户正听得入神突然音乐停了开始转圈这种体验足以让人直接卸载 App。AVPlayer 本身提供了一些缓冲机制但默认配置未必适合所有场景。首先可以通过AVPlayerItem的preferredForwardBufferDuration来控制预缓冲时长playerItem.preferredForwardBufferDuration 10.0这个值表示 AVPlayer 会尝试缓冲当前播放位置之后 10 秒的数据。设置得太小网络一波动就卡设置得太大起播速度会变慢而且浪费流量。我的经验是WiFi 环境下可以设 15-30 秒移动网络下设 5-10 秒比较合适。另外player.automaticallyWaitsToMinimizeStalling这个属性也值得关注。它默认为 true表示 AVPlayer 会自动等待缓冲到足够数据再开始播放以减少卡顿。但在某些网络环境下这个等待时间可能过长导致用户觉得点了没反应。如果你更看重起播速度可以把它设为 false但代价是可能更容易卡顿。还有一个实战技巧监听AVPlayerItem的isPlaybackLikelyToKeepUp属性。当这个值为 false 时说明 AVPlayer 判断当前缓冲不足以维持流畅播放你可以在 UI 上提前给用户一个提示而不是等到真的卡住了才显示加载状态。4.2 播放失败与网络异常的处理在线播放失败的原因五花八门URL 无效、服务器返回 404、网络超时、DNS 解析失败、证书问题……AVPlayer 会把错误信息放在AVPlayerItem.error里你需要根据错误类型做不同的处理。if let error playerItem.error as NSError? { switch error.code { case NSURLErrorNotConnectedToInternet: // 无网络连接 case NSURLErrorTimedOut: // 请求超时 case NSURLErrorCannotFindHost: // 找不到服务器 default: // 其他错误 } }实际项目中我建议对播放失败做分级重试第一次失败后立即重试一次如果还失败则等待 2 秒重试再失败等待 5 秒。重试次数不要太多3 次足够了否则用户会觉得 App 卡死了。重试期间要在 UI 上明确告诉用户正在重试而不是默默等待。还有一个容易被忽略的场景播放过程中网络断开。这时候 AVPlayer 不会立即报错而是进入缓冲状态。你需要监听AVPlayerItem的isPlaybackBufferEmpty和isPlaybackBufferFull来感知缓冲状态变化在网络恢复后自动继续播放。4.3 后台播放与锁屏控制的完整配置音乐类 App 如果不支持后台播放基本等于废了一半。配置后台播放需要两步第一步是在 Xcode 的 Signing Capabilities 中添加 Background Modes勾选 Audio, AirPlay, and Picture in Picture。第二步是在代码中确保音频会话类别为playback前面已经配置过了。做完这两步App 退到后台后音频会继续播放。但用户还需要在锁屏界面或控制中心看到播放信息、控制播放暂停。这就需要配置MPNowPlayingInfoCenter和MPRemoteCommandCenterimport MediaPlayer // 设置锁屏信息 let nowPlayingInfo: [String: Any] [ MPMediaItemPropertyTitle: 歌曲名, MPMediaItemPropertyArtist: 歌手名, MPMediaItemPropertyPlaybackDuration: totalDuration, MPNowPlayingInfoPropertyElapsedPlaybackTime: currentTime, MPNowPlayingInfoPropertyPlaybackRate: 1.0 ] MPNowPlayingInfoCenter.default().nowPlayingInfo nowPlayingInfo // 注册远程控制事件 let commandCenter MPRemoteCommandCenter.shared() commandCenter.playCommand.addTarget { _ in self.player.play() return .success } commandCenter.pauseCommand.addTarget { _ in self.player.pause() return .success }这里有个细节MPNowPlayingInfoPropertyElapsedPlaybackTime需要定期更新否则锁屏界面的进度条不会动。我一般会在addPeriodicTimeObserver的回调里顺便更新这个值。另外耳机插拔事件也需要处理。当用户拔掉耳机时系统会自动暂停播放这是playback类别的默认行为但你需要更新 UI 上的播放状态。监听AVAudioSession.routeChangeNotification可以捕获这个事件。5. 播放器状态管理与 UI 联动的实战设计5.1 播放状态的统一管理一个成熟的播放器需要管理多种状态加载中、准备就绪、播放中、暂停、缓冲中、播放结束、播放失败。如果把这些状态散落在各个 ViewController 里代码很快就会变成一团乱麻。我的做法是封装一个AudioPlayerManager单例对外暴露统一的播放接口和状态回调enum PlayerState { case idle case loading case ready case playing case paused case buffering case ended case failed(Error) } class AudioPlayerManager { static let shared AudioPlayerManager() private var player: AVPlayer? private var playerItem: AVPlayerItem? private var timeObserver: Any? var stateDidChange: ((PlayerState) - Void)? var progressDidUpdate: ((Double, Double) - Void)? // 当前时间, 总时长 func play(url: URL) { // 清理旧的播放项 cleanup() let item AVPlayerItem(url: url) self.playerItem item self.player AVPlayer(playerItem: item) observePlayerItem(item) observePlayer(player) stateDidChange?(.loading) player?.play() } private func cleanup() { if let observer timeObserver { player?.removeTimeObserver(observer) timeObserver nil } player?.pause() player nil playerItem nil } }这样设计的好处是播放逻辑集中在一处UI 层只需要订阅状态变化来更新界面切换歌曲时也只需要调用play(url:)一个方法。5.2 播放结束与自动切歌的处理在线播放音乐时一首歌播完后通常需要自动播放下一首。AVPlayer 提供了AVPlayerItemDidPlayToEndTime通知NotificationCenter.default.addObserver( forName: .AVPlayerItemDidPlayToEndTime, object: playerItem, queue: .main ) { [weak self] _ in self?.stateDidChange?(.ended) self?.playNextTrack() }这里有个坑通知的object必须指定为当前的 playerItem否则多个播放项的通知会混在一起。另外在通知回调里切换歌曲时要注意先移除旧的通知监听否则会重复触发。还有一个边界情况如果音频时长很短比如提示音可能在readyToPlay之前就已经播放结束了。这种情况下需要在状态监听里做额外判断避免状态错乱。5.3 播放进度条的手动拖动与时间跳转用户拖动进度条跳转是基本功能但在线播放场景下需要额外注意跳转的目标位置可能还没有缓冲。如果直接调用player.seek(to:)AVPlayer 会尝试从目标位置开始缓冲这可能导致一段时间的静默。let targetTime CMTime(seconds: newProgress * totalDuration, preferredTimescale: 600) player.seek(to: targetTime, toleranceBefore: .zero, toleranceAfter: .zero) { finished in if finished { // 跳转完成 } }toleranceBefore和toleranceAfter设为.zero表示精确跳转但代价是可能需要更长的缓冲时间。如果对精度要求不高可以设置一个容差值比如 1 秒这样跳转会更快。拖动过程中的处理也有讲究用户拖动时应该暂停进度更新否则进度条会跳回去。我的做法是在拖动开始时移除时间观察器拖动结束后重新添加。6. 性能优化与那些文档里不会写的经验6.1 内存管理与观察者清理AVPlayer 相关的内存问题主要出在两个地方时间观察器和通知监听。addPeriodicTimeObserver返回的观察者必须手动移除否则即使播放器释放了观察者回调仍然可能触发导致崩溃。我见过不少项目在deinit里忘记移除观察者结果在页面返回后收到回调访问已释放的对象。正确的做法是在播放器不再使用时立即清理deinit { if let observer timeObserver { player?.removeTimeObserver(observer) } NotificationCenter.default.removeObserver(self) }另外AVPlayer 和 AVPlayerItem 之间是强引用关系如果处理不当可能造成循环引用。建议在闭包中使用[weak self]并在合适的时机主动将player置为 nil。6.2 网络请求的优化策略AVPlayer 加载网络音频时默认使用系统的网络栈。如果你需要更精细的控制比如自定义缓存策略、添加请求头可以通过AVURLAsset的 options 来实现let headers [Referer: https://example.com] let options [AVURLAssetHTTPHeaderFieldsKey: headers] let asset AVURLAsset(url: url, options: options) let item AVPlayerItem(asset: asset)这个技巧在播放某些需要 Referer 校验的音频资源时特别有用。不过要注意这个 key 并不是官方公开的 API使用前需要评估风险。对于频繁播放的音频可以考虑在本地做一层缓存。AVPlayer 本身有磁盘缓存机制但缓存策略不透明你无法控制缓存大小和过期时间。如果需要精细控制可以自己实现下载缓存然后用本地 URL 创建 AVPlayerItem。6.3 不同网络环境下的参数调优最后分享一些我在实际项目中总结的参数配置经验场景preferredForwardBufferDurationautomaticallyWaitsToMinimizeStalling说明WiFi 音乐播放15-30 秒true网络稳定多缓冲减少卡顿移动网络音乐5-10 秒true平衡流量和流畅度播客/有声书30-60 秒true长内容用户对起播速度容忍度高短音频/提示音1-3 秒false追求快速响应弱网环境3-5 秒false优先保证能播卡顿总比没声音好这些值不是绝对的需要根据你的实际用户场景调整。我建议在 App 里加一个网络状态监听根据当前网络类型动态调整这些参数。还有一个经验不要在所有音频上都用同一套配置。音乐、播客、有声书的播放特征差异很大统一配置往往意味着每种场景都不是最优。如果条件允许针对不同内容类型做差异化配置体验提升会很明显。另外测试阶段一定要在真实网络环境下验证模拟器的网络环境和真机差异很大。我遇到过模拟器上播放流畅、真机上频繁卡顿的情况最后发现是模拟器走了 Mac 的网络而真机在弱网环境下缓冲策略需要调整。真机测试、弱网测试、网络切换测试这三项一个都不能少。