开篇:当视频第一次在列表里卡住
用户点开信息流里的一条短视频,画面转了半秒才出来,滑动时前一个视频还在后台继续出声,切到后台接了个电话回来,进度条回到了原点。这类问题几乎每个做媒体功能的团队都会遇到,它们不是某一个 API 用错,而是播放器生命周期、音频焦点、平台后台策略三件事没有一起考虑。
Flutter 本身不提供播放能力,媒体功能全部依赖插件:视频靠 video_player,音频靠 just_audio,相机靠 camera。这些插件把原生播放器(Android 的 ExoPlayer、iOS 的 AVPlayer)封装成 Dart 接口,因此真正的复杂度落在"什么时候创建、什么时候释放、什么时候抢占音频焦点"上。本文沿着媒体选型、视频播放、音频播放、相机集成、缩略图与上传、性能踩坑这条链路,把 Flutter 媒体能力一次讲透。
一、媒体能力全景与选型
1.1 常见媒体插件对比
Flutter 官方与社区提供了多个层次的媒体插件,选型时先看清楚它们各自封装的底层是什么。
| 插件 | 底层实现 | 主要用途 | 维护方 |
|---|---|---|---|
video_player | ExoPlayer / AVPlayer | 视频播放、本地与网络 | Flutter 官方 |
just_audio | ExoPlayer / AVPlayer | 音频播放、播放列表、变速 | 社区 |
audio_service | 平台媒体会话 | 后台播放、锁屏控制 | 社区 |
camera | Camera2 / AVFoundation | 预览、拍照、录像 | Flutter 官方 |
video_thumbnail | MediaMetadataRetriever | 本地视频抽帧 | 社区 |
| chewie | 基于 video_player | 带控制条的视频 UI | 社区 |
# pubspec.yaml 常用媒体依赖
dependencies:
flutter:
sdk: flutter
video_player: ^2.9.1
just_audio: ^0.9.40
audio_service: ^0.18.15
camera: ^0.11.0
video_thumbnail: ^0.5.3
1.2 选型决策要点
- 只要播放视频、不需要自定义控制条:直接用
video_player。 - 需要完整播放器 UI(进度、全屏、倍速):在
video_player之上用chewie。 - 音频播放、播放列表、循环、变速:用
just_audio,比video_player更贴音频场景。 - 需要后台播放与锁屏控制:
just_audio搭配audio_service。 - 需要相机预览与拍照:用
camera,但注意它不支持同时开前后摄像头。
一句话总结:先确认"视频还是音频、前台还是后台、要不要控制条",再决定用哪一层插件,能省掉大量返工。
1.3 平台配置前置项
Android 需要网络权限与明文流量策略;iOS 需要在 Info.plist 声明用途描述,否则上架审核会被拒。
<!-- android/app/src/main/AndroidManifest.xml -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<!-- ios/Runner/Info.plist -->
<key>NSCameraUsageDescription</key>
<string>需要访问相机以拍摄照片与视频</string>
<key>NSMicrophoneUsageDescription</key>
<string>需要访问麦克风以录制音频</string>
一句话总结:媒体功能在 Dart 层面写完只完成一半,平台权限与用途描述不做,功能与审核都会失败。
二、video_player 播放视频
2.1 初始化与生命周期
VideoPlayerController 是一个需要异步初始化、必须手动释放的资源对象。初始化完成后要调用 setLooping、setVolume 等配置,再交给 VideoPlayer Widget 渲染。
class VideoPlayerPage extends StatefulWidget {
const VideoPlayerPage({super.key, required this.url});
final String url;
@override
State<VideoPlayerPage> createState() => _VideoPlayerPageState();
}
class _VideoPlayerPageState extends State<VideoPlayerPage> {
late final VideoPlayerController _controller;
late final Future<void> _ready;
@override
void initState() {
super.initState();
_controller = VideoPlayerController.networkUrl(Uri.parse(widget.url));
_ready = _controller.initialize().then((_) {
_controller.setLooping(true);
setState(() {});
});
}
@override
void dispose() {
_controller.dispose(); // 必须释放,否则原生播放器泄漏
super.dispose();
}
@override
Widget build(BuildContext context) {
return FutureBuilder<void>(
future: _ready,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const Center(child: CircularProgressIndicator());
}
return AspectRatio(
aspectRatio: _controller.value.aspectRatio,
child: VideoPlayer(_controller),
);
},
);
}
}
2.2 播放状态与进度
VideoPlayerController.value 是一个不可变快照,包含 isPlaying、position、duration、buffered 等字段。要驱动 UI 更新,需要给控制器 addListener,并在回调里判断 value.isInitialized && mounted 后再 setState,让进度条与播放按钮跟随刷新。
| 字段 | 含义 | 常见用途 |
|---|---|---|
isInitialized | 是否完成初始化 | 决定是否渲染 |
isPlaying | 是否正在播放 | 切换播放按钮图标 |
position | 当前播放位置 | 进度条、字幕同步 |
duration | 总时长 | 进度条分母 |
buffered | 已缓冲区间 | 缓冲条显示 |
hasError | 是否出错 | 错误兜底 UI |
2.3 常见陷阱
- 在
build里创建控制器:会导致每次重建都新建播放器,必须放在initState。 - 忘记
dispose:原生播放器不释放,列表滚动会内存暴涨。 - 用
VideoPlayerController.network传 URL 字符串:新版要求Uri,用networkUrl。 - 在
dispose之后再调用setState:用mounted判断。
一句话总结:
video_player的所有问题几乎都围绕"谁创建、谁释放、谁监听",把这三件事写清楚,代码就稳了。
三、音频播放与后台播放
3.1 just_audio 基础用法
音频场景比视频更在意播放列表、循环模式、变速与音频焦点。just_audio 的 API 更贴合这些需求。
final player = AudioPlayer();
Future<void> playTrack(String url) async {
await player.setAudioSource(AudioSource.uri(Uri.parse(url)));
await player.setLoopMode(LoopMode.all);
await player.setSpeed(1.0);
player.play(); // 返回 Future,但不 await,避免阻塞
}
3.2 音频焦点与打断
Android 的音频焦点、iOS 的 AVAudioSession 中断是媒体应用必须处理的场景:来电、闹钟、其他 App 播放都会抢占焦点。audio_session 插件负责统一这两端行为。
final session = await AudioSession.instance;
await session.configure(const AudioSessionConfiguration.music());
session.interruptionEventStream.listen((event) {
if (event.begin) {
player.pause(); // 被打断时暂停
} else {
player.play(); // 打断结束后按需恢复
}
});
3.3 后台播放与锁屏控制
要在锁屏界面显示控制按钮、在后台继续播放,需要 audio_service 把播放器包装成一个 BaseAudioHandler。
class MyAudioHandler extends BaseAudioHandler {
final AudioPlayer _player = AudioPlayer();
@override
Future<void> play() => _player.play();
@override
Future<void> pause() => _player.pause();
@override
Future<void> seek(Duration position) => _player.seek(position);
}
<!-- Android 后台播放需要前台服务声明 -->
<service
android:name="com.ryanheise.audioservice.AudioService"
android:foregroundServiceType="mediaPlayback"
android:exported="true" />
iOS 侧则要在 Info.plist 的 UIBackgroundModes 数组中加入 audio,否则系统会在退到后台时直接掐断播放。
一句话总结:音频焦点决定"会不会被别的 App 打断",后台模式决定"切到后台还能不能响",两者缺一,用户体验都会断。
四、camera 相机集成
4.1 相机初始化与预览
camera 插件需要先枚举设备、选出合适的摄像头(分辨率、前后置),再初始化控制器并渲染预览。
final cameras = await availableCameras();
final back = cameras.firstWhere(
(c) => c.lensDirection == CameraLensDirection.back,
);
final controller = CameraController(
back,
ResolutionPreset.high,
enableAudio: true,
);
await controller.initialize();
初始化完成后,把 CameraPreview(controller) 放进一个按 controller.value.aspectRatio 约束的容器里即可渲染预览,注意不要在 build 中做 await。
4.2 拍照与录像
Future<void> takePhoto() async {
final file = await controller.takePicture();
debugPrint('照片路径:${file.path}');
}
Future<void> stopRecording() async {
final file = await controller.stopVideoRecording();
debugPrint('视频路径:${file.path}');
}
4.3 分辨率与性能取舍
| ResolutionPreset | 典型分辨率 | 适用场景 | 性能开销 |
|---|---|---|---|
low | 320×240 | 人脸检测预处理 | 极低 |
medium | 720p | 视频通话 | 中 |
high | 1080p | 常规拍照 | 较高 |
veryHigh | 2160p | 高清拍摄 | 高 |
max | 设备上限 | 专业拍摄 | 极高 |
4.4 相机生命周期管理
相机是独占资源:切到后台、切页面、来电都必须释放,否则下次初始化会失败。
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (!_controller.value.isInitialized) return;
if (state == AppLifecycleState.inactive) {
_controller.dispose(); // 失去前台时释放相机
} else if (state == AppLifecycleState.resumed) {
_recreateController();
}
}
一句话总结:相机最常见的崩溃是"资源被别的 App 占用",用
WidgetsBindingObserver监听生命周期并释放重建设备是标准解法。
五、缩略图、转码与上传
5.1 本地视频抽帧
列表里显示视频缩略图时,直接播放一帧成本太高,用 video_thumbnail 抽帧缓存更划算。
final bytes = await VideoThumbnail.thumbnailData(
video: file.path,
imageFormat: ImageFormat.JPEG,
maxWidth: 320,
quality: 70,
);
5.2 上传前压缩
大文件直接上传既慢又费流量,上传前压缩分辨率与码率是常规操作。压缩属于 CPU 密集任务,应放到 isolate 中执行,避免阻塞 UI 线程。
Future<void> compressInIsolate(String inputPath) async {
await compute(_compressTask, inputPath);
}
String _compressTask(String path) {
// 这里调用原生压缩或 ffmpeg 封装插件
return path;
}
5.3 上传与进度
用 http.MultipartRequest 组装表单并 send(),返回的 StreamedResponse 的 stream 会按块吐出字节,累计字节数除以文件总大小即可得到上传百分比;配合分片可进一步实现断点续传。
5.4 媒体存储与缓存策略
| 环节 | 推荐做法 | 反例 |
|---|---|---|
| 缩略图 | 本地缓存 + 磁盘淘汰 | 每次滚动重新抽帧 |
| 视频缓存 | 边下边播 + LRU | 全量下载后再播 |
| 上传 | 分片 + 断点续传 | 一次性整文件上传 |
| 临时文件 | 用后即删 | 拍摄文件长期堆积 |
一句话总结:媒体功能的性能瓶颈通常不在播放,而在缩略图、压缩、上传这三条链路上,提前做缓存与分片能省掉大量用户投诉。
六、性能优化与踩坑清单
6.1 列表中的视频播放
长列表里同时存在多个播放器是性能灾难。标准做法是"只保留一个活跃播放器",滚动时暂停并释放不可见项。
@override
void didUpdateWidget(covariant VideoItem oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.isVisible != oldWidget.isVisible) {
widget.isVisible ? _controller.play() : _controller.pause();
}
}
6.2 内存与解码器限制
Android 上同时存在的硬件解码器数量有限(通常 2~4 个),超出后会回退软解甚至直接失败。列表预加载不要超过两个播放器实例。
6.3 常见踩坑清单
- 在
build中await初始化:应放到initState并缓存 Future;播放器未dispose会导致原生层泄漏与内存增长。 - iOS 未设置
AVAudioSession类别:后台播放被系统掐断。 - Android 未声明前台服务类型:后台播放崩溃或被杀。
- 相机未监听生命周期:切后台再回来预览黑屏。
- 明文 HTTP 未开
usesCleartextTraffic:媒体加载失败;忘记mounted判断则会在异步回调里setState抛异常。
6.4 调试手段
Android 用 adb logcat -s ExoPlayerImpl VideoPlayerPlugin 观察解码器分配与播放器状态;iOS 用 xcrun simctl spawn booted log stream --predicate 'subsystem == "com.apple.coremedia"' 查看播放会话日志。两者结合能快速定位是"没声音"还是"没画面"。
一句话总结:媒体性能优化的核心是"同一时刻只让最少的东西在跑",其余全部释放,其次是让 CPU 密集任务离开主线程。
FAQ
常见问题:video_player 和 chewie 应该怎么选?
答:video_player 只提供播放内核与一个裸渲染 Widget,没有任何控制条;chewie 在它之上提供了进度条、全屏、倍速等完整 UI。如果你要自定义控制条,用 video_player;如果要开箱即用的播放器界面,用 chewie。
常见问题:为什么切到后台音频就停了?
答:两个原因之一:一是没有配置 audio_session,系统没有把应用标记为媒体应用;二是 iOS 没有在 Info.plist 开启 UIBackgroundModes 的 audio,Android 没有声明 mediaPlayback 前台服务。两端都要配齐。
常见问题:camera 插件能同时打开前后摄像头吗?
答:不能。camera 插件同一时刻只支持一个活动的 CameraController。需要双摄同时预览(如画中画直播)时,必须使用支持多摄像头的第三方插件或直接写平台通道。
常见问题:视频缩略图为什么每次滚动都重新生成?
答:因为没有做磁盘缓存。video_thumbnail 每次调用都会重新抽帧解码,应在生成后把字节写入本地缓存目录,并以视频地址的哈希作为文件名,下次直接读缓存。
常见问题:大视频上传经常失败怎么办?
答:整文件上传在网络抖动时必然失败。应改为分片上传,每片独立重试并记录已上传分片,实现断点续传;同时在上传前压缩分辨率与码率,降低单次传输体积。
常见问题:相机预览在部分 Android 机型上被拉伸变形?
答:这是预览宽高比与控件宽高比不一致导致的。应使用 controller.value.aspectRatio 约束预览容器,并注意前后置摄像头旋转角度的差异,必要时按 DeviceOrientation 做旋转变换。
常见问题:音视频功能需要引入 FFmpeg 吗?
答:多数播放、拍摄场景不需要,原生播放器已经足够。只有在需要转码、剪辑、滤镜合成等编辑能力时才引入 FFmpeg 封装插件,同时要评估包体积与许可证(LGPL/GPL)成本。
相关阅读
- Flutter 平台通道 — 插件底层如何与原生播放器通信
- Flutter 性能优化 — 列表与解码场景的帧率优化手段
- Flutter 异步与网络 — 媒体文件上传与下载的网络层设计
- Flutter 隔离与并发 — 压缩、抽帧等 CPU 任务的 isolate 方案
- Flutter 本地存储 — 媒体缓存目录与磁盘淘汰策略
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。