最近播放这个功能听着不复杂但用户在音乐类 App 里真的在意。早上通勤听了一半的歌到公司点开最近播放就能直接续上晚上健身前想起白天那首副歌翻开历史记录划两下就能找到。今天这篇是 Flutter for OpenHarmony 音乐播放器实战系列的第 23 篇核心就是把这套最近播放完整落地数据怎么存、怎么去重、列表怎么刷、删除和清空怎么做以及在 OpenHarmony 这套环境下有哪些和 Android 习惯不一样的坑。适合正在跟这个系列做播放器的朋友也适合想了解状态管理、本地持久化、列表复用这三者怎么配合的 Flutter 开发者。我会把每一步的取舍原因讲清楚代码可以直接抄抄完稍微改改对应到你自己的模型类就能跑。1. 功能设计先把“最近播放”的业务边界划清楚1.1 这个模块到底要解决什么问题先说边界。最近播放在我的 App 里只负责两件事第一记录用户真正播放过的歌曲第二按最近播放时间倒序展示并且支持点击续播。它不负责统计播放次数不负责生成年度报告也不负责做猜你喜欢的数据源——那些都是后话现在做进去就是过度设计。功能范围我建议控制在三条核心交互内点击列表项直接播放这首歌长按单项可以删除右上角提供清空全部。其余像播放全部加入歌单查看歌曲详情这些属于后续锦上添花可以在每项的弹出菜单里预留位置但不必在第一版全部做完。为什么这么克制因为最近播放这个模块的真正难点不在功能多而在数据链路正确。记录时机如果错了你会发现用户明明只点了歌曲详情页没播放列表里却多出一条播放完一首歌也会被记一次导致用户中途退出时反而没记录。所以我把设计重心放在记录必须发生在播放真正开始这个时间点而不是列表被点击或播放结束。1.2 数据模型设计一张表搞定的信息量最近播放记录的数据模型我最终设计成下面几个字段字段类型说明songIdString歌曲唯一标识本地曲库用专辑ID歌曲ID组合在线歌曲用音频源IDtitleString歌名列表展示用artistString歌手名副标题展示用coverUrlString封面地址本地文件路径或网络URLdurationint歌曲时长毫秒点击续播时用于进度恢复playedAtint最近一次播放时间戳毫秒排序和xx分钟前展示都靠它sourceString来源标记可选填记录来自歌单搜索最近播放哪个入口extraStringJSON预留字段以后加专辑名、音质标记不用迁移数据库这里有一个容易踩的坑主键到底是 songId 还是 songIdplayedAt。我的做法是列表里同一首歌永远只保留一条记录所以展示用的唯一标识是 songId但同时保留 playedAt 字段记录最近一次播放时间因为最近播放本质是时间线用户反复听同一首歌应该在列表里看到它跳到最上面而不是看到两条一样的记录。上限我设置为 100 条。有人可能觉得 100 太少但实测下来一个人平均每天播放 30 到 50 首歌100 条已经覆盖大概三天的使用量滚动列表也不会因为数据量过大而卡顿。数量设上限还有一个隐形好处持久化文件的体积可控启动时加载速度稳定在几十毫秒级别。1.3 技术方案对比为什么我选 JSON 文件而不是数据库判断一个模块该用哪种存储方案我一般看三个维度数据量、查询复杂度、跨端适配成本。最近播放在这三个维度上都是轻量级所以我不太建议一上来就上数据库。方案优点缺点我的结论SharedPreferences接入最简单key-value 即写即读存列表需要全量序列化调试不够直观类型约束弱只适合存少量配置不合适sqflite查询灵活适合扩展成播放统计OpenHarmony 上需要额外引入适配库依赖版本容易和 Flutter SDK 卡冲突对 100 条数据来说 SQL 有点大材小用等做统计报表再迁移JSON 文件 内存缓存数据结构直观序列化代码可控文件可读可备份跨端行为一致需要自己处理原子写、并发写最终选择我的思路是先用 JSON 文件把功能跑通文件方案的好处是你看一眼 JSON 就能知道数据结构对不对排错成本极低。后续如果要做播放次数统计、按歌手聚合、猜你喜欢推荐再迁移到 sqflite届时把 RecentPlayStore 换成数据库实现上层 UI 完全不用动。2. 数据持久化在 OpenHarmony 上的落地方案2.1 路径获取与模型序列化代码在 OpenHarmony 设备上跑 Flutter最直接的区别就是文件路径体系和 Android 不一样。好在社区已经把 path_provider 适配到了 OpenHarmony 平台getApplicationDocumentsDirectory() 可以直接用如果你们项目里没接这个插件也可以通过平台通道在端侧拿到应用文件目录再传回 Dart。import dart:io; import package:path_provider/path_provider.dart; FutureDirectory _getRecentPlayDir() async { final base await getApplicationDocumentsDirectory(); return Directory(${base.path}/recent_play); }拿到目录之后我会把记录先塞进一个简单的模型类再提供 toJson/fromJson。注意如果你的项目用 Dart 的 part/part of 来组织模型文件part 文件里缺失 import 会导致编译报错这是我实际踩过的坑所以下面的文件我是按独立文件写的不依赖 part。class RecentPlayRecord { const RecentPlayRecord({ required this.songId, required this.title, required this.artist, required this.coverUrl, required this.duration, required this.playedAt, this.source , this.extra {}, }); final String songId; final String title; final String artist; final String coverUrl; final int duration; final int playedAt; final String source; final String extra; factory RecentPlayRecord.fromJson(MapString, dynamic json) { return RecentPlayRecord( songId: json[songId] as String, title: json[title] as String? ?? 未知歌曲, artist: json[artist] as String? ?? 未知歌手, coverUrl: json[coverUrl] as String? ?? , duration: json[duration] as int? ?? 0, playedAt: json[playedAt] as int? ?? 0, source: json[source] as String? ?? , extra: json[extra] as String? ?? {}, ); } MapString, dynamic toJson() { return { songId: songId, title: title, artist: artist, coverUrl: coverUrl, duration: duration, playedAt: playedAt, source: source, extra: extra, }; } }fromJson 里加默认值这个习惯是我特别想强调的。真实场景下用户可能会升级 App老版本存的数据字段不全如果解析时直接 as String 强转可能一个空字段就把整个列表加载崩掉。给每个字段兜底哪怕只有一个默认封面也比白屏强。2.2 RecentPlayStore内存缓存 延迟写盘接下来是核心的存储类。我的设计思路是先内存、后磁盘所有读操作都走内存缓存保证 UI 响应快写操作先在内存里改再用 500 毫秒的防抖延迟合并写盘。这样连续播放多首歌时磁盘只会被写一次。import dart:async; import dart:convert; import dart:io; class RecentPlayStore { RecentPlayStore._(); static final RecentPlayStore instance RecentPlayStore._(); static const int maxCount 100; static const String fileName recent_play.json; final ListRecentPlayRecord _records []; bool _loaded false; Timer? _flushTimer; Directory? _dir; FutureListRecentPlayRecord load() async { if (_loaded) return List.unmodifiable(_records); try { final dir await _getRecentPlayDir(); if (!await dir.exists()) await dir.create(recursive: true); final file File(${dir.path}/$fileName); if (await file.exists()) { final text await file.readAsString(); final list jsonDecode(text) as Listdynamic; _records ..clear() ..addAll(list.map((e) RecentPlayRecord.fromJson(e as MapString, dynamic))); } } catch (e) { // 文件损坏时宁可空列表也不要让 App 崩溃 _records.clear(); } _loaded true; return List.unmodifiable(_records); } ListRecentPlayRecord addRecord(RecentPlayRecord record) { // 去重先把同一首歌的旧记录移除再插到队首 _records.removeWhere((r) r.songId record.songId); _records.insert(0, record); if (_records.length maxCount) { _records.removeRange(maxCount, _records.length); } _scheduleFlush(); return List.unmodifiable(_records); } void removeRecord(String songId) { _records.removeWhere((r) r.songId songId); _scheduleFlush(); } void clearAll() { _records.clear(); _flushTimer?.cancel(); unawaited(_writeToDisk([])); } void _scheduleFlush() { _flushTimer?.cancel(); _flushTimer Timer(const Duration(milliseconds: 500), () { unawaited(_writeToDisk(_records)); }); } Futurevoid flush() async { final list ListRecentPlayRecord.from(_records); await _writeToDisk(list); } Futurevoid _writeToDisk(ListRecentPlayRecord records) async { try { final dir await _getRecentPlayDir(); if (!await dir.exists()) await dir.create(recursive: true); final file File(${dir.path}/$fileName); final tmpFile File(${dir.path}/$fileName.tmp); await tmpFile.writeAsString(jsonEncode(records.map((e) e.toJson()).toList())); await tmpFile.rename(file.path); } catch (e) { // 写盘失败不影响内存数据下次调度会重试 } } }这里几个点值得单独说。第一removeWhere 之后再 insert(0)是天然的去重加置顶比先查再插更少写代码第二上限裁剪用的是 removeRange(maxCount, length)反正队尾是最早播放的直接截掉就行第三写完先落到 .tmp 文件再 rename这招叫原子替换能避免 App 写到一半被系统杀掉导致 JSON 半截损坏。实测下来100 条记录 encode 成 JSON 大约 3 到 8 毫秒文件写盘 10 到 30 毫秒纯异步执行对 UI 线程几乎无感。2.3 为什么“先内存后磁盘”扛得住高频写入播放器场景写入最近播放的频率其实比想象中高得多切歌一次、自动下一首一次、用户从锁屏界面恢复播放可能又触发一次。如果每次触发都直接 file.writeAsString在低端 OpenHarmony 设备上还是会出现可感知的卡顿而且频繁擦写对存储介质也不友好。我用的防抖方案是第一次 addRecord 后启动 500 毫秒定时器期间再进来新记录就取消旧定时器重新计时直到 500 毫秒内没有新事件才真正落盘。这个思路和输入框搜索防抖一模一样。不过防抖有个隐患如果用户在落盘前直接杀掉 App内存里那几条记录就丢了。所以我还给这个 Store 接了一个生命周期监听在 App 进入后台或即将退出时强制 flush 一次用 WidgetsBindingObserver 的 didChangeAppLifecycleState 判断代码不复杂但能补上防抖丢失数据的漏洞。3. 最近播放页面的 UI 与交互实现3.1 页面结构与状态管理选型状态管理这块我没有引入 bloc 或者 cubit虽然网上很多 flutter_cubit 教程写得不错但那套对于最近播放这种单页面、单列表的模块来说有点重。项目里全局本来就有播放器的 PlayerController我建议再单独拆一个轻量的 RecentPlayController用 ChangeNotifier AnimatedBuilder/ListenableBuilder 就够。class RecentPlayController extends ChangeNotifier { RecentPlayController(this._store); final RecentPlayStore _store; ListRecentPlayRecord records []; Futurevoid load() async { records await _store.load(); notifyListeners(); } void onSongPlayed({required RecentPlayRecord record}) { records _store.addRecord(record); notifyListeners(); } void removeRecord(String songId) { _store.removeRecord(songId); records _store.recordsSnapshot; notifyListeners(); } void clearAll() { _store.clearAll(); records []; notifyListeners(); } }页面结构就三块AppBar 放标题和清空按钮主体是一个 ListenableBuilder 包着的 ListView数据为空时切换成空态组件。列表项独立抽成 RecentPlayItem接收 record 和回调不要在 build 方法里写一大坨。Scaffold( appBar: AppBar( title: const Text(最近播放), actions: [ if (records.isNotEmpty) IconButton( onPressed: _showClearConfirm, icon: const Icon(Icons.delete_sweep_outlined), ), ], ), body: ListenableBuilder( listenable: controller, builder: (context, _) { if (controller.records.isEmpty) return const EmptyRecentView(); return ListView.separated( itemCount: controller.records.length, separatorBuilder: (_, __) const Divider(height: 1), itemBuilder: (context, index) { final record controller.records[index]; return RecentPlayItem( key: ValueKey(record.songId), record: record, onTap: () _playFromRecent(record), onLongPress: () _showItemMenu(record), ); }, ); }, ), )itemBuilder 里我特意给 RecentPlayItem 加了 ValueKey(record.songId)这个很重要。列表数据变化时Flutter 靠 key 判断哪些 item 可以复用如果没有 key当两条记录互换位置时StatefulWidget 里的状态可能串到错误的行上最直观的 bug 是点了第一首结果播放的是另一首歌的封面。3.2 播放事件怎么联动把记录时机卡准最近播放的数据来源不是用户点了歌曲而是播放器状态真正开始播放。我在 PlayerController 里定义了播放状态流转从 loading 到 playing 才算一次有效播放。所以在调用播放器 play(song) 并等待状态变成 playing 之后再回调 RecentPlayController.onSongPlayed。Futurevoid playSong(Song song, {String source }) async { // 省略创建播放器、设置数据源等 await _player.prepare(); await _player.play(); _state PlayState.playing; recentPlayController.onSongPlayed( record: RecentPlayRecord( songId: song.id, title: song.title, artist: song.artist, coverUrl: song.coverUrl, duration: song.duration, playedAt: DateTime.now().millisecondsSinceEpoch, source: source, ), ); }点击最近播放列表项时不要新写一套播放逻辑直接复用 playSong 并带上 source: recent。这样既保证记录时机统一又能在 source 字段里区分入口后续要统计最近播放列表的点击转化率时就有据可查。如果你们的播放器封装了事件流也可以在 PlayerController 里暴露一个 stream在页面 initState 里订阅这样页面和播放器解耦更彻底。我自己的项目因为播放器全局单例直接方法回调更直观两种方式都行。3.3 列表项布局与xx分钟前时间格式化RecentPlayItem 的布局我按从左到右封面56x56 圆角、中间一列歌名、歌手 · 来源、右侧相对时间 更多操作入口。封面如果已经下载到本地就直接 FileImage如果是网络图务必在加载失败时显示默认占位图否则 OpenHarmony 真机上网络异常时会出现整行白块还伴随 flutter_socketexception 之类的异常日志。相对时间格式化是最近播放里的一个细节写起来很简单但很多人忽略了刷新时机String formatPlayedAt(int playedAt) { final diff DateTime.now().difference(DateTime.fromMillisecondsSinceEpoch(playedAt)); if (diff.inMinutes 1) return 刚刚; if (diff.inHours 1) return ${diff.inMinutes} 分钟前; if (diff.inDays 1) return ${diff.inHours} 小时前; if (diff.inDays 7) return ${diff.inDays} 天前; final time DateTime.fromMillisecondsSinceEpoch(playedAt); return ${time.month}-${time.day} ${time.hour.toString().padLeft(2, 0)}:${time.minute.toString().padLeft(2, 0)}; }一个容易忽略的问题这个格式化结果是在 build 时算的如果页面一直挂着一小时后回来你会发现刚刚还是刚刚。我偷懒的做法是只在列表第一次展示和下拉刷新/重新进入页面时刷新因为最近播放页通常不会长时间驻留。如果你们的产品要求实时变化再加一个每分钟触发 notifyListeners 的 Timer注意在 dispose 里取消别让页面退出后定时器还在跑。3.4 长按删除、清空确认与空态设计长按单项弹出 BottomSheet我放了两个操作从最近播放中删除、查看歌曲信息。查看歌曲信息可以先不做跳转但入口留着。用户点删除后调用 controller.removeRecord(songId)这一条会立即从列表消失同时 Store 顺手把磁盘里的记录更新掉。清空全部的操作放在了 AppBar 的删除图标上点击后弹 AlertDialog 确认。这里有个交互原则破坏性操作必须二次确认而且确认按钮文案要写清空不要只写确定降低用户误触后的懊恼感。空态设计也别敷衍。用户第一次安装 App最近播放页一定什么也没有我用的是一个居中的图标加两行文字还没有播放记录和去首页听首歌它会出现在这里。再配一个跳转首页的按钮把空态变成一次引导而不是一堵墙。4. 排坑实录与性能优化4.1 OpenHarmony 平台上的差异化处理在 Android 上顺手的东西在 Flutter for OpenHarmony 里不一定顺手。第一个就是 path_provider。社区适配版本能用但如果你升级了 Flutter SDK偶尔会看到 The current configured Flutter SDK is not known to be fully supported. Please... 这串警告大多是适配版本滞后造成的不影响编译但建议锁定双方版本再构建别混用 nightly。第二个是渲染引擎。如果你发现 OpenHarmony 真机上页面有奇怪的渲染残影检查一下 Flutter 是否启用了 Impeller。Impeller 在部分 OpenHarmony 设备上还没完全启用碰到渲染问题可以临时切回 Skia配置方法是在 flutter 命令里带渲染器参数或者改项目的 GraphicsBackend 配置。新项目默认 Impeller 性能确实好但要视设备兼容情况灵活切换。第三个是目录隔离。OpenHarmony 对应用文件目录有严格的沙箱限制别试图把最近播放记录写到外部存储根目录老老实实用 getApplicationDocumentsDirectory 拿到的应用私有目录否则真机上大概率遇到权限异常。4.2 列表卡顿和状态错乱的排查思路最近播放列表本身数据量小理论上不该卡。如果你还是觉得滑动不跟手先查三件事第一列表项封面加载。网络封面每次 build 都重新请求是最大的卡顿源必须走缓存或内存 ImageCache。Flutter 的 Image.network 自带内存缓存但如果你自己用 FileImage 加载本地大图建议先缩略再显示56x56 的控件没必要塞一张 1920 的图。第二整页重建。如果页面里既监听 PlayerController 又监听 RecentPlayController播放状态一变整个列表跟着重建那就会卡。我推荐把依赖缩到最小只有 RecentPlayItem 内部需要根据播放状态改变高亮时才用 ListenableBuilder 包这一行不要包整个 ListView。第三复制列表的代价。每次 addRecord 我都返回 List.unmodifiableUI 每次 rebuild 会用新的列表实例这是刻意为之。如果你返回的是同一个 List 引用然后原地 insertFlutter 的 ListView 感知不到变化会渲染出错误的行数。这个坑我在调试时花了一个晚上才定位因为 notifier 明明触发了界面就是不动。4.3 常见问题速查表现象原因解决方案最近播放永远是空的load() 没在页面初始化时调用或文件路径不对initState 里 await controller.load()同一首歌出现多条addRecord 里没有先 removeWhere 去重按 songId 先删旧记录再 insert(0)JSON 解析失败导致白屏写盘时文件被中断半截 JSON用 .tmp 文件 rename 原子替换删除后列表又恢复播放状态重复回调又把记录写回去了在播放状态机上只记录开始播放一次时间显示一直是刚刚页面驻留太久没触发重建进入页面/下拉刷新时刷新或加分钟级 Timer列表滑动掉帧item 没有 Key 或整页重建加 ValueKey(songId)缩小监听范围网络封面加载失败白块SocketException 未捕获封面加载失败时回退默认占位图这里面我想单独展开的是播放状态重复回调。我一开始是在 onPlayerStateChanged 流里无条件写最近播放结果歌曲播放、暂停、再播放同一个循环里被记了三条。后来把记录逻辑收敛到 playSong 方法的状态已变为 playing分支之后问题立刻消失。这是逻辑收敛带来的好处别把同样的判断散落在多处。4.4 留给后面扩展的两个伏笔最近播放这个 Store 设计得相对独立后续两条扩展路线是现成的。一条是往统计方向扩把 RecentPlayRecord 扩展出一个 playCount 字段每次 addRecord 时把次数加一再改一下排序策略最近播放页就能加一个常听Tab。另一条是数据迁移等数据量真的变大或要做按歌手聚合把 RecentPlayStore 的持久化实现换成 sqflite保持 addRecord/removeRecord/clearAll 这些方法签名不变上层 Controller 和页面一行都不用改。我个人在实际开发中的体会是最近播放这种模块最考验的不是 UI 写得多花哨而是有没有把记录时机、去重策略、落盘方式、列表刷新这四件事想清楚。先把这四件事固定下来后面加任何功能都不会伤筋动骨。如果你正在跟这个系列做自己的播放器建议现在就动手把 JSON 文件在 OpenHarmony 设备上读一遍确认路径和权限都没问题再接着写列表页这个顺序能帮你省掉一半的排错时间。