1. 方案选型:为什么偏偏是Flutter + OpenHarmony这套组合
1.1 跨端框架对比:Flutter、Lynx、RN在OpenHarmony上的现实差异
先说背景。我接到的是一个带屏设备上的视频播放需求,既要跑在OpenHarmony设备上,又要在Android设备上同样能跑。设备形态大概是智能电视、带屏音箱或教育平板这一类,团队的技术栈一直是Flutter,所以第一反应就是:能不能用一套Flutter代码把两端都拿下?这个问题的答案,比想象中复杂。
跨端框架现在不是只有Flutter一家,Lynx在社区里热度也挺高,React Native更不用提。但具体到OpenHarmony这个平台,情况有点微妙。RN在鸿蒙上的适配主要靠第三方维护,版本跟进的节奏不稳定,一旦遇到鸿蒙SDK升级,很多时候要等社区补坑。Lynx的跨端渲染思路很有特点,字节系产品也在大规模使用,但在OpenHarmony上的工程化资料、插件生态、CI配套都还比较早期,现阶段做生产项目有点冒险。相比之下,Flutter的OpenHarmony分支有专门的组织在推,华为那边也有参与,典型的flutter_flutter仓库下有openharmony版本分支,社区已经有跑通上架的真实案例。
再补一个细节:OpenHarmony对Flutter的支持不只是能编译、能运行,它还专门做过渲染层的适配。Flutter默认用Skia或Impeller渲染,OpenHarmony这边是把渲染后端接到了自己的图形栈上,比如通过GPU的Vulkan接口跑。这意味着Flutter UI在鸿蒙设备上的渲染性能是有底子保障的,不是套个模拟层硬来。
所以我最后的取舍很简单:跨端UI层用Flutter,平台能力走插件通道,视频解码和显示交给系统底层。这套组合在OpenHarmony上的可行性和长期维护性,比另起炉灶上Lynx或者硬写两套原生要稳得多。
1.2 播放能力分层:解码走原生,UI走Flutter
视频播放器这个场景,最忌讳的就是试图在Dart层做解码。虽然Flutter生态里有video_player这种插件,它底层还是调平台播放器,但如果你播放的视频流比较特殊,比如某些私有协议、硬解H.265、多音轨、外挂字幕,那么通用插件往往撑不住。这时候就必须把解码能力下沉到平台侧。
我的做法是分层设计:OpenHarmony侧用系统底层的AVPlayer能力,或者通过HDI接口调用设备解码器,负责解封装、解码、渲染视频帧;Flutter侧只负责控制栏、手势、页面跳转、UI状态。两层之间用MethodChannel发命令、用EventChannel收事件,视频画面本身通过PlatformView嵌入Flutter的Widget树。
这个分层有个明显的好处:播放器的核心能力(解码、缓冲、AVSync)不需要重复实现,Flutter层和Android层共用同一套控制栏逻辑和交互代码,真正做到"一份UI,两端运行"。控制栏这种纯交互的东西,恰恰是Flutter最擅长的领域,你让原生写一套一模一样的控制栏,工作量至少翻倍。
2. 自定义视频控制栏:交互设计与状态管理
2.1 控制栏的模块划分:不止是播放/暂停按钮
视频控制栏听起来简单,很多人以为就是一个播放暂停按钮加一个进度条。实际上手之后会发现,它是个典型的交互状态机,不同状态之间有严格的互斥和跳转关系。
我的控制栏至少拆成这几块:播放/暂停按钮、当前时间/总时长文本、可拖动的进度条、全屏/退出全屏按钮、加载中指示器、重播按钮。进阶点的还有倍速切换、集数切换、清晰度菜单、手势调节亮度音量的浮层提示。这些模块不是堆在屏幕底部就完事,而是要定义好一组状态:初始化、缓冲中、播放中、暂停中、播放出错、播放结束。状态之间只有合法的跳转路径,比如播放出错之后不能直接进播放中,必须先回到初始化或者错误重试。
这个状态机是整个控制栏的骨架。我在项目里用一个PlaybackState枚举来标记,所有UI组件都围绕这个枚举做响应式变化。比如缓冲中时,播放按钮要隐藏,转圈要显示;播放结束时,进度条归零或者停在末尾,按钮变成重播图标。这些细节直接决定用户体感,很多播放器被吐槽"卡死了不知道怎么恢复",往往就是状态机漏了分支。
2.2 状态管理:为什么选ValueNotifier + ChangeNotifier
控制栏的状态管理,我直接用了Flutter自带的ChangeNotifier和ValueNotifier,没有上Provider,更没上Riverpod或Bloc。理由很简单:控制栏的状态范围是局部的,只在一个页面内生效,不需要跨页面共享,用重量级状态管理框架反而引入序列化和依赖注入的成本。
具体一点,我定义了一个PlayerStateModel,它混入ChangeNotifier,内部持有播放状态、当前播放位置、总时长、缓冲进度、倍速这些字段。业务层从EventChannel收到播放器的进度回调后,更新Model并notifyListeners(),控制栏UI用AnimatedBuilder或ListenableBuilder监听这个Model,局部重建变化的部分。
这里有个很重要的优化点:控制栏的进度文本每秒要刷新一次,如果用setState重建整个控制栏,连播放按钮的图标都会被重建,浪费性能。我用ValueNotifier<Duration>单独管理当前播放位置,时间文本只监听这个Notifier,进度条滑块也单独监听另一个ValueNotifier<double>,这样每次刷新只重建文本和滑块,按钮图标纹丝不动。实测在低端鸿蒙设备上,帧率抖动明显减少。
代码结构大致是这样:
class PlayerStateModel extends ChangeNotifier { PlaybackState playbackState = PlaybackState.initializing; final ValueNotifier<Duration> position = ValueNotifier(Duration.zero); final ValueNotifier<double> bufferProgress = ValueNotifier(0); Duration total = Duration.zero; double speed = 1.0; bool isFullscreen = false; }监听侧配合ValueListenableBuilder,做到颗粒度最小的重建。
2.3 手势拖动与进度条实现细节
进度条是老播放器里坑最多的地方。第一个坑是拖动期间的进度显示和拖动结束后的seek要分开:拖的时候只更新本地UI,手指离开后才真正去调播放器seek。如果拖动过程中每动一像素就seek一次,播放器会疯掉,尤其在网络流上会产生大量seek请求,缓冲反复重启,体验极差。
我的做法是用GestureDetector的onHorizontalDragStart、onHorizontalDragUpdate、onHorizontalDragEnd三个回调。手指按下时记录起点,拖动时根据手势的globalPosition和进度条总宽度计算百分比,限制在0到1之间,然后直接更新ValueNotifier<double>,这个更新只影响UI显示。只有手指抬起时才调用原生播放器的seek命令,同时把进度条状态置为"正在seek",等EventChannel的进度回调回来后再校正位置。
进度条的UI层我用了自定义绘制,没有直接用Slider组件。原因是Slider在视频进度条场景下定制成本太高:要改轨道高度、改圆形thumb、改缓冲条的颜色、改拖动和普通状态的不同反馈。Canvas手绘反而更可控。基础结构是三个层:底层轨道、中间已缓冲层、上层已播放层,再加上一个thumb点。绘制代码不复杂,但需要处理高分屏的像素密度,以及在拖动时适当放大thumb的触达面积,不然在电视遥控器场景下很难操作。
另外时间格式化的细节也值得一提。超过一小时的要显示1:02:03,没超过的显示02:03。Dart里直接duration.toString()会带毫秒,不能直接用,我写了一个formatDuration工具方法,手动处理小时、分钟、秒的补零,顺便把毫秒截断掉。这个小函数在Android和OpenHarmony两侧的显示一致性上帮了大忙,因为系统原生时间文本的格式在不同平台上是有差异的。
3. Flutter与OpenHarmony跨端通信与视频层嵌入
3.1 EventChannel + MethodChannel通道设计
Flutter与OpenHarmony之间的通信,核心就两条:MethodChannel用来发命令,EventChannel用来收事件。这条通道是整个播放器的神经网络,设计得不清晰,后面所有功能都要返工。
我先定义了统一的通道名:
const MethodChannel _channel = MethodChannel('player/method'); const EventChannel _eventChannel = EventChannel('player/event');MethodChannel这侧,我定义了这些方法:play、pause、seekTo、setSpeed、setVolume、enterFullscreen、exitFullscreen、setSource、release。每个方法的参数都用Map封装,方便两端解析。例如seek命令:
await _channel.invokeMethod('seekTo', { 'positionMs': position.inMilliseconds, });EventChannel这侧,播放器主动往Flutter推事件,事件类型用type字段区分:
{ "type": "progress", "positionMs": 10240, "totalMs": 2700000 }事件类型我定义了:prepared(准备好,返回总时长)、progress(播放进度)、playStateChanged(播放状态切换)、bufferUpdate(缓冲百分比)、error(错误码和错误信息)、completion(播放完成)。这个协议一旦定下来,两端就独立开发,不互相等。
OpenHarmony侧用ArkTS写插件时,核心动作是取到MethodChannel和EventChannel的实例,在onLoad时注册方法处理器,然后把播放器的状态回调通过EventChannel的send推给Flutter。注意ArkTS里事件通道的send方法是异步的,不要在播放器高频回调里直接同步send,否则会造成事件积压。我的处理是做了一个轻量级节流:进度事件每200毫秒最多发送一次,时间文本显示每秒刷新就够了,没必要每个视频帧都上报。
3.2 PlatformView接入原生视频画面
视频画面怎么嵌入Flutter页面,这是跨端播放器里最核心的技术决策。有两个方案:一个是基于纹理注册的方式,也就是Flutter侧拿一个Texture(textureId: ...)Widget,平台侧把解码后的视频帧渲染到这个纹理上;另一个是PlatformView,也就是把原生视图嵌进Flutter的Widget树。
在OpenHarmony上,我建议优先用PlatformView思路,OpenHarmony官方建议的承载组件是XComponent,把XComponent的NativeWindow拿给AVPlayer做画面输出,然后再把整个XComponent封装成PlatformView接给Flutter。这样做的好处是解码和渲染完全在平台侧完成,性能损耗最低,视频色调、HDR这类参数也能保持原生表现。
Android侧的方案类似,用TextureView或SurfaceView,SurfaceView因为独立的窗口层级,在做动画和截屏时会有问题,所以我最终统一用了TextureView再封装PlatformView,保证两端行为一致。
Flutter创建PlatformView的代码大致长这个样子:
Widget buildNativeVideoView() { return PlatformViewLink( viewType: 'video_platform_view', surfaceFactory: (context, controller) => AndroidView( viewType: 'video_platform_view', creationParams: {'texture': true}, onPlatformViewCreated: _onPlatformViewCreated, ), onCreatePlatformView: (params) => _createPlatformView(params), ); }OpenHarmony侧封装XComponent为PlatformView时,最需要注意的是生命周期。Flutter页面切换、应用退后台、设备息屏,都会导致PlatformView销毁重建,这时必须同步处理播放器的暂停、释放和恢复。我踩过的一个坑是:从全屏状态退出后,XComponent的尺寸变了,但视频没有重新调整输出画面比例,导致画面被拉伸或留黑边。后来在尺寸变化的回调里重新设置了AVPlayer的视频缩放模式和窗口大小,问题才解决。
3.3 OpenHarmony侧插件适配与XTS认证
OpenHarmony侧做插件适配,不只是把接口跑通那么简单。设备上架应用市场要过XTS认证,认证里有一项就是检测应用是否适配了OpenHarmony的系统能力规范,包括权限声明、后台任务限制、API版本兼容这些。
播放器这个项目涉及到的权限主要是网络权限,如果播放本地文件还需要存储读取权限。OpenHarmony的权限声明在module.json5里配置,这点和Android的Manifest类似,但权限弹窗的交互和授权时机有差异,开发时要注意适配。另外,如果要用到设备独有的解码能力,比如硬解某些格式,可能要过HDI接口。HDI是OpenHarmony的硬件设备接口层,统一定义了硬件能力的调用方式,播放器接入硬解时需要先确认设备驱动是否已暴露对应的HDI接口,不同芯片平台的实现细节不太一样,最好是提前拿到设备的接口文档再动手写适配层。
XTS认证还有一个细节:应用在OpenHarmony上不能随意在后台播放视频。系统对后台任务有管控,如果你的播放器在退出到桌面后继续播放声音,会被系统判定为违规后台行为。所以在插件层我要监听应用前后台切换事件,退到后台时主动暂停,回到前台时恢复播放。
4. 实操记录:从工程骨架到控制栏完工
4.1 环境与工程结构
工程这块,现在的Flutter鸿蒙开发流程比早期顺畅多了。开发时用Android Studio或DevEco Studio都行,关键是拉对Flutter SDK分支。OpenHarmony的Flutter SDK是独立版本,不能直接用谷歌官方的稳定版,我用的版本是基于Flutter 3.x的分支,支持OpenHarmony 5.0以上的系统接口。
创建项目时,我会先建一个标准的Flutter工程,然后通过鸿蒙插件模板添加ohos平台目录。目录结构大概是这样的:
lib/ main.dart player/ player_control_bar.dart player_state_model.dart player_channel.dart video_view.dart pages/ player_page.dart ohos/ entry/src/main/ ets/ plugin/ VideoPlayerPlugin.ets pages/ Index.ets插件层的ArkTS代码独立放在ohos目录下,不要和Flutter页面混在一起,方便后续以Har包的形式复用。Flutter侧通过MethodChannel调用插件,插件内部再调用系统的AVPlayer,职责非常清晰。
我当时在搭建工程时还注意到一个问题:热词里大家经常搜"如何as创建flutter项目",实际在Android Studio里创建好Flutter工程后,要把OpenHarmony的依赖加进去,需要在pubspec.yaml里加上鸿蒙SDK的依赖源,并配置好本地的OpenHarmony SDK路径。这个过程现在有命令行工具可以辅助,但配置感觉还是偏手工,建议把这个环境初始化文档存下来,不然换台机器又要重新弄一遍。
4.2 控制栏UI实现代码
控制栏的UI实现,我尽量用最少的控件完成最多的功能。底部控制栏的布局是一个Row,里面依次放播放按钮、当前时间、进度条、总时长、全屏按钮。进度条在中间用Expanded撑满剩余空间,这样在不同屏幕宽度下都能自适应。
核心代码如下:
Widget buildControlBar(PlayerStateModel model) { return Container( height: 48, color: Colors.black.withOpacity(0.6), padding: EdgeInsets.symmetric(horizontal: 8), child: Row( children: [ IconButton( icon: Icon(model.playbackState == PlaybackState.playing ? Icons.pause : Icons.play_arrow), onPressed: () => _togglePlay(model), ), ValueListenableBuilder<Duration>( valueListenable: model.position, builder: (_, position, __) => Text( formatDuration(position), style: TextStyle(color: Colors.white, fontSize: 12), ), ), Expanded(child: VideoProgressBar(model: model)), Text('${formatDuration(model.total)}', style: TextStyle(color: Colors.white, fontSize: 12)), IconButton( icon: Icon(model.isFullscreen ? Icons.fullscreen_exit : Icons.fullscreen), onPressed: () => _toggleFullscreen(model), ), ], ), ); }这个代码看起来平铺直叙,其实里面藏了几个关键决策:按钮图标是根据播放状态动态切换的,所以IconButton需要监听状态变化;时间文本单独用ValueListenableBuilder包裹,避免整个Row被频繁重建;进度条是一个独立的自定义组件,内部再拆成滑块和进度条两个可独立更新的子组件。
控制栏自动隐藏也是视频播放器的标配。我用一个Timer实现,播放状态下5秒没有操作就隐藏控制栏,隐藏时用AnimatedOpacity做渐变。注意一个细节:自动隐藏定时器必须在用户任意一次交互时重置,否则会出现刚看完进度条就缩回去的情况。还要在隐藏之前判断当前是否正在拖动进度条,拖动中一定不能隐藏,否则用户手指还按着呢,控制栏没了,体验非常糟糕。
4.3 全屏切换与系统栏隐藏
全屏切换这个功能,在手机上已经很成熟,但在带屏设备和电视上要注意的点不太一样。手机全屏通常走一个新的全屏页面或者旋转屏幕,而电视上更常见的是保持横屏,把系统状态栏和导航栏隐藏掉,让视频铺满整个屏幕。
我的实现方案是:全屏切换时,Flutter侧调用MethodChannel通知原生侧隐藏系统栏,同时把自己的页面布局从"视频上方有标题栏"切换成"视频完全铺满"。原生侧隐藏系统栏在OpenHarmony上可以用Window的setWindowSystemBarEnable接口,传一个空数组,等于把状态栏和导航栏都隐藏。
代码示意:
Future<void> _toggleFullscreen(PlayerStateModel model) async { model.isFullscreen = !model.isFullscreen; await _channel.invokeMethod(model.isFullscreen ? 'enterFullscreen' : 'exitFullscreen'); if (model.isFullscreen) { SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); } else { SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); } setState(() {}); }全屏状态下还有一个重要的适配:PlatformView的尺寸要跟着变。Flutter侧切换布局之后,原生侧的XComponent会和新的布局尺寸重新布局,但有时候AVPlayer的输出窗口没有及时更新,画面会保持原来的宽高比。我在OpenHarmony侧对XComponent的onSizeChanged事件做了监听,在尺寸变化时重新计算视频的显示区域和缩放模式,保证画面不拉伸、不黑边。
5. 踩坑记录与问题排查速查
5.1 Flutter侧高频问题
第一坑是Navigator切换页面后控制栏状态丢失。很多人问"flutter navigator切换页面后,会丢失状态吗",答案取决于页面是怎么压栈的。如果播放页是一个新的PageRoute,页面被完全覆盖,控制栏的State还在;但如果路由被移除或者页面被销毁,那状态就没了。我的播放器设计原则是:播放器实例是全局单例,页面只是播放器的一层壳。页面销毁时只释放控制栏相关的资源,播放器实例和当前进度都保留,这样从播放页跳出去再回来,进度还在原来的位置。
第二坑是Future和事件回调的顺序问题。有开发问"flutter future的then回调是放入微任务队列吗",答案是肯定的,then注册的回调会进入微任务队列,在当前同步代码执行完之后按顺序执行。这意味着如果你在播放按钮的onPressed里先await play()再读取播放状态,状态可能还是旧的,因为原生侧的事件还没推到Flutter。正确的做法是:播放命令发出后,不要依赖返回值判断状态,而是通过EventChannel持续监听状态变化来更新UI。
第三坑是Flutter 3.44版本后的渲染引擎变化。新版默认启用了Impeller渲染,视频纹理在Impeller下的表现和Skia下不太一样,我在某个OpenHarmony设备上遇到过视频画面偶尔闪一下的问题,后来排查发现是纹理注册和销毁的时机与渲染帧不同步导致的。临时方案是把特定设备的Impeller回退到Skia,最终方案是把PlatformView的创建时机调整到页面initState之后而不是build过程中。
第四坑是打包报错,热词里那句java.lang.assertionerror: java.lang.exception: could not close input我也遇到过。这个本质是构建时某个资源文件被其他进程占用或文件损坏,通常清理构建缓存就能解决。执行flutter clean后,删除ohos目录下的临时构建产物再重新打包,就能恢复正常。
5.2 OpenHarmony侧适配问题
OpenHarmony侧的坑主要集中在这几类:权限、编解码器、XTS认证和资源访问。
权限问题最常见。视频播放需要网络权限,而OpenHarmony上如果应用声明了网络权限但用户没有授权,播放会直接失败,而且错误信息不一定明确。我排查过一个案例:播放网络流时一直回调error,但错误码非常泛化,最后检查才发现是权限没配置好。建议在插件初始化的入口就把需要的权限动态申请一遍,并在日志里打印权限状态,能省很多排查时间。
编解码器兼容性问题也很头疼。OpenHarmony设备种类多,不同芯片平台支持的硬解格式不一样。同一个H.265视频在一台设备上流畅播放,在另一台上却只有声音没有画面,多半是解码器不支持硬解,又没有软解回退。我的插件层加了一个能力探测逻辑:初始化时查询当前设备的解码器能力列表,如果目标格式不支持硬解,就主动切换软解码器,或者返回一个明确的错误码让上层提示用户。
还有一个比较隐蔽的问题:FTP和本地资源访问。热词里出现了"openharmony ftp",在播放器场景里,FTP协议的资源通常不会被系统播放器直接支持。我的方案是在插件层单独实现了FTP资源的拉取逻辑,先拉到临时文件再交给播放器,避免直接拿FTP URL去调AVPlayer导致无法播放。
XTS认证我前面提了一嘴,这里补充一个具体的坑:认证时会模拟各种异常场景,比如弱网、断电后恢复、磁盘满。其中磁盘满这个场景很容易让播放器崩溃,因为写入缓存日志时抛异常没捕获。在插件层的日志模块和缓存模块里,一定要对所有IO操作做异常兜底,否则XTS认证直接就挂了。
5.3 性能与体验调优建议
控制栏的性能优化,我的经验是"能不动就不动、能局部重建绝不整棵重建"。核心思路是:把控制栏拆成多个独立刷新的区块,每个区块只监听自己关心的状态。进度文本只监听position,图标只监听playbackState,进度条缓冲层只监听bufferUpdate,互不干扰。
动画方面,控制栏的显隐切换用AnimatedOpacity和AnimatedSlide配合,隐藏时向下滑出并淡出,显示时向上滑入并淡入。不要用过于夸张的动画,控制栏是功能性组件,花哨的动画会拖慢响应速度。尤其要避免在低端鸿蒙设备上使用大面积的模糊滤镜,ImageFilter.blur这种特效在GPU负载高的场景一开就会卡。
另外有个细节:视频加载时,控制栏上最好显示一个独立的缓冲指示器,而不是复用旋转圈。播放中的转圈和加载中的转圈在语义上完全不同,用户看到转圈就知道是在缓冲,看到进度条仍在走就知道是在正常播放,两者不能混着用。
列表页的预览图也有很多开发者忽略。退出播放页时,用RepaintBoundary截一帧当前画面存到内存里,下次进列表时直接展示这张预览图,比重新创建播放器去解码首帧要省太多资源。视频首帧的解码成本是很高的,能省则省。
最后分享几点个人体会
做这个跨端播放器项目,最大的感受是:控制栏这种看似不起眼的东西,反而是整个项目里最容易暴露设计短板的部分。播放器内核只要把解码和播放管好就行,控制栏却要跟用户的手指、眼睛、耐心打交道,一旦状态机有漏洞,用户在UI上立刻就能感受到"卡"、"晃"、"没反应"。
如果让我重新开工,我一定会先把事件协议和后端状态机写清楚,再碰UI。协议字段没定好就动手画界面,后面必然返工。控制栏的时间格式、进度条行为、全屏切换逻辑,Android和OpenHarmony要提前约定成同一个规范,不然一样的东西两端体验不一致,用户会觉得是两台完全不同的播放器。
这个项目还可以继续扩展的地方包括音轨切换、字幕加载、弹幕、手势双击快进快退、小窗播放,核心架构不变,只需在事件协议里增加事件类型,在控制栏上增加模块,播放器本体保持稳定。底子打好了,后面加功能的成本都很低,这也是我把控制栏和播放器内核分离做得比较彻底的原因。