☰
Flutter适配OpenHarmony:推荐视频模块从设计到真机的完整实践
2026/10/4 7:45:28 网站建设 项目流程

如果你正在做视频类 App,而且团队最近开始评估 OpenHarmony 怎么适配,那"推荐视频"这个模块大概率是你第一个想拿来试水的功能。我的理由很简单:推荐流是典型的 UI + 数据驱动场景,业务逻辑相对独立,几乎不碰系统底层能力,拿来验证 Flutter 在 OpenHarmony 上的表现再合适不过。这篇文章是我真实项目里的经验记录——在一款跨端视频播放器里,用 Flutter 搭"推荐视频"模块,再把它跑上 OpenHarmony 真机的完整过程。内容包括模块怎么拆、数据怎么流动、UI 怎么实现,以及怎么通过 MethodChannel 把原生播放器能力接进来。如果你也在评估 Flutter × OpenHarmony 这条路,这篇应该能帮你少走不少弯路。

1. 为什么推荐流要单独拆出来做成 Flutter 模块

1.1 推荐模块在播放器里的定位

一款视频播放器的首页通常由推荐、分类、搜索、观看历史这几块组成,其中推荐流几乎承担了 60% 以上的用户停留时长。这类模块有个显著特点:内容形态变化快。今天可能是大图横滑,明天变成双列瀑布流,后天又要加挂角标、排行榜、运营位。产品同学改需求的速度,往往比原生版本的排期快得多。所以从组织生产的角度看,推荐流最适合做成高内聚、低耦合的独立模块,甚至可以独立于播放器主工程发版。

推荐模块虽然长在播放器里,但它的业务逻辑和播放内核其实没什么强耦合。它只负责三件事:拉取推荐数据、渲染视频卡片、把用户点击转换成跳转动作。真正解码、渲染帧、处理音频之类的脏活累活,全部交给原生播放内核去干。这个分层决定了推荐模块非常适合跨端复用——只要两端平台都能提供差不多的 UI 渲染能力和一层稳定的播放器接口,那这套代码就能跑在 Android、iOS 和 OpenHarmony 上。

1.2 Flutter 和原生各管一段的边界在哪里

Flutter 的系统架构说起来就是三层:Dart 层负责业务逻辑和组件树,Engine 层负责 Dart VM、渲染和平台通道,Embedder 层负责把画面呈现在具体操作系统上。跨端一致性的核心来源是渲染层——所有平台的 UI 都用同一套 Skia 或 Impeller 绘制,而不是依赖各平台的原生控件。这也是为什么 Flutter 做推荐流这种 UI 重、交互多的页面,体验一致性天生就好。

但视频播放恰恰是 Flutter 跨端能力里最薄弱的一环。视频解码依赖硬件厂商的编解码器、ColorSpace 适配、DRM 方案,每个平台差异很大。Flutter 官方至今也无法提供一套同时覆盖 Android、iOS 的完美播放方案,更不用说 OpenHarmony 了。所以我们的边界拍得很死:Flutter 负责推荐页全部 UI、状态管理、埋点上报和跳转逻辑;原生负责真正创建播放器、操控播放状态、回调进度和事件。中间通过 MethodChannel 和 EventChannel 通信。这样一来,哪天需要替换播放内核,Flutter 业务代码一行都不用动。

1.3 为什么是"第一个试水模块"

如果你的团队从零开始评估 OpenHarmony 适配,我强烈建议拿推荐模块当第一个试验田。原因有三点:

  • 数据接口简单,通常就是 GET 一个推荐列表,不涉及复杂权限和系统 API。
  • 页面场景丰富但不危险,轮播、瀑布流、下拉刷新、骨架屏、图片加载,全是 Flutter 最擅长的场景,风险可控。
  • 失败了能快速回退,即便推荐模块在 OpenHarmony 上有问题,播放器主流程不受影响,不至于把整个 App 卡死。

