在 OpenHarmony 设备上跑 Flutter 这件事,过去两年从我第一次调通环境到现在,身边问的人越来越多。坦白讲,这个组合早期多少带点“硬凑”的意味,但最近做音乐播放器项目的歌单详情模块时,我是真心觉得这套技术栈能干活了。这篇文章就把我在 Flutter for OpenHarmony 上实现歌单详情页的完整过程拆开讲:涉及页面怎么拆、状态怎么管、列表怎么优化,以及最关键的怎么让音频真正通过 OpenHarmony 的媒体服务播出来。如果你正准备在 OpenHarmony 上用 Flutter 做应用,或者已经在做但被各种适配问题卡住,这份实战记录应该能帮你省下不少排查时间。
1. 为什么要在 OpenHarmony 上跑 Flutter
先说几句背景。OpenHarmony 的应用生态目前有两个主流路线:一是用 ArkUI 加原生能力写应用,完全走系统自己的技术栈;二是通过自带 Flutter 引擎的交叉编译方案,把现有的 Flutter 代码库直接跑在 OpenHarmony 设备上。我这次选后者,不是因为它更高级,而是因为它能解决一个很现实的问题:团队本来就有成熟的 Flutter 业务代码,如果全部迁到 ArkUI 重写,歌单详情这种页面虽然不复杂,但涉及列表滚动、封面加载、播放状态联动,一整套交互逻辑重写的成本让人头疼。能复用 Flutter 的组件体系和状态管理,直接跑在 OpenHarmony 上,投入产出比要高得多。
具体到技术实现,OpenHarmony 对 Flutter 的支持主要靠自维护的 Flutter 引擎分支和对应的 SDK 适配层。这套东西和标准 Flutter 框架基本同源,开发时你写的还是 Dart,用的还是 Widget,只是构建产物和运行时依赖换成了 OpenHarmony 的版本。实际开发中的体感是:大部分 UI 代码可以直接迁移,但涉及系统能力——比如音频播放、通知栏控制、权限申请——就必须通过 OpenHarmony 提供的平台通道或者媒体服务接口来完成了。换句话说,界面层很爽,系统层要下功夫。
这个组合适合谁?首先是手里已经有一套 Flutter 代码、想在 OpenHarmony 设备上快速落地的个人开发者或小团队。其次是做一些轻量级工具、内容浏览类应用的人,这类场景对系统能力的依赖不深,Flutter 的优势能充分发挥。至于要重度调用系统硬件能力、对性能要求极其苛刻的场景,我还是建议老老实实用 ArkUI 加原生实现,没必要为了技术统一而牺牲体验。
2. 歌单详情的功能拆解与数据建模
2.1 页面该有哪些东西
歌单详情页在音乐类应用里属于标准功能页,核心职责就三块:展示歌单封面和标题信息、呈现歌曲列表、支持点击歌曲切换到播放。听起来简单,实际做起来涉及的数据关联和交互状态比想象中多。
我按功能把页面划分成这几个模块:顶部封面区(包含歌单封面图、标题、歌曲数量、总时长、收藏/分享入口)、歌曲列表区(按序号排列的歌曲信息行,通常显示歌名、歌手、专辑、时长,行尾是更多操作按钮)、底部迷你播放条(显示当前播放的歌曲信息,点击可展开全屏播放页)、以及下拉刷新和上拉加载这类列表增强交互。这次我聚焦的是数据层和列表实现,封面区、迷你播放条这些可以参照我之前那篇播放器骨架文章的思路来设计,这里不再重复讲了,重点讲歌单详情页特有的数据建模和列表性能。
2.2 数据模型怎么设计才能支撑整页交互
歌单详情的核心数据是一个歌单对象和一组歌曲对象。在 Flutter 里,我不建议把字段摊开用一堆 Map 或者裸 JSON 传来传去,一是类型不安全,改字段时容易踩坑;二是后期加排序、筛选功能时没有明确的类型约束,代码很快就烂掉了。我建议为歌单详情定义两个实体类。
class Playlist { final String id; final String title; final String coverUrl; final String description; final int songCount; final int totalDuration; // 秒 final bool subscribed; final String creatorName; final String creatorAvatar; Playlist({ required this.id, required this.title, required this.coverUrl, this.description = '', this.songCount = 0, this.totalDuration = 0, this.subscribed = false, this.creatorName = '', this.creatorAvatar = '', }); } class Song { final String id; final String name; final String artist; final String album; final int duration; // 秒 final String url; final bool isPlaying; final bool isFavorite; Song({ required this.id, required this.name, required this.artist, this.album = '', this.duration = 0, required this.url, this.isPlaying = false, this.isFavorite = false, }); }模型定义里有两个字段很容易被忽略:totalDuration(歌单总时长)和url(歌曲播放地址)。前者在歌单封面区要展示,后者是点击列表行时播放器要用的关键数据。我在设计时把 URL 直接放在 Song 里,避免播放前再根据歌曲 ID 二次请求,这个小决策在后续做列表点击播放时省了不少事。isPlaying是我刻意加到模型里的“展示状态”——它不该作为播放器的全局状态唯一来源,但歌曲行需要知道当前行的播放状态来决定是否高亮,后面讲状态管理时会细说。
2.3 数据仓库层的取舍
数据来源上,歌单详情页通常需要请求歌单信息和歌曲列表,这两个接口可以合并也可以拆分。我习惯用 Repository 模式包一层,页面和状态管理层只依赖 Repository 接口,不关心数据是来自本地 JSON、服务端接口还是缓存。
我给这个页面设计的仓库接口大致是这样:
abstract class PlaylistRepository { Future<Playlist> fetchPlaylist(String playlistId); Future<List<Song>> fetchSongs(String playlistId, {int offset = 0, int limit = 50}); Future<void> subscribePlaylist(String playlistId, {required bool subscribed}); Future<void> toggleFavorite(String songId, {required bool favorite}); }分页参数offset和limit我一开始没加,后来列表做到分页加载时才意识到仓库层必须从最早就把分页语义设计进去,不然后面改接口签名会牵连一堆调用点。另外,接口返回的歌曲数可能和歌单里的songCount不一致,UI 展示以songCount为准,列表以实际返回的歌曲数据为准,这两个概念最好不要混在一起,否则后期数据对不上时排查成本很高。
3. 状态管理选型与数据流设计
3.1 为什么这个场景我不建议用 setState
单人维护的小页面用setState确实简单,但歌单详情页有一个特殊之处:歌曲行的播放状态和底部迷你播放条、播放器后端状态是联动的,而页面本身又有页面级 UI 状态(加载中、错误、刷新中)。如果所有状态都用setState塞在页面 State 里,页面会迅速膨胀成一个大杂烩,而且歌曲行组件化后,子组件想修改父组件的状态就会被迫传一堆回调,代码写起来非常负担。
这次我用了 Provider 加 ChangeNotifier 的组合,这也是 Flutter 社区最主流的轻量状态方案。用 Provider 组织两个核心模型:PlaylistDetailStore负责歌单详情页的业务状态,包括歌单信息、歌曲列表、加载状态、分页控制;PlayerStore负责播放器状态,包括当前播放歌曲、播放暂停状态、进度信息。歌单详情页是 PlayerStore 的消费者,但不会直接改它的内部状态,只会调用播放器提供的方法,比如playSong(song),这种单向数据流让问题排查变得很清晰。
3.2 用代码说明状态模型的核心逻辑
PlaylistDetailStore的核心实现骨架如下,关键是加载状态机的设计:
enum DetailLoadStatus { initial, loading, success, error, loadMore } class PlaylistDetailStore extends ChangeNotifier { PlaylistDetailStore(this._repository); final PlaylistRepository _repository; Playlist? _playlist; List<Song> _songs = []; DetailLoadStatus _status = DetailLoadStatus.initial; String? _errorMessage; bool _hasMore = true; int _offset = 0; static const int _pageSize = 50; Playlist? get playlist => _playlist; List<Song> get songs => _songs; DetailLoadStatus get status => _status; bool get hasMore => _hasMore; Future<void> loadDetail(String playlistId) async { _status = DetailLoadStatus.loading; notifyListeners(); try { final results = await Future.wait([ _repository.fetchPlaylist(playlistId), _repository.fetchSongs(playlistId, offset: 0, limit: _pageSize), ]); _playlist = results[0] as Playlist; _songs = results[1] as List<Song>; _offset = _songs.length; _hasMore = _songs.length >= _pageSize; _status = DetailLoadStatus.success; } catch (e) { _status = DetailLoadStatus.error; _errorMessage = e.toString(); } notifyListeners(); } Future<void> loadMore() async { if (_status == DetailLoadStatus.loadMore || !_hasMore) return; _status = DetailLoadStatus.loadMore; notifyListeners(); try { final more = await _repository.fetchSongs( _playlist!.id, offset: _offset, limit: _pageSize, ); _songs.addAll(more); _offset += more.length; _hasMore = more.length >= _pageSize; _status = DetailLoadStatus.success; } catch (e) { _status = DetailLoadStatus.success; } notifyListeners(); } Future<void> toggleSubscribe() async { if (_playlist == null) return; final target = !_playlist!.subscribed; // 乐观更新:先改 UI,请求失败再回滚 _playlist = Playlist( id: _playlist!.id, title: _playlist!.title, coverUrl: _playlist!.coverUrl, subscribed: target, songCount: _playlist!.songCount, totalDuration: _playlist!.totalDuration, ); notifyListeners(); try { await _repository.subscribePlaylist(_playlist!.id, subscribed: target); } catch (e) { // 回滚 _playlist = Playlist( id: _playlist!.id, title: _playlist!.title, coverUrl: _playlist!.coverUrl, subscribed: !target, songCount: _playlist!.songCount, totalDuration: _playlist!.totalDuration, ); notifyListeners(); } } }很多初次接触状态管理的朋友会觉得代码绕,其实关键点就两个:一是用枚举明确表达页面当前处于哪个加载阶段,UI 根据枚举去渲染骨架屏、错误页还是列表;二是“乐观更新”策略——用户点击收藏按钮时先立刻改变 UI,请求失败再悄悄回滚,体验上比一直转圈好得多。这个小技巧同样用在了下一节要讲的歌曲收藏按钮上。
PlayerStore 我这里就不展开写了,核心就一个playSong(Song song)方法。页面点击歌曲行时会先获取当前点击的 Song 对象,交给 PlayerStore 播放,然后把这个 Song 标记为正在播放的行。注意:Song.isPlaying的更新发生在 PlayerStore 播放成功后,避免用户快速连点多个歌曲时状态闪烁。
4. 歌单详情页 UI 实现:列表的组装与性能
4.1 页面骨架层要注意的滚动结构
歌单详情的页面结构用 CustomScrollView 组装最灵活,因为顶部封面区要跟随列表一起滚动,而底部迷你播放条要固定悬浮。CustomScrollView配合SliverAppBar可以实现封面区折叠效果,这是音乐播放器经典交互,体验好实现也不算复杂。
CustomScrollView( slivers: [ SliverAppBar( expandedHeight: 280, pinned: true, leading: IconButton( icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.of(context).pop(), ), actions: [ IconButton( icon: Icon( store.playlist?.subscribed == true ? Icons.favorite : Icons.favorite_border, ), onPressed: () => context.read<PlaylistDetailStore>().toggleSubscribe(), ), const IconButton( icon: Icon(Icons.more_vert), onPressed: null, ), ], flexibleSpace: FlexibleSpaceBar( title: Text(store.playlist?.title ?? '歌单详情'), background: _PlaylistHeader( playlist: store.playlist, ), ), ), SliverPadding( padding: const EdgeInsets.all(16), sliver: SliverList( delegate: SliverChildBuilderDelegate( (context, index) => _SongListTile( song: store.songs[index], index: index, ), childCount: store.songs.length, ), ), ), ], )用SliverList而不是简单ListView来解决歌单数量大的问题很关键。我测试过几百首歌的列表,SliverList的 lazy 构建机制能保证只有可见区域的行被构建,内存占用和滚动性能都好很多。如果你直接在一个 ListView 里嵌套 Column 加封面再加歌曲行,列表一长滚动就会卡顿掉帧。
4.2 歌曲行组件怎么实现点击播放和收藏
歌曲行是页面里复用最频繁的组件,理论上几百行也得保持流畅。我的做法是坚持组件本身用const构造,配合SliverChildBuilderDelegate的按需构建。歌曲行的布局不复杂:左侧是序号索引,中间是歌名、歌手、专辑信息,右侧是时长和收藏按钮。点击整个行时切换到播放,点击收藏按钮时只切换收藏状态且不触发播放。
class _SongListTile extends StatelessWidget { const _SongListTile({ required this.song, required this.index, }); final Song song; final int index; @override Widget build(BuildContext context) { final detailStore = context.watch<PlaylistDetailStore>(); return ListTile( selected: song.isPlaying, selectedTileColor: Colors.primary.withOpacity(0.08), leading: SizedBox( width: 32, child: Center( child: song.isPlaying ? Icon(Icons.graphic_eq, color: Theme.of(context).colorScheme.primary) : Text( '${index + 1}', style: TextStyle(color: Theme.of(context).textTheme.bodyMedium), ), ), ), title: Text( song.name, maxLines: 1, overflow: TextOverflow.ellipsis, ), subtitle: Text( '${song.artist} - ${song.album}', maxLines: 1, overflow: TextOverflow.ellipsis, ), trailing: Row( mainAxisSize: MainAxisSize.min, children: [ Text(_formatDuration(song.duration)), IconButton( icon: Icon( song.isFavorite ? Icons.favorite : Icons.favorite_border, size: 20, ), onPressed: () { detailStore.toggleFavorite(song.id, favorite: !song.isFavorite); }, ), ], ), onTap: () { context.read<PlayerStore>().playSong(song); }, ); } }这里有一个容易踩的坑:context.watch<PlaylistDetailStore>()会让整个行在 store 的任何通知发生时都重建。如果几首歌的收藏状态发生变化,所有行都会重新 build,性能确实受影响。但这个页面里 store 的 notifyListeners 频率不算高,实测几十行时完全流畅,几百行时也还可以。如果未来歌单大规模扩展,可以考虑用Selector或为每行做更细粒度的状态拆分,这个作为后续优化的方向,现在不必过度设计。
播放状态的图标我用Icons.graphic_eq(均衡器动效图标)来标识当前播放行,比单纯变颜色直观得多。另外,实测发现selectedTileColor这个属性在不同版本 Flutter 上表现有差异,如果你在自己的 OpenHarmony Flutter SDK 版本上发现选中底色不生效,可以单独用 Container 包一层手动控制颜色。
5. 在 OpenHarmony 上让歌单真正播放音频
5.1 OpenHarmony 音频播放的正确姿势
歌单列表做得再好看,歌曲点不动、没声音,这个页面就是失败的。在 OpenHarmony 上做音频播放,和标准 Android 上直接用系统 MediaPlayer 不同,需要调用 OpenHarmony 的媒体服务接口。我这次采用的方式是写一个平台通道(MethodChannel)插件,在原生侧用系统音频框架实现播放,Flutter 侧只负责下发指令和接收状态回调。
音频播放的插件接口我定义成这几组方向:
- Flutter 到原生:play(url)、pause()、resume()、seekTo(position)、stop()、release()
- 原生到 Flutter:onPrepared(duration)、onPlaying(position)、onPaused()、onCompletion()、onError(code, message)
命令字串和回调通道都用同一个 MethodChannel,命名为ohos_music_player,两边约好消息协议就行。原生侧具体用系统音频框架的哪个类、怎么创建播放器,不同版本的系统 API 差别比较大。我建议你在自己的目标设备上先用官方音频播放示例跑通一遍,再把它封装成插件,不要一上来就在 Flutter 侧铺代码然后花大力气调试原生调用。
5.2 Flutter 侧播放器的封装测试
原生插件封装完成后,Flutter 侧我会再用一个 PlayerAdapter 类包一层,让 PlayerStore 不直接接触 MethodChannel,降低耦合。这是标准的依赖倒置思路,也让你能在单元测试时传入 Mock 播放器。
class PlayerAdapter { PlayerAdapter(this._channel); final MethodChannel _channel; Future<void> play(String url) async { await _channel.invokeMethod('play', {'url': url}); } Future<void> pause() async { await _channel.invokeMethod('pause'); } Future<void> resume() async { await _channel.invokeMethod('resume'); } Future<void> seekTo(int positionMs) async { await _channel.invokeMethod('seekTo', {'position': positionMs}); } void setEventHandler(Future<void> Function(MethodCall) handler) { _channel.setMethodCallHandler(handler); } }PlayerStore 内部维护一个currentSong、isPlaying、positionMs、durationMs,收到原生回调时更新这些字段并 notifyListeners。UI 上需要展示播放进度的地方就订阅 PlayerStore,这样歌曲行状态、迷你播放条、未来的全屏播放页都能自动保持同步。
5.3 权限和切换歌曲的细节处理
音频播放有两个工程细节容易让人折腾很久。
一是权限。OpenHarmony 上播放网络音频,如果应用要访问网络资源,需要在应用配置文件里声明网络权限。这里注意:你必须在项目的 OpenHarmony 工程配置文件(oh-package.json5 / module.json5 这类描述文件)里正确声明 INTERNET 权限,否则插件调用系统网络栈时会直接报安全异常,而且错误信息不一定指向权限问题,排查起来很头疼。在真机上调试我发现,改完配置后需要同步到设备再重启应用,有些系统版本对权限变更的应用杀进程不彻底,导致测试时以为没生效。
二是切换歌曲的竞态问题。用户快速点击多首歌曲时,前一曲的播放请求可能还没返回,后一曲的播放请求已经到了。如果不做控制,两个播放请求在原生侧互相覆盖,状态回调就乱了。我的处理方案是在原生插件侧对播放器做队列化:无论 Flutter 侧下发了多少个 play 指令,原生侧保证同一个播放器实例只响应最新一次请求,并且用 requestSerial 做去重。Flutter 侧则保证 PlayerStore 同一时间只发一个 play 请求,点击其他歌曲前先调用一次 stop 或直接复用一个播放器实例。这套逻辑跑起来后,快速连点多首歌曲就不再出现状态错乱的问题了。
6. 列表性能优化与封面加载的实战细节
6.1 封面图不能拖慢列表滚动
歌单封面一般是一张比较大的图,放在 SliverAppBar 的背景里,如果直接加载原图,解析耗时和内存占用都相当可观。我采用的处理手段是:从接口请求到图片 URL 时,直接请求两张——一张小的模糊封面图用于快速展示背景,一张大图用于封面区域的最终显示。小图先占位,大图加载完成后渐隐切换,体验比让用户干等转圈好得多。
如果你用的是官方 Image 组件,建议配合缓存和占位图处理。实测过 OpenHarmony Flutter 版本对 Image.network 的缓存支持并不总是符合预期,所以在项目里我统一用自定义的图片加载组件:先用内存 cache 判断是否有图,没有则显示占位色块,后台加载完成后 fade 切图。这样避免了每次滚动经过封面上方都触发一次重新加载的糟糕体验。
6.2 长列表的“三缓存”思路
长列表的流畅性可以从三级缓存去思考:Widget 复用、数据缓存、图片缓存。Widget 复用由 SliverList 的 lazy 机制解决,数据缓存体现在 Repository 层——如果歌单详情页滚动到底部加载更多后又往回翻,数据不要重复请求。图片缓存用组件层的内存 cache 兜底。把这三件事想清楚,歌单详情页这种量级的列表基本就没有性能焦虑了。
实测中还发现一个很有意思的问题:在部分 OpenHarmony 设备上,RepaintBoundary对列表滚动有明显的提升作用。我后来排查才发现原因是列表行里有透明度动画(比如点击时的水波纹、收藏图标缩放),这些动画会导致系统反复重绘,RepaintBoundary 把每一行的绘制隔离后,重绘范围就只限定在那一行内部了。所以我的建议是:每一行列表项都包一层 RepaintBoundary,即便当前没有动画也无妨,这是便宜的保险。
7. 常见问题排查与避坑实录
我整理了一张排查对照表,基本涵盖我在开发中遇到的典型问题:
| 问题现象 | 根本原因 | 解决方案 |
|---|---|---|
| 点击歌曲无声音 | 应用缺少 INTERNET 权限 | 在 OpenHarmony 工程配置中声明 INTERNET 权限,重新编译部署 |
| 列表滚动卡顿掉帧 | 图片未做裁剪缓存或列表未走 Sliver 懒加载 | 小图占位加大图渐入;用 SliverList/SliverGrid |
| 切换歌曲时状态回跳 | 原生播放器未做请求去重 | 原生侧用请求序列号去重,同实例只响应最新请求 |
| 收藏按钮连点状态错乱 | 乐观更新和请求回滚未配对 | toggle 封装统一入口,回滚时基于最新状态取反 |
| 后台播放无法控制 | 未接入系统媒体信息发布 | 需要额外实现元数据上报和通知栏控制接口 |
| 封面区在高频滚动时闪烁 | SliverAppBar 背景图反复加载 | 图片改为固定内存缓存,避免每次重建加载 |
除了表里的问题,还有三个实践心得值得重视。
第一个是日志要尽早规划。OpenHarmony 上 Flutter 和原生侧的日志打印机制不完全一致,调试 MethodChannel 消息时,两边日志时间线对不上会非常折磨。我后来在 Flutter 侧每次收发通道消息都打带序列号的日志,原生侧同样记录,排查问题效率提高很多。第二个是测试设备的系统版本会影响音频 API 细节,建议至少准备一台运行最新稳定系统的设备外,再留一台旧版本设备做兼容性验证。第三个是不要忽略内存泄漏,尤其是 Page 退出后播放器是否被释放。我在初次实现时忽略了dispose,结果页面反复进出几次后,底层播放器实例积压导致内存持续增长,后来在前端页面销毁时统一通知原生侧释放播放器实例,问题才解决。
另外,背景播放和通知栏控制是歌单详情之后的自然需求。我目前的做法是在原生侧监听播放状态变化,通过系统媒体会话机制上报歌曲元数据和播放状态,让通知栏和控制中心能显示和操作。这个功能涉及的原生代码量明显增加,但做音乐播放器迟早要接,建议尽早规划接口,别等页面做完再回头补。
我在这个项目里最大的感受是:Flutter for OpenHarmony 已经从“能不能跑”的阶段进入了“好不好用”的阶段。歌单详情页这种典型内容密集型页面,Flutter 的组件生态和开发效率确实能打,真正的复杂点不在 UI 而在系统能力的对接上。做这套实践前,最好先想清楚自己最依赖的系统能力是哪些:音频播放、通知栏、权限管理、还是多媒体的底层控制,把这些能力的原生插件稳定下来,剩下的界面开发就是 Flutter 的舒适区了。希望这篇记录能给你在 OpenHarmony 上做 Flutter 应用提供一份可复用的参考。