AVPlayer播放在线与本地视频:必须分家的关键设置
做播放器这几年,只要需求里出现“既能播本地缓存、又要能播在线地址”这一条,我基本能预判后面至少会有一轮返工。原因不复杂:AVPlayer 对外暴露的接口看着极简,AVPlayer(url:)一行就能出声出画,本地文件和在线地址在代码里长得几乎一模一样。可一旦真正接进业务链路,两者的行为差异会从资源构建、缓冲策略、seek 精度、状态机、错误处理、音频会话一路扩散出去。很多“明明能播但就是不对”的 bug,本质都是把在线视频的那套设置无脑套到了本地文件上,或者反过来。
这篇东西就是把这个差异摊开讲清楚:AVPlayer 在播放在线视频和本地文件时,究竟哪些设置必须分家、为什么要分家、分开之后每一步怎么落地。我会按资源构建、缓冲与状态监听、关键参数对照、问题排查这几个层面拆,代码以 Swift 为主,Objective-C 的思路完全一致。适合已经能跑起一个最简播放器、但在真实项目里被各种边界情况绊住的中初级 iOS/macOS 开发者;也适合做跨端播放器、需要在原生层做统一封装的同行参考。全程只讲 AVFoundation 公开能力,不涉及任何非常规手段。
1. 先弄明白 AVPlayer 的播放链路
1.1 播放器、播放项、资源的三层关系
很多人调 AVPlayer 出问题,是因为脑子里只有“播放器”一个概念,实际上它是三层。最底下是AVAsset,负责描述媒体本身——时长、轨道、编码格式、是否可播放,它不负责解码,也不负责播放,纯粹是元信息的抽象。中间是AVPlayerItem,它是资源的运行时载体,管理缓冲、状态、时间线,所有跟“这个资源现在怎么样了”相关的信息都在它身上。最上面才是AVPlayer,它管的是播放这个动作本身:速率、时间、音量、当前播放项。
这个分层决定了后面所有差异的落点。本地文件的问题基本出在AVAsset这一层——路径不对、格式不支持、轨道读不出来;在线视频的问题基本出在AVPlayerItem这一层——缓冲不够、状态没到 readyToPlay、网络请求被拒。搞清楚问题出在哪一层,排查效率会差好几倍。我见过有同行在AVPlayer上反复调rate,实际上是AVPlayerItem.status一直是.unknown,根本没进到可播放状态。
还有一层容易被忽略的渲染载体:AVPlayerLayer或AVPlayerViewController。它们不参与资源加载,但会直接影响“有没有画面”。本地文件音频轨正常、视频轨编码不支持的时候,播放器状态是 readyToPlay,声音也有,就是黑屏,这时候如果只盯着AVPlayer看,会绕很久。
1.2 在线与本地在底层链路上的分岔点
把一条播放链路拆开看,本地文件和在线地址在第一个环节就分叉了。在线地址走的是AVURLAsset内部的资源加载器,它会发起 HTTP 请求,读响应头、拿 Content-Length、发 Range 请求、按需下载分片,整个过程是异步的、可能失败的、受网络状况影响的。本地文件走的是文件系统读取,打开文件句柄、按偏移量读,同步、快速、几乎不会失败(除非文件不存在或权限有问题)。
这个分叉带来的连锁反应是:在线视频必须处理“还没准备好”这个中间态,本地文件几乎可以直接认为“一上来就准备好了”;在线视频的时长、轨道信息可能要等元数据下载完才准,本地文件读一次就有;在线视频的 seek 依赖服务端支持字节范围请求,本地文件的 seek 只是移动文件指针。所以你会看到同一个播放器组件,播本地视频丝滑,播在线视频各种转圈,或者反过来,在线上做了精细的缓冲控制,播本地时反而人为制造了延迟。
提示:判断一个播放问题属于哪一侧,最快的办法是把同一个文件先放本地播一遍,再放服务器播一遍。如果本地正常、线上异常,问题在资源加载或网络层;如果两边都异常,问题在解码、渲染或音频会话。
2. 资源构建这一步就已经分家
2.1 本地文件:路径、URL 类型与沙盒权限
本地文件最常见的翻车点不是播放,是 URL 造错了。URL(string:)和URL(fileURLWithPath:)看着都能吃字符串,行为完全不同。前者把入参当 URL 字符串解析,要求符合 RFC 3986,路径里只要有空格、中文、#、?这类字符就会解析失败或者被截断;后者把入参当文件系统路径,自动做必要的转义,还能正确处理~之外的相对路径拼接。
我踩过最典型的一次是缓存文件名带上了视频标题,标题里有中文和括号,用URL(string: path)构造出来是 nil,代码里做了兜底所以没崩,但播放器一直黑屏,日志里干干净净。后来改成URL(fileURLWithPath:)立刻就正常了。所以本地资源统一用这个构造器,别图省事。
let cacheDir = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0] let fileURL = cacheDir.appendingPathComponent("video_1024.mp4") guard FileManager.default.fileExists(atPath: fileURL.path) else { // 文件不存在,走下载或降级逻辑 return } let asset = AVURLAsset(url: fileURL) let item = AVPlayerItem(asset: asset) let player = AVPlayer(playerItem: item)沙盒权限这块也值得单独说一句。应用沙盒内的文件(Documents、Caches、tmp)可以直接读,不需要额外授权。但如果你要播的是“文件”App 里用户选的视频,那就得走UIDocumentPickerViewController拿到 security-scoped URL,播放前调startAccessingSecurityScopedResource(),播完调stopAccessingSecurityScopedResource()。这一对调用必须成对出现,忘了 stop 会导致后续再也拿不到访问权限,而且是那种“重启应用就好了”的诡异现象。我的做法是封装一个持有 URL 的类,在deinit里 stop,虽然不够严谨但能兜住大部分场景。
还有一个格式问题。AVPlayer 对容器和编码的支持范围是有限的,mp4、mov、m4v、mp3、m4a、aac、caf 这些没问题,mkv、flv、rmvb、avi 里的某些编码基本播不了。本地文件尤其容易踩这个坑,因为用户从各种渠道拿到的文件格式五花八门。上线前建议加一道预检:读asset.isPlayable和asset.tracks,播不了就直接提示,别让用户对着黑屏等。
// iOS 16 及以上推荐用 async 版本 Task { do { let playable = try await asset.load(.isPlayable) let duration = try await asset.load(.duration) guard playable, duration.isValid, duration.seconds > 0 else { // 走不支持提示 return } await MainActor.run { player.replaceCurrentItem(with: item) } } catch { // 资源读取失败 } }2.2 在线地址:URL 校验、协议与请求头
在线地址这边,第一道坎是 URL 本身可能不干净。服务端下发、后台配置、用户粘贴进来的链接,带首尾空格、带换行、带中文参数、带未转义的&,这些都会让URL(string:)返回 nil。我现在的固定写法是先 trim,再做一次合法性校验,两次都过不了才报错。
let raw = rawString.trimmingCharacters(in: .whitespacesAndNewlines) guard let url = URL(string: raw), let scheme = url.scheme?.lowercased(), scheme == "https" || scheme == "http" else { return }协议上,正式环境一律走 HTTPS,这是基本要求。开发阶段如果服务端还没上证书,需要在 Info.plist 里为特定域名做 ATS 例外配置,只针对调试域名开放,上线前删掉。这个配置不要图省事开全局开关,公开上架的应用这么做很容易在审核环节被要求说明用途。
真正让在线播放和本地播放拉开差距的是请求头。本地文件没有“请求”这个概念,在线视频经常需要带鉴权信息:Token、签名、防盗链的 Referer、业务侧的渠道标识。AVURLAsset有一个options字典,但官方公开的键只有AVURLAssetPreferPreciseDurationAndTimingKey和AVURLAssetAllowsCellularAccessKey这几个,并没有公开的自定义请求头入口。
社区里流传很广的做法是往options里塞一个非公开的字符串键,能生效,但属于未公开接口,上架有被拒的风险,我不建议在正式产品里用。更稳的路子是URLProtocol拦截:注册一个自定义的URLProtocol子类,在canInit(with:)里判断是否属于你的播放域名,在startLoading里用URLSession重新发起请求并注入 header,把拿到的数据和响应回吐给 AVPlayer。这个方案代码量大概一两百行,但完全走公开 API,可控性也更好,还能顺便做日志埋点和失败重试。
final class PlaybackURLProtocol: URLProtocol { override class func canInit(with request: URLRequest) -> Bool { guard let host = request.url?.host else { return false } return host == "your-cdn-host.com" } override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } override func startLoading() { var mutable = request mutable.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") mutable.setValue("https://your-site.com", forHTTPHeaderField: "Referer") let task = URLSession.shared.dataTask(with: mutable) { data, response, error in if let error { self.client?.urlProtocol(self, didFailWithError: error) return } if let response { self.client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) } if let data { self.client?.urlProtocol(self, didLoad: data) } self.client?.urlProtocolDidFinishLoading(self) } task.resume() } override func stopLoading() { } }注册时机很关键,必须在创建AVURLAsset之前完成URLProtocol.registerClass或者配置到URLSessionConfiguration.protocolClasses,晚了就不生效。这一点我第一次做的时候调了半小时,一直以为是 header 没带对。
2.3 需要精确时长时,两个场景的取舍不同
AVURLAssetPreferPreciseDurationAndTimingKey这个选项,在线和本地的取舍是反的。本地文件读时长几乎零成本,直接开精确模式没坏处,进度条拖动也会更准。在线视频开这个选项,AVFoundation 可能为了拿到精确时长去下载更多元数据甚至整段文件,首帧时间会明显变长,弱网下尤其明显。
我的默认策略是:本地一律开,在线默认关,只有做“精确到帧的剪辑预览”这类功能时才在线开启。如果在线视频必须显示准确总时长,更好的做法是让服务端在接口里直接返回 duration,播放器只做展示,不去读 asset 的时长。
3. 缓冲策略与状态监听的差异
3.1 缓冲进度:本地几乎不用管,在线必须盯
AVPlayerItem.loadedTimeRanges返回的是已缓冲的时间区间数组。本地文件通常一上来就接近全量,或者瞬间就能读到整个文件,用它画缓冲条意义不大,甚至会出现“缓冲条一下满格”的突兀感。在线视频这个值就是命根子,二级缓冲条的宽度全靠它。
func bufferedSeconds(of item: AVPlayerItem) -> Double { guard let range = item.loadedTimeRanges.first?.timeRangeValue else { return 0 } let start = CMTimeGetSeconds(range.start) let duration = CMTimeGetSeconds(range.duration) guard start.isFinite, duration.isFinite else { return 0 } return start + duration }注意这里返回的是“已缓冲到的绝对时间点”,不是缓冲了多少秒。画进度条的时候要除以总时长得到比例,别直接拿duration当分子。另外loadedTimeRanges可能有多段,用户 seek 之后旧区间的数据可能被丢弃,取first还是last要看具体实现,我一般取覆盖当前播放位置的那一段,找不到再退回first。
本地文件还有个特殊情况:播放到接近结尾时,缓冲进度可能一直显示不满,因为播放器不会预读超过文件末尾。这时候进度条和缓冲条对不上,得做 clamp 处理,把缓冲比例限制在 1.0 以内。
3.2 状态机:从 status 到 timeControlStatus
早期的写法是监听AVPlayerItem.status,.readyToPlay就开启播放,.failed就走错误处理。这套逻辑到现在依然有效,但它只告诉你“资源能不能播”,不告诉你“现在是不是真的在出画面”。iOS 10 之后AVPlayer.timeControlStatus补上了这块:.paused、.waitingToPlayAtSpecifiedRate、.playing。
在线视频最需要关注的是.waitingToPlayAtSpecifiedRate,它意味着播放器想播但播不动,原因在reasonForWaitingToPlay里:.toMinimizeStalls是缓冲不足,.evaluatingBufferingRate是在评估网速,.noItemToPlay是没东西可播。这几种情况在 UI 上的表现应该是不一样的,缓冲不足该转圈,评估网速该静默等一下,没东西可播该报错。很多播放器把三者混成一个 loading,用户体验很差。
本地文件的timeControlStatus基本只在.paused和.playing之间跳,几乎不会进.waitingToPlayAtSpecifiedRate。所以判断“是否在缓冲”的逻辑如果只按timeControlStatus写,本地场景会白白多出一堆无用分支。我的习惯是在播放器封装里加一个isRemote标记,缓冲相关的 UI 和逻辑只对远端资源生效。
还有一套老的属性isPlaybackLikelyToKeepUp、isPlaybackBufferEmpty、isPlaybackBufferFull,iOS 10 之后基本被标记为不推荐,新项目直接用timeControlStatus加reasonForWaitingToPlay就行,混着用容易出现状态互相矛盾的情况。
3.3 缓冲参数:preferredForwardBufferDuration怎么定
这个参数决定播放器愿意预先缓冲多少秒。设成 0 表示交给系统自动决策,设成正数就是硬性目标,设成特别大的值(比如几百秒)在某些场景下等价于“尽量缓全”。
在线视频的核心矛盾是“起播快”和“少卡顿”之间的取舍。我一般按内容类型分档:短视频(一分钟以内)用自动,让它尽快起播;中长视频用 10 到 15 秒,兼顾首帧和后续流畅;直播流用 3 到 5 秒,延迟优先。这些都是经验值,真实项目要结合自家 CDN 的响应速度和用户网络分布来调,最好做成可下发配置,不同地区不同档位。
if isRemote { item.preferredForwardBufferDuration = 10 player.automaticallyWaitsToMinimizeStalling = true } else { item.preferredForwardBufferDuration = 0 player.automaticallyWaitsToMinimizeStalling = false }本地文件这里我习惯把自动等待关掉。原因是本地不存在“等缓冲”的必要,开着它反而可能让播放器在起播时多等一小会儿,尤其是刚从在线切到本地、状态机还没完全复位的时候。关掉之后起播更干脆,用户的感受就是“点了就出画面”。
注意:
automaticallyWaitsToMinimizeStalling设成 false 后,在线视频一旦卡顿就不会自动暂停等待,而是继续“假播放”,表现为画面静止、进度条继续走。除非你在外面自己做了卡顿检测,否则在线场景别关。
4. 关键参数逐条对照
4.1 一张表看清哪些设置必须分开
把前面散落的点收拢成一张对照表,落地的时候照着查就行。这张表是我从几个项目里攒出来的,每一条都在实际场景中被验证过。
| 配置项 | 本地文件 | 在线视频 | 说明 |
|---|---|---|---|
| URL 构造 | URL(fileURLWithPath:) | URL(string:)+ trim + 校验 | 本地路径中文空格必须走专用构造器 |
| Asset 类型 | AVURLAsset直接建 | AVURLAsset+ options | 在线常需配合URLProtocol注入 header |
| 精确时长 | 建议开启 | 默认关闭 | 在线开启会拉长首帧 |
| 缓冲时长 | 0 或不设 | 10 到 15 秒(点播) | 直播另算 |
| 自动等待防卡顿 | 建议关闭 | 必须开启 | 本地关闭可加快起播 |
| 缓冲进度 UI | 可隐藏 | 必须展示 | 本地缓冲几乎瞬时满格 |
| seek 容差 | 可为 0 | 建议给 0.3 到 1 秒 | 精确 seek 在线上代价高 |
| Range 支持 | 不涉及 | 服务端必须返回 206 | 不支持时 seek 会退化为重下 |
| 音频会话 | 需要 | 需要 | 两者都要配,逻辑一致 |
| 后台播放 | 需要 | 需要 + 网络中断处理 | 在线还要监听网络状态 |
| 错误处理 | 文件不存在 / 格式不支持 | 4xx / 5xx / 超时 / 解码失败 | 错误码体系完全不同 |
| 内存占用 | 稳定 | 随缓冲增长 | 长视频在线需关注内存峰值 |
表格里“seek 容差”这一条值得单独展开讲,因为它是最容易被忽略、又最影响体验的一项。
4.2 seek 精度:本地可以狠,线上必须松
AVPlayer.seek(to:toleranceBefore:toleranceAfter:)的后两个参数决定允许误差。传.zero表示精确 seek,播放器会找到目标时间点对应的关键帧再往后解码到准确位置。本地文件随便传.zero,文件就在磁盘上,解码几十帧的成本可以忽略。
在线视频就不一样了。精确 seek 意味着播放器可能要下载目标位置附近的整段数据、解码大量帧,弱网下用户会看到明显的转圈,甚至误以为卡死。我一般的做法是:在线点播给CMTime(seconds: 0.5, preferredTimescale: 600)的容差,用户感知不到半秒的偏移,但流畅度提升明显;做字幕对齐、片段精确定位这类功能时才临时收紧。
let target = CMTime(seconds: seconds, preferredTimescale: 600) let tolerance: CMTime = isRemote ? CMTime(seconds: 0.5, preferredTimescale: 600) : .zero player.seek(to: target, toleranceBefore: tolerance, toleranceAfter: tolerance) { finished in if finished { // 可以在这里刷新 UI 上的当前时间 } }还有一个前提条件:服务端必须支持 HTTP Range 请求,也就是返回206 Partial Content。如果服务端把整个文件一次性返回200,播放器拿到的是全量数据,seek 会退化成“重新下载全部”,长视频上基本不可用。这一点要在联调阶段就用 curl 或者抓包工具确认,别等上线了才发现。验证方法很简单,发一个带Range: bytes=0-1023的请求,看返回码是 206 还是 200。
4.3 音频会话与后台播放:两边都要配,但关注点不同
AVAudioSession的配置对本地和在线是一套逻辑,播视频前把 category 设成.playback,需要混音时用.playback加.mixWithOthers,播完记得还原。这块不因为资源类型而变。
do { try AVAudioSession.sharedInstance().setCategory(.playback, mode: .moviePlayback) try AVAudioSession.sharedInstance().setActive(true) } catch { // 音频会话切换失败,通常是其他应用占用 }后台播放需要在工程的 Background Modes 里勾上 Audio,这一步两边一样。差异在于:本地文件在后台播放基本不会中断,在线视频在后台可能会因为网络切换、锁屏后网络策略收紧而中断。我的处理方式是在后台播放时监听AVPlayerItem的error和timeControlStatus,一旦进入长时间 waiting 就主动暂停并记录断点,回到前台再恢复。
还有中断处理。来电、闹钟、其他 App 抢占音频会话,这些对本地在线一视同仁。AVAudioSession.interruptionNotification收到.began时把播放器暂停,.ended时看options里有没有.shouldResume再决定恢复。中断恢复时如果你播的是在线视频,恢复瞬间可能正好赶上缓冲区已经耗尽,表现就是“恢复后卡一下”,这时候可以主动触发一次 seek 到当前时间点,让缓冲重新建立。
5. 常见问题与排查实录
5.1 本地文件没声音、没画面、或者干脆不播
本地文件的排查有一套固定顺序,我基本按这个走:先确认文件存在且大小正常,再确认asset.isPlayable,然后确认文本轨道以外的音视频轨道是否读得出来,最后查渲染层和音频会话。文件存在但大小为 0,通常是下载中断或者写文件时没 close,这种在缓存场景特别常见。
guard FileManager.default.fileExists(atPath: url.path), let attrs = try? FileManager.default.attributesOfItem(atPath: url.path), let size = attrs[.size] as? NSNumber, size.intValue > 1024 else { return }有声音没画面,绝大多数是视频轨编码不支持,或者AVPlayerLayer没有正确加进视图层级、frame 是零。AVPlayerLayer的 frame 必须跟着视图的layoutSubviews更新,用 Auto Layout 的时候记得videoGravity设成.resizeAspect否则画面会被拉伸。有画面没声音,先查音频会话是不是被别的模块占了,再查文件音量轨是不是空的。
还有一种极隐蔽的情况:文件本身是加密的或者分片的,看起来是个 mp4,实际需要配套的索引文件才能播。这种只能靠预检时读一下asset.tracks的数量来判断,读出来是 0 就直接报不支持。
5.2 在线视频首帧慢、起播转圈久
这是被问得最多的一类。我的排查顺序是:先量从点击播放到timeControlStatus变成.playing的耗时,再看这段时间里耗在了哪里。常见原因有这么几个:URL 指向的不是最终的媒体地址而是 302 跳转,每跳一次都要多一轮握手;服务端没开 HTTP/2 或者 TLS 握手慢;元数据体积过大,moov原子在文件尾部,播放器必须先拿到尾部才能起播;preferredForwardBufferDuration设得过大,播放器非要攒够才起播。
第二个原因有个很直接的验证方法:用命令行工具拉一次首字节时间,和正常网站对比。如果首字节就要一两秒,那问题在服务端,播放器侧再怎么调都没用。
moov原子在尾部这个坑值得多说两句。mp4 的元数据可以放在文件头也可以放在文件尾,放在尾部的文件在线播放时,播放器得先把整个文件读完拿到尾部,才能知道时长和关键帧分布,首帧自然慢。解决办法是服务端做一次 Fast Start 处理,把moov挪到头部。这个改造在服务端做一次就行,播放器侧无解,只能在选型阶段就要求内容方按这个规范产出文件。
5.3 播放到一半卡住、进度条继续走但画面不动
这种“假播放”状态最迷惑人。原因通常是automaticallyWaitsToMinimizeStalling被关掉了,播放器在缓冲耗尽后没有自动暂停,rate还是 1.0,进度条照走,但解码器拿不到数据。另一种情况是网络请求还在但速度极慢,缓冲区补不上。
排查时先看reasonForWaitingToPlay,如果是.toMinimizeStalls就说明在等缓冲,再去看loadedTimeRanges的增长速度,判断是真的没数据还是数据来了但被丢弃。我用过一个土办法:在网络层加日志,记录每次 Range 请求的发起时间、耗时、返回大小,几次卡顿之后规律就出来了。有一次定位到是 CDN 的边缘节点在特定地区响应异常,播放器侧所有参数都正常。
还有一种卡住是 seek 之后卡住。在线视频 seek 到未缓冲区域,播放器会重新发起请求,如果服务端对 Range 请求的支持有问题(比如返回 200 全量而不是 206 分片),表现就是 seek 之后长时间无响应。这个前面提过,联调阶段必须验证。
5.4 常见问题速查表
| 现象 | 高概率原因 | 优先排查动作 |
|---|---|---|
| 本地视频黑屏无报错 | URL 构造错误,路径含中文 | 打印url.absoluteString确认转义 |
| 本地视频有声音无画面 | 视频轨编码不支持 | 读取asset.tracks看轨道类型 |
| 在线视频一直转圈 | 服务端未支持 Range 或 CDN 异常 | 手动发带 Range 的请求看返回码 |
| 在线视频首帧超过 5 秒 | moov在文件尾或缓冲参数过大 | 检查文件结构,下调缓冲时长 |
| seek 后长时间无响应 | 服务端返回 200 全量 | 抓包确认是否 206 |
| 进度条走画面不动 | 关闭了自动等待防卡顿 | 在线场景重新开启 |
| 后台播放被中断 | 音频会话未配置或未激活 | 检查 category 与 setActive |
| 切到其他 App 后回来无声 | 音频会话未恢复 | 监听中断通知并重新激活 |
| 播放器内存持续上涨 | 长视频缓冲未做上限控制 | 下调缓冲时长,及时替换 item |
| 部分机型播不了 | 硬解码能力差异 | 降级到软解或转码源文件 |
5.5 几条我认为最值钱的实操心得
第一条,播放器封装一定要把“资源类型”作为一等公民暴露出去,而不是内部猜。传入 URL 之后判断 scheme 是file还是http(s),这个判断结果要能影响缓冲参数、seek 容差、UI 展示、错误处理分支。我见过太多封装把这一切写死成一套逻辑,结果本地和在线互相拖累。
第二条,AVPlayer的强引用要握在自己手里。很多人把 player 存在局部变量里,函数一返回播放器就释放了,表现是“播一秒就停”。还有人用replaceCurrentItem频繁切换资源,旧 item 的 KVO 观察者没移除,几次之后崩溃。我的做法是封装一个播放器管理类,持有 player 和 item,切换资源时先removeObserver、replaceCurrentItem(with: nil)、再建新的 item,顺序不能乱。
第三条,KVO 的观察者一定要在 dealloc 或者显式销毁方法里移除,iOS 11 之后虽然有自动移除的机制,但依赖它容易出诡异问题。统一用一套addObserver和removeObserver的配对方法包起来,比散落在各处安全得多。
第四条,错误信息别只看item.error,errorLog()和accessLog()里有更细的东西。errorLog会给出具体的错误码和发生时间,能区分是网络错误、格式错误还是解码错误;accessLog记录每次请求的字节数、耗时、服务端地址,排查 CDN 问题特别有用。这两个在线上环境也建议采集,比单纯的“播放失败”有用一百倍。
if let log = item.errorLog() { for event in log.events { print("error: \(event.errorStatusCode) \(event.errorComment ?? "") at \(event.uri ?? "")") } } if let access = item.accessLog() { for event in access.events { print("bytes: \(event.numberOfBytesTransferred) duration: \(event.transferDuration)") } }第五条,本地缓存的视频文件,播放前一定要做一次完整性校验。下载中断、磁盘写满、应用被杀,这些都会留下一个“看起来存在但不完整”的文件。校验方式可以简单到比对文件大小和服务端返回的 Content-Length,也可以复杂到校验哈希。我倾向用前者,成本低见效快,能挡掉九成的问题。
6. 最后说几个印象最深的坑
有一个坑我到现在还记得清楚:某项目的播放器在首页一切正常,进了详情页就黑屏,日志里什么错都没有。查了大半天,原因是详情页的播放器复用了首页的AVPlayer实例,只调了replaceCurrentItem,但音频会话的 category 在首页被设成了混音模式,详情页没重置,导致视频轨正常解码、音频轨被系统静音,AVPlayerItem.status一直是.readyToPlay,播放器的timeControlStatus也是.playing,就是没声音。最后是在每次切页时强制重置音频会话解决的。这件事让我彻底改掉了“音频会话只配一次”的习惯,凡是涉及播放上下文切换的场景,都重新配一遍。
另一个是把在线视频那套缓冲参数直接套到本地文件上。表现是用户点开一个已经缓存好的视频,居然还要转一秒钟圈。原因就是automaticallyWaitsToMinimizeStalling开着,播放器习惯性地等了一下缓冲判定。改成本地关闭之后,起播的体感差别非常明显。这类问题的特点是不报错、不影响功能,纯粹拖体验,靠自动化测试测不出来,只能靠自己在真机上反复点。
还有一个是 URL 里带中文参数的在线地址。服务端下发的地址里有?title=测试,URL(string:)直接返回 nil,代码里做了可选绑定所以静默失败了,用户看到的是点了没反应。后来统一在构造前做一次百分号编码,用.urlQueryAllowed这个字符集,但要注意它允许&和=通过,如果参数值本身包含这些字符,得自己再去掉。这个细节不复杂,但特别容易漏。
我现在给播放器写配置的时候,基本会先问自己三个问题:这个资源是本地还是远端、这个设置对另一个场景会不会有副作用、出问题的时候我靠什么日志能定位到。三个问题都答上来,再动手写代码,返工次数能少一大半。