ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Flutter for OpenHarmony音乐播放器:收藏功能全链路实现与踩坑记录

2026/9/8 7:58:25 拓冰建站 浏览量
Flutter for OpenHarmony音乐播放器:收藏功能全链路实现与踩坑记录 音乐App里如果只能留三个页面播放页、歌单页之外我一定会留“我喜欢的音乐”。这个功能看似简单不就是点个心形、存个列表嘛但真做起来牵扯到数据持久化、全局状态同步、列表展示、播放队列联动这一整套链路。今天这篇是这个Flutter for OpenHarmony音乐播放器系列的第24篇我把做“我喜欢的音乐”这个功能时踩过的坑和最终的实现方案完整过一遍。如果你没看前面的连载也没关系这篇涉及的代码模块相对独立收藏功能的设计思路你可以直接拿去用。做这个功能之前我原本也是想“两小时搞定”结果真正落地时发现小细节一个比一个磨人收藏状态在列表页和播放页怎么同步重启App之后数据还在不在手滑删除了能不能找回这些才是用户真正感知到的体验。下面我会从数据层、状态层、UI层到常见问题排查完整展开。1. 功能定位与整体设计1.1 “我喜欢的音乐”到底要做成什么样先别急着写代码我们花两分钟把功能边界画清楚。音乐App里“我喜欢的音乐”本质上是一个用户私有歌单但它和普通歌单有一个关键区别它是全局的、隐式的、高频操作的。用户在任何歌曲列表页、播放页都可以随时把歌扔进去它的入口也必须无处不在。我这个版本的“我喜欢的音乐”功能清单如下歌曲列表页、播放页展示当前歌曲的喜欢状态。点击心形按钮切换喜欢/取消喜欢交互要有即时反馈。“我喜欢的音乐”页面展示所有已收藏歌曲按收藏时间倒序排列。点击列表条目立即播放该歌曲并把这个列表作为后续播放队列。支持滑动删除单首歌曲也支持清空整个列表。App重启后收藏数据不丢失。交互边界也很重要。我做设计时明确了几条规则取消喜欢不弹二次确认框。用户点心形就是一瞬间的冲动操作弹框反而打断节奏。但滑动删除属于“大动作”我会用Dismissible的confirmDismiss做一个轻量确认。收藏列表里的歌曲信息存的是“快照”。即使歌曲在云端已经下架本地依然能显示名称和歌手用户仍然可以播放本地缓存或看到一条灰置记录。本期不支持排序、分组和搜索只按收藏时间倒序。这些能力留到后面的迭代不在一期里堆功能。1.2 技术方案选型的思考技术选型这件事我结合前面23篇的经验给出一个对比。“收藏列表”存储选型有三个常见候选shared_preferences、sqflite、Hive。方案类型平台依赖OpenHarmony适配难度适合场景shared_preferences键值对平台通道中等需要路径适配少量配置项sqflite关系型数据库平台通道较高依赖原生SQLite复杂SQL查询Hive轻量NoSQL几乎纯Dart低结构化轻量数据我的结论是用Hive。原因有三第一收藏列表这种数据结构简单、单条记录不大、总量量级在几千条以内根本用不着关系型数据库的JOIN、索引这些能力。Hive序列化后直接写文件读几百条数据耗时在毫秒级体验完全够用。第二Hive的核心实现是纯Dart平台通道只涉及文件目录获取。在OpenHarmony上跑的时候只要能把应用可写目录传给Hive初始化剩下的读写逻辑不需要关心宿主系统是Android、iOS还是OpenHarmony。这一点对做多端适配的人来说省了太多事。第三Hive不需要为每个实体类手写建表SQL定义好adapter之后就是put、get、delete三板斧开发效率高。状态管理我选的是ChangeNotifier Provider。Flutter圈现在Riverpod、Bloc都挺流行但“我喜欢的音乐”本质上是一份全局共享的集合数据派生的UI主要是“某个id是否喜欢”和“收藏列表”两处。ChangeNotifier完全能表达清楚没有复杂的异步事件流没必要引入重量级状态管理。选择状态管理的第一原则是匹配业务复杂度而不是追新。整个数据流是单向的界面事件触发LikeController的方法方法先修改内存状态并通知UI刷新然后异步把结果写进Hive。UI不直接操作数据库Box避免出现在这里改了、那里没改的失控局面。2. 数据层收藏记录怎么存2.1 收藏数据模型设计先定义模型。这里有一个关键决策收藏记录里到底存什么一开始我偷懒只存了songId结果发现不靠谱。用户打开“我喜欢的音乐”页面如果每一首歌都要拿id去远端接口重新拉歌曲信息遇到网络慢、接口挂、歌曲下架列表就是一片空白。所以在本地存一份歌曲信息快照是必要的。看一下我的LikeSong模型class LikeSong { final String songId; final String songName; final String artist; final String album; final int duration; // 单位秒 final String coverUrl; // 封面图地址可为空 final int likedAt; // 收藏时间戳毫秒 LikeSong({ required this.songId, required this.songName, required this.artist, required this.album, required this.duration, required this.likedAt, this.coverUrl , }); MapString, dynamic toJson() { return { songId: songId, songName: songName, artist: artist, album: album, duration: duration, coverUrl: coverUrl, likedAt: likedAt, }; } factory LikeSong.fromJson(MapString, dynamic json) { return LikeSong( songId: json[songId] as String, songName: json[songName] as String, artist: json[artist] as String, album: json[album] as String, duration: json[duration] as int, coverUrl: json[coverUrl] as String? ?? , likedAt: json[likedAt] as int? ?? 0, ); } }注意demo中的coverUrl加了一个兜底空串likedAt做了空值保护。这种兼容处理在你后续升级字段时非常有用老用户的本地产物可能缺少新字段读不出来整个Box崩溃就尴尬了。likedAt有一个微妙的作用排序。收藏列表我默认按时间倒序用户刚喜欢的歌出现在最顶部符合直觉而且这样完全不需要用户手动拖拽排序产品逻辑简单一大截。2.2 Hive初始化与存取封装接下来是数据访问层。我不建议业务代码直接操作Box而是包一层DAO这样做有两个好处第一后续如果要把本地存储替换成后端同步只需要改DAO内部实现第二DAO可以统一处理Box的打开、异常捕获、字段兼容业务侧只管调用。Hive在OpenHarmony上的初始化核心问题是拿到一个可写的应用目录。如果你的Flutter for OpenHarmony壳工程里已经把path_provider适配好了直接用getApplicationDocumentsDirectory()即可如果还没适配可以通过平台通道拿宿主沙箱路径再传给Hive.init。import package:hive/hive.dart; import package:path_provider/path_provider.dart; class LikedSongsDao { static const String _boxName likedSongsBox; late Box _box; Futurevoid init() async { final dir await getApplicationDocumentsDirectory(); Hive.init(dir.path); _box await Hive.openBoxMap(_boxName); } Futurevoid addLike(LikeSong song) async { await _box.put(song.songId, song.toJson()); } Futurevoid removeLike(String songId) async { await _box.delete(songId); } LikeSong? getLike(String songId) { final value _box.get(songId); if (value null) return null; return LikeSong.fromJson(MapString, dynamic.from(value as Map)); } ListLikeSong getAllLikes() { final values _box.values.toList(); final songs values .map((e) LikeSong.fromJson(MapString, dynamic.from(e as Map))) .toList(); songs.sort((a, b) b.likedAt.compareTo(a.likedAt)); return songs; } Futurevoid clearAll() async { await _box.clear(); } }有几个点说一下。Hive的Box打开之后会常驻内存不要反复打开关闭。我在App启动阶段调用一次init后续全局复用。_box.openBoxMap这里我用了Map类型。Hive支持直接存取对象但需要给对象写TypeAdapter我的做法是序列化成Map再存少写一个Adapter反正字段不多可读性也更好。最后有一个细节如果Box里的数据是旧版本模型比如字段从likedAt改成了createTime那么fromJson里的空值兜底就要兜得住。实际项目中建议再包一层try-catch把损坏数据单独隔离而不是让它拖垮整个list。2.3 数据写盘失败的兜底策略写盘失败的场景在真机上真的会遇到存储空间满了、权限异常、文件被系统清理。我的策略很简单先改内存、再落盘落盘失败回滚内存。为什么不是先落盘再改内存因为用户点击心形按钮UI如果要在磁盘写完之后才变化网络差或IO慢的时候会明显卡顿。正确的体感是按钮瞬间变红/变灰后台异步写盘失败了再回滚。DAO层和Controller层要配合好这部分逻辑在下一章展开。3. 状态层喜欢状态全局同步3.1 LikeController的结构设计状态层的核心是一个全局单例的LikeController它维护两份内存数据class LikeController extends ChangeNotifier { final LikedSongsDao _dao; final SetString _likedIds {}; final ListLikeSong _likedSongs []; bool _loaded false; LikeController(this._dao); bool get isLoaded _loaded; ListLikeSong get likedSongs List.unmodifiable(_likedSongs); int get count _likedIds.length; bool isLiked(String songId) _likedIds.contains(songId); }为什么要维护两份_likedIds是一个Set用来做“某首歌是否喜欢”的O(1)判断。在列表页构建几千行时如果每次都去遍历List性能就是灾难。_likedSongs则负责给UI列表提供有序数据。两份数据在增删时必须同步更新这是最容易出bug的地方我会封装私有方法统一操作。加载数据的方法也很直接Futurevoid load() async { final list await _dao.getAllLikes(); _likedSongs ..clear() ..addAll(list); _likedIds.clear(); for (final song in list) { _likedIds.add(song.songId); } _loaded true; notifyListeners(); }load一般放在App启动流程里。注意load方法只在内存中重建数据不写盘。重复调用不会引发数据重复因为clear了。3.2 toggleLike的完整逻辑与回滚核心方法是toggleLike也是所有业务入口最终都会调用的方法。Futurevoid toggleLike(SongModel song) async { final wasLiked _likedIds.contains(song.songId); if (wasLiked) { _likedIds.remove(song.songId); _likedSongs.removeWhere((s) s.songId song.songId); } else { final likeSong LikeSong( songId: song.songId, songName: song.songName, artist: song.artist, album: song.album, duration: song.duration, coverUrl: song.coverUrl, likedAt: DateTime.now().millisecondsSinceEpoch, ); _likedIds.add(song.songId); _likedSongs.insert(0, likeSong); } notifyListeners(); try { if (wasLiked) { await _dao.removeLike(song.songId); } else { await _dao.addLike(_likedSongs.firstWhere((s) s.songId song.songId)); } } catch (e) { // 回滚内存状态 if (wasLiked) { final originalIndex _likedSongs.indexWhere((s) s.songId song.songId); final restored _likedSongs[originalIndex]; _likedIds.add(song.songId); _likedSongs.insert(originalIndex, restored); } else { _likedIds.remove(song.songId); _likedSongs.removeWhere((s) s.songId song.songId); } notifyListeners(); debugPrint(toggleLike rollback for ${song.songId}: $e); } }注意上面这段的回滚逻辑里我在移除_likedSongs之后又用indexWhere去查原索引这在真实代码里是拿不到的所以正确写法是在修改前先把likedSong存到一个局部变量。我在这里简化了实际项目里应该这样LikeSong? snapshot; if (wasLiked) { final idx _likedSongs.indexWhere((s) s.songId song.songId); if (idx 0) snapshot _likedSongs[idx]; }回滚逻辑是兜底的正常情况不会触发但写的时候一定要把快照先存下来。另外这个方法里调用了notifyListeners()两次成功和回滚各一次这样UI始终与内存最终状态一致。3.3 冷启动加载与启动优化冷启动时Hive初始化需要拉取文件、反序列化用户会看到一个加载过程。我的做法是在启动页或首页initState里调用load并给UI一个loading状态。还有一种做法是“先渲染、后补数据”但收藏列表这个场景不适合用户点进“我喜欢的音乐”如果先看到一个空页面零点几秒后又突然出现一堆歌视觉跳动很怪。所以收藏页宁可多等一个loading转圈也不要让内容闪烁。如果你的App后面还有“每日推荐”“猜你喜欢”这种功能加载顺序可以做个优先级先加载最核心的播放配置再并行加载收藏列表不要让一个IO拖垮整个启动流程。Flutter的async并发很好写直接用Future.wait。4. UI层收藏列表页与喜欢按钮4.1 收藏列表页实现收藏列表页的整体结构比较标准AppBar显示标题和数量body根据状态切换加载中、空列表、列表数据三种视图。class LikedSongsPage extends StatelessWidget { const LikedSongsPage({super.key}); override Widget build(BuildContext context) { final controller context.watchLikeController(); return Scaffold( appBar: AppBar( title: Text(我喜欢的音乐), actions: [ if (controller.count 0) IconButton( icon: const Icon(Icons.delete_sweep_outlined), onPressed: () _showClearConfirm(context, controller), ), ], ), body: _buildBody(controller), ); } Widget _buildBody(LikeController controller) { if (!controller.isLoaded) { return const Center(child: CircularProgressIndicator()); } if (controller.likedSongs.isEmpty) { return const _EmptyView(); } return ListView.separated( itemCount: controller.likedSongs.length, separatorBuilder: (_, __) const Divider(height: 1), itemBuilder: (context, index) { final song controller.likedSongs[index]; return _LikedSongTile( song: song, onTap: () _playFromLikedSongs(context, controller, index), onRemove: () controller.toggleLike(SongModel.fromLikeSong(song)), ); }, ); } }空状态视图不要只放一个“暂无喜欢歌曲”的文字太干。我放了一张本地占位图、一句“点一下心形把喜欢的歌留在这里”、再加一个“去逛逛”按钮引导去热门歌单页。这个细节对新手用户很友好。列表项组件里最值得说的一点是key。ListView.separated的itemBuilder里每一行的根widget我建议显式给一个key值用song.songId。Flutter虽然会根据索引复用元素但当你删除中间某一项时有key的widget树才能精确匹配和移动避免出现状态串位。4.2 全局喜欢按钮的状态同步“喜欢”按钮会出现在歌曲列表页的每一行、播放页、部分歌单详情页。这些页面之间怎么保持一致答案只有一个不要各自维护状态全部走LikeController。歌曲列表行里的心形按钮完整写法是这样class _LikedSongTile extends StatelessWidget { const _LikedSongTile({ required this.song, required this.onTap, required this.onRemove, }); final LikeSong song; final VoidCallback onTap; final VoidCallback onRemove; override Widget build(BuildContext context) { return Dismissible( key: ValueKey(song.songId), direction: DismissDirection.endToStart, confirmDismiss: (_) async { final confirmed await showDialogbool( context: context, builder: (ctx) AlertDialog( title: const Text(移除这首歌), content: Text(《${song.songName}》将从我喜欢的音乐中移除。), actions: [ TextButton( onPressed: () Navigator.pop(ctx, false), child: const Text(取消), ), TextButton( onPressed: () Navigator.pop(ctx, true), child: const Text(移除), ), ], ), ); return confirmed ?? false; }, onDismissed: (_) onRemove(), background: Container( color: Colors.redAccent, alignment: Alignment.centerRight, padding: const EdgeInsets.only(right: 20), child: const Icon(Icons.delete_outline, color: Colors.white), ), child: ListTile( leading: CoverThumb(url: song.coverUrl), title: Text( song.songName, maxLines: 1, overflow: TextOverflow.ellipsis, ), subtitle: Text( ${song.artist} · ${song.album}, maxLines: 1, overflow: TextOverflow.ellipsis, ), trailing: GestureDetector( behavior: HitTestBehavior.opaque, onTap: onRemove, child: Icon( Icons.favorite, color: Colors.redAccent, ), ), onTap: onTap, ), ); } }这里有一个必须注意的坑Dismissible的onDismissed回调触发后这个widget会从列表数据源中被移除但Dismissible组件本身要求“被删除的item在下一次build时不再出现”。如果onDismissed调用后数据源没有立刻同步就会报A dismissed Dismissible widget is still part of the tree。我的做法是onRemove直接调用controller.toggleLike这个方法内部会先改内存再notifyListeners正好保证下一帧该item就消失了。播放页的心形按钮类似只是多了一个监听AnimatedBuilder( animation: likeController, builder: (context, child) { final liked likeController.isLiked(currentSong.songId); return IconButton( icon: Icon( liked ? Icons.favorite : Icons.favorite_border, color: liked ? Colors.redAccent : Colors.white70, ), onPressed: () likeController.toggleLike(currentSong), ); }, )注意这里的currentSong必须是播放器当前正在播放的歌曲对象。如果同一个页面因为某种原因在播放跳转后没有刷新currentSong按钮就会显示成上一首歌的状态。我排查过一次原因就是播放器切歌时只更新了内部状态没有回调UI层。4.3 点击收藏列表项播放从收藏列表点击一首歌目标是把整个收藏列表作为播放队列并从点击的这首开始播。这要求播放器服务有一个playQueue(ListSongModel queue, int index)方法。void _playFromLikedSongs(BuildContext context, LikeController controller, int index) { final queue controller.likedSongs .map((e) SongModel.fromLikeSong(e)) .toList(); PlayerService.instance.playQueue(queue, index); // 可选的页面跳转 Navigator.push(context, MaterialPageRoute( builder: (_) const PlayerPage(), )); }这个队列不是一次性把几百首全部塞给播放器底层而是由PlayerService持有一个List 的引用播放器只记录当前索引。切歌时根据播放模式列表循环、顺序播放、随机播放计算下一首索引。这部分前面写播放器核心链路的时候已经实现过了这次只是把数据源换成收藏列表。有一个容易忽略的点如果用户正在播放A歌曲然后去收藏列表点了B歌曲原播放列表应该整体替换。否则从B切下一首时会突然跳回A歌曲所在的旧队列体验非常割裂。所以playQueue里必须重置播放模式的状态。5. 细节优化与关键取舍5.1 取消喜欢后的数据一致性取消喜欢这个动作有几个连带场景要处理好。正在播放的歌被取消了喜欢播放不能中断只改变UI上的心形状态。等这首歌唱完或用户手动切歌播放器继续按当前队列走。收藏列表正在被播放时用户滑掉了一首歌。我做过一个版本滑掉后队列也跟着移除结果播着播着突然少了一首索引全乱。后来改成滑掉只影响收藏列表当前正在播放的队列不变除非用户重新从收藏列表进入播放。这符合大多数音乐App的行为至少排队逻辑不会被这种边缘操作搞乱。重新喜欢一首刚刚取消的歌它应该回到列表的哪个位置我选择插入到最顶部理由是新喜欢的时间戳是最新的按时间倒序它就该在第一行。这个行为不用额外写逻辑insert(0, likeSong)就搞定了。5.2 性能与体验优化收藏列表如果到了上千首ListView的itemBuilder会按需构建性能没问题。瓶颈主要在封面图加载。如果每首歌都从网络加载封面快速滑动时会有大量请求。我的建议是给图片组件加一层内存缓存用cached_network_image这类库或者你自己实现一个简单的MapString, Uint8List缓存。在OpenHarmony上如果某些第三方图片库的缓存路径有问题可以退化为自研的简单内存缓存反正封面图单个文件不大1000首占几百MB内存在真机上可以接受但建议设个LRU上限。另外列表项构建时尽量避免在build里做复杂的字符串拼接和重复的fromJson操作。把SongModel都准备好再丢给列表不要在itemBuilder里临时new对象。5.3 为后端同步预留扩展点热词里有一条“Flutter 做本地数据库后端同步”很多音乐App最后都会做收藏云备份。我在设计LikeController的时候就留了接口DAO层封装了所有持久化操作业务层根本不知道数据存哪。将来要做云同步只需要写一个RemoteLikedSongsRepository在toggleLike成功后同时推送远端即可。同步冲突的常规做法是用updatedAt时间戳做最后写入优先LWW或者用增量同步记录本地变更序列。这个属于多端同步的范畴等真做的时候再展开但当前这种“内存 DAO”的结构已经为后续演进留好了空间。6. 常见问题与踩坑实录这里我把实际开发中遇到的高频问题整理成一张速查表都是能直接对号入座的。问题现象根本原因解决方案重启App后收藏全部丢失Hive未初始化成功或盒子未打开就读写在启动流程中await初始化确保openBox完成后再load数据列表滑动时心形按钮状态错乱ListView复用了item状态只存在widget内部状态统一走LikeControllerkey使用songIdDismissible滑动删除报“still part of the tree”onDismissed触发后数据源未立刻同步onRemove里先调用toggleLike改内存再notifyListeners播放页喜欢按钮显示上一首歌的状态切歌时只更新了播放器内部currentSong没有触发UI重建用AnimatedBuilder监听播放器currentSong变更OpenHarmony上Hive无法写入文件path_provider未适配目录不可写手动获取应用沙箱路径传给Hive.init大量收藏时启动页面卡顿在UI线程同步反序列化大量数据load方法用async异步执行初始化完成后notifyListeners点击列表项心形按钮触发了整行的播放手势冲突点击事件被父级ListTile拦截用心形按钮的外层套GestureDetector设置HitTestBehavior.opaque必要时在onTap中阻止事件冒泡有一个问题特别容易在OpenHarmony真机上遇到某些目录在开发调试期是可写的但打包安装后变成只读。所以Hive的初始化路径不要用相对目录或者“我以为可写”的路径一定要在运行期通过path_provider或平台通道获取真正的应用沙箱目录。另外Hive的Box文件有格式版本如果你从Git历史里拉回旧代码可能出现“Box文件版本不兼容”的异常。我的习惯是给Box名字带上版本后缀比如likedSongsBoxV1将来数据结构大改时直接换新Box老数据做一次迁移脚本而不是在同一个Box里做破坏性的字段变更。反编译问题也提一句。Flutter的Dart代码编译成AOT之后反编译难度比Java原生低很多尤其是字符串常量是留在产物里的。所以如果你在收藏功能里做了云同步不要把用户token直接拼在SQL或Box的key里。敏感信息应该走系统级的安全存储方案至少也要做一层加密。最后再分享一个细节给“我喜欢的音乐”页面做统计埋点的时候不要只埋“点击喜欢”和“取消喜欢”还要埋“从收藏列表播放了歌曲”这个行为。这个数据能直接反映收藏功能的真实活跃度。我后期看数据时发现用户收藏歌曲的频次远高于从收藏列表播放的频次这说明很多用户把这里当“歌单仓库”而不是“播放入口”。后来我在收藏页顶部加了一个“随机播放全部”按钮数据立刻涨了一截。做这个功能最大的体会是先把“状态从哪来、改到哪里去”画清楚再写UI。代码顺序永远是数据层、状态层、UI层反过来写一定是返工的命。如果你正在做类似的功能希望这篇能帮你少踩几个坑。