ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Flutter适配OpenHarmony:推荐视频模块从设计到真机的完整实践

Flutter适配OpenHarmony:推荐视频模块从设计到真机的完整实践 如果你正在做视频类 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(MapString, 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); FutureListRecommendVideo 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; } FutureListRecommendVideo fetchNextPage(int page) async { return _api.fetchRecommend(page: page, pageSize: _pageSize); } }这样设计的理由很实际首屏能不能秒开往往决定推荐模块的留存。第一次进页可能要走网络但之后每次冷启动都先出缓存、再在后台刷新用户体感会好很多。缓存只放第一页翻页数据不缓存避免覆盖图 URL 失效导致老图片一直占存储。2.2 状态管理与组件通信推荐流里的组件通信场景很多Banner 轮播要告诉页面当前是第几张视频卡片被移出可视区时要停止预加载下拉刷新完成后要通知列表重置。我在这个项目里用的是状态管理三件套ChangeNotifier 管列表状态ValueNotifier 管局部大图状态controller 管滚动和播放器联动。推荐页主体状态用一个 ChangeNotifier 子类维护class RecommendFeedState extends ChangeNotifier { ListRecommendVideo _videos []; int _page 1; bool _loading false; bool _hasMore true; String? _errorMessage; ListRecommendVideo get videos List.unmodifiable(_videos); bool get isLoading _loading; bool get hasMore _hasMore; String? get errorMessage _errorMessage; Futurevoid 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(); } } Futurevoid 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 帧。后来改成ValueNotifierString? _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 ListViewBanner 和运营位会被迫一次性全部 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 ListRecommendVideo 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 Futurevoid 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 StreamPlaybackState 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 的前后台切换事件从原生侧通过通道转发给 FlutterFlutter 再决定要不要暂停推荐页里的自动轮播和预加载任务。这一块做得不干净XTS 的媒体相关用例和功耗用例很容易挂。5. 上真机之后踩过的坑渲染、内存、异步三座大山5.1 Impeller 渲染后端在 OpenHarmony 上的表现Flutter 3 之后陆续切到 Impeller 渲染后端理论上渲染性能和 Skia 的锯齿问题都会改善。但 OpenHarmony 适配分支对 Impeller 的支持进度和原生 Android 并不同步我们在第一批测试机上明显感觉到某些转场动画掉帧。排查路径是这样的先看是否 GPU 驱动能力不满足 Impeller 要求再看是否因为设备使用软件渲染。我们最后在测试机上做了对比实验用flutter run --dart-defineFLUTTER_FORCE_SKIAtrue强制切回 Skia动画掉帧问题就消失了。所以如果你的推荐模块在 OpenHarmony 设备上出现莫名的转场卡顿先别急着优化业务代码花半小时做一个渲染后端 AB 对比往往就能定位问题。等到适配分支的 Impeller 成熟之后再统一切回默认后端。5.2 封面图内存膨胀一个数字的教训这是我这个项目里最值得讲的一个坑。推荐页有 20 个卡片每个卡片封面原图 1080p按 3x 密度解码之后大约占用 12MB 内存。20 个就是 240MB连 Android 中端机都扛不住OpenHarmony 的低内存设备直接 OOM。我们当时的解决策略有三板斧所有封面统一用memCacheWidth: 360限宽解码单图内存降到 1.5MB 左右Banner 大图和列表卡片图分别使用独立的缓存 keyBanner 缓存长期持有卡片图只在滚动窗口内缓存卡片滑出可视区很远后主动从 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 适配不妨也从推荐模块开始先跑通通道再谈优化。希望这篇文章能帮你把第一脚踩实。
返回列表