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.dartapi_client.dart 检查 /animated-video 接口和 token
视频能下载但不能播放 插件和控制器是否正常 video_player 依赖、详情页 检查插件注册和 Controller 初始化
多次进入重复下载 视频请求是否合并 _videoFileRequests 同一 cacheKey 复用 pending Future

视频链路比图片多了本地文件和播放器初始化两个环节。排查时要区分“拿不到视频字节”和“拿到字节但播放器播不了”。

视频文件命名策略

视频下载到本地后,文件名不能只用照片 id。因为同一张照片可能重新生成视频,旧视频地址和新视频地址不同。如果文件名只按照片 id 命名,重新生成后可能继续播放旧文件。

photoId + animatedUrlHash -> 本地视频文件名

这个策略和项目里的 cacheKey 思路一致。照片 id 解决归属问题,地址 hash 解决版本问题。重新生成视频后 hash 变化,本地缓存自然失效。
器初始化两个环节。排查时要区分“拿不到视频字节”和“拿到字节但播放器播不了”。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