☰
Flutter在OpenHarmony的最近播放实践:上报、去重与增量刷新
2026/9/28 5:58:32 网站建设 项目流程

做音乐播放器App的“最近播放”功能之前,我以为这事很简单:把用户听过的歌存到一张表里,按时间倒序显示就行。真正动手才发现,这里头藏着一堆问题:同一首歌频繁循环播放时列表要不要每次都刷新?用户听到一半切走,回到App点“最近播放”又该不该从上次的位置继续播放?在OpenHarmony上跑Flutter,本地存储、事件通道、列表渲染每个环节都有适配层面的坑要踩。

这篇是Flutter for OpenHarmony音乐播放器系列的第23篇,单独把“最近播放”拎出来写,是因为它比收藏、歌单这类“纯列表型功能”复杂得多——它服务的是用户连续收听的行为轨迹,背后要同时处理数据上报、本地存储、去重聚合和UI增量刷新。下面我把完整实现过程拆开讲,包括最终的数据表设计、上报链路和几个真实的坑。

1. 最近播放的真正需求:远不止一张时间倒序列表

1.1 一句话定义“最近播放”

先给需求下一个准确的定义:最近播放 = 用户播放行为的最近记录列表,用于快速回到之前听过的内容,并支持尽可能恢复收听进度。

听起来像废话,但它直接决定了数据结构怎么设计。如果只是“听过的歌”列表,那一个数组或者一张表就够了。但一旦加上“恢复收听进度”,每条记录就必须额外携带:歌曲ID、本地路径或在线地址、上次播放的秒数、总时长。这就不是简单存一个名字了。

我做第一版的时候只存了歌曲ID和播放时间,结果用户点列表里的歌,直接从头开始播。测试同学提了个bug:“我昨晚听到18分27秒,今早要从18分27秒接着听。”没办法,只能加字段,重新设计表结构。这个教训我后面会细讲。

1.2 三个核心衡量指标:唯一、时效、可续播

“最近播放”做得好不好,可以用三个指标衡量:

  • 唯一性:同一首歌听十遍,列表里应该只有一条记录,而不是十条。用户看到满屏重复的歌会直接疯掉。这条要求意味着写入时要走去重逻辑。
  • 时效性:今天播放的歌排在前面,昨天的排后面,一周前的基本可以沉底。列表要能按时间维度分组,而不是永远从数据库读全量再慢悠悠排序。
  • 可续播:点进去要尽量回到上次的播放位置,至少要保存“上次听到第几秒”。这条是体验分水岭:能不能让用户觉得“这个App懂我”,全靠它。

我后面所有设计都围绕这三个指标展开。

1.3 明确边界:正在播放和已播放完是两种状态

还有一个容易搞混的点:“当前正在播”的记录和“曾经播完或播到一半跳走”的记录,应该分开处理。

我正在听的歌,如果它同时出现在“最近播放”列表里,这条记录的位置到底是跟随当前歌曲实时变化,还是定格在我开始听的那一刻?我的做法是:当前正在播放的歌曲在最顶部用“正在播放”标记,进度条显示实时位置;其他历史记录则显示“上次听到第几分几秒”,并在用户切歌或播放结束时把定格时间写入数据库。

这样处理的好处是,用户扫一眼列表就能知道“哪首正在播、哪首是以前听的”,不用靠猜。后面UI章节我会给出具体实现。

2. 音频服务层的上报链路:让播放器主动汇报“听没听、听了多久”

2.1 播放器事件流是最近播放的数据源头

在Flutter for OpenHarmony项目里,音频播放底层是原生的,Flutter侧通过EventChannel接收播放器回调。这是Flutter和原生通信的一种机制:原生侧可以主动向Dart侧发送事件流,Dart侧则像听广播一样订阅。

我的上报链路是这样设计的:

原生播放器事件 └─ EventChannel 推送 └─ Dart 侧 PlayerEventDispatcher ├─ 播放状态变化:开始播放、暂停、停止、播放完成 └─ 进度心跳:当前秒数、总时长 └─ RecentPlayRepository 择优落库

