做Flutter跨平台开发的朋友应该清楚,音乐播放器这类App最绕不开的除了播放引擎,就是内容运营模块。今天这篇实战记录是系列的第17篇,讲讲在OpenHarmony上用Flutter写音乐播放器App时,怎么把“每日推荐”这个功能从零到一落地。我用了大概一个周末的时间,把推荐页的UI、数据链路、缓存策略和OpenHarmony的适配全部跑通,过程中踩了不少坑,尤其是跨天刷新的边界条件和开发板上的图片加载问题,这篇都会展开聊聊。
先说清楚这个功能解决什么问题:每日推荐不是简单的“随机歌单”,而是一套结合用户行为、时间维度、内容分发的数据链路。对音乐App来说,它是日活和播放时长的核心入口;对开发者来说,它牵扯到网络请求、本地缓存、状态管理和平台适配四个层面。如果你正在用Flutter做OpenHarmony端的应用,或者准备接手音乐类项目的推荐模块,这篇实战笔记可以直接当作参考。
1. 每日推荐的整体设计与数据链路
1.1 需求拆解:每日推荐到底在推荐什么
我见过不少新手拿到“每日推荐”这个需求就急着写UI,结果后端接口一给,前端各种手忙脚乱。其实第一步不是写代码,而是把推荐模块的边界理清楚。在我这个项目里,每日推荐页面需要承载几个核心区块:顶部的日期问候与天气/心情标签、中部可横滑的推荐理由标签页、下方主列表的推荐歌曲卡片。
这里的难点在于“推荐理由”的多样性。同样是推荐一首歌,系统可能因为“你最近常听民谣”推荐,也可能因为“你收藏了同一个歌手的另一首歌”推荐。这些不同理由会直接影响UI上标签的展示,所以接口返回的不应该只是一串歌曲ID,而应该包含reason(推荐理由)、reasonType(理由类型)、source(来源)这些字段。
对这个项目来说,我最终定的数据模型长这样:
dart复制class RecommendSong {
final String songId;
final String title;
final String artist;
final String album;
final String coverUrl;
final String reason;
final String reasonType;
final int duration;
final int heatScore;
RecommendSong({
required this.songId,
required this.title,
required this.artist,
required this.album,
required this.coverUrl,
required this.reason,
required this.reasonType,
required this.duration,
required this.heatScore,
});
factory RecommendSong.fromJson(Map<String, dynamic> json) {
return RecommendSong(
songId: json['songId'] as String,
title: json['title'] as String,
artist: json['artist'] as String,
album: json['album'] as String,
coverUrl: json['coverUrl'] as String,
reason: json['reason'] as String,
reasonType: json['reasonType'] as String,
duration: json['duration'] as int,
heatScore: json['heatScore'] as int,
);
}
}
字段里的heatScore是我额外加的,用于前端兜底排序。如果服务端没有返回排序权重,前端就可以用这个字段做降序排列,保证推荐列表的头部是热度最高的内容。
另外,推荐理由标签页建议做成“固定标签 + 动态标签”的混合结构。固定标签就是“为你推荐”“每日30首”,直接写死;动态标签来自服务端,比如“因为你听了陈粒”“相似歌手推荐”。这样即使某天动态标签为空,页面也不会显得空荡荡。
1.2 数据层设计:推荐接口、缓存与降级方案
每日推荐的数据层设计,我习惯把重点放在“缓存策略”上。因为推荐内容的时效性很强,但用户不可能每次都等待网络加载,尤其在某信、某Q这类IM软件霸占用户时间的环境下,超过2秒的加载就会明显流失用户。所以在OpenHarmony和Android双端上,我统一采用“本地先出、远端再更”的策略。
具体流程是:
- 进入每日推荐页面,先检查本地缓存中是否存在今天的推荐数据。
- 如果存在,直接用缓存数据渲染首屏,同时异步请求最新接口。
- 如果不存在,展示骨架屏,请求接口成功后再渲染,并把结果写入缓存。
- 接口请求失败时,如果本地有历史数据,展示历史数据并提示“离线推荐”。
- 本地也没有数据时,才展示空白错误页。
存储方面,我用的是shared_preferences,键名按日期做隔离:
dart复制static String cacheKey(DateTime date) {
return 'daily_recommend_${date.year}-${date.month}-${date.day}';
}
这样天然支持跨天判断。进入页面时,用今天的日期去拼接缓存键,如果昨天的键存在而今天的不存在,说明跨天了,必须触发刷新。
数据模型设计上,我单独把网络返回和本地缓存建了映射关系。这里有个容易踩的坑:直接用shared_preferences存复杂嵌套JSON虽然能跑,但读取和解析都会变慢。所以我建议把JSON序列化好的字符串存进去,而不是存Map。
dart复制class DailyRecommendCache {
static Future<void> save(List<RecommendSong> songs) async {
final prefs = await SharedPreferences.getInstance();
final list = songs.map((e) => e.toJson()).toList();
prefs.setString(cacheKey(DateTime.now()), jsonEncode(list));
}
static Future<List<RecommendSong>?> load() async {
final prefs = await SharedPreferences.getInstance();
final raw = prefs.getString(cacheKey(DateTime.now()));
if (raw == null) return null;
final list = jsonDecode(raw) as List<dynamic>;
return list
.map((e) => RecommendSong.fromJson(e as Map<String, dynamic>))
.toList();
}
}
另外,接口层建议把“网络错误”和“推荐为空”分开处理。因为每日推荐理论上不应该为空,一旦为空,大概率是服务端配置问题,前端要做兜底逻辑。我这边兜底逻辑是本地内置一份静态推荐歌单,当接口返回空数组时,直接从assets里加载预置数据,保证页面有内容可展示。
1.3 状态管理:从加载到渲染的状态机
状态管理我用的还是Provider,这个在之前的系列文章里也提过。每日推荐页的状态我划分成四个:loading、success、empty、error。不要小看状态拆分,很多推荐页卡死或白屏,就是因为把“加载失败”和“加载成功但数据为空”混为一谈。
状态机的核心放在一个DailyRecommendProvider里:
dart复制enum RecommendStatus { loading, success, empty, error }
class DailyRecommendProvider extends ChangeNotifier {
RecommendStatus _status = RecommendStatus.loading;
List<RecommendSong> _songs = [];
String _errorMsg = '';
RecommendStatus get status => _status;
List<RecommendSong> get songs => _songs;
String get errorMsg => _errorMsg;
Future<void> loadDailyRecommend({bool forceRefresh = false}) async {
_status = RecommendStatus.loading;
notifyListeners();
if (!forceRefresh) {
final cached = await DailyRecommendCache.load();
if (cached != null && cached.isNotEmpty) {
_songs = cached;
_status = RecommendStatus.success;
notifyListeners();
_fetchInBackground();
return;
}
}
try {
final result = await DailyRecommendApi.fetchDaily();
if (result.isEmpty) {
final fallback = await FallbackSongs.loadFromAssets();
_songs = fallback;
_status = RecommendStatus.success;
} else {
_songs = result;
_status = RecommendStatus.success;
DailyRecommendCache.save(result);
}
} catch (e) {
final cached = await DailyRecommendCache.load();
if (cached != null && cached.isNotEmpty) {
_songs = cached;
_status = RecommendStatus.success;
} else {
_status = RecommendStatus.error;
_errorMsg = e.toString();
}
}
notifyListeners();
}
}
这里有个小细节:loadDailyRecommend如果发现本地已经有今天的缓存,还是会触发一次_fetchInBackground,就是为了后台静默更新,前端不打断用户操作。这个小优化在OpenHarmony开发板上尤其重要,因为开发板的网络栈性能一般,如果每次进页面都卡在加载圈上,交互体验会很差。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心UI实现:推荐页的布局与交互
2.1 顶部日期栏与推荐Banner
推荐页的头部我设计成三个部分:日期问候、推荐主题Banner、标签筛选栏。日期问候很好做,用intl包格式化一下:
dart复制String getGreeting() {
final hour = DateTime.now().hour;
if (hour < 6) return '凌晨了,听听歌吧';
if (hour < 12) return '早上好,开始新的一天';
if (hour < 18) return '下午好,放松一下';
return '晚上好,享受音乐';
}
推荐Banner我这边用的是PageView加Stack实现,可以支持多张运营图轮播,每张图点击后可以跳转到对应歌单。这里要注意OpenHarmony和Android在PageView上的手势冲突问题,实测下来在开发板上横滑偶尔会提前被父级ScrollView拦截。解决方案是给PageView设定明确的physics属性:
dart复制PageView(
controller: _pageController,
physics: const ClampingScrollPhysics(),
onPageChanged: (index) {
setState(() {
_bannerIndex = index;
});
},
children: bannerList,
)
用ClampingScrollPhysics而不是默认的PageScrollPhysics,在OpenHarmony的触控事件分发机制下更稳定,不会出现滑动一半回弹的诡异情况。
标签栏我用的是一排横向滚动的ChoiceChip,选中的标签会联动下方列表的筛选项。这个逻辑不复杂,但要注意chip的状态管理,建议把选中标签的index提升到Provider层,而不是用StatefulWidget内部状态,否则列表筛选逻辑会因为widget重建而丢失状态。
2.2 歌曲卡片列表:下拉刷新与加载更多
每日推荐的歌曲列表,我最终没做成传统的列表行,而是采用“大卡片 + 列表行”混合布局。前两首使用大卡片展示,突出运营位置;后面的歌曲使用紧凑列表行,提高信息密度。
大卡片布局核心代码:
dart复制Widget buildLargeCard(RecommendSong song) {
return Card(
clipBehavior: Clip.antiAlias,
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: InkWell(
onTap: () => playSong(song),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AspectRatio(
aspectRatio: 16 / 9,
child: CachedNetworkImage(
imageUrl: song.coverUrl,
fit: BoxFit.cover,
placeholder: (context, url) => buildSkeleton(),
errorWidget: (context, url, error) => const Icon(Icons.music_note),
),
),
Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(song.title,
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
const SizedBox(height: 4),
Text('${song.artist} · ${song.reason}',
style: TextStyle(fontSize: 12, color: Colors.grey[600])),
],
),
),
],
),
),
);
}
列表部分用ListView.separated,因为推荐列表的item数量在30首以内,不需要做复杂的回收复用优化。但要注意的是,CachedNetworkImage在OpenHarmony上如果没配置好网络权限,会直接报错,这个我在第四节会专门讲。
下拉刷新用的是RefreshIndicator,注意onRefresh里要返回Future,而且要设置forceRefresh参数走真正的新接口:
dart复制RefreshIndicator(
onRefresh: () => context.read<DailyRecommendProvider>().loadDailyRecommend(
forceRefresh: true,
),
child: ListView(...),
)
加载更多方面,因为每日推荐一般固定30首,不需要无限滚动。但我还是预留了分页字段page和pageSize,因为后续可能接入“换一批”功能,每次替换掉当前列表的50%内容,那时候分页逻辑就能直接用。
2.3 播放交互与切换逻辑
点击推荐歌曲卡片后,需要把歌曲加入播放队列,并自动开始播放。这个逻辑在音乐App里属于基础功能,但跨页面状态同步经常出bug。我的做法是维护一个全局的PlaybackProvider,在推荐页点击后直接改全局状态,而不是在推荐页内部自己持有播放列表。
具体切歌逻辑:
dart复制Future<void> playSong(RecommendSong song) async {
final playback = context.read<PlaybackProvider>();
final queue = context.read<DailyRecommendProvider>().songs.map((e) => e.songId).toList();
final index = queue.indexOf(song.songId);
await playback.playQueue(queue, startIndex: index);
}
这里有个体验细节:推荐页进入时会预加载第一首歌的封面图和音频流头部,这样用户点击第一首歌时几乎是秒开,不会出现点击后等2秒才开始播放的尴尬。实现方式是用AudioPlayer的setAudioSource但不调用play,只建立管道:
dart复制void preloadFirstSong(RecommendSong song) {
playback.preload(song.songId, song.audioUrl);
}
当然,预加载会消耗一定的流量和内存,需要在设置项里加一个开关。我这边默认打开,因为OpenHarmony的开发板跑本地音乐文件较多,预加载的收益还是很明显的。
3. 推荐算法与业务逻辑落地
3.1 基于播放历史的推荐打分
服务端接口没就绪之前,我想让前端也能“自运营”。所以我在前端写了一个简易版推荐打分器,逻辑很简单,就是基于用户最近7天的播放历史做权重计算:
dart复制class LocalRecommender {
static List<RecommendSong> recommend({
required List<PlayRecord> history,
required List<RecommendSong> pool,
}) {
final Map<String, int> artistScore = {};
final Map<String, int> genreScore = {};
for (final record in history) {
artistScore[record.artist] = (artistScore[record.artist] ?? 0) + record.playCount;
genreScore[record.genre] = (genreScore[record.genre] ?? 0) + record.playCount;
}
final scored = pool.map((song) {
int score = 0;
score += (artistScore[song.artist] ?? 0) * 3;
score += (genreScore[song.genre] ?? 0) * 1;
score += song.heatScore ~/ 10000;
return _ScoredSong(song: song, score: score);
}).toList();
scored.sort((a, b) => b.score.compareTo(a.score));
return scored.take(30).map((e) => e.song).toList();
}
}
这个打分器虽然简单,但已经能解释“为什么推了这首歌给你”。如果用户最近狂听某个歌手的歌,那这个歌手的其他作品加权分就会明显靠前,推荐理由标签可以显示“因为你常听XXX”。
这里要注意的是,打分器只负责排序,不要把歌切掉。也就是说,用户听过的歌要不要过滤掉,得看产品需求。我这边考虑到“重复推荐会让人烦躁”,默认过滤掉最近3天播放超过5次的歌曲。
3.2 每日种子与跨天刷新机制
每日推荐区别于普通歌单的关键在于“每日更新”。我这边设计了两个触发刷新的时间点:
- 进入页面时,用当前日期匹配本地缓存键,键不存在就刷新。
- 应用从后台恢复时,重新计算日期,如果跨天就静默刷新。
第一个时间点比较直观,第二个时间点容易漏。用户昨晚睡前打开App,今天早上从后台切回来,如果不监听生命周期,页面就还停留在昨天的推荐列表。我用了WidgetsBindingObserver来监听:
dart复制class RecommendLifecycleObserver with WidgetsBindingObserver {
final VoidCallback onDayChanged;
RecommendLifecycleObserver(this.onDayChanged);
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
final now = DateTime.now();
if (!_isSameDay(now, _lastEnterDate)) {
onDayChanged();
_lastEnterDate = now;
}
}
}
bool _isSameDay(DateTime a, DateTime b) {
return a.year == b.year && a.month == b.month && a.day == b.day;
}
}
还有一个更隐蔽的边界:跨天时用户恰好停留在推荐页,且页面处于Stack的顶层。这时候如果只靠生命周期回调,可能没有触发,因为App一直处于前台。更好的做法是在页面每次build的时候检查一下日期,如果发现当前日期和构建时的缓存日期不一致,就主动触发刷新。
我最终的做法是在DailyRecommendProvider的构造函数里记录_currentDay,然后提供一个checkDayChanged方法,在build方法中调用:
dart复制@override
Widget build(BuildContext context) {
context.read<DailyRecommendProvider>().checkDayChanged();
return Scaffold(...);
}
实测下来,这个方案最稳,不会漏掉任何跨天场景,虽然每次build都会有一个日期比较的微开销,但可以忽略不计。
3.3 自动降级与前端兜底策略
推荐接口在高并发或者服务端故障时容易超时,所以前端必须做好降级策略。我的方案分三级:
- 一级降级:接口超时,但本地有今天的缓存,展示缓存内容并显示顶部提示条“网络不给力,当前为离线推荐”。
- 二级降级:接口超时,本地没有缓存,但有历史推荐,展示历史推荐,并在列表顶部显示“上次推荐:昨天”。
- 三级降级:全都没有,加载内置的assets歌单,保证页面不白屏。
内置assets歌单我放在assets/fallback_songs.json里,大约放20首公版音乐,重点是coverUrl要使用本地资源,不能依赖网络图片,否则降级方案就成了摆设。
内置歌单的加载逻辑:
dart复制class FallbackSongs {
static Future<List<RecommendSong>> loadFromAssets() async {
final raw = await rootBundle.loadString('assets/fallback_songs.json');
final list = jsonDecode(raw) as List<dynamic>;
return list
.map((e) => RecommendSong.fromJson(e as Map<String, dynamic>))
.toList();
}
}
这里要注意,assets里的图片路径尽量用assets/images/cover_xx.png这种格式,不要用完整的file:///路径,因为OpenHarmony的文件路径和Android不一样,直接写死路径会导致图片加载失败。正确做法是把图片资源也声明在pubspec.yaml的assets段里,用Image.asset加载。
4. OpenHarmony平台适配与Flutter工程实践
4.1 OpenHarmony下的网络与图片加载适配
在OpenHarmony上跑Flutter,最典型的适配问题是网络权限和证书校验。Flutter在OpenHarmony上的网络请求走的是标准socket,本身不需要额外配置,但如果你用了CachedNetworkImage这类依赖http的库,就需要确保设备能访问外网,同时检查HTTP明文流量是否被拦截。
OpenHarmony默认对明文HTTP有限制,如果推荐接口用的是http://而不是https://,需要在工程配置里放开明文流量。这里要强调,不同开发板上的实现路径不一样,RK3568和RK3588的镜像配置存在差异。我实测在RK3568上,http://请求会直接报Connection refused或者Cleartext HTTP traffic not permitted,需要在entry/src/main/resources/base/profile/network_config.json里配置:
json复制{
"network-security-config": {
"base-config": {
"cleartextTrafficPermitted": true
}
}
}
这个文件的具体路径和字段名在不同的OpenHarmony SDK版本里有差异,建议查阅当前SDK版本对应的文档,但思路是一致的:把明文流量权限打开。
图片加载方面,我建议在OpenHarmony端不要直接使用CachedNetworkImage的默认缓存目录,因为它用的是path_provider获取的临时目录,在OpenHarmony上的路径命名可能与Android不同。我封装了一个简单的图片加载组件,先尝试从本地缓存加载,没有再去网络拉取。
图片加载还有一个坑是并发数。推荐页首屏一次性有30首歌的封面要加载,如果直接并发发30个请求,开发板的网络栈会受不了,导致部分图片一直转圈。我用了dio的并发限制,或者简单粗暴限制图片缓存组件同时加载的数量不超过6个。
dart复制class LimitedImageLoader {
static final Semaphore _semaphore = Semaphore(6);
static Future<File> loadImage(String url) async {
return _semaphore.runExclusive(() async {
// 实际图片下载与缓存逻辑
});
}
}
4.2 音频播放的选型与接入
音频播放是音乐App的核心,但在OpenHarmony生态里,Flutter的音频插件选择非常有限。Android上常用的just_audio、audioplayers,在OpenHarmony上支持度各不相同。我在项目里最终用的是audioplayers的OpenHarmony适配版本,配合media_kit做备选方案。
这里有个经验:不要一上来就引入重量级播放器插件,先确认插件是否支持OpenHarmony的音频焦点和后台播放。每日推荐页面的播放场景是轻量的,用户点击歌曲后需要快速起播,audioplayers能满足。但如果要做后台播放、锁屏控制、耳机线控,就需要更底层的接入方案,这个我打算在后续系列里单独讲。
播放器接入的核心代码:
dart复制final player = AudioPlayer();
Future<void> playUrl(String url) async {
await player.stop();
await player.play(UrlSource(url));
player.onPlayerComplete.listen((event) {
// 播放完成,自动切下一首
_playNext();
});
}
在OpenHarmony开发板上实测,audioplayers播放本地音频文件(assets路径)问题不大,但播放网络音频流偶尔会卡顿,尤其是Wi-Fi信号不稳时。这里的建议是播放前加一个缓冲区预填,而不是直接调用play。
4.3 真机调试与性能优化
OpenHarmony的真机调试和Android有个明显区别:你无法直接用Android的adb命令查看所有日志,有一部分系统日志需要通过OpenHarmony的hdc工具获取。比如你要看系统版本,用hdc shell param get const.product.name。在调试每日推荐功能时,如果页面出现闪退,不要只盯着Flutter的日志,建议先抓一下hdc的hilog日志,确认是不是系统层面的内存不足或者权限拒绝。
每日推荐页的性能优化,我关注三个点:
- 首帧渲染时间:通过
SchedulerBinding.addTimingsCallback监听首帧耗时,控制在500ms以内。 - 列表滑动流畅度:避免在
build方法里做耗时操作,尤其是jsonDecode这种CPU密集操作,必须放到compute或者Isolate里。 - 内存占用:图片缓存会吃掉大量内存,尤其是推荐页加载30张大图时,需要在
CachedNetworkImage里设置memCacheWidth,不要无脑加载原图。
dart复制CachedNetworkImage(
imageUrl: song.coverUrl,
memCacheWidth: 300,
memCacheHeight: 300,
maxWidthDiscard: 400,
maxHeightDiscard: 400,
)
这个设置在低配开发板上效果很明显,实测内存峰值从500MB降至220MB左右。
5. 常见问题与排查实录
5.1 每日推荐不刷新的定位过程
有次我在RK3588开发板上测试,发现无论怎么重启App,每日推荐列表都不变。第一反应是缓存问题,清掉shared_preferences后仍然不变,说明不是缓存逻辑的问题。后来用hdc抓日志,发现接口请求根本没有发出去,因为我在进入页面时判断了“缓存存在且日期一致”就直接返回,没有触发后台刷新。
最终定位到问题出在_fetchInBackground里,我在发送请求前写了一个判断:如果_isFetching为true就跳过。但_isFetching初始值是false,而我在缓存加载完成后忘了把_isFetching重新置为false,导致后续请求一直被拦截。修复很简单,在loadDailyRecommend方法开头重置状态:
dart复制_isFetching = false;
这种低级错误在真实业务里特别容易发生,尤其是加了各种防抖、节流、异步锁之后,状态管理就变得错综复杂。建议在Provider里维护一个完整的请求生命周期日志,每次请求的发起、成功、失败、缓存命中都打印出来,排查问题会快很多。
5.2 图片加载失败与缓存雪崩
OpenHarmony上图片加载失败的报错信息通常比较模糊,第一次遇到时我花了不少时间才定位到是证书信任问题。开发板上没有预装任何一个权威CA根证书,所以https://的图片请求也会失败。解决方案是在初始化图片库时配置一个自定义的HttpClient,跳过证书校验(仅限调试用):
dart复制final httpClient = HttpClient()
..badCertificateCallback = (cert, host, port) => true;
这种方式只适合开发阶段,上线版本一定要做正规的证书校验,否则会有中间人攻击风险。
另一个图片相关的问题是缓存雪崩。推荐页首屏一次性加载30首歌的封面,如果恰好缓存全部失效,会瞬间产生30个并发下载请求,在低配开发板上直接把网络栈打满,导致后续的音频请求排队超时。我最终的方案是在图片加载组件上做一个简单的并发控制,同一时间最多6个下载请求,多余请求排队。
5.3 列表卡顿与内存泄漏排查
每日推荐页在长时间使用后出现滑动卡顿,通常有两个原因:一是图片缓存占满内存,二是页面销毁后播放器没有释放。后者在Flutter里特别隐蔽,因为AudioPlayer是单例还是页面级对象,很多人搞混。
我遇到的情况是:从推荐页切到歌单页再切回来,推荐页的内存暴涨。排查发现是PlaybackProvider里的播放队列一直持有推荐页的RecommendSong对象,而推荐页轮播图PageView在销毁时没有调用dispose,导致图片流监听器泄漏。
解决方法是在推荐页的dispose方法里主动释放资源:
dart复制@override
void dispose() {
_pageController.dispose();
WidgetsBinding.instance.removeObserver(_lifecycleObserver);
super.dispose();
}
同时,在PlaybackProvider里提供一个clearQueue方法,切换到非推荐页时清空队列中不属于当前页面的歌曲引用。
写在最后的实践经验
这一篇写了挺多踩坑记录,大部分坑都是因为OpenHarmony的生态还在快速演进,工具链和Android有差异,照搬Android的经验很容易翻车。我个人建议做OpenHarmony端Flutter开发时,从第一天开始就把OpenHarmony真机作为第一优先级测试目标,不要只在模拟器里跑。模拟器上网络、图片、音频的表现和真机差距很大,尤其是RK系列开发板的性能和后台机制,跟手机完全是两种体验。
如果你打算自己复刻这个每日推荐功能,建议从数据模型和缓存策略开始动手,先把离线推荐、跨天刷新、降级方案这些非UI逻辑跑通,再去做UI。这样即使UI调整十次,核心逻辑也不用大改。下一篇打算写推荐页的动效实现,包括卡片入场动画、播放进度同步和歌词页联动,有兴趣的可以关注。
