近期将 Flutter APP 的播放器从官方 video_player 替换为 media_kit(基于 libmpv),主要原因是 video_player 不支持配置缓冲区大小,导致 HLS 直播流”播一片加载一片”的卡顿体验。本以为只是换一个包的事,结果踩了一堆坑,前前后后修了十几个问题才稳定下来。
问题背景
APP 是一个影视点播应用,播放源大多是 HLS(.m3u8)流。原来的播放链路:
视频URL → 后台解析 → VideoPlayerController.networkUrl() → AVPlayer/ExoPlayer
痛点:
video_player不暴露任何缓冲配置 API- iOS 端 AVPlayer 缓冲策略保守,播两个分片就要等
- 接入广告过滤后问题更严重(分段经过本地 HTTP 代理,每段多一次 RTT)
踩坑实录
坑一:HLS 代理的 206 Partial Content
现象: 开启广告过滤后,播放器直接黑屏,只有加载中。
排查: 加了日志发现——mpv(media_kit 底层)请求 HLS 播放列表时带了 Range: bytes=0- 请求头!而我们的本地代理转发了这个头,上游 CDN 返回了 206 Partial Content。但代理的 _fetchText 方法只接受 200 OK,导致播放列表始终加载失败。
[HlsLocalProxy] GET /hls.m3u8?... ua=libmpv range=bytes=0-
[HlsLocalProxy] _handleHls: fetchText returned null
解决: _fetchText 同时接受 200 和 206 状态码。之前 video_player 的底层播放器(AVPlayer/ExoPlayer)不会对 m3u8 请求发 Range 头,所以没触到这个 bug。
坑二:Segment 响应没调用 close()
现象: 修复 206 后,有进度条了、有声音了,但视频一直是黑屏+”加载中”。
排查: mpv 的 HTTP 客户端(libcurl)在收到 HTTP 响应后,会等连接关闭或 EOF 才认为数据完整。仔细检查 _pipeAndCache 方法——成功从上游获取 segment 后只调用了 response.add(bytes),但忘了 response.close()!mpv 一直在等更多数据,永远不结束缓冲状态。
// 修复前
response.add(bytes);
// 修复后
response.add(bytes);
await response.close();
而缓存命中路径和错误路径都有 close(),唯独最关键的”网络获取成功”路径缺失了。video_player 的底层播放器可能根据 Content-Length 就能判断响应结束,所以没暴露这个问题。
坑三:两个 Player 同时播放导致声音叠加
现象: 退出播放页后声音还在继续;偶尔出现双重声音叠加(一个去广告、一个没去广告)。
排查过程:
- 先以为是
dispose()没调——但代码里确实调了 - 仔细看了 media_kit 源码发现——
Player.dispose()内部有 5 秒延迟才调用mpv_terminate_destroy,音频会持续到真正销毁 - 进一步排查发现更严重的问题:两个
_init()在同时运行!
initState() 里调用了 _init()(fire-and-forget)。在 _init() 的异步过程中(创建 Player → open → play),didUpdateWidget 可能因为参数变化而触发第二次 _reinitializePlayer()。两次 _init() 各创建了一个 Player,第一个播原片(有广告),第二个播过滤后的(无广告)。等第一个的 _initGen 检查发现已被取代时,两个 Player 都已经开始播放了,声音已经叠加上了。
最终方案: 引入 generation 计数器:
int _initGen = 0;
Future<void> _init() async {
final gen = ++_initGen; // 每次 init 生成唯一序号
...
if (gen != _initGen) { // 完成时检查是否被后续 init 取代
await player.dispose();
return;
}
_player = player;
}</void>
同时在 open() 和 play() 之后都加 gen 检查,一旦被取代立即 dispose,不让它继续创建 VideoController、seek 等。
坑四:Android 上 widListener 导致的视频不显示
现象: 短视频模式正常,但普通播放页只有声音没有画面。
根源: media_kit 的 AndroidVideoController 有一个 widListener,在 Android 原生 Surface 创建完成后会重新配置 mpv 的视频输出(设置 --vo=null → --wid → --vo=gpu),最后 seek 到当前位置。
如果 VideoController 在 open() 之前创建,这个 seek 会干扰正在进行的播放列表加载;如果在 open() 之后创建,视频输出还没就绪就开始播放,导致几秒黑屏。
最终方案: 采用 open() → VideoController() → play() 的顺序,play() 后等 waitUntilFirstFrameRendered 超时(5秒兜底)。
坑五:进度条时有时无
现象: 视频明明正常播放,但进度条不显示。
排查: PlayerControls 的进度条只在 durationSec > 0 时渲染。_duration 由 player.stream.duration 订阅更新。但 duration 是一次性事件——mpv 解析完媒体就确定了,而我们是在 open() + play() + setRate() 之后才订阅流,早就错过了这个事件。所以 _duration 一直是 Duration.zero。
解决: 订阅流之后,立即用 player.state.duration 同步读取当前值:
_setupStreams(player);
// 流可能已经 emit 过了,从同步 state 读取初始值
final ps = player.state;
_position = ps.position;
_duration = ps.duration;
_isPlaying = ps.playing;
这告诉我们:media_kit 的流并不保证 emit 当前值给新订阅者,对于一次性事件尤其要注意。
总结
核心教训
- 播放器底层差异远超预期 — 本以为 media_kit 只是换个包,实际上 mpv 的 HTTP 行为(Range header、连接管理、Content-Type 要求)与 AVPlayer/ExoPlayer 完全不同
- Fire-and-forget 异步是 race condition 温床 — Dart 没有 cancel 机制,多次异步 init 叠加时必须用 generation 计数器保证结果正确
- Android 视频输出是最大的坑 — Surface/Texture 创建异步 + widListener seek + 播放时序交错,理论推导几乎不可行,只能靠日志反复验证
经验教训
这种”看似简单、实则深坑”的迁移,最好的策略是一次只解决一个问题。好在我没有同时改其他逻辑,否则根本分不清是哪个改动导致的 bug。
讨论
还没有留言,来留下第一条评论吧!