这里可能需要给入门读者补充一句:EventChannel和MethodChannel不一样。MethodChannel是“Dart主动请求,原生返回结果”,一问一答;EventChannel是“原生持续推送,Dart被动接收”,适合播放状态这类持续变化的数据。

2.2 上报点的设计:不是所有时机都要写库

我一开始想得太简单,以为收到事件就落库,结果数据库写操作频繁到卡UI。后来梳理出四个关键上报点:

  • 播放开始:记录歌曲ID、开始时间、总时长、当前进度(通常是0)。这是“最近播放”的主要条目来源,代表用户确实开始听了。
  • 进度跳变(seek):用户拖动进度条后,需要更新本地的定格进度。比如用户从第10分钟拖到第35分钟,这条记录里“上次听到”就应该刷新。
  • 中途切歌:当前歌曲被切换时,要把此刻的进度解析出来,写入数据库。这是最容易被漏掉的上报点,我会在踩坑部分重点说。
  • 播放完成:进度归零或写一个已完成标记,保证下次点击从此歌开头播就行。

如果用户只是把App切到后台但歌曲仍在播放,这个状态下不要频繁落库,只要在内存里维护实时进度;等切歌、暂停、退出等关键节点再统一写。

2.3 进度心跳的节流策略:两秒一次足够

为了支持界面显示“正在播放的歌曲当前进度”,原生侧一般会每隔几百毫秒推送一次进度。但这里必须做节流。

我的实验结论是:进度事件每2秒落一次库或更新一次UI即可,低于这个频率用户感知不到差别,反而白白浪费IO。进度条UI可以说是走一个200毫秒的平滑动画,完全不影响体验。

具体实现上,我在Dart侧包了一层节流器,核心逻辑是:

class Throttle { DateTime _last = DateTime.fromMillisecondsSinceEpoch(0); final Duration interval; Throttle(this.interval); bool shouldEmit() { final now = DateTime.now(); final delta = now.difference(_last); if (delta >= interval) { _last = now; return true; } return false; } }

只有shouldEmit()返回true时,才把进度解析出来更新内存对象或者写库。实测在OpenHarmony设备上,这样写能明显减小EventChannel的消息积压,列表滑动也不会卡顿掉帧。

2.4 踩坑记录:EventChannel的“迟到事件”导致重复上报

这是我在调试过程中踩过最难受的一个坑。切换歌曲时,原生侧会发出一个“旧歌暂停”和“新歌开始”的事件,这两个事件几乎同时到达Dart侧。但EventChannel的事件到达顺序在某些设备上并不严格保证,偶尔会出现“新歌开始”先到、“旧歌暂停”后到的情况。

结果就是,本来想写“新歌A”的最近播放记录,半路被迟到的“旧歌B暂停”事件覆盖了,列表里显示的是旧歌,而且新歌反而不在列表里。

解决办法是在Dart侧引入事件序号(sequence)。原生侧每发送一个播放器事件,都带一个自增序号;Dart侧只有序号比当前缓存大的事件才被处理,迟到的旧事件直接丢弃:

