去年我把手上一个播放器项目往鸿蒙上迁,整体UI用的是 Flutter,目标系统是 HarmonyOS 6.0 这一代。迁移过程中最让我头疼的,不是视频解码,也不是插件适配,反而是“视频控制栏”这块看似谁都能画出来的界面。它要同时处理播放状态同步、手势冲突、自动隐藏、全屏切换、生命周期恢复,任何一个细节没做好,用户都会觉得“这个播放器不跟手”。
所以这篇把我做「忆影播放器」控制栏的完整思路和代码整理出来,重点说清楚:控制栏为什么难写、怎么拆状态、怎么处理 UI 与交互、以及鸿蒙环境下到底踩了哪些坑。适合手头有 Flutter 项目准备适配鸿蒙的人,也适合准备自己做播放器 UI、不想用现成皮肤的开发者参考。
1. 项目拆解:视频控制栏到底难在哪
1.1 控制栏不是一组按钮,而是一个状态机
很多第一次做播放器的同学,会觉得控制栏就是“底部放一排按钮,播放暂停,拉进度,全屏”。做完才发现,用户随便点几下,问题就冒出来了:点暂停后控制栏一直显示不消失,拖进度条时数字在跳动,切后台回来视频画面黑了但声音还在,全屏返回时状态栏死活不隐藏。
这些问题的本质是:控制栏的每一个可见元素,都在同时被多个状态源驱动。播放按钮要读播放状态,进度条要读 position,时间文本要读 duration,缓冲转圈要读 buffering,自动隐藏要读“是否正在播放 + 最近一次操作时间”。如果这些状态散落在各个 Widget 里各管各的,很快就会失去同步。
所以项目的第一步不是写代码,而是先把控制栏当成一个“可预测的状态机”来设计。我把控制栏涉及的状态全部收敛到一个 PlayerState 对象里,UI 只是这个状态的投影,所有交互都是对这个状态发指令。这样控制栏显示的每一个元素,永远只跟一个数据源说话,不会各读各的。
1.2 播放器内核与控制栏的边界怎么划
做播放器之前先想清楚:哪些事交给内核,哪些事自己写。我用的内核是 video_player 这一类插件,它负责解码、渲染、播放暂停、seek、获取 position/duration/buffering 事件。这些底层能力我绝对不会自己造轮子,因为视频解码这块水太深,播放器内核踩过 n 年坑的东西,没必要重复造。
控制栏这边则完全自己实现,包括 Toolbar 显隐、进度条 UI、时间格式化、手势逻辑、自动隐藏计时器、全屏路由。这样做的最大好处是 UI 完全可控,以后想改成悬浮球、迷你窗、列表页小窗,底层播放内核不用动,改动都集中在控制栏这一层。
边界划清之后,我在 PlayerState 里只暴露了上层真正需要的东西:isPlaying、position、bufferedPosition、duration、isBuffering、isCompleted、events 流。内核的原始事件全部经过一个 adapter 层,翻译成业务字段,控制栏永远不直接调 controller 的内部方法,统一走 PlayerController 的语义化接口,比如 play()、pause()、seekTo()、toggleFullScreen()。
2. 技术选型:在 HarmonyOS 6.0 上跑 Flutter 的几种路子
2.1 Flutter 在鸿蒙 6.0 上的主流接入方式
鸿蒙 6.0 原生应用用的是 ArkUI,理论上完全可以用 ArkUI 重写一遍播放器。但手里已经有一套 Flutter 跨端 UI 的话,重写成本太高,而且后续要维护两套代码。有经验一点的团队会优先考虑“把 Flutter 引擎打包进鸿蒙应用”,在 ArkUI 的页面里嵌入一个 Flutter 容器,业务 UI 全部跑在 Flutter 侧。
这种方式有几个前提:需要能编译出适配鸿蒙系统的 Flutter 引擎,或者拿到对应 SDK;Flutter 要用到的基础插件,都要有鸿蒙的实现版本。实际项目里我采用了社区维护的 ohos 分支来编译引擎,配合自己的 Dart 业务层,最后以 AAR/HAR 的形式集成到鸿蒙应用工程里。
从体验上讲,Flutter 跑在鸿蒙上解决的是“跨端 UI 复用”的问题,不是“用 Flutter 替代鸿蒙原生”。涉及系统能力调用时,比如读取本地视频列表、申请权限、后台播放,最终还是通过 platform channel 走到系统 API 里。这一点要提前和团队对齐预期,并不是把 UI 层搬到 Flutter 就万事大吉。
2.2 播放内核与 UI 皮肤的组合方案
播放器本身的选择,我对比过三条路:
| 方案 | 优点 | 缺点 | 适合场景 |
|---|---|---|---|
| video_player 全家桶 | 生态成熟、API 稳定 | 内置 UI 简陋,鸿蒙适配依赖插件实现 | 快速验证、Demo |
| chewie 现成皮肤 | 开箱即用、有控制栏 | 定制受限,适配鸿蒙较慢 | 不想画 UI、标准播放器 |
| video_player + 自研控制栏 | 完全可控、扩展性最强 | 开发量大,需要自己踩坑 | 正式产品、定制播放体验 |
我最后选了第三条路。原生的 video_player 自带那套空状态 UI 真的没法用,Chewie 虽然能拖出来就带控制栏,但在鸿蒙新版本上对事件和纹理的处理并不算及时,一旦遇到黑屏、手势冲突这类问题,等于要读懂别人封装层,反而比自己写还久。
另外还要提一句,控制栏其实也可以基于自绘 Canvas 或 Flutter 的 Stack 布局实现。我的建议是,优先用 Stack + 基础组件,不要一上来就画 Canvas。Canvas 适合做播放器里的特殊效果,比如手势拖动时的缩略图浮层、音量指示条,但常规按钮、进度条、时间文本用普通组件开发效率高很多,还方便做动画和可访问性。
3. 控制栏整体设计与状态流
3.1 功能清单与交互映射
一个完整的控制栏,远不止底部那一条。我做了完整版:“顶部栏 + 底部栏 + 手势层”三层结构。顶部栏放返回按钮、标题、更多操作;底部栏放播放/暂停、时间、进度条、全屏按钮;手势层负责单击显示/隐藏、双击播放暂停。
| 功能 | 交互方式 | 依赖的状态 |
|---|---|---|
| 播放/暂停 | 点击底部按钮 / 双击屏幕 | isPlaying |
| 进度跳转 | 拖动进度条 | position/duration |
| 显示/隐藏控制栏 | 单击屏幕 | isControlsVisible |
| 自动隐藏 | 播放中无操作 5s | timerTrigger |
| 进/退 10 秒 | 左右双击区域(可演进) | position |
| 全屏切换 | 点击右上角/底部按钮 | isFullscreen |
功能列得越细,越容易发现状态依赖的重复性。比如“全屏切换”这件事,调用的是系统 UI 策略 + 路由跳转,但按钮的 icon 展示依赖 isFullscreen,这就意味着所有修改 isFullscreen 的路径都必须保证系统状态和业务状态同步刷新。
3.2 状态模型:为什么不能用一堆 setState 解决
控制栏涉及的状态至少有 8 个,如果全部 setState 散落处理,改动一个播放状态就得手动记得刷新时间文本、进度条、按钮 icon、自动隐藏计时器,几乎必然漏。我的做法是定义一个 PlayerState 作为 ChangeNotifier,所有变更都走 notifyListeners,控制栏小组件按需 listen。
class PlayerState extends ChangeNotifier { bool isPlaying = false; bool isBuffering = false; bool isCompleted = false; Duration position = Duration.zero; Duration bufferedPosition = Duration.zero; Duration duration = Duration.zero; bool isControlsVisible = true; bool isFullscreen = false; void syncWith(Duration pos, Duration buffered, Duration total) { position = pos; bufferedPosition = buffered; duration = total; notifyListeners(); } }控制栏组件只监听自己关心的字段,比如 Positioned 的 AnimatedBuilder 只 watch position,IconButton 只 watch isPlaying。这个模式的关键在于:任何地方想改变播放状态,都调用 PlayerController 的方法,由 controller 更新 PlayerState;UI 永远不直接去改播放内核,也不跨层修改其他 UI 组件的状态。
这等于把“用户想做的事”和“界面上看到的反馈”完全解耦。用户点暂停按钮,controller 调内核 pause(),内核产生事件,controller 收到事件后更新 isPlaying,UI 收到通知刷新 icon。看起来绕了一圈,但因为内核事件的产生时机不可预测,这一圈反而是最可靠的路径。
4. 控制栏 UI 实现要点
4.1 底部控制栏的布局结构
底部控制栏用 Stack 放在视频画面之上,整体结构是这样一层层套下来的:
Stack( children: [ Positioned.fill( child: GestureDetector( onTap: _onSingleTap, onDoubleTap: _onDoubleTap, onVerticalDragStart: _onVerticalDragStart, ), ), Positioned( left: 0, right: 0, bottom: 0, child: AnimatedOpacity( opacity: state.isControlsVisible ? 1.0 : 0.0, duration: const Duration(milliseconds: 250), child: _buildBottomControls(), ), ), ], )_buildBottomControls 内部就是一行 Row:播放暂停按钮、当前时间、进度条、总时长、全屏按钮。有一个容易被忽略的点:进度条要用 Expanded 包裹,两侧文字宽度不固定时,进度条才不会挤得忽宽忽窄。加一点最小间距和 SafeArea 适配,底部就不怕全面屏手势条遮挡。
按钮热区很重要。播放暂停按钮我设置了 40 x 40 的视觉尺寸,但实际点击热区是 48 x 48,否则在手机上很容易点不到;全屏按钮的热区也可以适度放大。如果控制栏里将来要放倍速、锁定、截图等按钮,建议都用同一个 IconButton 封装,内部统一 manage 热区尺寸,避免每个按钮单独调 padding。
4.2 进度条的自定义思路
原生 Slider 能直接用于最基本的进度展示,但包含缓冲进度时就不够用了。我用一个 Stack 包了双层 Container 作为缓冲背景,再叠一层 Slider:
Stack( children: [ Container( height: 3, decoration: BoxDecoration( color: Colors.white24, borderRadius: BorderRadius.circular(2), ), ), FractionallySizedBox( widthFactor: bufferedFraction, child: Container( height: 3, color: Colors.white54, ), ), Slider( value: displayValue.clamp(0, 1), activeColor: Colors.white, inactiveColor: Colors.transparent, onChanged: onDragging, onChangeEnd: onSeekEnd, ), ], )自定义 Slider 时有两个参数很关键:trackHeight 和 thumbRadius。默认 Slider 轨道很粗,整体偏 Material 风格,在播放器场景下不协调。我把轨道高度设为 3,滑块半径设为 6,视觉上会精致很多。滑块还会把轨道撑高,所以真正点击的热区是滑块加上下各 12 的透明区域,需要测试确认误触率不高。
进度条拖动最大的问题是“拖动中”和“播放中”的数据竞争。播放器每秒可能回调几十次 position,如果直接拿 controller.position 去 setState,拖到一半被覆盖,thumb 会跳回原位。解决办法是拖动过程中维护一个 dragValue,进度条 UI 显示 dragValue,只有松手时才真正 seek,seek 成功后由 position 回调接管显示。
4.3 时间显示与格式化细节
时间显示是控制栏里最容易偷懒、但做不好会露怯的地方。标准做法是“当前时间 / 总时长”,当前时间必须随 position 实时更新,总时长在初次加载时可能未知,要显示占位符。格式化规则很简单:不足一小时显示 m:ss,超过一小时显示 h:mm:ss,超过 10 小时还要保证小时至少有两位,否则列表切片时会错位。
String formatDuration(Duration d) { final h = d.inHours; final m = d.inMinutes.remainder(60); final s = d.inSeconds.remainder(60); if (h > 0) { return '${h.toString().padLeft(2, '0')}:${m.toString().padLeft(2, '0')}:${s.toString().padLeft(2, '0')}'; } return '${m.toString().padLeft(2, '0')}:${s.toString().padLeft(2, '0')}'; }时间文本建议用等宽字体或者固定一个最小宽度,否则播放时数字宽度不断变化,整个控制栏左右晃动,观感很差。我用的是 tabular figures 属性或直接固定 width,测试下来稳定很多。
4.4 控制栏背景渐变与显隐动画
为了让控制栏在浅色画面上可读,底部需要一层渐变背景。我在底部栏下方放了一个 Container,从 Colors.transparent 渐变到 Colors.black54,高度约 80,这样顶部区域不遮挡视频画面。
显隐动画我用 AnimatedOpacity 控制整层淡入淡出,配合 250ms 的时长。不要只在 opacity 上做动画,还应该把控制栏的 Positioned bottom 值从挂载位置略微下拉,比如隐藏时 bottom 设为 -20,视觉上是“滑出屏幕”。纯透明度动画在部分鸿蒙设备上会有边缘闪烁,加上位移后观感反而更自然。
隐藏时还有一个容易被忽略的点:控制栏完全透明后,依然需要保留一个几像素高的透明热区去接收手势,否则用户再次点击屏幕会点到视频层而无法唤出控制栏。我的做法是底部始终保留一个 30 高的透明区域,控制栏本体在隐藏时接受点击但不做视觉回应。
5. 控制栏交互逻辑与核心实现
5.1 单击显隐、双击播放暂停的手势共存
播放器屏幕到处都要响应手势,手势冲突是控制栏最大的工程难点。单击显示/隐藏控制栏,双击播放/暂停,这两个手势默认会打架,因为双击时必然伴随两次单击。Flutter 的 GestureDetector 自身有手势竞技场机制,我在同一层级同时注册 onTap 和 onDoubleTap,Flutter 会等待双击超时后才回调 onTap,双击时只触发 onDoubleTap,不会同时触发 onTap。
这里有个细节:如果同时还需要 onLongPress,最好把 onTap 的响应延迟接受,体验会偏“钝”。我的策略是控制栏可见时单击只负责隐藏,设置一个较短的 window;控制栏隐藏时单击直接显示,不等待双击超时。这样操作反馈更跟手,也不影响双击播放/暂停。
自动隐藏计时器通常跟着播放状态走。只有正在播放时才开启 5 秒倒计时,暂停、缓冲、完成后都不隐藏。每次用户触控屏幕都要重置计时器。我用一个 Timer 实例,每次重置前 cancel 旧的,避免多个计时器叠加导致提前消失:
void resetAutoHideTimer() { _hideTimer?.cancel(); if (!state.isPlaying) return; _hideTimer = Timer(const Duration(seconds: 5), () { _setControlsVisible(false); }); }5.2 进度拖动与 seek 的正确时序
进度拖动必须区分“展示值”和“真实值”。我的实现是维护 _dragPosition,Slider 的 value 始终取当前展示值;如果用户在拖动,展示值等于 _dragPosition;否则展示值等于 state.position。这样才能保证“拖动时不跳、松手后跟随真实进度”。
seek 的时序也要严谨。onChangeEnd 里拿到目标值后,调用 controller.seekTo,然后立刻把 _dragPosition 清空,让进度条显示回到 state.position。但 seek 本身是异步的,内核可能先回调几次旧 position 再更新到正确位置,如果不做保护,松手一瞬间进度会先跳回去再跳过来。我加了一个简单防抖:onChangeEnd 之后 300ms 内忽略旧的 position 回调,只保留新值。
对于视频时长较长的场景,我还会在拖动时把 Slider 的划分步进设置为 500ms 粒度,防止拖动过于灵敏导致精确定位困难。电影类内容不需要逐帧拖动,500ms 足够。
5.3 全屏切换与系统 UI 适配
全屏切换不只是“旋转屏幕”,还包括系统状态栏、导航栏的显隐策略。我选择从普通页面跳转到一个全屏路由,而不是在原页面直接旋转布局。原因是全屏路由可以独立控制生命周期,返回时也只需要 pop 一次,不需要反向恢复一堆布局参数。
进入全屏时,我调用 SystemChrome.setPreferredOrientations 锁定横屏,同时用 SystemUiMode.immersiveSticky 隐藏系统栏。信息展示区域要考虑 Cutout 和圆角,否则刘海下面内容会被遮;我通过 MediaQuery.padding 对控制栏左右各加了对应间距,同时把视频画面按比例裁切进安全区域。
退出全屏时,要恢复之前的系统栏模式、方向、页面布局。这里有个容易犯的错:退出全屏时如果直接恢复所有配置,用户可能本来就在横屏模式下使用,恢复成竖屏反而打乱用户习惯。正确做法是进入全屏前记录当前方向和系统 UI 模式,退出时只恢复到记录值。
5.4 生命周期处理与控制栏恢复
播放器切后台再回来,是控制栏最容易出 bug 的场景。回到前台时,position 可能已经不同,播放状态可能被系统打断,surface 也可能重建。我在页面 State 里混入 WidgetsBindingObserver,在 didChangeAppLifecycleState 里监听生命周期变化。
@override void didChangeAppLifecycleState(AppLifecycleState state) { if (state == AppLifecycleState.paused) { _playerController.pause(); _setControlsVisible(true); } else if (state == AppLifecycleState.resumed) { _reinitializeTexture(); _playerController.refreshState(); } }paused 时强制暂停是因为视频在后台继续播放会消耗资源,而且用户回到前台时往往期待停留在离开的画面;resumed 时重新初始化 texture 并刷新状态,能解决一部分鸿蒙 Surface 重建导致的纹理失效问题。控制栏也需要主动 setState,否则计时器在后台已经触发隐藏,回前台后控制栏可能直接消失,用户不知道如何唤出。
6. 鸿蒙适配层避坑实录
6.1 纹理黑屏与 surface 重建问题
这是我在鸿蒙 6.0 上跑 Flutter 播放器时遇到的重灾区。表现为:视频第一次播放正常,切后台再回来,画面变成黑屏,声音还在继续。原因是视频渲染的 texture id 与 surface 绑定,系统回收 surface 后 Flutter 侧持有的纹理还指向旧句柄,导致画面丢失。
解决思路分两步:第一步,在 resumed 生命周期里主动销毁旧的 texture 句柄,重新初始化视频渲染表面,简单粗暴但有效;第二步,从根上降低重建概率,尽量复用同一个 FlutterEngine 而不是反复创建新引擎,这样 surface 会被引擎层面管理得更稳定。实测下来,第二步对“切后台后持续播放”的场景更管用,但第一步依然是兜底方案。
社区里的另一个做法是放弃 Flutter 侧 texture 渲染,改用 PlatformView 承载原生播放器 view。PlatformView 在鸿蒙上的性能表现相对可控,但会失去 Flutter 统一布局的优势,控制栏和视频画面变成两个图层,手势、动画、叠加组件都要跨层沟通,开发复杂度上升很多。除非的确无法绕过黑屏,否则我建议先用重建纹理方案。
6.2 权限与本地视频读取差异
播放器要做本地视频列表,就绕不开权限申请。鸿蒙 6.0 的权限声明不是在 AndroidManifest 里写的,而是在工程 module.json5 中声明。读取媒体库、读取本地存储、访问网络的权限名和系统 API 与 Android 也有差异,需要逐个映射。
实际项目里我遇到的另一个坑:读取本地视频时,文件系统路径与 Android 的 content:// 结构完全不同。鸿蒙提供的媒体库 API 返回的是统一的媒体 URI 格式,视频播放内核能不能直接识别这个 URI,取决于内核插件是否做了适配。我最后写了一层 MediaSourceAdapter,把本地媒体 URI 统一转换为内核能识别的临时源,转换过程要处理好权限校验和文件拷贝策略,避免大视频直接把临时目录撑爆。
6.3 控制栏手势与系统返回手势冲突
鸿蒙系统默认从屏幕左侧边缘右滑返回,而视频全屏场景的这个区域经常要放“返回”按钮,系统手势会和按钮点击抢事件。我在全屏路由里用了 PopScope 拦截系统返回手势,统一交给控制栏来处理:控制栏可见时点返回先隐藏控制栏,隐藏时返回退出全屏。本来这个逻辑并不复杂,难在“系统返回手势是否触发”检测不到,最后只能通过 PopScope 全局接管,再用状态机判断优先级。
还有一个小坑:全屏时底部有一条手势条,这条手势条在沉浸模式打开后会被自动隐藏,但一旦用户触摸屏幕某块区域,系统可能提前唤出导航栏。我的控制栏显隐动画只能控制 Flutter 图层,无法控制系统导航栏的弹出时机,所以我会在 userActivity 事件触发后重新调用一次 SystemUiMode.immersiveSticky,保证导航栏不在视频播放过程中反复抢视线。
6.4 常见问题速查表
| 问题现象 | 原因 | 解决方案 |
|---|---|---|
| 视频黑屏但声音正常 | surface 重建后 textureId 失效 | 监听 lifecycle,resumed 时重建纹理 |
| 拖动进度条时 thumb 回跳 | position 回调覆盖 dragValue | 拖动中用本地缓存值,松手后同步 |
| 控制栏不自动隐藏 | 计时器在 pause 时取消,resume 未重开 | play 状态变更时重置 timer,检查状态依赖 |
| 双击偶尔触发单击 | 手势竞技场判定不稳定 | 用 onDoubleTap + onTap,必要时加超时延迟 |
| 返回全屏后状态栏无法隐藏 | 系统 UI 策略恢复时机不对 | 记录进入全屏前的状态,退出时恢复记录值 |
| 本地视频路径无法直接播放 | 鸿蒙媒体 URI 格式与内核不兼容 | 写一个 MediaSourceAdapter 做转换 |
| 切后台再回来播放卡住 | 播放器未正确处理 paused/resumed | 统一生命周期处理,resumed 时刷新 position |
| 控制栏宽度抖动 | 时间文本宽度变化 | 固定时间文本宽度或使用等宽数字 |
7. 写在最后的个人体会
整个播放器控制栏做下来,我最大的体会是:控制栏真正考验的不是“会不会写 UI”,而是“有没有把事情拆成状态的意识”。按钮、进度条、时间文本都是表层的,底层那套状态流、生命周期、手势仲裁、平台适配才是真正花时间的地方。
如果让我重新做一遍,我会在一开始就先把 PlayerState 和 controller 接口定义得更干净,然后让 UI 直接长在这个状态模型上。不要先写布局再回头补状态,那样改起来非常痛苦。控制栏这种组件,代码量不算多,但状态之间的时序关系特别容易出问题,建议给关键逻辑都写上日志,出错时能快速定位是哪个状态被谁改了。
后续我还想给「忆影播放器」加上长按倍速预览、拖动缩略图浮层、迷你小窗模式。这几个需求都不需要动底层内核,只要在现有控制栏的状态机上增加新指令新的 state 字段就行。这也是我坚持自研控制栏的底气——UI 永远攥在自己手里,产品想要什么交互,就能迅速做出来。