先在一个独立模块上把 Flutter × OpenHarmony 的工程链路打通(工程编译、插件注册、MethodChannel 通信、打包安装),再逐步扩大到设置、搜索、个人中心这类页面,这个节奏比较稳。

2. 推荐模块的数据链路与组件拆分

2.1 模块分层的落地方式

我把推荐模块拆成了五层:页面层、状态层、仓库层、接口层、缓存层。业务代码只依赖仓库层的抽象,不直接 new 接口实例。

来看一个实际的数据模型定义:

class RecommendVideo { final String videoId; final String title; final String coverUrl; final String author; final int playCount; final int duration; // 秒 final String category; final String? redirectUrl; // 运营位跳转 const RecommendVideo({ required this.videoId, required this.title, required this.coverUrl, required this.author, required this.playCount, required this.duration, required this.category, this.redirectUrl, }); factory RecommendVideo.fromJson(Map<String, dynamic> json) { return RecommendVideo( videoId: json['videoId'] as String, title: json['title'] as String, coverUrl: json['coverUrl'] as String, author: json['author'] as String, playCount: json['playCount'] as int, duration: json['duration'] as int, category: json['category'] as String, redirectUrl: json['redirectUrl'] as String?, ); } }

仓库层是典型的数据门面:

class RecommendRepository { final RecommendApi _api; final RecommendCache _cache; final int _pageSize = 20; RecommendRepository(this._api, this._cache); Future<List<RecommendVideo>> fetchFirstPage() async { final cached = _cache.readFirstPage(); if (cached != null && cached.isNotEmpty) { return cached; } final result = await _api.fetchRecommend(page: 1, pageSize: _pageSize); _cache.writeFirstPage(result); return result; } Future<List<RecommendVideo>> fetchNextPage(int page) async { return _api.fetchRecommend(page: page, pageSize: _pageSize); } }

这样设计的理由很实际:首屏能不能秒开,往往决定推荐模块的留存。第一次进页可能要走网络,但之后每次冷启动都先出缓存、再在后台刷新,用户体感会好很多。缓存只放第一页,翻页数据不缓存,避免覆盖图 URL 失效导致老图片一直占存储。

2.2 状态管理与组件通信

推荐流里的组件通信场景很多:Banner 轮播要告诉页面当前是第几张;视频卡片被移出可视区时要停止预加载;下拉刷新完成后要通知列表重置。我在这个项目里用的是状态管理三件套:ChangeNotifier 管列表状态,ValueNotifier 管局部大图状态,controller 管滚动和播放器联动。

推荐页主体状态用一个 ChangeNotifier 子类维护:

class RecommendFeedState extends ChangeNotifier { List<RecommendVideo> _videos = []; int _page = 1; bool _loading = false; bool _hasMore = true; String? _errorMessage; List<RecommendVideo> get videos => List.unmodifiable(_videos); bool get isLoading => _loading; bool get hasMore => _hasMore; String? get errorMessage => _errorMessage; Future<void> refresh() async { _loading = true; _errorMessage = null; notifyListeners(); try { final freshList = await _repository.fetchFirstPage(); _videos = freshList; _page = 1; _hasMore = freshList.length >= 20; } catch (e) { _errorMessage = '网络异常,点击重试'; } finally { _loading = false; notifyListeners(); } } Future<void> loadMore() async { if (_loading || !_hasMore) return; _loading = true; notifyListeners(); try { final nextPage = await _repository.fetchNextPage(_page + 1); if (nextPage.isEmpty) { _hasMore = false; } else { _videos.addAll(nextPage); _page += 1; } } catch (_) { _hasMore = false; // 翻页失败不再无限重试 } finally { _loading = false; notifyListeners(); } } }

这里我踩过一个和"组件通信"有关的小坑:刚开始我图省事,把"当前播放的视频卡"状态放在页面顶层,用 InheritedWidget 往下透传。结果列表项在滚动时频繁重建,InheritedWidget 每次都会触发所有子节点依赖刷新,帧率直接掉到 40 帧。后来改成ValueNotifier<String?> _playingVideoId,卡片只在自己 id 匹配时才监听,问题立刻消失。做列表类页面时,跨组件通信尽量用细粒度的可监听对象,别用上方大范围的 InheritedWidget。

2.3 分页、下拉刷新和重试的状态机

推荐流的用户操作只有三类:下拉刷新、向上翻页、失败重试。但把这三种操作写进一个状态机里还是有讲究的。我维护三个标志位:isLoading表示请求中,hasMore表示是否还有下一页,errorMessage表示是否有全局错误。

一个容易犯的错是在 loadMore 失败后把_loading置 false 并允许用户反复上拉触发同一请求。正确做法是失败后标记"已到底",让用户通过页面上的"加载失败,点击重试"按钮恢复,而不是继续滚动触发。这既避免后端被拖垮,也让错误状态在 UI 上更明确。

void retryAfterError() { if (_errorMessage != null) { refresh(); } else if (!_hasMore) { _hasMore = true; loadMore(); } }

用一句话总结这层的设计原则:状态机要能回答"现在页面上为什么是这个样子",而不是只有"正在加载"和"加载完成"两种状态。

3. 推荐流的 UI 实现:从轮播到信息流

3.1 用 CustomScrollView 搭推荐页骨架

推荐页的骨架不是简单的 ListView,而是 CustomScrollView 组合多个 Sliver。因为页面从上到下依次是:轮播 Banner、运营位入口、视频卡片流。如果用 Column + ListView,Banner 和运营位会被迫一次性全部 build,内存和首帧成本都不划算。

我的页面结构是这样组织的:

Widget build(BuildContext context) { return RefreshIndicator( onRefresh: _state.refresh, child: CustomScrollView( physics: const AlwaysScrollableScrollPhysics(), slivers: [ SliverToBoxAdapter( child: _RecommendBanner(videos: _state.videos), ), SliverToBoxAdapter( child: _ChannelEntries(onTap: _handleChannelTap), ), SliverList( delegate: SliverChildBuilderDelegate( (context, index) => _VideoCard( video: _state.videos[index], isActive: _playingVideoId.value == _state.videos[index].videoId, onTap: () => _openPlayer(_state.videos[index]), ), childCount: _state.videos.length, ), ), ], ), ); }

用 Sliver 的另一个好处是滚动位置可以由底层精确驱动。后面接播放器页面时,我可以把推荐页的滚动偏移量传给播放器页,做到"从哪个位置点进去,返回时就停在哪个位置",不用额外维护一个临时变量。

3.2 轮播 Banner 的无限循环实现

Banner 看起来简单,实现却很容�易翻车。我的做法是 PageView + 大 itemCount 模拟无限循环:

class _RecommendBanner extends StatefulWidget { final List<RecommendVideo> videos; const _RecommendBanner({required this.videos}); @override State<_RecommendBanner> createState() => _RecommendBannerState(); } class _RecommendBannerState extends State<_RecommendBanner> { Timer? _autoPlayTimer; final PageController _controller = PageController(initialPage: 1000); int _currentIndex = 0; @override void initState() { super.initState(); _autoPlayTimer = Timer.periodic(const Duration(seconds: 4), (_) { if (_controller.hasClients) { _controller.nextPage( duration: const Duration(milliseconds: 400), curve: Curves.easeOut, ); } }); } @override void dispose() { _autoPlayTimer?.cancel(); _controller.dispose(); super.dispose(); } @override Widget build(BuildContext context) { if (widget.videos.isEmpty) return const SizedBox.shrink(); final realCount = widget.videos.length; return SizedBox( height: 160, child: PageView.builder( controller: _controller, onPageChanged: (index) { setState(() => _currentIndex = index % realCount); }, itemCount: 10000, itemBuilder: (context, index) { final video = widget.videos[index % realCount]; return _BannerItem(video: video); }, ), ); } }

这里有个细节:初始页设为 1000,保证用户向左滑也能滑足够多次。而itemCount设成 10000 而不是用null(无限),是为了避免极端情况下 PageView 布局缓存过多 item。Banner 的图片我会单独用一个大图缓存,不跟列表里的封面共用同一份缓存键。

3.3 视频卡片和信息流的分页加载

视频卡片是推荐流的门面。我的卡片结构分四层:16:9 封面、左上角时长角标、下方渐变遮罩、底部标题和作者信息。封面加载用 cached_network_image,但要在内存缓存上做约束:

CachedNetworkImage( imageUrl: video.coverUrl, fit: BoxFit.cover, memCacheWidth: 360, // 按屏幕宽度 1 倍图加载,避免 2x/3x 浪费内存 placeholder: (context, url) => const _CardSkeleton(), errorWidget: (context, url, error) => const _CoverErrorPlaceholder(), )

这里面最容易忽略的是memCacheWidth。视频 App 的封面原图动辄 1080p 甚至 2K,直接丢进 ImageCache 会让内存飙升。按屏幕实际显示宽度限制解码尺寸,视觉上几乎没有差别,内存却能省下好几倍。

分页加载我是在 ScrollController 里监听滚动位置触发的:

_controller.addListener(() { if (_controller.position.pixels > _controller.position.maxScrollExtent - 600) { _state.loadMore(); } });

600 像素这个阈值不是拍脑袋定的,它是"用户滚动速度 x 网络延迟"的经验值。阈值太小会导致上拉到顶才触发请求,用户能看到列表底部空白;阈值太大则可能一下预加载好几页,浪费流量。

3.4 从卡片点击到播放器页的衔接

推荐卡片点击后要跳转到播放器页。我们最初实现是简单Navigator.push,传 videoId,播放器页自己再拉详情。后来发现一个问题:连续点不同卡片,返回时播放器页会短暂闪一下加载中的状态,体验很差。

改进方案是点击时先传完整的 Video 对象,播放器页先用已有数据渲染首帧,再异步拉取更详细的播放信息和推荐相似内容:

void _openPlayer(RecommendVideo video) { _playingVideoId.value = video.videoId; Navigator.of(context).push( MaterialPageRoute( settings: const RouteSettings(name: '/player'), builder: (_) => PlayerPage(initialVideo: video), ), ); }

如果之后页面跳转逻辑变复杂,再引入 go_router 也不迟。但要提醒一句:不要在小项目阶段就上路由框架,路由框架的过度封装在跨端适配时会变成额外的排错成本,尤其是 OpenHarmony 平台的 deep link 支持和映射规则还没那么成熟的时候。

4. 和 OpenHarmony 原生侧打交道:播放器的接入方式

4.1 先想清楚边界:Flutter 管 UI,原生管解码

推荐流 UI 可以全部跑在 Flutter 里,但播放器绝对不能。原因不只是性能,更关键的是 OpenHarmony 的媒体能力栈和 Android/iOS 完全不同。OpenHarmony 提供了媒体播放框架和硬件驱动接口层,底层走系统多媒体框架,这意味着如果你想绕过系统播放器自己解封装、喂帧、同步音画,接入成本会非常高。所以我们的选择是:Flutter 只负责把"播放请求"发出去,播放在原生侧完成,播放器的画面可以是一个原生 SurfaceView 也可以是 Flutter Texture,看具体设备性能选。

这里有两种常见的渲染路径。第一种是原生播放器自带 SurfaceView,通过 PlatformView 嵌入到 Flutter 层级里;第二种是原生解码后把帧回传给 Flutter Texture,由 Flutter 引擎统一合成。推荐模块里我强烈建议用 Texture 方案,因为 PlatformView 在列表页里会有严重的层级性能问题,尤其当视频卡片还在滚动中时,PlatformView 的 GPU 合成路径和滚动开销会互相拖累。

4.2 MethodChannel 的接口设计

平台通道的接口设计要遵循一个原则:薄而稳定。不要把业务逻辑写进通道里,通道只做最基础的播放器指令和状态回传。

我在 Dart 侧定义了一个很薄的封装:

class NativePlayerBridge { static const MethodChannel _channel = MethodChannel( 'com.example.player/recommend', ); static const EventChannel _eventChannel = EventChannel( 'com.example.player/recommend_events', ); static Future<void> openVideo({ required String videoId, required String title, required String coverUrl, required String playUrl, int position = 0, }) async { try { await _channel.invokeMethod('openVideo', { 'videoId': videoId, 'title': title, 'coverUrl': coverUrl, 'playUrl': playUrl, 'position': position, }); } on MissingPluginException { // 原生侧还没注册插件时给出明确提示 debugPrint('NativePlayerBridge: plugin not registered'); } on PlatformException catch (e) { debugPrint('NativePlayerBridge: ${e.code} ${e.message}'); } } static Stream<PlaybackState> playbackStateStream() { return _eventChannel .receiveBroadcastStream() .map((event) => PlaybackState.fromMap(event)); } }

对应的播放状态模型:

enum PlaybackStatus { preparing, playing, paused, buffering, completed, error, } class PlaybackState { final PlaybackStatus status; final int positionMs; final int durationMs; final String? errorCode; ... }

MethodChannel 的 method 列表我用一张表固定下来,方便两端对齐:

方法名方向参数说明
openVideoDart -> NativevideoId, title, coverUrl, playUrl, position打开并准备播放
playDart -> Native无继续播放
pauseDart -> Native无暂停
seekToDart -> NativepositionMs跳转
releaseDart -> NativevideoId释放播放器实例
onPlaybackStateNative -> Dartstatus, positionMs, durationMs状态变更事件

通道参数要尽量只传基础类型。有段时间我们图方便传了自定义对象,结果在 OpenHarmony 侧的序列化层直接翻车,排查了两天才发现是对象图里有循环引用,后来统一改成扁平 Map 就稳了。

4.3 OpenHarmony 侧的插件注册与通道实现

OpenHarmony 跑 Flutter 应用,实际用的是 OpenHarmony SIG 维护的 Flutter 适配分支。它的工程结构里,宿主 App 是一个标准 OpenHarmony 应用,Flutter 作为一个模块集成进去。项目里 source、feature 可以分别理解为原生入口和业务模块,插件注册入口和 Android 的 V2 embedding 很相似。

我在 OpenHarmony 侧做通道注册时,流程大概是:应用启动时初始化 Flutter engine,然后在 engine 的 plugin registrant 里把我这个NativePlayerBridgePlugin注册上,注册时挂接 MethodChannel 和 EventChannel。原生侧接到openVideo指令后,调用系统多媒体播放能力创建播放器实例,设置数据源,然后开始 prepare。

这里有一个经常被忽略的点:EventChannel 在 Flutter 侧是receiveBroadcastStream(),它是可多订阅的广播流。如果推荐页和播放器页同时订阅了播放状态事件,两个订阅者都会收到同一组事件。我在推荐页只关心"当前播放的视频是否还是推荐列表里的某一个",所以每个订阅都会做一次 id 匹配再决定是否刷新卡片状态,防止卡片上的"正在播放"角标张冠李戴。

4.4 真机适配和 XTS 认证视角下的注意事项

OpenHarmony 设备要上量,应用和设备的 XTS 认证绕不开。XTS 是一套兼容性测试套件,会从应用行为、接口兼容性、权限使用等维度做检测。我们在推荐模块上遇到的相关问题主要集中在生命周期处理上:播放器切后台后必须主动暂停并释放部分资源,切回前台恢复播放;后台长时间运行要避免媒体焦点冲突;申请存储和网络权限时必须走系统的权限弹窗,不能直接调用隐藏接口。

这些要求听起来像常识,但在原生播放器 + Flutter UI 组合下容易出 bug。原生播放器是系统多媒体组件,Flutter 引擎只是 UI 层,两端生命周期是各自独立管理的。我在工程里专门做了一个 LifecycleBridge,把 App 的前后台切换事件从原生侧通过通道转发给 Flutter,Flutter 再决定要不要暂停推荐页里的自动轮播和预加载任务。这一块做得不干净,XTS 的媒体相关用例和功耗用例很容易挂。

5. 上真机之后踩过的坑:渲染、内存、异步三座大山

5.1 Impeller 渲染后端在 OpenHarmony 上的表现

Flutter 3 之后陆续切到 Impeller 渲染后端,理论上渲染性能和 Skia 的锯齿问题都会改善。但 OpenHarmony 适配分支对 Impeller 的支持进度和原生 Android 并不同步,我们在第一批测试机上明显感觉到某些转场动画掉帧。

排查路径是这样的:先看是否 GPU 驱动能力不满足 Impeller 要求,再看是否因为设备使用软件渲染。我们最后在测试机上做了对比实验,用flutter run --dart-define=FLUTTER_FORCE_SKIA=true强制切回 Skia,动画掉帧问题就消失了。所以如果你的推荐模块在 OpenHarmony 设备上出现莫名的转场卡顿,先别急着优化业务代码,花半小时做一个渲染后端 AB 对比,往往就能定位问题。等到适配分支的 Impeller 成熟之后,再统一切回默认后端。

5.2 封面图内存膨胀:一个数字的教训

这是我这个项目里最值得讲的一个坑。推荐页有 20 个卡片,每个卡片封面原图 1080p,按 3x 密度解码之后大约占用 12MB 内存。20 个就是 240MB,连 Android 中端机都扛不住,OpenHarmony 的低内存设备直接 OOM。我们当时的解决策略有三板斧:

  • 所有封面统一用memCacheWidth: 360限宽解码,单图内存降到 1.5MB 左右;
  • Banner 大图和列表卡片图分别使用独立的缓存 key,Banner 缓存长期持有,卡片图只在滚动窗口内缓存;
  • 卡片滑出可视区很远后,主动从 ImageCache 里移除对应缓存的图片。

第三点容易做错,正确做法是在卡片 dispose 时通过imageCache.evict把这张封面移除,但要确认这张图没有其他页面还在使用。推荐页和播放器页会用同一张封面做过渡动画,所以播放器页持有期间不能 evict。

5.3 Future 微任务队列和"界面迟迟不刷新"问题

推荐页有个现象:点击关注按钮后,按钮上的文字要隔几百毫秒才变化,像是 UI 卡了。后来从引擎日志里发现,请求返回后then回调里套了一层又一层 Future,整个微任务队列被漫长的同步逻辑堵住了。

这里得说清楚 Dart 的事件循环模型。Dart 里 Future 的回调不是立刻执行,而是作为微任务排到队列尾部,当前同步代码全部执行完才会处理微任务队列。所以你在一个很重的同步方法里连续 await,中间没有让出事件循环,那后面的 UI 更新就会被推迟。Future.then回调确实是放进微任务队列的,这句话不假,但很多人忽略了微任务队列也需要事件循环有空闲才能被消费。

我们的修复方案是:把封面读取、缩略图生成这类 CPU 密集操作丢到compute或者 Isolate 里,主 Isolate 只保留轻量的状态更新;对于连续滚动触发的加载请求,用Stream加 debounce 合并,避免一瞬间往队列里塞十几个请求回调。修完之后,按钮响应和列表滚动都恢复正常了。

5.4 吓人的引擎报错:unhandled exception 的真相

开发期和灰度期都会看到这样的日志:

E/flutter (31173): [ERROR:flutter/runtime/dart_vm_initializer.cc(41)] Unhandled exception

第一次看到时我们非常紧张,以为是引擎崩了。后来把堆栈完整打出来才发现,绝大多数情况是两类:一类是MissingPluginException,说明原生侧没注册对应插件;另一类是事件通道里传进来的 Map 与 Dart 侧解析模型不匹配,最常见的是 null 值没兼容。定位这类问题有个笨但有效的办法:在FlutterError.onError里加一个统一的捕获入口,把异常堆栈和当时的页面路由、参数一起上报,别让异常在引擎层静默吞掉。

有个排查表格,我贴在这里供参考:

报错特征常见原因处理方向
MissingPluginException原生插件未注册或注册顺序错误检查 plugin registrant、确认 Flutter engine 初始化顺序
type 'Null' is not a subtype通道数据里有 null 字段模型解析统一用as String?再判空
MethodChannel 高频超时原生侧主线程被占播放器操作放入原生子线程,回调再切主线程
偶发 SIGSEGV低端机硬件解码崩溃播放内核加降级策略,部分设备强制软解或关闭倍速

5.5 下拉刷新和滚动冲突的那点事

推荐页用 RefreshIndicator 包着 CustomScrollView,本来很正常。但播放器页返回后,推荐页滚动位置会自动恢复到之前的位置,这时如果用户在接近顶部的位置执行下拉,RefreshIndicator 的触发距离和恢复滚动之间会打架,表现为"拉不动"或"一松手又弹回顶部"。

这个问题的根因是滚动恢复是异步的,而手势识别在对齐之前就已经开始。我们的解法很简单:推荐页在RouteAware的didPopNext回调里先等待一个帧再恢复滚动,同时给 CustomScrollView 的 physics 加一段保护逻辑,在滚动恢复完成前禁用下拉手势。

@override void didPopNext() { super.didPopNext(); WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted && _scrollController.hasClients) { _scrollController.jumpTo(_scrollController.offset); } }); }

如果你在真机上发现推荐页和一个含内嵌滚动区域的原生页面联动时总是卡,优先怀疑这种"滚动位置恢复"事件和手势竞技场的时序问题,而不是 Flutter 滚动组件本身有 bug。

6. 一些最终建议和个人体会

做完整套 Flutter × OpenHarmony 推荐模块之后,我最想说的反而是工程决策层面的事,而不只是代码。

第一,平台通道的接口要薄,而且要有 mock。我们在单元测试里用MethodChannel的 mock 全部替换成假数据,跑通推荐页的所有交互用例。这样就算原生播放器没就绪,Flutter 业务也能独立开发、独立测试,两端只要对着那几张通道表格开发就不会打架。

第二,OpenHarmony 的 adapter 版本和 Flutter 引擎版本必须锁死。我们踩过适配分支自己跟进的 engine 上游版本和线上 Flutter SDK 不一致,导致本地编译没问题、集成到 OpenHarmony 工程里就跑不起来的坑。现在 CI 里专门加了一步:每次升级 Flutter SDK,必须同时更新 OpenHarmony 适配分支的版本,并跑一遍 XTS 里和媒体相关的用例。

第三,做跨端前先想清楚"为什么是 Flutter"。我见过不少团队是听说 Flutter 能跨端,就把播放器整个迁进 Flutter,最后耗尽精力在地面硬件加速和帧同步上。正确的姿势是:UI 层使劲复用,能力层老实接入原生。这套组合在推荐视频模块上跑得很稳,但并不意味着所有模块都适合这么干。

这个项目做完后,我在团队分享时反复强调一句话:跨端方案的价值不在于"多写一份代码",而在于"少维护一份状态"。推荐流这类业务形态迭代快、页面状态复杂,恰恰是最值得用跨端框架承载的地方。如果你现在正好也在评估 Flutter 跑 OpenHarmony 适配,不妨也从推荐模块开始,先跑通通道,再谈优化。希望这篇文章能帮你把第一脚踩实。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询