class PlayerEventDispatcher { int _lastSeq = 0; void onPlayerEvent({required int seq, required String type, required int position}) { if (seq < _lastSeq) { return; // 迟到的旧事件,丢弃 } _lastSeq = seq; // 正常处理 } }

这个改动成本极低,但一下子把列表里记录错乱的bug治好了。

3. 本地存储选型与表结构:为什么我放弃了偏好存储和JSON文件

3.1 三种存储方案对比

最初就有同事建议,最近播放不就一个小列表吗,用SharedPreferences存个JSON数组得了。我后来翻了需求,发现完全行不通。这里把方案对比列出来:

维度SharedPreferences / 偏好存储JSON文件SQLite
数据结构化只能存字符串,高层JSON难维护还行,但要自己处理并发强结构化,字段明确
查询能力几乎为零,全量读出再内存过滤全量载入再过滤SQL条件查询、聚合去重
频繁写入不合适,全量序列化性能差每次写入都要很小心事务、增量更新,稳定
单条更新要读全量再写全量同左UPDATE单行
数据量上限很小如果歌不多还行十万级也没问题

OpenHarmony上跑Flutter,很多老插件其实没有直接适配版本,但SQLite几乎总是有社区的适配方案(例如借助原生平台能力封装的don database库),而且它的关系模型最适合“最近播放”这种需要按字段筛选、按时间排序、做去重聚合的场景。

3.2 表结构设计:四个字段打底,两个字段进阶

我在项目里最终使用的表结构如下:

CREATE TABLE recent_play ( id INTEGER PRIMARY KEY AUTOINCREMENT, song_id TEXT NOT NULL UNIQUE, played_at INTEGER NOT NULL, -- 最后播放的Unix毫秒时间戳 progress INTEGER NOT NULL DEFAULT 0, -- 上次听到的秒数 duration INTEGER NOT NULL DEFAULT 0, -- 歌曲总秒数 source TEXT NOT NULL DEFAULT '', -- 来源:本地/在线/收藏 is_finished INTEGER NOT NULL DEFAULT 0 -- 是否已完整播放完 ); CREATE INDEX idx_recent_played_at ON recent_play(played_at DESC);

几个容易被忽视的点:

