做跨端播放器这段时间,我最大的一个体会是:Flutter × HarmonyOS 6.0 这种组合,真正考验人的不是视频解码能力,而是“视频控制栏”这一层看似轻薄的交互壳。进度条拖两下就卡、快进快退不同步、点按事件跟原生手势抢响应——这些才是让人熬夜的根因。这篇博文就围绕我写的这个「忆影播放器」项目,把 Flutter 接入 HarmonyOS 6.0 后构建视频控制栏的完整思路、架构取舍、核心代码、踩坑过程全部摊开讲清楚。
文章适合两类人看:一类是打算在鸿蒙上做 Flutter 播放器、但不清楚原生层和 UI 层怎么分工的开发者;另一类是已经跑通了视频画面,却被控制栏交互搞得焦头烂额的同行。无论你属于哪一类,我都尽量用工程实践说话,把为什么这么做、踩了什么坑、最后怎么解决都写明白,方便你直接参考复现。
1. 切入正题:控制栏为什么值得单独搞一套架构
1.1 控制栏没你想的那么简单
很多人觉得视频控制栏不就是播放暂停键、进度条、时间显示三件套么?我一开始也这么想,直到在鸿蒙上真机跑起来才发现,问题全藏在“状态”两个字里。
一个称得上能用的视频控制栏,至少要同时维护这几类状态:播放器的真实状态(空闲、缓冲中、播放中、暂停、播放结束)、控制栏自身的 UI 状态(是否可见、当前显示的是横屏布局还是竖屏布局)、拖动手势的临时状态(正在拖动进度时,要显示目标时间而不是当前播放时间)、以及音量亮度这类系统状态的回调结果。这些状态交叠在一起,用普通 setState 三下五除二,很快就失控了。
再加上 HarmonyOS 6.0 这一代鸿蒙系统本身用的是 ArkTS 生态,Flutter 跑在上面本质上是一套适配过的 OHOS 引擎。视频解码走的是鸿蒙原生播放框架,UI 却要用 Flutter 的 Widget 树渲染。说直白点,控制栏的每个按钮按下去,都要完成“Dart 事件 → Channel 通信 → ArkTS 原生调用 → 播放器回调 → 反向 Channel 事件 → Dart UI 刷新”这整整一圈闭环。这一圈闭环里任何一个环节慢了、断了、丢了,用户感知就是“按钮没反应”“进度条自己乱跳”。
所以这里我先把结论放在前面:跨端播放器的控制栏,技术难点不在控件画得漂不漂亮,而在于状态模型是否统一、通信链路是否可靠、手势事件是否分得清。
1.2 忆影播放器是什么、做了什么
忆影播放器是我自己维护的一个本地视频播放器项目,不是商业级产品,但功能上一直在向主流播放器看齐。它基于 Flutter 编写全部 UI 层,视频解码与渲染使用鸿蒙系统的原生能力,通过 PlatformView 嵌入 Flutter 页面中。目前的版本已经实现了播放暂停、进度拖动、快进快退、倍速切换、横竖屏切换、亮度与音量手势控制、软硬解切换,以及断点续播等基础能力。
在 HarmonyOS 6.0 这个环境里,这套方案踩通之后,我有几个非常直观的感受。第一,Flutter 侧的动画和布局能力比直接用 ArkUI 写控制栏要顺手太多,尤其是进度条这种需要高频局部刷新的组件;第二,鸿蒙原生播放器对常见视频格式的硬解支持很完整,省去自己折腾解码库的工夫;第三,两边通信如果设计不好,体验会直接崩掉,所以我的大部分精力都花在了 Channel 层的可靠性上。
1.3 为什么一定要 Flutter 和鸿蒙配合,而不是二选一
坦白说,如果只是做一个鸿蒙专用的小播放器,完全用 ArkTS + ArkUI 开发,很多问题都不存在。但我做这个项目有一个隐藏目标:同一套 Flutter 代码要能跑在 Android、iOS 以及鸿蒙上,视频播放只做平台差异适配。这就要求我把 UI 层全部留在 Flutter,把音视频底层放到各自系统原生侧。
鸿蒙原生播放器要接进来,无非两条路:一条是只用视频输出,也就是嵌一块原生 View 进来负责渲染画面;另一条是把播放控制也全部交给原生,Flutter 只发指令收状态。忆影播放器选择的是中间态:渲染交给原生,控制逻辑由 Flutter 主导。选择这种模式的原因是它在跨端一致性和交互灵活性之间最平衡,你能用 Flutter 的动画和手势能力构建完全一致的控制栏体验,又能享受到鸿蒙播放器对硬解、HDR、音频输出这些底层能力的原生支持。
2. 架构设计:能力边界、通信方案与状态模型
2.1 原生渲染 + Flutter UI:能力边界的第一层拆分
先说说我画的第一张架构图(虽然是脑内构图,但思路得先理清)。整个播放器页面从下往上分三层:
- 最底层是鸿蒙原生视频渲染视图,负责解码后的画面输出;
- 中间层是 Flutter 的 PlatformView 容器,负责把原生视图挂进 Widget 树;
- 最上层是 Flutter 绘制的一整组控制栏浮层,包括半透明渐变遮罩、播放按钮、进度条、时间文本、倍速菜单等。
这个分层的核心思想是:谁擅长什么就干什么。原生播放器负责解码渲染,Flutter 负责所有 UI 展示与交互。控制栏按钮点击后走 MethodChannel 调用原生;播放器内部状态变化后走 EventChannel 上报给 Flutter。两条通道各管各的,方向明确,不容易乱。
能力边界一定要在一开始就划清楚。比如音量、亮度这种功能,控制栏的手势可以负责“计算目标值”,但真正调节必须交给原生系统能力;比如视频的时间轴,Flutter 可以负责“展示进度百分比”,但 seek 之后真正的帧定位必须等待原生回抛事件来确认。谁抢了谁的活,都会导致状态不一致。
2.2 MethodChannel 与 EventChannel:请求/响应与推流的分工
Flutter 与原生通信有 MethodChannel、EventChannel、BasicMessageChannel 三种常用方式。播放器场景里我用了前两种,理由是它们的语义恰好匹配需求:
| 通道类型 | 语义 | 播放器中的应用场景 |
|---|---|---|
| MethodChannel | 请求/响应,一问一答 | 播放、暂停、seek、设置倍速、切换画质 |
| EventChannel | 订阅/推送,持续流式 | 播放器状态变化、进度回调、缓冲进度、播放错误 |
| BasicMessageChannel | 双向消息 | 本项目基本未使用 |
MethodChannel 像打电话,打通说一句,对方回一句;EventChannel 像听广播,订阅之后只管接收,不需要你主动问。播放器里的进度和时间状态,天然是高频推送数据,我总不可能每秒用 MethodChannel 去调一次原生层拿进度吧?EventChannel 就是为这种场景设计的,一次建立订阅,原生侧持续往 Dart 侧推消息。
具体到实现上,Dart 侧建立一个服务类统一管理这两个通道,避免页面里到处散落 MethodChannel 实例:
// player_channel_service.dart import 'package:flutter/services.dart'; class PlayerChannelService { PlayerChannelService._(); static const MethodChannel _controlChannel = MethodChannel('com.yiying.player/control'); static const EventChannel _eventChannel = EventChannel('com.yiying.player/events'); static Future<dynamic> invoke(String method, [Map? args]) { return _controlChannel.invokeMethod(method, args); } static Stream<Map<Object?, Object?>> eventStream() { return _eventChannel.receiveBroadcastStream() .cast<Map<Object?, Object?>>(); } }这里我踩过的第一个坑先透露一下:EventChannel 的receiveBroadcastStream()必须确保在页面真正加载完成后再调用,否则会出现事件丢失。你调用得早,原生侧可能还没 ready,后面就算有状态推送,Dart 侧也收不到。
2.3 接入 Native 视图:PlatformView 的鸿蒙实践
在 Flutter 里嵌入原生视图,Android 上是AndroidView/SurfaceAndroidView,iOS 上是UiKitView。到了鸿蒙,Flutter 的 OHOS 适配分支提供了类似的平台视图接入能力。具体名字在不同 SDK 版本里有点差异,但思路是共通的:通过一个 Factory 类,把原生 View 包一层,再让 Flutter 侧通过PlatformViewLink或PlatformView组件挂到 Widget 树里。
我在忆影播放器里没有采用 Flutter 官方 video_player 那类插件的做法,而是自己接管原生渲染视图,因为控制栏对渲染层和 UI 层之间的层级关系要求更高。结构上大致长这样:
Widget buildVideoArea() { return Stack( children: [ Positioned.fill( child: PlatformViewLink( viewType: 'com.yiying/native_player', onPlatformViewCreated: _onPlatformViewCreated, onCreatePlatformView: (params) => PlatformViewsService.initSurfaceAndroidView( params, viewType: 'com.yiying/native_player', ), ), ), // 控制栏浮层 Positioned.fill( child: _buildControlsOverlay(), ), ], ); }需要注意,鸿蒙侧的 PlatformView 有些版本只支持 Texture 合成,有些支持 Surface 直通。这直接影响视频画面的渲染效率和旋转后的表现。我当时用 Surface 直通花了不少时间,因为它能避免每帧从 GPU 拷贝纹理,但缺点是竖屏旋转横屏时如果原生层没处理好,画面会闪黑。如果你的系统版本支持,建议优先 Surface 直通;不支持就退回 Texture 合成,稳定性优先。
2.4 统一状态模型:控制栏不卡顿的第二层地基
通信方案定了之后,接下来要解决状态模型。播放器控制栏最容易出现“手忙脚乱”的根源,就在于多条状态流互相打架。比如正在拖动进度条时,播放器内部也在回调进度,如果你直接用回调进度刷新 UI,进度条就会一边被你拖着、一边被原生推上来的值拉回去,视觉上那个跳动感让人疯掉。
我最终把状态模型拆成三层:
- 播放器真实状态:来自 EventChannel,只有 idle/preparing/playing/paused/buffering/completed/error 几个枚举;
- UI 呈现状态:来自用户交互与真实状态折叠,比如控制栏显隐、播放按钮图标切换、锁屏按钮状态;
- 临时交互状态:拖进度时的目标时间、松手后是否 seek、手势类型等,只在交互期间生效。
三层状态里,播放器真实状态是唯一的数据源,UI 状态由它派生,临时交互状态只在手势生命周期里覆盖 UI 状态的一部分。等到视频控制栏真正实装时,这个模型带来的好处比想象中大得多——后面第 4 节我再结合代码展开。
3. 鸿蒙侧接入:工程初始化与原生播放器插件改造
3.1 环境准备:SDK 版本与 Flutter OHOS 分支的匹配
先讲环境匹配,这环节最容易被忽略,也是让我白花过最多时间的地方。HarmonyOS 6.0 这一代对应的 SDK 版本体系和 HarmonyOS NEXT 早期版本不完全一样。标题里提到 API 12+ 和 5.0.0(12),其实就是 HarmonyOS NEXT 5.0.0 对应的 API 12 基线;到了 HarmonyOS 6.0,API 版本继续演进,但底层机制基本延续了这套" Stage 模型 + ArkTS + 系统播放框架"的设计。
要在鸿蒙上调试 Flutter 应用,一个是 DevEco Studio,一个是 Flutter SDK 的 OHOS 分支(或者官方合入 OHOS 支持的版本)。我这边的配置大致是:
| 组件 | 版本参考 |
|---|---|
| DevEco Studio | 对应 HarmonyOS 6.0 的版本(5.x 及以上) |
| HarmonyOS SDK | API 12 及以上 |
| Flutter SDK | 带 OHOS 支持的分支,3.22 之后的几个版本对鸿蒙支持逐渐完善 |
| Dart SDK | 随 Flutter SDK 自带,无需单独安装 |
| 项目语言模式 | ArkTS,Stage 模型 |
这里给新入坑的读者一个硬建议:不要用你电脑上现有的任何 Flutter 稳定版来建鸿蒙项目。必须去拉专门支持鸿蒙的 Flutter SDK 分支并配置到 IDE 里,否则工程创建时根本看不到鸿蒙的编译目标。创建项目后,习惯用 Android Studio 的同事会发现 "如何创建 Flutter 项目" 的流程都懂,但工程里的ohos目录、module.json5、EntryAbility这些全新结构需要单独熟悉。
3.2 鸿蒙工程里接线 Channel:原生侧如何响应与上报
鸿蒙侧的 Flutter 插件机制,核心是继承FlutterPlugin并实现MethodCallHandler、EventChannel.StreamHandler等接口。原生侧注册的通道名必须和 Dart 侧完全一致,否则会静默失败。我注册的通道有两个:com.yiying.player/control用于接收 Dart 指令,com.yiying.player/events用于上报播放器状态。
ArkTS 侧核心逻辑示意如下(以实际 SDK 为准,但思路是成立的):
// PlayerPlugin.ets 示意代码 import { FlutterPlugin, MethodCall, MethodChannel, EventChannel } from 'ohos/flutter_ohos'; export class PlayerPlugin implements FlutterPlugin, MethodCallHandler, EventChannel.StreamHandler { private eventSink?: EventChannel.EventSink; private controller = new AVPlayerController(); onAttach(flutterEngine: FlutterEngine) { const methodChannel = new MethodChannel(flutterEngine.dartExecutor, 'com.yiying.player/control'); methodChannel.setMethodCallHandler(this); const eventChannel = new EventChannel(flutterEngine.dartExecutor, 'com.yiying.player/events'); eventChannel.setStreamHandler(this); } onMethodCall(call: MethodCall, result: MethodChannel.Result) { switch (call.method) { case 'play': this.controller.play(); result.success(true); break; case 'seekTo': const posMs = call.arguments as number; this.controller.seek(posMs, () => { this.emitEvent('seekCompleted', posMs); result.success(posMs); }); break; // pause / setSpeed / setLoop 等类似 } } onListen(arguments: any, eventSink: EventChannel.EventSink) { this.eventSink = eventSink; this.controller.onStateChange((state) => { this.emitEvent('stateChanged', state); }); } private emitEvent(type: string, data: Object) { if (this.eventSink) { this.eventSink.success({ type: type, data: data }); } } }事件上报这里有个细节:原生侧的回调线程和 Flutter 主线程不是同一个。如果你在播放器回调里直接调 eventSink,可能会出现极低概率的消息乱序,尤其在 seek 场景里,旧回调和新回调的时间戳交叉,Dart 侧收起来就错乱了。稳妥的作法是在 ArkTS 侧做一次串行化,把同一时刻的事件按播放器时间戳排序后再推给 Flutter。
3.3 页面销毁、断流与生命周期处理
还有一个我一开始完全没注意、后来被用户反馈打得满头包的环节:页面销毁。Flutter Navigator 切走页面后,控制栏页面本身已经 dispose 了,但原生播放器还在后台播放,EventChannel 管道也已经断开了。返回页面时,Dart 重新订阅 EventChannel,原生却还持有旧状态,导致黑屏但有声音、进度突然跳回开头这类问题。
现在的处理方案是:Flutter 侧在dispose里主动调用 MethodChannel 的release方法,通知原生层暂停播放并释放视频资源;原生层收到 release 后,也要把 eventSink 置空,防止继续往已断开的通道推消息。在didChangeAppLifecycleState里也做了类似处理,切后台自动暂停,回到前台按用户设置决定是否自动恢复。
这里也回应一个很多人问过的问题:Flutter Navigator 切换页面后,播放器状态会丢失吗?我明确回答:默认一定会丢,除非你做了状态保持。我用的是页面级状态提升——播放器实例提升到应用顶层单例持有,页面切换只销毁 UI,不销毁播放器对象;再配合 PageStorageKey 保持页面滚动位置,控制栏和播放进度才不会因为切入切出而重置。这也是忆影播放器断点续播实现的基础。
4. 控制栏完整实装:UI、手势、进度同步与控制联动
4.1 控制栏 UI 布局与自动隐藏策略
控制栏 UI 我按照观看场景分成两套布局:竖屏时底栏常驻,提供进度条、播放键、当前时间/总时长、全屏按钮;横屏时切换成上层抽屉式控制栏,左上角标题,底栏增加倍速按钮、锁屏按钮、下一集按钮,顶部下滑还能拉出更多设置。两套布局共用同一套 Widget 组件,只是位置和显隐不同。
自动隐藏这个功能,看起来简单,做起来有不少花样。我采用的控制逻辑是:
- 播放中且无交互时,3 秒后自动隐藏控制栏;
- 暂停、缓冲、播放结束、拖进度、点击菜单时,控制栏必须保持显示;
- 每次用户触碰屏幕时,重置隐藏计时器;
- 隐藏和显示使用同一个 AnimatedOpacity + AnimatedSlide 组合,动画时间 220ms 左右,避免生硬的闪现。
有一点要注意:自动隐藏计时器不能再 UI 每次刷新时重建。如果用 Timer 在 build 里重复创建,屏幕刷新快的场景下计时器永远到不了点,控制栏永远不会隐藏。我在实现里把计时器放在 ControlOverlay 的状态类里,只在用户手势触发时 reset。
4.2 手势区划分与事件优先级
视频播放器的手势是最容易出事的区域。由于控制栏浮层是整个铺满屏幕的,手势识别必须分区域、分状态。我的划分逻辑是:
| 手势区域 | 手势类型 | 触发动作 |
|---|---|---|
| 全屏视频区域 | 单击 | 控制栏显隐切换 |
| 全屏视频区域 | 双击 | 播放/暂停 |
| 全屏左侧 1/3 垂直滑动 | 垂直拖动 | 亮度调节 |
| 全屏右侧 1/3 垂直滑动 | 垂直拖动 | 音量调节 |
| 全屏底部 1/4 水平滑动 | 水平拖动 | 进度 seek 预览 |
| 控制栏按钮区域 | 点击 | 对应控制事件 |
这些手势用 Flutter 的 GestureDetector 是不够的,因为单击、双击、水平拖动、垂直拖动各有竞争关系。我做了一个手势仲裁层:RawGestureDetector配合GestureRecognizer集合,把水平拖动和垂直拖动分别指定到横竖两个 Axis 方向的 recognizer 上;单击双击之间设置 200ms 的等待窗口;任何拖动开始后,取消单击和双击的注册,主要避免拖动结束后误触发点击。
这里有一个我真实遇到的线程冲突:手势 recognizer 在手指离开屏幕后可能触发 onEnd,但原生侧的状态推送恰好也在同一毫秒级时间点到达,就会导致播放器先 seek 到目标点,又因为旧的 updateProgress 回调把 UI 上的进度拉回去。这个问题最后是靠第 2 节说的“拖动态进度值不进播放器真实状态”解决的——我从数据源头隔离了交互状态和播放器推送状态。
4.3 进度条拖动与请求帧同步
进度条的实现,我分成两个部分:底部细进度条和横屏中央缩略预览进度条。其中缩略预览在鸿蒙原生侧也有配合,拖动时显示当前拖动时间的视频帧缩略图。
先说底部细进度条:
// video_progress_bar.dart 核心逻辑 class VideoProgressBar extends StatefulWidget { final double progress; // 0.0 - 1.0 final double buffered; // 0.0 - 1.0 final ValueChanged<double>? onSeek; final ValueChanged<double>? onDragging; } class _VideoProgressBarState extends State<VideoProgressBar> { double? _dragValue; void _handleHorizontalDragUpdate(DragUpdateDetails details) { final box = context.findRenderObject() as RenderBox; final width = box.size.width; final dx = details.localPosition.dx; final value = (dx / width).clamp(0.0, 1.0); setState(() => _dragValue = value); widget.onDragging?.call(value); } // ... }拖动态时,_dragValue覆盖widget.progress,时间文本显示目标时间;松手调onSeek后,控制栏切回“等待 seek 完成”状态,进度条停在指定位置,直到原生侧回抛 seekCompleted 事件才把真实进度校准过来。这套流程我在最初版本里没做,结果拖动进度条经常出现“拖完往后退回一段”的诡异现象——这就是前面反复强调的交互状态与真实状态分离问题。
4.4 状态驱动的组件刷新与防抖
控制栏并不是每 100ms 都要全部刷新一遍。播放器进度回调的推送频率通常是 200ms ~ 500ms 一次,如果你的控制栏整体 build 成本高,这种频率会导致丢帧。我做了几个优化:
首先,把进度条区域、时间文本区域、控制按键区域拆成三个独立 Widget,互不关联的状态更新只触发对应子树刷新。其次,使用ValueNotifier+ValueListenableBuilder而不是 setState 刷新进度数据。最后,对原生侧的高频事件做节流,比如进度事件在 Dart 侧再包一层 100ms 节流,避免极端情况下 UI 出现抖动。
这些优化做完后,真机上控制栏在 4K 视频播放时的 GPU 占用也没有明显上升,手感上拖进度条非常跟手。
还有一个容易忽视的控制联动问题:进度条拖到尾部松手,播放器如果已经走完流程进入 completed 状态,此时再点播放键应该从头播放。我在控制逻辑里对 completed 状态单独处理,seek 到 0 并重新 play,而不是简单地调 play 方法,不然用户会有"点了播放没反应"的错觉。
5. 联调与排坑:我在鸿蒙上踩过的那些真实深坑
5.1 打包构建:本地签名、Dart VM 初始化报错与 Gradle 无关项
鸿蒙工程打包跟 Android 有很多相似处,但也有自己独特的问题。首先是本地签名,HarmonyOS 应用必须配置本地调试证书和 Profile,否则安装不进真机。用 DevEco Studio 的自动签名功能可以生成,但要注意每次重新生成后 bundleName 要保持一致,不然会报签名不匹配。
第二个高频报错是 Flutter 在鸿蒙上偶发的 Dart VM 初始化异常,日志类似:
E/flutter (31173): [ERROR:flutter/runtime/dart_vm_initializer.cc(41)] Unhandled Exception: ...这类报错本质不是 Dart VM 坏了,而是平台通道在异常时序下被反复调用。我在查这种问题时,一般先用堆栈定位到具体 channel,再回到 ArkTS 侧看是否在插件 attach 之前就触发了方法调用。解决办法是在 Dart 侧加一个 channel 是否 ready 的布尔变量,所有对原生侧的调用都先检查这个开关。
第三个问题来自 Flutter 自身工具链。我之前在 Android 项目里见过java.lang.AssertionError这一类构建期错误,在鸿蒙对接 Flutter 时如果误用了原来的 Flutter 工具链,也会触发相似错误。解决的路径就是前面强调的:确认使用的是 OHOS 适配分支,并且flutter doctor能正确识别出鸿蒙工具链。
5.2 EventChannel 事件丢、收不到、重复收到
EventChannel 是播放器里最关键的通信方式,也是坑最多的环节。我按实际遇到过的情况整理成速查表:
| 现象 | 根因 | 解决方式 |
|---|---|---|
| 刚进入页面时状态推送全丢 | Dart 在页面 initState 就订阅,但原生插件还没 attach | 在页面首个 frame 渲染完成后延迟订阅,或让原生侧在有订阅后才开始推流 |
| 切后台再回前台后收不到事件 | 原生播放器被系统回收,eventSink 失效但 Dart 还持有流 | 监听 AppLifecycle 变化,恢复前台时重新订阅并主动请求一次全量状态 |
| 同一事件收到多份 | 页面重建导致多次订阅没取消 | 在dispose阶段先取消订阅再销毁页面;使用同一个单例 StreamController 做事件分发 |
| 带数据的事件收不到但无异常 | EventChannel 数据里包含 null 或超大整数 | ArkTS 侧把数据统一转成 number/string,避免不兼容类型串扰 |
其中“切后台再回前台”这组情况最坑。原生播放器可能会因为资源清理而自动重置,但 Flutter 侧的 UI 还停留在暂停菜单,然后用户一点播放又发现没有声音。现在的做法是在AppLifecycleState.resumed回调里,主动通过 MethodChannel 询问原生侧“当前播放器状态、当前进度、缓冲进度”,用这次全量同步来校正所有 UI 状态。这个方法也被复用在播放器从后台恢复的场景。
5.3 PlatformView 黑屏、画面拉伸与图层问题
PlatformView 在鸿蒙上最常见的表现就是黑屏。第一次接入时,我一度以为视频源出问题,后来才发现是原生表面没有正确插到平台上。排查思路是:先打开开发者选项中的“显示布局边界”,看有没有渲染区域;再在 ArkTS 侧打日志确认 video view 是否 attach 成功;最后确认 Flutter 侧的 PlatformView 容器尺寸是否为非零且没有在导航动画期间被裁剪。
另一个大坑是画面比例。竖屏播放 16:9 视频,控制栏底栏在屏幕底部,视频画面如果直接填满整个 Stack,就会发生拉伸变形,人脸变宽。解决方式是使用 FittedBox + AspectRatio 包裹平台视图区域,但这里要注意平台视图的 surface 渲染不受 Flutter 布局约束,需要原生侧同步设置视频 scaling mode。这个我最终在 ArkTS 侧给播放器设置了VIDEO_SCALING_MODE_SCALE_TO_FIT_WITH_CROPPING,按照视频比例裁剪填充,在控制栏和视频画面之间有黑边时也做了背景遮罩处理。
5.4 控制栏状态丢失问题:Navigator 与状态保持
文章开头提到的 Navigator 页面切换丢状态,在实际测试中我自己试过三种路由方式:
Navigator.push默认模式,切走再切回来,播放器实例保持,但控制栏 UI 因为页面重建会回到初始状态;showDialog做的半屏面板,切走时底层页面状态保持但不更新,事件进入队列可能堆积;- 底部导航切换 Tab,如果 Tab 页面用
IndexedStack包裹,可以保持所有 Tab 状态,但内存开销变高。
我的最终方案是:视频播放器实例不跟着页面走,而是挂在一个全局的PlayerHolder单例上;页面切走时保留播放器继续播放,切回来时读取单例里的全量状态刷新 UI。能做到这一步的底气,正是第 2 节中把播放器真实状态独立成模型层,UI 只做展示和折叠,因此页面重建只是重新订阅,不会重新初始化播放器。
5.5 关于 Impeller 与渲染引擎的一个补充
热词里一直有人问 Flutter 在鸿蒙上的渲染引擎,尤其是 Impeller 逐步成为新一代 Flutter 默认渲染引擎之后,鸿蒙适配情况如何。我在忆影播放器里测试过,带 OHOS 支持的 Flutter 分支已经在逐步跟进 Impeller 渲染,但现阶段视频播放场景下,PlatformView 的相关路径仍以 Skia 兼容链路更成熟。如果遇到视频画面和控制栏 UI 出现闪烁或边界锯齿,先把渲染引擎切回兼容模式试一下,再决定要不要继续排查其他因素。这个经验是实测出来的,等项目后续版本把 Impeller 的视频合成路径吃透,我大概率会再写一篇单独的测试记录。
最后再分享一点个人心得。做 Flutter 和鸿蒙的混合开发,最忌讳的就是把所有东西都往 Flutter 这边硬拉,或者把所有逻辑都往原生塞。控制栏这套东西,本质上是在两条技术栈之间竖起一座桥,桥稳不稳,取决于桥墩子也就是通信层碎不碎。这套方案里我反复打磨的就是 Channel 的可靠性、状态模型的单一数据源、以及事件时序的控制。后续如果继续扩展这个播放器,我大概率会先把字幕解析和音轨切换接进来,再把投屏相关的协议层放上来跑跑看。到时候再回来更新这篇实战记录,也欢迎读者在实操过程中把你的踩坑结论分享出来,这类跨端工程的疑难杂症,往往越是具体越有参考价值。