ARTICLE DETAIL

资讯详情

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

Flutter在OpenHarmony的最近播放实践:上报、去重与增量刷新

Flutter在OpenHarmony的最近播放实践:上报、去重与增量刷新 做音乐播放器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 { Futurevoid 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或NaNUI直接崩。5.3 局部刷新不整页重建List最近播放页面有一个很魔幻的场景正在播放的歌如果也在列表里它的进度需要实时更新。如果整个ListView都监听播放器的进度事件那么每秒都会触发一次全表重建滚动列表时卡到你怀疑人生。我的做法是把“正在播放的那一条”单独抽成一个ProgressTrackTile组件它自己通过ValueNotifierdouble监听进度变化class ProgressTrackTile extends StatefulWidget { final String songId; final ValueNotifierdouble progressNotifier; // ... } class ProgressTrackTileState extends StateProgressTrackTile { override Widget build(BuildContext context) { return ValueListenableBuilderdouble( 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这些字段就是为那天铺路。回到文章的起点“最近播放”真正的难点从来不只是“记住用户听过什么”而在于用最小的存储代价、最合理的刷新频率、最准确的去重策略把用户“上次听到哪里”这个核心体验做到极致。我在实际开发中越来越确信一件事这类偏“轨迹型”的功能宁可前期多做一点存储和上报的设计也不要等用户量上来后追着补。表结构你可以随时加字段但一旦上线后的数据脏了清洗的成本远比预想的高。希望这篇能帮你一次就把最近播放做对。
返回列表