  • song_id设置UNIQUE约束:这直接用数据库兜底了“唯一性”,避免上层去重逻辑漏了导致重复记录。替代方案是应用层先查再插,但并发场景可能有空档;有UNIQUE约束,就算应用层逻辑写错了,第二次插入也会抛出冲突异常,能及早发现bug。
  • played_at只存最后播放时间:不需要保存播放历史明细。用户只关心“我最后一次听它是什么时候”,太细的历史除了占存储空间没有意义。
  • is_finished:这个字段很实用。用户完整播完一首歌,再点开列表时我不希望显示“上次听到第59分30秒”之类的残留信息,而是直接从头开始;只有没播完的歌才需要恢复进度。

3.3 落库时机:宁可延迟,不要频繁

数据库写入如果过于频繁,会挤占UI线程。我的策略是构建一个RecentPlayRepository,所有写操作都通过它异步排队:

class RecentPlayRepository { Future<void> write({ required String songId, required int playedAt, required int progress, required int duration, String source = 'local', bool isFinished = false, }) async { final db = await _dbProvider.database; await db.insert( 'recent_play', { 'song_id': songId, 'played_at': playedAt, 'progress': progress, 'duration': duration, 'source': source, 'is_finished': isFinished ? 1 : 0, }, conflictAlgorithm: ConflictAlgorithm.replace, ); } }

这里的ConflictAlgorithm.replace刚好利用song_id的UNIQUE约束,实现“新数据顶掉旧记录”的效果。实时进度先保留在内存中的活跃播放对象里,切换歌曲或者播放完成时再调write()落库,用户往下拉列表也只查只读的SQLite,不会阻塞操作。

3.4 踩坑记录:打开数据库的时机比想象中早

OpenHarmony设备上,应用冷启动后第一时间可能就会回放“最近播放”列表,但数据库插件异步初始化的速度跟不上,导致List页面刚打开时查询抛异常。

我的处理办法是在App启动流程里提前预初始化数据库连接,而不是等进入页面才打开。例如:

void main() { WidgetsFlutterBinding.ensureInitialized(); final dbProvider = DatabaseProvider(); dbProvider.preOpen(); // 异步预打开 runApp(MyApp(dbProvider: dbProvider)); }

页面查询时用await等待同一个provider实例,这样数据就绪顺序可控,再也没出现过启动闪退。

4. 去重、修剪与排序:让历史记录列表看起来“聪明”

4.1 唯一性方案的取舍:先删后插 vs 冲突替换

前面提到UNIQUE约束和ConflictAlgorithm.replace。这里展开讲一下两条实现路径:

  • 先删后插:应用层先DELETE FROM recent_play WHERE song_id = ?,再INSERT。逻辑直观,但DELETE和INSERT是两个操作,如果第二步失败,会丢记录。
  • 冲突替换:靠INSERT OR REPLACE或ConflictAlgorithm.replace,一条SQL搞定。缺点是旧记录被整体替换,如果把自增id作为外键与其他表关联,要注意替换后外键变化。

我的场景里recent_play表没有其他表引用它,所以直接用了冲突替换,简单可靠。如果你以后要扩展“播放次数统计”之类的功能,建议再增加一个play_count字段,利用upsert语义做play_count = play_count + 1,效果更好。

4.2 播放次数到底要不要计

这里有个产品层面的选择:最近播放列表要不要显示播放次数?

我的答案是默认不显示。因为最近播放和常听榜单是两个概念:前者强调“最近”和“瞬间的连续性”,后者才强调“高频”。把播放次数塞进这个列表,会让用户的注意力从“我上次听到哪”转移到“我听了多少次”,信息密度反而下降。

但如果后台需要参考热度,可以在写入数据时顺带更新一张聚合表。这个属于进阶需求,当前版本不用做。先保证列表干净。

4.3 列表上限的策略:只保留最近100条

数据库表如果无限增长,查询和同步都会变慢。我给最近播放设了一个硬上限:只保留最近100条记录。超过之后,把最旧的那批删掉。

DELETE FROM recent_play WHERE id NOT IN ( SELECT id FROM recent_play ORDER BY played_at DESC LIMIT 100 );

这条查询我一般不在每次写入后执行,而是每天首次启动时跑一次。这样批量清理一次完成,不用每次插入都做一次全表扫荡。你也可以在played_at上建索引后定期执行,数据量大时执行时间也不会太长。

4.4 聚合查询:按天分组的SQL写法

UI展示“今天”“昨天”“更早”三个分组,没必要在Dart里写一堆循环判断。直接让SQL把时间戳转成本地日期的前缀:

SELECT song_id, MAX(played_at) AS lastPlayedAt, progress, duration, source, is_finished FROM recent_play GROUP BY song_id ORDER BY lastPlayedAt DESC;

这里由于song_id本身有UNIQUE约束,普通查询不会出现重复行;如果你以后放开约束,务必加上GROUP BY和MAX(played_at)聚合,否则列表会出现同歌多行。我踩过一次这个坑,最后发现是测试环境数据库的约束被手工改掉了,导致查询结果里同一个歌重复出现。排查了半天才意识到是环境问题,不是代码问题。这也是个提醒:不要轻易修改线上环境的表结构约束。

5. UI层落地:分组展示、进度恢复与状态刷新

5.1 时间维度分组:今天、昨天、一周前

“最近播放”列表如果平铺,用户很难一眼看出哪些是今天听的。我参照大多数音乐App的做法,按时间戳做分组:

  • 今天:played_at是今天的任意时刻
  • 昨天:played_at是昨天的任意时刻
  • 7天内:一周以内
  • 更早:一周以前

分组判断放在模型层,不放在Widget里:

enum RecentGroup { today, yesterday, thisWeek, older } RecentGroup groupOf(DateTime playedAt, DateTime now) { final todayStart = DateTime(now.year, now.month, now.day); final yesterdayStart = todayStart.subtract(const Duration(days: 1)); final weekStart = todayStart.subtract(const Duration(days: 7)); if (playedAt.isAfter(todayStart)) return RecentGroup.today; if (playedAt.isAfter(yesterdayStart)) return RecentGroup.yesterday; if (playedAt.isAfter(weekStart)) return RecentGroup.thisWeek; return RecentGroup.older; }

这里注意时区问题:不要直接对毫秒时间戳做减法来分天,因为“今天”的边界是自然日,不是24小时前。必须先把时间戳转成当地时间的DateTime,再取日期前缀比较。

5.2 列表项卡片设计:进度回放是第一优先级

每个列表项我放了三块信息:

  • 主标题:歌曲名
  • 副标题:歌手 + 时长
  • 右侧或底部:播放进度状态

进度状态分三种情况展示:

场景展示
播放中动态进度条 + “正在播放”标签
上次听到中间圆形进度环 + “上次听到 18:27”
已播完无进度显示,播放按钮位于唱片中心

圆形进度环用Flutter自带的CircularProgressIndicator包一层就行,不需要额外依赖。关键是你需要从仓库里拿到该歌曲的progress和duration,然后:

final percent = duration <= 0 ? 0.0 : progress / duration;

要注意除零问题,SD卡上有些歌曲的时长信息可能解析失败,duration会为0,直接除会得到Infinity或NaN,UI直接崩。

5.3 局部刷新:不整页重建List

最近播放页面有一个很魔幻的场景:正在播放的歌如果也在列表里,它的进度需要实时更新。如果整个ListView都监听播放器的进度事件,那么每秒都会触发一次全表重建,滚动列表时卡到你怀疑人生。

我的做法是把“正在播放的那一条”单独抽成一个ProgressTrackTile组件,它自己通过ValueNotifier<double>监听进度变化:

class ProgressTrackTile extends StatefulWidget { final String songId; final ValueNotifier<double> progressNotifier; // ... } class ProgressTrackTileState extends State<ProgressTrackTile> { @override Widget build(BuildContext context) { return ValueListenableBuilder<double>( valueListenable: widget.progressNotifier, builder: (context, progress, _) { // 只有进度变化时,才重建这一个tile return _buildTileContent(progress); }, ); } }

这样播放器每200毫秒推一次进度,实际被重建的Widget只有一个,ListView的其他部分完全不受影响。这是性能优化的关键一步,实测帧率从十几帧提升到稳定60帧。

5.4 空态和加载顺序:别让空列表闪一下

还有一个体验细节。最近播放列表刚打开时如果先展示空态,再慢慢加载数据,用户会觉得“我明明听过歌,怎么列表是空的”。

我用的方案是:进入页面时直接读SQLite最新100条记录,期间展示一个轻量骨架屏,而不是空态。用户看到的是几条灰色块在闪,最多0.2秒就被真实数据替换。只有当查询结果确实为空时,才展示“你还没有播放记录”的引导,配上“去发现音乐”的按钮。

这种细节不会写进需求文档,但直接决定用户对这个功能的第一印象。我始终觉得,流畅感和数据准确性,是这个功能的两条生命线。

6. 可复用的扩展思路:最近播放和“稍后听”联动

最后聊一点后续可以扩展的方向。

如果你的音乐App不是纯本地播放,而是有在线曲库,“最近播放”里还可以加一个“缓存到本地”的小按钮。用户点击后,把这首歌的在线音频地址交给下载队列,下次在无网络环境下也能从最近播放列表里直接点开。

我正好在做这个联动时发现,下载队列和最近播放的进度数据需要共用一套歌曲ID规范。如果你的音乐ID有多个体系,比如本地音乐一个ID、在线歌单另一个ID,最近播放表最好再存一个type字段,区分“本地音频”“在线音频”“播客”。我当前版本有source字段,但没有细分type,后续要扩展时可以用一个字符串字段做扩展标记。

另外,数据恢复也是一个很值得做的点。最近播放属于“用户行为数据”,可以定期上传到云端,换设备或者重装App后拉回来,用户之前的收听轨迹就能无缝衔接。这个涉及账号体系和后端接口,不在本文范围内,但表结构设计时预留好played_at、progress、source这些字段,就是为那天铺路。

回到文章的起点,“最近播放”真正的难点从来不只是“记住用户听过什么”,而在于用最小的存储代价、最合理的刷新频率、最准确的去重策略,把用户“上次听到哪里”这个核心体验做到极致。我在实际开发中越来越确信一件事:这类偏“轨迹型”的功能,宁可前期多做一点存储和上报的设计,也不要等用户量上来后追着补。表结构你可以随时加字段,但一旦上线后的数据脏了,清洗的成本远比预想的高。希望这篇能帮你一次就把最近播放做对。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询