这个项目标题里有三个关键词容易让人误解,一个是“跨端”,一个是“HarmonyOS 6.0”,还有一个是“难忘”。先说明白一点:所谓跨端,在这里不是说一套 UI 跑遍所有平台,而是说同一套 Flutter 业务代码,尽量在 Android、iOS 和鸿蒙之间复用;所谓 6.0,对应的是鸿蒙 SDK 的一个新版本,API 和权限模型都有调整;而“难忘”才是这个播放器的灵魂——除了基础播放暂停,还要做观看进度记忆、精彩片段收藏、时间点标记这些能力,让用户每次打开都能回到上次最想看的画面。
这篇文章我按自己实际做这个项目的顺序来写,从环境准备到播放内核封装,从手势交互到数据持久化,最后聊发布和踩坑记录。适合两类人看:一类是准备在鸿蒙设备上用 Flutter 落地产品的开发者,另一类是想给自己的播放器增加“记忆”功能的产品设计师。整个过程没有停留在理论上,每一步都有对应的代码思路和实测结论。
1. 项目到底在做什么:三个关键词的深度拆解
1.1 先从“跨端”说起:一套 Flutter 代码到底能跑多远
很多人对 Flutter 跨端有一个误解,以为写一次就能无脑跑全平台。实际上,Flutter 跨端最舒服的部分是 UI 层和业务状态层,这两块的复用率能做到 90% 以上;而一旦涉及系统能力,比如相机、定位、蓝牙、视频解码,就必须依赖插件或者自己写原生代码。
视频播放器恰好是“系统能力依赖非常重”的场景。视频要解码、要渲染、要处理音频焦点,这些不是 Flutter 引擎自己能搞定的。哪怕是社区里那些看起来很全能的播放器插件,底层也还是每个平台各自的播放内核在干活。所以我在设计这个项目时的第一个决策是:UI 和业务全部放 Flutter,播放能力走平台通道,鸿蒙侧调用系统级播放器,安卓侧同理。这样一来,Flutter 自身跨端优势保住了,两端的原生能力也拿到了。
实际跑下来,这个架构在 Flutter、HarmonyOS 6.0 这个组合下是比较稳的。后续如果还要接 iOS,同理再做一层适配即可,UI 完全不动。
1.2 HarmonyOS 6.0 带来的变化:从兼容到原生的分水岭
HarmonyOS 6.0 给开发造成最大影响的,是应用包形态和权限模型变了。以前那种“安卓 APK 直接搬上去跑”的做法在新版本上行不通,应用必须按鸿蒙应用格式来打包。这意味着,Flutter 项目里所有带有原生依赖的第三方插件,都得重新验证一遍鸿蒙支持情况。
很多老玩家在评估 Flutter 上鸿蒙时,第一反应是“找个现成的播放器插件,能跑就行”。实测下来这事没那么简单。以我使用的方案为例,Flutter 本身在鸿蒙上是通过社区维护的适配分支运行的,这个分支和 Flutter 官方主干的版本并不是同步的,而所有带有原生代码的插件,都依赖这个适配分支暴露出来的平台通道。所以每引入一个插件,都要先确认它是否在这个生态里可用。
那是不是就劝退了?不是。只要思路调整一下,把“找全能插件”变成“自己封装原生能力”,问题反而变得可控。这也是我接下来要详细展开的部分。
1.3 “难忘”这个产品定义:不只是播放器
“难忘视频播放器”这个名字,强调的不是视频本身,而是围绕视频产生的那些“记忆点”。我自己常用的场景是拿它看长视频教程:老师讲到关键知识点了,我想收藏这一段;看了半小时想休息,下次打开希望能直接续播,而不是从头找。
所以这个项目在 MVP 阶段就定了四个功能:
- 基础播放控制:播放、暂停、进度条拖动、倍速播放;
- 自动保存观看进度:退出后再次打开,恢复到退出时的位置;
- 精彩片段收藏:为视频某一时间点打上标记,并附带一句备注;
- 收藏列表快速回跳:点击收藏卡片,直接跳转到对应时间点。
每个功能听起来都不复杂,但把它们组合起来,就是一个完整的“个人影像记忆库”。不做账号体系、不做在线社区、不搞多端云同步,先把本地体验做顺。
2. 环境搭建与工程初始化:先把鸿蒙上的 Flutter 跑起来
2.1 鸿蒙原生开发环境准备
申请一个鸿蒙开发工具的安装包,装好后里面自带 HarmonyOS SDK 管理能力。第一次启动时要把 SDK 组件下载完整,当前网络环境建议直接选择最新稳定版。如果你是打算在真机上跑,还需要申请调试用的签名证书,模拟器则可以跳过这一步。
我在项目初期主要用模拟器做功能验证,等 UI 和播放器封装基本稳定后才换真机调性能。这个顺序不建议反过来。模拟器对传感器和硬解码的支持不太真实,但用来验证 Flutter 页面布局、动画、手势冲突这些纯 UI 问题效率很高。
配置完成后,在鸿蒙开发工具里新建一个空工程,先跑一次鸿蒙原生“Hello World”,确认本机的环境没问题。很多人跳过了这个步骤,直接跑来跑 Flutter 项目,最后报错也不知道是 Flutter 的问题还是鸿蒙 SDK 没配好。先验证最小链路,再往上层加东西,能省很多排查时间。
2.2 拉取支持鸿蒙的 Flutter SDK 与工程初始化
Flutter 官方主干目前没有直接合入鸿蒙平台支持,所以需要先获取社区维护的支持鸿蒙的 Flutter 分支。拿到分支代码后,用下面的方式切换命令行 SDK 指向,并验证环境:
git clone -b <适配鸿蒙的分支名> <你的Flutter来源仓库> export PATH="$PWD/flutter/bin:$PATH" flutter doctorflutter doctor如果能看到 HarmonyOS 相关的工具链检测项,说明基础环境没问题。接着创建项目:
flutter create --platforms=ohos,android,iOS my_player这里我把鸿蒙、安卓、iOS 三个平台一起生成。创建成功后,工程里会出现ohos/目录,它就是鸿蒙侧的原生工程。之后每次改鸿蒙原生代码,都是在这个目录下面操作。
需要注意一点:鸿蒙分支的 Flutter 版本是锁定的,不要随便升级。团队协作时,一定要把分支名和版本号写进项目文档,否则同事 A 用分支 X,同事 B 用分支 Y,编出来的平台通道签名都对不上。
2.3 最小骨架:播放器页面先跑起来
工程创建完成后,我先在lib/目录下写一个最简单的播放器占位页,验证 Flutter 到鸿蒙设备这条链路是否通畅。项目结构大致长这样:
my_player/ ├── lib/ │ ├── main.dart │ ├── pages/ │ │ └── player_page.dart │ └── services/ │ ├── player_api.dart │ └── player_controller.dart ├── ohos/ │ ├── entry/ │ │ └── src/main/ │ │ ├── ets/ │ │ └── resources/ │ └── build-profile.json5 └── pubspec.yaml页面里先不放真正的视频画面,只是显示一个居中的“播放器区域”文本框,然后用flutter run -d your-device-id启动。能在鸿蒙模拟器上看到这个 Flutter 页面,就说明环境链路全通了,接下来可以进入核心功能开发。
3. 播放内核选型与跨端封装:用稳定方案替代碰运气
3.1 三种跨端播放方案的取舍
在 Flutter 里做视频播放,社区常见做法有三类,我逐个试过之后列了一张对比表:
| 方案 | 底层实现 | 鸿蒙适配难度 | 优点 | 缺点 |
|---|---|---|---|---|
| 官方视频插件 | 安卓 ExoPlayer / iOS AVPlayer | 高,基本不可用 | 接口简单、文档多 | 鸿蒙上没有现成支持 |
| 第三方全能播放器 | libmpv / FFmpeg 系 | 中,需验证版本 | 解码能力强、格式多 | 体积大、二进制适配繁琐 |
| 自研平台通道封装 | 各端原生播放器 | 可控 | 最贴近系统、体积小、定制自由 | 需要维护两端原生代码 |
我最后选了第三种:自研平台通道封装。原因很实际:播放器这种基础能力,稳定性比方便性重要。第三方方案在鸿蒙上要么编译成本高,要么对 6.0 新权限体系还不适配。自研封装虽然要多写一些原生代码,但每一步都能掌控,出了问题也好定位。真正核心的播放控制逻辑,一套协议就能覆盖,工作量并没有想象中那么大。
3.2 用 Pigeon 定义播放器通信协议
跨端通信我强烈建议用 Pigeon 而不是手写 MethodChannel。Pigeon 是官方维护的代码生成工具,先定义协议文件,再生成 Dart 和原生两端的类型安全代码。手写 MethodChannel 最容易犯的错是字符串拼错、类型对不上,运行到一半才发现。Pigeon 在编译期就把这些错误拦截了。
我在项目里定义的最小播放协议是这样的:
import 'package:pigeon/pigeon.dart'; class PlayerOptions { final String uri; final String title; const PlayerOptions({required this.uri, required this.title}); } @HostApi() abstract class PlayerApi { int create(PlayerOptions options); void play(int playerId); void pause(int playerId); void seekTo(int playerId, int positionMs); void setRate(int playerId, double rate); void release(int playerId); }这里每个方法都带一个int playerId,是因为播放器可能要同时管理多个实例,比如列表页预加载、后台继续播放等场景。用 ID 来区分实例,比每次创建新对象更清晰。
生成代码后,鸿蒙侧需要手动实现一套对应的桥接。Pigeon 目前对鸿蒙的直接代码生成支持还不完善,所以我的做法是在鸿蒙原生工程里写一个抽象方法集,然后再写一个PlayerApiImpl,把 Dart 侧的调用映射到 ArkTS 层的系统播放器上。
3.3 ArkTS 侧实现系统播放器封装
鸿蒙侧播放能力基于系统媒体框架。我在ohos/entry/src/main/ets/services/PlayerController.ets里维护了一个控制器,核心逻辑就是提供给 Dart 侧的那五个方法。
import { media } from '@kit.MediaKit'; class PlayerController { private players: Map<number, media.AVPlayer> = new Map(); private nextId: number = 1; create(uri: string): number { let player = new media.AVPlayer(); this.players.set(this.nextId, player); // 设置媒体来源并进入初始化准备状态 return this.nextId++; } play(playerId: number) { let player = this.players.get(playerId); player?.play(); } pause(playerId: number) { let player = this.players.get(playerId); player?.pause(); } seekTo(playerId: number, positionMs: number) { let player = this.players.get(playerId); player?.seek(positionMs); } release(playerId: number) { let player = this.players.get(playerId); player?.release(); this.players.delete(playerId); } }代码只是示意,但结构是真实的:播放器实例用 Map 管理,所有对外的操作都通过 ID 路由。真正开发时还要处理若干回调,比如onStateChange、onError、onEndOfStream,我通过 EventChannel 把这些事件广播给 Flutter 侧。
为什么要这么封装?因为直接暴露播放器对象给 Flutter 会破坏边界。Flutter 侧只关心“调哪个方法”,并不关心底层是鸿蒙还是安卓播放器。抽象出协议层之后,未来如果 Android 端复用同一套 Flutter 代码,只需要在安卓原生侧实现同一个协议即可。
3.4 状态上报:事件流与状态机设计
播放器对时序非常敏感,我专门用事件流把状态从原生侧上报给 Flutter。定义两类事件:一类是进度事件,每秒或每几百毫秒上报一次当前播放位置;另一类是状态事件,包括缓冲开始、缓冲结束、播放完成、出错等。
Dart 侧用一个StreamController.broadcast()来分发这些事件,播放控制器订阅后更新 UI。事件通道在 Flutter 里是异步的,顺序不能保证,所以对于快速点击“暂停-拖动-播放”这类操作,不能依赖事件到达顺序做判断,而要在 Flutter 侧维护一个播放器状态机。
enum PlayerStatus { idle, loading, ready, playing, paused, completed, error, }状态机的规则很简单:状态只能按合法路径流转。比如paused可以转playing,但不能直接转completed;loading状态下不能执行 seek。我在操作播放器之前,都会先判断当前状态是否允许这次操作。这个设计在后面排查“进度条乱跳”问题时帮了大忙。
事件流还有一个关键细节:EventChannel 是单订阅的,如果页面被重建多次,容易把订阅丢失。所以我把订阅放在全局控制器里,而不是放在播放页面里。页面只是订阅控制器的状态流,避免重复订阅和资源泄漏。
4. 播放器 UI 与手势交互:让控制区有“手感”
4.1 播放页信息架构:把控制区做减法
播放页面用的是 Stack 结构:底层是视频画面,上层是控制层。控制层平时隐藏,点击屏幕唤出。顶部放返回按钮、标题、“难忘时刻”入口;底部放播放暂停键、弹出式进度条、当前时间和总时长、倍速按钮、收藏按钮。
一开始我想把所有控件都铺在页面上,结果发现视频区域被压缩得没法看。后来想明白了:播放器页面的第一优先级永远是视频画面本身,控制区要保证“用完即走”。所以底部栏做得比较高,配合半透明渐变背景,只在触摸时出现。
这里我的布局原则是用 Flex 弹性布局而不是固定宽高。不同鸿蒙设备的屏幕比例差异大,固定宽高最容易出现按钮错位。所有控制项的大小都基于屏幕尺寸做百分比缩放,实测在平板和手机上都能保持基本一致的比例。
4.2 手势控制:亮度、音量、进度一滑搞定
控制区的按钮只解决“点击”需求,真正提升手感的是手势。我做了一套常见播放器手势:
- 单击屏幕:唤出/隐藏控制层;
- 双击画面:播放/暂停;
- 在左半屏上下滑动:调节亮度;
- 在右半屏上下滑动:调节音量;
- 水平滑动:前后拖动进度。
Dart 侧实现基于 GestureDetector,核心代码结构如下:
GestureDetector( behavior: HitTestBehavior.opaque, onDoubleTap: () => controller.togglePlay(), onVerticalDragUpdate: (details) { final screenWidth = MediaQuery.of(context).size.width; if (details.globalPosition.dx < screenWidth / 2) { controller.changeBrightness(-details.delta.dy); } else { controller.changeVolume(-details.delta.dy); } }, onHorizontalDragUpdate: (details) { controller.seekByDelta(details.delta.dx); }, )真正调节亮度、音量的部分,还是得走平台通道。Flutter 侧拿到的只是一个手势“意图”,实际的亮度变化通过原生侧的系统能力实现。手势增量到具体数值的映射,我这里采用线性关系,也就是说上下滑动一屏对应 0 到 1 的变化范围。手感是否合适,需要反复试验,没有公式能一步到位。
4.3 全屏切换与沉浸式适配
全屏功能我设置了两种触发方式:竖屏下点全屏按钮旋转到横屏;横屏状态自动进入全屏。全屏切换的核心是隐藏系统状态栏,Flutter 侧直接用 SystemChrome 设置:
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); SystemChrome.setPreferredOrientations([ DeviceOrientation.landscapeLeft, DeviceOrientation.landscapeRight, ]);这里有个容易踩的细节:退出全屏时,不要只恢复方向列表,还要恢复系统 UI。如果顺序反了,会出现“方向已经竖回来了但状态栏还藏着”的尴尬。正确做法是先恢复系统 UI,再恢复方向。
刘海屏适配是另一个坑。全屏时如果不做 SafeArea 处理,画面会直接延伸到挖孔区域。但视频画面本身希望尽可能铺满屏幕,所以我的做法是:让视频画面真正全屏延伸,把系统控件避开安全区,画面内容不做避让。
5. 难忘时刻功能实现:进度记忆与收藏的数据设计
5.1 数据持久化选型:鸿蒙上的可靠性排序
做进度记忆和收藏,需要一个可靠的本地存储。在普通 Flutter 项目里,大家会用 SQLite 或 Hive,但在这个项目里,这两个方案在鸿蒙上的适配并不顺畅。SQLite 方案依赖插件里的原生编译,鸿蒙分支一升级可能整个插件编不过;Hive 也类似,虽然纯 Dart 部分没问题,但它的文件路径依赖也绕不开原生层。
所以我选择了最稳妥、看起来笨但最可控的方式:用平台通道做文件读写,数据以 JSON 格式存本地。Flutter 侧只负责拼 JSON 对象,鸿蒙侧 Native 层负责文件创建、读写、删除。这让数据层完全受我控制,不需要等待任何第三方插件适配鸿蒙。
如果你偏好更工程化的方案,也可以引入鸿蒙侧的关系型数据库,然后通过 Channel 暴露接口。这次项目没有选择它,是因为文件方式已经覆盖了 MVP 的全部场景,代码量更少。
5.2 数据表与 JSON 结构设计
所有数据归为两类:视频信息和收藏记录。视频信息保存每个视频的路径、标题、时长、上次播放位置;收藏记录保存视频 ID、收藏的时间点、备注文字。
{ "videos": [ { "id": "v1", "title": "演示视频", "path": "/storage/.../demo.mp4", "durationMs": 367000, "lastPositionMs": 83000, "updatedAt": 1712400000000 } ], "favorites": [ { "id": "f1", "videoId": "v1", "positionMs": 120000, "note": "关键知识点", "createdAt": 1712300000000 } ] }之所以把视频和收藏分开,是因为一个视频可以有多个收藏点,而收藏列表将来可能要按时间排序展示。合在一个结构里会导致数据重复和更新冲突。每次写入时用updatedAt字段标记时间,方便将来做多端同步时做增量合并。
5.3 观看进度自动保存与续播回跳
进度自动保存的核心设计是“防覆盖”。我一开始的做法是定时器每 10 秒存一次lastPositionMs,但很快发现问题:用户手动 seek 后,旧位置的定时器可能已经执行了,导致新位置还没存,旧位置就把数据覆盖了。
我的解法是这样的:只在播放状态才启动定时器,seek 的时候暂停保存;seek 结束后,把当前播放位置写一次数据库,再继续计时。同时,每次保存前判断一下“当前上报位置和上次保存位置的差值”,如果大于 10 秒就更新,小于 10 秒就跳过。这样既减少了频繁写入,也避免了旧数据覆盖新数据。
续播弹窗的逻辑也很简单:加载视频时读取lastPositionMs,如果大于 5000 毫秒,就弹窗问用户“是否从上次位置继续播放”。这样处理比直接跳转更尊重用户。以前我也做过“打开就跳”的方案,但用户经常想从头看一遍,所以加了一个轻确认。
6. 性能优化与发布上线:别让播放器毁在细节上
6.1 首帧速度优化:播放器预初始化
播放器最常见的性能问题是进入页面后黑屏等待太久。原因是点击入口到页面显示,中间要创建播放器、解析视频信息、准备渲染,每一步都耗时。实测在部分鸿蒙模拟器上,这段等待能达到 1 到 2 秒。
我做了两件事改善:
第一,列表页支持“预创建”。当用户滑动到某个视频卡片附近,就提前在后台创建播放器实例并解析媒体信息,真正进入播放页时复用这个实例,页面秒开。
第二,加载时先显示一张静态封面图。这个封面图来自视频首帧,尽量让用户先看到画面再等声音。视觉上的等待感会大幅削弱。
有一点要提醒:预创建实例会占用解码资源,所以只预创建当前可见的前后各一个,不能无限创建。
6.2 生命周期管理与内存释放
播放器是内存大户,生命周期管理做不好,页面退出了声音还在响,甚至多次进出后直接内存溢出。我在 Flutter 侧定义了严格的“释放链”:
- 页面销毁时,先移除 UI 监听;
- 再调用
release(playerId)释放原生播放器; - 最后将本地持有的播放器状态置为 idle。
释放动作通过 Pigeon 的release方法调用,原生侧同时删除 Map 里对应播放器实例。这个顺序不能乱。如果先释放原生播放器再移除 UI 监听,最后到达的进度事件就可能触发 UI 更新,造成空对象访问。
另外,竖屏全屏切换时,页面会经历重建。如果只是全屏切换,不需要销毁播放器,否则会出现全屏切换时视频闪断的问题。我的做法是全屏切换只改变 UI 布局配置,播放器实例保持不变。
6.3 鸿蒙 HAP 签名打包与权限清单
打包阶段要核对两件事:权限声明和签名配置。
在鸿蒙工程里,权限声明写在module.json5中。播放器需要访问本地视频文件,所以要声明读存储的权限;如果后续做了网络播放,还要加上网络访问权限。不同权限对应的申请时机不同,有的需要在运行时动态申请,有的用操作。我在项目里优先选择本地文件播放,避免在 MVP 阶段就陷进动态权限的复杂逻辑里。
签名方面,模拟器调试可以跳过签名,真机安装必须配置调试签名。如果用自动化打包脚本,记得把签名文件路径和环境变量分离,不要写死在代码里。多端产物管理我采用一套配置生成三端产物:安卓 APK、鸿蒙 HAP、iOS 安装包。三个平台各有各的签名和图标,用一个统一的构建脚本串起来。
7. 常见问题速查与个人踩坑记录
7.1 高频问题排查速查表
| 症状 | 可能原因 | 处理方式 |
|---|---|---|
| 播放器无法创建 | 媒体来源格式不支持 | 先转通用格式验证,确认系统解码支持范围 |
| 视频黑屏但有声音 | 渲染 Surface 初始化过晚 | 调整创建顺序:先建渲染层,再加载媒体 |
| seek 后画面花屏 | 缓冲未完成就执行播放 | 先暂停,等待 seek 完成回调再播放 |
| 页面退出后仍有声音 | 释放链未执行 | 页面销毁时检查 release 是否被调用 |
| 全屏后状态栏不消失 | SystemChrome 调用顺序错误 | 先隐藏系统 UI,再切换方向 |
| 进度条频繁回跳 | 定时保存和 seek 冲突 | 采用“保存前比较差值”策略 |
7.2 我实测中踩过的坑与对应解法
踩过最深的坑是关于“快速 seek”。我在测试时连续点按进度条,视频画面和进度条数字对不上,甚至出现播放位置倒退。排查后发现,原生播放器每次 seek 之后,会回传当前真实位置,而快速的多次 seek 请求,原生侧并不会按顺序完成,最后完成的那次请求决定最终位置。
解决办法是在原生侧加一个串行操作队列,每次只接受一个 seek 请求,完成后再处理下一个。UI 层同时加入 300 毫秒的防抖,把高频 seek 合并成最后一次。
另一个坑是动态权限。模拟器上安装后能直接读取本地视频,而真机上装了之后发现视频列表是空的。折腾半天发现新版本的鸿蒙系统对存储权限的管理更严格,光在配置里声明还不够,需要在运行时动态申请。这类问题有一个统一排查思路:在真机上跑一个最简单的原生 Demo,只做读文件一件事,如果原生 Demo 都读不到,那就是系统的权限或文件格式问题,和 Flutter 无关。
还有一个体验层面的坑:收藏列表点击某个收藏点跳转播放时,由于视频还没呈就绪,播放位置会自动从 0 开始。我的解决方式是在跳转时携带一个“期望播放位置”,播放器状态进入ready后才执行 seek,再执行 play。千万不要在loading状态下就尝试 seek,那样很容易丢失目标位置。
如果让我重新做一遍这个项目,我会把“播放内核封装”的协议表设计得更早一些,不要一上来就写 UI。播放器这个领域,卡壳的从来不是 UI,而是内核状态、事件时序和生命周期。先把这些用协议确定下来,UI 层只是换皮。
另外,鸿蒙生态的开发体验还在快速变化,不同版本之间的 API 差异比较明显。如果你正在读这篇文章,先把 SDK 版本固定下来,再按官方文档核对一遍。不要照抄旧项目的代码,尤其不要照抄别的播放器插件的调用方式,能省掉很多“我明明照着写却编译不过”的尴尬。这是我这次项目踩坑最深的体会。