ARTICLE DETAIL

资讯详情

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

Flutter在OpenHarmony上的音乐播放器首页实战:状态管理与跨端通信

Flutter在OpenHarmony上的音乐播放器首页实战:状态管理与跨端通信 这是《Flutter for OpenHarmony 音乐播放器 App 实战》系列的第三篇。前两篇我们把 Flutter 在 OpenHarmony 上的 SDK 环境搭好、工程跑通、基础路由也铺了但说实话那时候项目还只是一个能编译的壳子。这篇要做的是让用户真正打开 App 看到第一屏——首页实现。首页在音乐播放器这个场景里是最能提现整体工程质量的地方它不光是几个组件的堆叠还牵扯到状态管理、跨端通信、列表性能这些硬骨头而且这些都要在 OpenHarmony 这个相对较新的 Flutter 平台上跑稳。这篇我会把从设计拆解到代码落地的完整过程写出来特别是 Flutter 与 OpenHarmony 原生能力交互的部分适合正在搞 Flutter 鸿蒙化的同学做参考。1. 首页定位与整体结构设计1.1 用户打开 App 看到的第一个页面做音乐播放器首页之前我先把首页到底承担什么任务想清楚了。用户打开 App 的第一屏不是用来展示技术细节的而是要在三秒内回答三个问题今天有什么可以听、我上次听到哪了、怎么快速找到一个想听的歌。所以首页的布局沿用了行业里非常成熟的四段式结构顶部 AppBar 放产品名和搜索入口往下是运营推荐位Banner 轮播接着是推荐歌单卡片横向滚动区再往下是最近播放列表。底部再常驻一个迷你播放条显示当前正在播放的歌以及播放进度。这套结构的好处是信息密度高、扫视路径清晰而且每一块都对应着一个明确的用户行为。在设计稿阶段我纠结过要不要把首页拆成多个 Tab比如推荐、排行榜、歌手各一个页面。后来想清楚了音乐 App 首页的推荐位和歌单区本来就是一屏内容拆成 Tab 只会增加切换成本。OpenHarmony 上 Flutter 的页面栈性能还没有 Android 那么成熟能在一个页面里聚合的内容就不要让用户多一次路由跳转。1.2 页面结构选型单页多区块的取舍首页的页面骨架我选择了一个 Scaffold 管理整页body 内部用 ListView 纵向承载各个区块每个区块内部再各自横向滚动。为什么不用多页面拼接或者自由布局堆叠核心原因有两个。第一个原因是懒加载。ListView 对于不可见的区域不会主动 build这对首页这种内容多的页面来说很重要。如果我用 Column SingleChildScrollView 把所有区块一下子都构建出来Banner 轮播、歌单卡片、最近播放数十个 item 会全部参与布局计算首帧时间会明显拉长。第二个原因是统一的滚动体验。首页各区块之间的边界本来就是连续的用户一个手势从上滑到下中间不需要因为区块切换而打断滚动节奏。这里有个容易踩的坑底部迷你播放条不能放在某个页面内部。因为用户从首页切到搜索页、再进入播放详情页播放条都应该始终存在。我把整个 App 的根级结构设计成 Scaffoldbody 用 Column 分成两块上面是 Expanded IndexedStack 管理的各个 Tab 页面栈下面固定放迷你播放条组件。IndexedStack 在这里的价值是切换 Tab 时不销毁页面状态首页的滚动位置、Banner 的轮播状态都能保留不会出现切走再切回来就回到顶部的尴尬。1.3 状态管理与数据流规划首页涉及的状态可以分成两类页面自身的数据加载状态以及全局的播放器状态。数据加载状态我用 Provider 包了一层 HomeViewModel负责管理 Banner 列表、歌单列表、最近播放列表的加载、刷新和异常处理。全局播放器状态单独用一个 PlayerController 管理它是整个 App 的顶层 Provider因为不只首页要用后续的播放详情页、搜索页都要监听它。选 Provider 而不是 Bloc 或者 Riverpod我的理由是对 OpenHarmony 这种需要快速验证的平台来说Provider 依赖最小、心智负担最低而且 ChangeNotifier 的监听机制玩透了后面换任何状态管理框架都顺。PlayerController 内部直接订阅 EventChannel 传来的原生播放状态流把收到的状态转成 UI 可以直接消费的 PlaybackStatus 对象再 notifyListeners 通知所有监听者刷新。数据流向是单向的原生播放引擎 → EventChannel → PlayerController → UI上层永远只消费控制器的输出不反向去查询原生状态。2. 数据模型与 OpenHarmony 侧通信2.1 先定模型三个核心数据类首页要展示的内容看起来多抽象成数据模型其实就三类歌曲、歌单、Banner 运营位。写 UI 之前我先把这三个模型定死在代码里因为后续的 Mock 数据、真实 API、事件通道监听都要围绕这个结构展开。Song 模型是最基础的字段包括 id、标题、歌手、封面 URL、时长。歌单模型包含 id、名称、封面图和歌曲数量。Banner 模型相对简单就是 id、图片地址、跳转目标。这里我坚持了三个原则字段类型全部显式声明、构造函数用 const、所有字段设为 final。const 构造函数在 Flutter 里是一个免费的优化允许组件重建时跳过重复创建final 保证模型不可变状态管理的时候不容易出现诡异的引用问题。class Song { final String id; final String title; final String artist; final String coverUrl; final int duration; const Song({ required this.id, required this.title, required this.artist, required this.coverUrl, required this.duration, }); } class Playlist { final String id; final String name; final String coverUrl; final int songCount; const Playlist({ required this.id, required this.name, required this.coverUrl, required this.songCount, }); }我建议大家在正式写页面之前把这几个 model 文件的单测也顺手写了。OpenHarmony 开发中最头疼的问题就是运行期才发现类型不匹配Dart 的强类型给了编译期保护但字段缺失还是要靠测试兜底。我现在每个 model 配一个简单的测试用例成本很低后面对接真实接口时心里踏实很多。2.2 Mock 数据与 Repository 抽象首页的数据在现阶段还没有真实后端所以我用 Mock 数据顶上。但为了将来切换到真实 API 时页面代码一行不动我在数据层做了一层 Repository 抽象。页面和 ViewModel 只依赖抽象接口不关心数据来自本地 JSON、内存硬编码还是 HTTP 请求。abstract class HomeRepository { FutureListBannerItem fetchBanners(); FutureListPlaylist fetchPlaylists(); FutureListSong fetchRecentSongs(); }Mock 实现很简单每个方法延迟 300 到 800 毫秒返回固定数据这个延迟不是随便给的是为了模拟网络耗时让首页的加载态和骨架屏有真实的展示机会。后面接真实后端时我再写一个 NetworkHomeRepository 实现同样的接口把替换点收敛在 Provider 初始化的位置。这里有一个 OpenHarmony 平台特有的细节如果将来从 Mock 切换成真实网络请求记得在 module.json5 里声明 INTERNET 权限。OpenHarmony 的权限机制和 Android 类似但又不完全一样网络权限不声明的话Image.network 加载图片会直接失败而且报错信息有时候很隐晦。我在开发阶段就先把这个权限加上了避免后面排查半天发现是权限问题。2.3 EventChannel 实时接收播放状态这是首页实现里跟 OpenHarmony 平台结合最紧密的一个环节。音乐播放器里真正负责解码、控制音量的 AVPlayer 实例跑在 OpenHarmony 原生侧Flutter 侧只是 UI。那么问题来了用户在首页点了播放按钮原生侧开始放歌后Flutter 怎么知道歌曲进度走到哪了答案是 EventChannel。它是 Flutter 和原生之间专门用于单向持续事件流的通道特别适合这种原生往 Flutter 推状态的场景。我在工程里封装了一个 PlayerStatusChannelclass PlayerStatusChannel { static const EventChannel _statusChannel EventChannel(com.example.music/player_status); static StreamMapObject?, Object? get statusStream { return _statusChannel.receiveBroadcastStream(); } }原生侧在 AVPlayer 状态变化、进度更新的回调里把这些信息封装成 Map 往 Flutter 推Flutter 侧收到后解析成 PlaybackStatus 对象。进度更新大概每 500 毫秒推一次这个频率既能让进度条动画看起来流畅又不会因为频繁跨端通信带来性能压力。有人会问为什么不用 MethodChannel 定期查询因为查询模式是拉需要 Flutter 主动问轮询延迟高、浪费资源EventChannel 是推原生状态一变就通知实时性和资源开销都更优。但控制命令暂停、下一首恰好相反它们是 Flutter 主动发起的所以用 MethodChannel 调原生方法。一张一收两条通道各管各的这是 Flutter 跨端通信的标准姿势。3. 首页三大区块实战实现3.1 Banner 轮播不引三方库自己写网上有现成的轮播库比如 carousel_slider功能很丰富但我最后还是决定自己写一个 Banner 组件。原因很直接这些轮播库在 OpenHarmony 上没有被大量验证过翻源码发现它们往往依赖一些跟平台相关的行为比如手势冲突处理、RenderObject 的特定假设在 Android/iOS 上没问题换到 OpenHarmony 可能就出现莫名的卡顿或触摸失效。自实现轮播的核心逻辑其实不复杂。用 PageView.builder 加载图片页外面套一个 Timer.periodic 定时器每隔 5 秒调用 nextPage 方法下面再放一排指示器小圆点用当前页码控制高亮。关键点在于定时器的生命周期管理我手动绑定到了 AppLifecycle 上页面可见时启动不可见或者页面销毁时取消避免 App 切后台后定时器还空转也避免 PageView 销毁后 onPageChanged 报错。class HomeBanner extends StatefulWidget { const HomeBanner({super.key, required this.items}); final ListBannerItem items; override StateHomeBanner createState() _HomeBannerState(); } class _HomeBannerState extends StateHomeBanner { final PageController _pageController PageController(); Timer? _timer; int _currentPage 0; void _startAutoPlay() { _timer?.cancel(); _timer Timer.periodic(const Duration(seconds: 5), (timer) { if (!_pageController.hasClients) return; final next (_currentPage 1) % widget.items.length; _pageController.animateToPage( next, duration: const Duration(milliseconds: 400), curve: Curves.easeInOut, ); }); } override void dispose() { _timer?.cancel(); _pageController.dispose(); super.dispose(); } // 省略 build 中的指示器与 PageView.builder }顺便说一句Banner 图片如果来自网络在这个阶段我会先不急着加各种 fade 效果用一个简单的 loading 占位色块就够让首页先把路径跑通。视觉细节可以等真实运营图到位之后再打磨架构上不影响。3.2 推荐歌单与最近播放列表推荐歌单和最近播放列表看起来都是一排卡片但实现方式有细微差别。歌单区我用的是横向滚动列表。每个卡片是一张 2:3 或 1:1 的封面图加歌单名为了让一屏里能看到 3 到 4 个歌单卡片宽度设计为屏幕宽度的 0.42 倍。横向列表用 SizedBox 固定高度包着 ListView.builder指定 scrollDirection 为 Axis.horizontal。这里要注意不能在纵向 ListView 里直接嵌套另一个纵向 ListView 或者把子列表的 shrinkWrap 设为 true。横向套纵向天然安全因为两个轴不冲突但 shrinkWrap 会让子列表把所有 item 都布局出来等于把懒加载卖掉了首页会明显卡。最近播放列表同理用横向滚动的歌曲小卡片来展示。这个区块的封面图需要是圆角矩形CRI 上我会在加载成功后加一个 AnimatedOpacity 的透明度过渡让图片不是突然蹦出来的。区块之间我用一个 Padding 按 16:10 的比例留出纵向间距标题行左侧放区块名右侧放一个更多入口。这个更多入口我这一版先做成跳到一个占位页但路由已经在第二篇铺好了所以改动很小。3.3 底部迷你播放条页面级的全局组件迷你播放条是首页实现里面最容易做砸的部分。很多人在首页里平铺一个 Container 放播放条切到别的页面就没了。我前面提到过这个组件放在根级 Scaffold 的 Column 底部所以天然全局存在。播放条的 UI 结构是左侧封面图40 到 48 像素中间一列显示歌名和歌手名右侧两个按钮一个收藏、一个播放/暂停。底部还有一条超过整宽的细进度条用当前播放进度除以总时长计算进度比例。播放按钮的状态不能自己想当然要监听 PlayerController。PlayerController 内部订阅了前面写的 EventChannel 流所以原生侧播放器的状态一变播放条的按钮图标和进度条就跟着变。动画方面播放暂停切换时我给按钮加了一个 AnimatedSwitcher 的缩放切换效果封面图在播放状态下会缓慢旋转用 AnimationController 驱动 20 秒转一圈做一个简单的角度变化。有一个真实遇到过的性能问题进度条如果用 StreamBuilder 每秒重建整个 Container播放条会闪烁且浪费资源。我的做法是把进度更新收敛到 PlayerController 内部用 ValueNotifier 保存相对进度值只有真正发生变化才通知这样进度条组件重度重建的频率会降下来。在 OpenHarmony 模拟器上这个优化感知特别明显帧率直接从 40 多帧恢复到 60 帧。4. 下拉刷新、骨架屏与滚动性能4.1 RefreshIndicator 下拉刷新实现音乐 App 的首页内容更新频率不低推荐位和歌单经常要换所以下拉刷新是刚需。Flutter 官方提供了 RefreshIndicator直接用就可以但有两个细节坑要处理好。第一个坑是 RefreshIndicator 要求 child 必须是可滚动组件如果直接包一个空内容的 ListView在内容不满一屏时下拉手势不会触发。解决方案是给 ListView 加一个 physics: AlwaysScrollableScrollPhysics()强制它在任何情况下都可以滚动。第二个坑是刷新逻辑要并发而不是串行。首页的三个数据区块Banner、歌单、最近播放是相互独立的串行拉取会让刷新动画转太久。我用 Future.wait 把三个请求并发出去等全部完成后再更新 UI。RefreshIndicator( onRefresh: () _viewModel.refreshAll(), child: ListView( physics: const AlwaysScrollableScrollPhysics(), children: [ // Banner 区块 // 歌单区块 // 最近播放区块 ], ), )_viewModel.refreshAll 内部就用 Future.wait 并发执行三个 Repository 方法。刷新过程中如果某一个接口失败我暂时先打个日志等后续版本再引入全局的错误提示组件。在 OpenHarmony 上我实测 RefreshIndicator 的触发灵敏度和动画在模拟器上表现正常但真机上建议把拖拽距离调大一点因为部分设备的触摸采样率和 Android 有差异。4.2 骨架屏提升首屏感知首页数据从请求到渲染中间有一段加载时间我用了骨架屏而不是转圈菊花用户感知会好很多。最简单的骨架屏实现是一组灰色圆角矩形按照真实布局的位置和尺寸摆放用 AnimatedOpacity 做透明度循环模拟加载波动的效果。我没有引入 shimmer 库这类物理动画库在 OpenHarmony 上的 shader 兼容性没有保障一旦渲染异常就是整片白花花的 bug排查起来很痛苦。自己写骨架屏的成本非常低三个区块各自对应一个占位组件数据到达后替换为真实组件加一个 AnimatedSwitcher 做 200 毫秒的淡入淡出过渡。还有个经验是基于数据加载阶段本身做文章。即使接入了真实网络也建议在 Repository 里维护一个本地缓存优先策略先把上次缓存的数据交给页面渲染等新数据回来再静默替换。这样用户第二次打开首页几乎是秒开根本感知不到网络耗时。这个策略我是在 Mock 阶段就在 Repository 接口层面规划好了后面实现成本不大。4.3 滚动性能的三个优化点首页滚动的流畅度直接决定用户的第一印象。我做了三个优化每一个都值得展开说说。第一所有静态组件加 const 构造。Banner 的指示器小圆点、区块标题、封面占位框这些不依赖运行时的组件全部声明为 const让 Flutter 在 widget 重建时直接复用已有的 Element 树节点。第二给纵向列表统一设置 itemExtent。首页虽然是一个 ListView 多个子区块的结构但内部其实也有很多子列表。对子列表里固定高度的卡片我设置了 itemExtent 等于卡片高度。这样列表滚动时可以直接通过索引计算高度不需要动态测量每个 item 的尺寸在 OpenHarmony 模拟器上能明显看到 build 次数下降。第三控制网络图片的加载策略。首页图片多我不让每张图都立刻全量加载而是用 ImageCache 配合合理的宽高设置。封面图统一按实际显示尺寸的 2 倍做 targetWidth 和 targetHeight避免加载原图大图。这个优化在图片数量多的时候差异显著内存占用能少一半以上。5. 避坑实录OpenHarmony 适配手记5.1 三方库兼容性的排查思路之前一直有人问我Flutter 生态里那么多现成的库能不能直接拿来在 OpenHarmony 上用。我的建议是先排查再使用而不是装了才发现不行。排查思路分三步先看这个库是否依赖了 platform channel再看依赖链里有没有 FFI 或者原生代码最后去项目的 pubspec.yaml 里扫一眼有没有指向原生 SDK 的引用。比如常见的缓存图片库它的底层会依赖本地文件读写插件这类插件在 OpenHarmony 上如果没有对应的原生实现运行时就会抛 MissingPluginException。遇到这种情况优先在 OpenHarmony 生态里找替代的插件找不到就自己包一层薄的原生插件。只要你把能力边界限定在图片缓存这种低耦合场景封装成本其实不高。我在这篇首页实现里刻意控制了第三方依赖的数量除了 Provider 这样的纯 Dart 状态管理库网络、存储、播放相关的能力全部封装在自研代码里。这样做的直接好处是任何一个环节出问题我都能顺着代码定位到具体的 OpenHarmony API 调用而不是在黑盒库的报错堆栈里猜。5.2 PlatformView 能不用就先别用PlatformView 是 Flutter 用来嵌入原生视图的机制比如你想在页面里直接放一个 OpenHarmony 原生控件就需要通过它。在 OpenHarmony 上对应的实现叫 UIKitView适配版 Flutter 引擎已经支持了。但我的态度是能不用就先别用。原因很简单首页这样的高频滚动页面如果嵌入了原生视图每次列表滚动、页面切换时都要做 Flutter 和原生两个渲染体系的同步帧开销和触摸事件的分发都存在不确定性。我在验证阶段试过把一个原生搜索框嵌进来结果滚动时原生视图明显比 Flutter 层慢半拍果断放弃了。音乐 App 首页基本没有必须用原生视图的场景Banner、歌单、文字Flutter 自绘都能做得很好。如果你真要嵌入建议提前关注 PlatformView 的混合模式参数并且在真机上反复测滚动帧率和触摸响应不要在模拟器上验证了就当稳了。5.3 XTS 认证与权限声明OpenHarmony 应用如果要上架应用市场或者跑官方兼容性测试会走 XTS 认证流程。这个流程会重点检查权限声明和实际调用是否匹配。首页这边涉及的就是网络权限和可能的存储读取权限都要在 module.json5 文件里明确声明。我见过不少项目在开发阶段跑得挺好一提交 XTS 认证就挂很多是权限声明不完整。比如 Flutter 侧用 Image.network 加载网络图片但原生层没有声明 INTERNET 权限图片加载在 Flutter 层表现为静默失败XTS 却会直接上报权限缺失。这种跨层排查的问题是最耗时间的开发期就把权限清单列清楚是最好的习惯。另外如果首页将来接入真实推荐接口涉及用户画像之类的信息要注意隐私合规的弹窗说明。这一块属于产品层面的工作但从技术侧要提前留好埋点位不要在认证前临时加需求容易把链路搞乱。5.4 杂项字体、深色模式与安全区字体方面Flutter 默认字体在 OpenHarmony 上中文显示正常但不同设备的默认字重和渲染效果可能不一致。为了保持统一的视觉效果我在全局 ThemeData 里指定了字体族首选项是 HarmonyOS Sans这个字体的授权对开发场景友好。没有鸿蒙字体文件时就回落到系统默认用 fontFamilyFallback 兜底。深色模式是很多开发者的盲区。首页的高亮色、背景色、卡片色如果不针对深色主题做适配在深色模式下会非常刺眼。我基于 ThemeData.brightness 判断当前模式背景色和文字色都通过主题色板取用而不是在组件里写死颜色。这样深色模式切换后首页整体表现是协调的不会出现一片白块。安全区适配主要是给播放条留出底部空间。OpenHarmony 设备有不同类型有的是悬浮手势条有的是实体导航栏我用 MediaQuery.viewPaddingOf 判断并给播放条加对应的 bottom padding。这个问题不解决设备上播放条会被系统导航区域挡住一半点击区域变小而且很难看。6. 这一篇做完了下一站在哪6.1 首页的整体效果与测试清单首页实现完我在 DevEco Studio 的模拟器上完整跑了一遍整体效果符合预期。启动 App 后首屏不是白屏3 秒内完成 Mock 数据加载Banner 开始自动轮播下拉手势可以触发刷新点击播放条按钮原生侧 AVPlayer 会真实响应对应的音频操作首页切走再回来滚动位置和播放状态都在。记录一下我自己使用的测试清单大家在实现同类页面时可以照抄验证测试项验证点预期结果首屏加载冷启动后首页展示用时3 秒内出现骨架屏并进入可交互状态轮播自动播停留首页 10 秒Banner 自动切换至少 2 次指示器同步下拉刷新从顶部下拉并松手出现刷新动画数据区重新加载播放状态同步点击播放按钮播放条按钮变暂停态进度条开始移动切 Tab 状态保持首页切到其他 Tab 再切回滚动位置不变轮播继续从原位播放深色模式切换系统深色模式首页背景、卡片、文字颜色协调安全区遮挡在带手势条设备上查看首页底部播放条完整显示不被遮挡6.2 后续页面的联动规划首页做完之后项目里的页面骨架已经立起来了。下一篇我打算先把播放详情页做出来这一步会真正把 EventChannel 和 MethodChannel 的能力用满播放页需要展示当前歌曲的大图、歌词滚动、播放进度拖动还要把上一首、下一首的控制命令正确发送到原生侧。歌词文件从哪来、怎么同步解析这块本身可以单独写两篇。搜索页也是首页 AppBar 上预留了入口就等实现的模块核心是搜索结果的实时展示和搜索历史的本地存储这会涉及 Flutter 和 OpenHarmony 的本地存储能力交互又是一个适配点。再往后就是对接真实后端 API把首页的 Mock Repository 换成 HTTP 实现同时引入登录态和用户歌单。这一系列文章从环境搭建到现在已经实现了从能编译到能展示的跨越。我个人做完整套下来最大的感受是在 OpenHarmony 上做 Flutter不是简单地把 Android 或 iOS 代码复制一遍而是每个功能点都要重新过一遍平台能力清单——能不能用三方库、权限有没有声明、原生通道通不通、渲染引擎支不支持。这套首页实现里我频繁用到的排查顺序是先跑最小 Demo 验证平台能力再套进真实业务里看性能最后才是调细节视觉。你按这个顺序去做鸿蒙上的 Flutter 页面踩坑的概率能降一半。
返回列表