10-Flutter 鸿蒙实战 10:活化视频播放,video_player 与本地缓存
10. Flutter 鸿蒙实战 10:活化视频播放,video_player 与本地缓存

不直连临时外链,通过后端稳定接口下载视频,再交给 video_player 播放。
为什么需要本地缓存
照片活化视频不是普通图片,文件大、加载慢、外部链接可能过期。项目变更记录里有过一次真实问题:故事详情页直接下载外部 animatedUrl,外部视频临时链接会 403 或过期,导致 Android 和鸿蒙端都播放失败。后来项目改为通过自有后端 GET /api/photos/:id/animated-video 输出稳定视频,再由客户端缓存到本地播放。
StoriesRepository 中负责视频缓存的字段:
static final Map<String, String> _videoFileCache = {};
static final Map<String, Future<String?>> _videoFileRequests = {};
一个缓存最终文件路径,一个缓存正在下载的请求。
根据照片信息加载视频
videoFileForPhoto 会根据照片活化信息决定是否下载:
Future<String?> videoFileForPhoto(StoryPhotoInfo? info) async {
if (info == null || !info.shouldLoadAnimatedVideo || info.id.isEmpty) {
return null;
}
final cacheKey =
'photo:${info.id}:${info.animatedUrl.hashCode.toUnsigned(32).toRadixString(16)}';
final cached = _videoFileCache[cacheKey];
if (cached != null && File(cached).existsSync()) return cached;
final pending = _videoFileRequests[cacheKey];
if (pending != null) return pending;
final request = _downloadVideoBytes(
cacheKey,
() => _api.getBytes('/api/photos/${info.id}/animated-video'),
).whenComplete(() {
_videoFileRequests.remove(cacheKey);
});
_videoFileRequests[cacheKey] = request;
return request;
}
cacheKey 包含照片 id 和视频地址 hash,避免同一张照片更新视频后仍然使用旧缓存。
临时目录缓存
视频缓存放到系统临时目录:
final cacheDirectory = Directory(
'${Directory.systemTemp.path}${Platform.pathSeparator}story_videos',
);
临时缓存适合这类可重新下载的资源。它不是用户原始数据,丢失后可以重新从后端拉取。
详情页播放
故事详情页引入了 video_player:
import 'package:video_player/video_player.dart';
当本地视频路径准备好后,可以用 VideoPlayerController.file(File(path)) 初始化。播放控件要处理加载中、失败、播放、暂停、进度等状态。还要注意一个体验细节:用户开始播放视频前,应暂停故事语音,避免两个声音同时播放。
活化状态轮询
活化视频可能处于 processing、caching、done、failed 等状态。详情页通过 _syncAnimationPolling(_photoInfo) 同步轮询逻辑。视频未准备好时不能假装完成,应展示真实状态;视频加载失败后刷新照片状态,让后端有机会重新排队或修复缓存。
插件注册
pubspec.yaml 中的 video_player 使用 OpenHarmony 适配分支:
video_player:
git:
url: https://gitcode.com/openharmony-tpc/flutter_packages.git
ref: br_video_player-v2.9.2_ohos
path: packages/video_player/video_player
鸿蒙侧注册:
flutterEngine.getPlugins()?.add(new VideoPlayerPlugin());
如果只改 Dart 依赖、不生成或注册鸿蒙插件,HAP 构建或运行时都会出问题。

视频来源治理
端内播放要关注资源来源
视频播放的难点不只是 VideoPlayerController,更重要的是视频从哪里来。项目一开始直接使用外部 animatedUrl,后来因为 403 和临时链接失效,改成后端稳定视频接口。
() => _api.getBytes('/api/photos/${info.id}/animated-video')
移动端只信任自己的后端接口,后端再去处理第三方链接、缓存和修复。这样权限、错误提示、重试逻辑都更可控。
后端视频接口比临时链接更稳定
animationStatus=done 后来被修正为“服务器已缓存视频”。这个改动的核心不是改文案,而是把端内播放依赖从第三方临时链接转到自有后端接口。
_api.getBytes('/api/photos/${info.id}/animated-video')
这样移动端只关心自己的后端是否能返回视频字节。第三方链接过期、403、重试和缓存修复都交给后端处理,App 侧的错误分支会少很多。
视频本地化播放
视频资源为什么要先落本地
移动端视频播放通常不适合直接拿远端临时链接反复播放。链接可能过期,网络可能抖动,播放器也更适合稳定的本地文件。这个项目把视频字节下载到临时目录,再交给 video_player 播放。
Future<String?> videoFileForPhoto(StoryPhotoInfo? info) async {
if (info == null || !info.shouldLoadAnimatedVideo || info.id.isEmpty) {
return null;
}
final cacheKey =
'photo:${info.id}:${info.animatedUrl.hashCode.toUnsigned(32).toRadixString(16)}';
final cached = _videoFileCache[cacheKey];
if (cached != null && File(cached).existsSync()) return cached;
...
}
缓存 key 同时包含照片 id 和视频地址 hash。视频地址变化后,旧缓存不会被误用;同一视频重复打开时,又能直接复用本地文件。
状态语义要以端内可播放为准
服务端说视频已生成,不代表端内一定能播放。移动端还要考虑下载失败、文件损坏、播放器初始化失败等情况。所以详情页需要独立维护视频文件路径和播放控制器状态。
服务端状态:processing / ready / failed
端内状态:downloading / cached / initializing / playable / error
这两组状态不能混成一个字段。服务端状态说明任务有没有完成,端内状态说明当前设备能不能播放。用户看到的提示应该结合两者判断。
下载失败不能影响故事详情
视频是附加能力,故事正文、照片、年代地点才是主内容。项目在 prepareStoryDetail 里预取视频时捕获异常,不让视频失败阻止详情页打开。
if (videoFilePath == null && info?.shouldLoadAnimatedVideo == true) {
try {
videoFilePath = await videoFileForPhoto(info);
} catch (_) {
// 详情页会刷新照片状态并显示可读状态,预取失败不阻断打开。
}
}
这个设计和上传后处理的原则一致:主内容优先可用,附加能力单独展示状态。视频失败时,用户仍然能读故事、听语音、编辑信息。
video_player 的鸿蒙依赖
video_player 必须使用带 OpenHarmony 实现的版本,并且注册文件里要能看到 VideoPlayerPlugin。
video_player:
git:
url: https://gitcode.com/openharmony-tpc/flutter_packages.git
ref: br_video_player-v2.9.2_ohos
path: packages/video_player/video_player
flutterEngine.getPlugins()?.add(new VideoPlayerPlugin());
如果 Dart 代码里引入了 video_player,但鸿蒙侧没有插件实现或没有注册,构建或运行都会出问题。插件依赖、生成注册、真机播放要一起验证。
视频功能验证路径
1. 没有视频时,详情页不显示错误的播放入口。
2. 视频生成中时,页面显示处理中状态。
3. 视频可用时,先下载到本地再初始化播放器。
4. 断网后已缓存视频仍能复用。
5. 服务端链接失效时,错误只影响视频区域,不影响故事正文。
6. 离开详情页后释放播放器资源。
视频播放链路比图片更长,问题也更隐蔽。把服务端任务状态、本地缓存状态、播放器状态拆开,后续排查会清楚很多。
视频播放排查
视频缓存属于临时资源
活化视频可以从服务端重新下载,因此适合放在临时目录,不需要进入用户长期数据目录。临时缓存丢失后重新拉取即可。
适合长期保存:用户上传的原始照片、本地草稿
适合临时缓存:视频播放文件、图片缩略图、接口派生资源
这个边界能避免 App 占用越来越大。后续如果要做缓存清理,也能优先清理视频和缩略图,不影响用户原始数据。
下载请求也要合并
故事详情可能多次触发视频加载。如果同一个视频正在下载,应该复用正在进行的 Future,而不是并发下载多次。
final pending = _videoFileRequests[cacheKey];
if (pending != null) return pending;
final request = _downloadVideoBytes(
cacheKey,
() => _api.getBytes('/api/photos/${info.id}/animated-video'),
);
_videoFileRequests[cacheKey] = request;
视频文件通常比图片大得多,并发重复下载会浪费流量,也容易让播放器拿到不完整文件。请求合并在视频场景里比图片更重要。
轮询要有边界
视频活化可能需要等待,详情页可以轮询状态,但不能无限轮询。轮询要随着页面销毁停止,也要在状态完成或失败后停止。
Timer? _animationPollTimer;
void dispose() {
_animationPollTimer?.cancel();
_audioPlayer.dispose();
super.dispose();
}
如果离开页面后轮询还在继续,就会浪费网络,并可能在页面销毁后触发状态更新。移动端长任务一定要处理生命周期。
播放失败的排查顺序
视频无法播放时,按下面顺序排查效率更高:
1. 照片详情接口里视频状态是否 ready。
2. /api/photos/{id}/animated-video 是否能返回字节。
3. 本地临时文件是否写入成功。
4. video_player 插件是否注册。
5. VideoPlayerController 是否初始化成功。
这个顺序从服务端状态到端内播放逐层推进。直接改播放器 UI 往往解决不了根因。
视频播放问题定位表
| 现象 | 优先检查 | 对应文件 | 处理方向 |
|---|---|---|---|
| 视频入口不显示 | 照片状态是否允许加载 | StoryPhotoInfo |
检查 shouldLoadAnimatedVideo |
| 视频下载失败 | 字节接口是否可用 | stories_repository.dart、api_client.dart |
检查 /animated-video 接口和 token |
| 视频能下载但不能播放 | 插件和控制器是否正常 | video_player 依赖、详情页 |
检查插件注册和 Controller 初始化 |
| 多次进入重复下载 | 视频请求是否合并 | _videoFileRequests |
同一 cacheKey 复用 pending Future |
视频链路比图片多了本地文件和播放器初始化两个环节。排查时要区分“拿不到视频字节”和“拿到字节但播放器播不了”。
视频文件命名策略
视频下载到本地后,文件名不能只用照片 id。因为同一张照片可能重新生成视频,旧视频地址和新视频地址不同。如果文件名只按照片 id 命名,重新生成后可能继续播放旧文件。
photoId + animatedUrlHash -> 本地视频文件名
这个策略和项目里的 cacheKey 思路一致。照片 id 解决归属问题,地址 hash 解决版本问题。重新生成视频后 hash 变化,本地缓存自然失效。
器初始化两个环节。排查时要区分“拿不到视频字节”和“拿到字节但播放器播不了”。
更多推荐



所有评论(0)