做音播App最怕的就是页面花架子,光好看不抗揍。而“发现音乐”这种高密度内容页,更是检验跨端框架成色的试金石。我最近把一套Flutter写的音乐播放器完整跑到了开源鸿蒙(OpenHarmony)设备上,重点啃下了“发现音乐”这个模块,整个过程比预想的要曲折,但也真摸出了一些可复用的门道。这篇记录会把从工程接入、UI拆解、状态管理到音频播控、真机适配的完整链路讲清楚,准备在OpenHarmony上做复杂业务、或者正在评估Flutter跨端方案的团队,可以直接照着我踩过的路走一遍,能省下不少试错时间。
1. 项目全景与方案选型:为什么是Flutter for OpenHarmony
1.1 需求背景:发现音乐页到底要承载什么
很多人以为“发现音乐”就是几个推荐位拼在一起,真上手做才发现它是播放器里功能密度最高的页面。我接到的需求大致是这样的:顶部要有一个可搜索入口和天气/问候语;中间是Banner轮播位;往下是“每日推荐”的横向滑动卡片,“热门歌单”的网格瀑布流,以及“新歌首发”“飙升榜”等多个Tab聚合列表。这些模块的数据来源不是同一个接口——Banner来自运营配置平台,歌单推荐来自个性化算法,榜单来自运营手动排序。这意味着UI层要同时聚合多路异步数据,且每个模块的加载状态、失败重试策略、曝光埋点都不一样。
这个页面最能暴露一个跨端框架的成熟度。用它来做项目切入点,纯粹是因为它足够复杂:复杂到能把网络层、缓存层、状态管理、列表性能、图片加载、播放器播控全部串起来验证一遍。如果Flutter能把“发现音乐”在OpenHarmony上跑得顺滑,那其他常规页面基本不会再有挡路的难点。
另一个考虑是需求侧的。音乐App的内容运营节奏很快,运营位、排序规则、UI样式经常要跟着热点调整,原生双端同步开发的成本会越来越高。引入Flutter之后,UI层完全由Dart统一渲染,视觉差异、功能错版这些问题会在根上减少一大半。对于开源鸿蒙这种生态还在成长期的系统来说,用成熟跨端框架补齐应用生态,是现阶段比较务实的路线。
1.2 技术选型对比:原生、Web渲染还是Flutter
项目启动前,团队内部其实做了一轮选型对比。当时摆在桌面上的方案有四个:OpenHarmony原生ArkTS开发、Flutter跨端、WebView渲染、以及静态H5套壳。四者各有利弊,但落到“发现音乐”这个场景时,差距就非常明显。
| 方案 | UI还原度 | 开发效率 | 性能表现 | 生态现状 |
|---|---|---|---|---|
| 原生ArkTS | 最高 | 低,积累少 | 高 | 组件库和第三方SDK偏少 |
| Flutter | 高,Skia自绘 | 高,组件丰富 | 高 | 成熟,但需适配桥接 |
| WebView | 中 | 高 | 中,白屏感明显 | 依赖前端生态 |
| H5套壳 | 低 | 最高 | 低 | 体验最差,不适合音播 |
我个人的判断是:OpenHarmony当前最缺的不是原生能力,而是上层业务组件的沉淀,也就是那些别人已经封装好的“轮播、宫格、弹窗、播放条”。Flutter的widget生态正好能把这一块补上,而且它不依赖系统原生控件去渲染UI,而是自己用Skia引擎绘制,所以就算OpenHarmony上某些系统控件的行为和安卓有细微差异,Flutter UI层也不容易受影响。这就是“UI一致性”在跨端实践里最实在的价值。
当然,Flutter方案也有它的代价,主要是插件生态不能直接照搬安卓那一套。OpenHarmony的原生接口体系、权限模型、媒体会话管理都和安卓不完全一样,需要自己做一层桥接。这块我在第五章会详细讲,是整项目最有含金量的部分。
1.3 工程结构设计:壳与芯分离
确定了技术路线,接下来是工程结构。我这边的做法是“OpenHarmony壳工程 + Flutter业务模块”分离,仓库上分成两个独立工程,通过标准流程打包进应用。
MusicApp/ ├── harmony/ # OpenHarmony 壳工程,负责系统入口、权限、服务注册 │ ├── entry/ │ │ └── src/main/ │ │ ├── module.json5 # 应用模块配置,权限声明在这里 │ │ └── ets/ # 原生层入口与桥接代码 │ └── framework/ # 系统能力封装,如媒体会话、音频焦点 ├── flutter/ # Flutter 业务工程 │ ├── lib/ │ │ ├── main.dart │ │ ├── core/ # 网络、缓存、工具、通用组件 │ │ ├── features/ │ │ │ ├── discover/ # 发现音乐页 │ │ │ ├── player/ # 播放器核心 │ │ │ └── search/ # 搜索页 │ │ ├── models/ # 数据模型 │ │ └── services/ # 业务服务 │ ├── pubspec.yaml │ └── assets/壳和芯分离不是单纯为了代码好管理,更重要的是编译节奏不互相阻塞。OpenHarmony原生部分改动频率低,Flutter业务部分天天在改,分开编译能各自走各自的质量卡口和发布节奏。数据流上我采用了单向流动:View层只发事件,ViewModel处理业务逻辑,Repository聚合数据源,ApiClient负责网络请求。这样做的直接好处是,后面从模拟数据切真实接口时,改动被限制在Repository层,UI完全不用动。
“发现音乐”模块在这个结构里是标准的feature,内部再按组件拆分子目录,每个子组件自带数据模型。这块后面第三章详细展开。
2. 数据模型与接口设计:先定契约再写UI
2.1 发现页数据契约与模型设计
很多新手上来就写UI,然后再补接口,这是惰性开发习惯,页面一复杂必返工。我的习惯是先和引擎、运营、客户端同学把数据契约对齐,再落成Dart模型。“发现音乐”我定义了一套聚合模型,一个接口拿到底层各模块的目录结构:
class DiscoverData { final List<BannerModel> banners; // 顶部轮播 final List<PlaylistCard> playlists; // 热门歌单 final RecommendBlock dailyPick; // 每日推荐 final List<RankBlock> ranks; // 多个榜单入口 } class BannerModel { final String id; // 唯一标识,点击上报用 final String title; // 标题文案 final String imageUrl; // 运营图 final String targetUrl; // 跳转地址或App内路由 final int sortWeight; // 排序权重 } class PlaylistCard { final String id; final String name; final String coverUrl; final int playCount; // 播放量,需要做万位格式化 final List<String> tags; // 歌单标签 } class RankBlock { final String rankId; final String rankName; final List<SongBrief> songs; // 榜单内歌曲列表 }一个模型定义得够不够好,要看它能不能同时满足“UI渲染”和“业务动作”的需要。比如BannerModel里必须有id,因为它不只是用来展示的,在运营后台配置点击跳转时,上报逻辑要靠这个id做索引;targetUrl则为跳转提供精确目标。还有playCount,UI要显示“万”还是“亿”,必须原始数据保留完整数值,格式化放在展示层去做。模型如果只图简单,到后面运营提需求加字段,就会陷入改动波及面失控的泥潭。
2.2 网络层封装与统一异常处理
网络层我直接用Dio,没有自己造轮子,原因很朴素:Dio的拦截器体系、连接复用、取消请求、上传下载这些能力已经很成熟,没必要重写。封装的核心是做三件事:统一拼装、统一校验、统一异常。
class ApiClient { ApiClient({required this.baseUrl}) { _dio = Dio(BaseOptions( baseUrl: baseUrl, connectTimeout: const Duration(seconds: 10), receiveTimeout: const Duration(seconds: 15), headers: {'Content-Type': 'application/json'}, )); } Future<T> get<T>(String path, { Map<String, dynamic>? query, T Function(dynamic json)? fromJson, }) async { try { final res = await _dio.get(path, queryParameters: query); final code = res.data['code']; if (code != 0) { throw ApiException(code, res.data['msg'] ?? '请求失败'); } return fromJson != null ? fromJson(res.data['data']) : res.data as T; } on DioException catch (e) { throw ApiException.fromDio(e); } } }这里有两个细节容易被忽视。第一是connectTimeout和receiveTimeout要分开配,弱网环境下连接超时和接收超时的表现完全不同,合并成一个会导致定位问题困难。第二是接口返回的通用包裹结构(比如{code, msg, data})要在拦截器里统一解掉,业务层只拿data,错误码统一转成业务异常,这样ViewModel里只要catch一种类型的异常,处理逻辑会干净很多。
开发初期后端接口没就绪,我在Repository层加了一个开关,在“本地Mock JSON”和“线上接口”之间切换。Mock JSON不是随便贴个文件,而是用一个MockInterceptor拦截Dio请求,按路径返回对应JSON。这样View层从第一天起就在“走真实网络”的状态下开发,联调时只需要把开关翻过去,意外兼容问题会少很多。
2.3 接口聚合策略:并行还是串行
“发现音乐”页面的接口很碎——Banner一个、热门歌单一组、每日推荐一个、榜单又一个。如果像瀑布一样层层依赖串行请求,首屏时间会非常难看。这里的策略是:没有依赖关系的模块全部并行,只有少数有依赖的才等待前序结果。
class DiscoverRepository { Future<DiscoverData> fetchHomeData() async { final results = await Future.wait([ _api.fetchBanners(), // 并行发起 _api.fetchHotPlaylists(), _api.fetchDailyPick(), _api.fetchRanks(), ]); return DiscoverData( banners: (results[0] as List).cast<BannerModel>(), playlists: (results[1] as List).cast<PlaylistCard>(), // ... ); } }Future.wait会等所有请求返回,如果某个接口超时拖垮整个页面,体验就是灾难。所以我给每个请求都套了一个超时兜底:单个子接口失败时,返回一个默认空数据,不让整批失败。空数据在UI层按照“骨架屏结束但模块区域显示兜底图”的逻辑展示,保证页面主体不崩。
实际测试下来,四个接口并行请求和串行请求分别耗时320ms和1.2s,感知差异非常明显。功能越重的首页,越要坚持“并行为首、局部依赖”的聚合原则。
3. 发现音乐页的Widget聚合与状态管理
3.1 页面骨架:用CustomScrollView和Sliver做整体编排
搭建“发现音乐”页面骨架时,我用的是CustomScrollView加一组Sliver组件,而不是普通的ListView。原因很简单:这个页面的头部区域很多,SliverAppBar负责做可折叠头图,SliverToBoxAdapter承载轮播和宫格区,SliverGrid和SliverList负责后面的歌单网格和榜单列表。它们可以被统一挂在一棵滑动树里,滚动联动是一致的,不会出现“列表区域自己滚、头部固定不动”的割裂感。
CustomScrollView( controller: _scrollController, slivers: [ SliverAppBar( expandedHeight: 160, pinned: true, flexibleSpace: FlexibleSpaceBar( title: Text('发现音乐'), background: _headerBackground(), ), actions: [IconButton(icon: Icon(Icons.search))], ), SliverToBoxAdapter( child: BannerSwiper(banners: _state.banners), ), SliverToBoxAdapter( child: _SectionTitle(title: '热门歌单', onMoreTap: _openPlaylists), ), SliverGrid( gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 2, mainAxisSpacing: 12, crossAxisSpacing: 12, childAspectRatio: 0.85, ), delegate: SliverChildBuilderDelegate(..., childCount: _state.playlists.length), ), SliverToBoxAdapter( child: _SectionTitle(title: '热歌榜', onMoreTap: _openRanking), ), SliverList.builder(...) ], )网格区的childAspectRatio是封面图和文字信息高度比的一个调和值,设成0.75会显得图太扁,0.9又让文字区太挤,我是在真机上反复调的。这里多说一句:SliverGridDelegateWithFixedCrossAxisCount的网格间距和圆角卡片尺寸,最好用MediaQuery去换算,而不是写死像素值,否则不同屏幕密度下观感差异会很明显。
3.2 状态管理:从Provider换到Riverpod的实战理由
状态管理方案我一开始用的Provider,代码写了一段时间后,发现有两个痛点:一是底部播放条和发现页之间需要共享当前播放曲目状态,Provider的ChangeNotifier在跨页面监听时总要多写很多通知触发;二是接口状态(加载中、成功、失败)如果要细分,ChangeNotifier的样板代码非常猛。中途我把主状态管理切到了Riverpod,换了之后整体复杂度反而降下来了。
Riverpod的AsyncNotifier天然支持“加载中/有数据/异常”三种状态的建模,这一点特别适合“发现音乐”这种多模块异步加载的页面:
class DiscoverState extends AsyncNotifier<DiscoverData> { @override Future<DiscoverData> build() async { return ref.read(discoverRepositoryProvider).fetchHomeData(); } Future<void> refresh() async { state = const AsyncLoading(); state = await AsyncValue.guard(() async { return ref.read(discoverRepositoryProvider).fetchHomeData(); }); } } final discoverStateProvider = AsyncNotifierProvider<DiscoverState, DiscoverData>( DiscoverState.new, );然后View层这么消费状态:
final discoverState = ref.watch(discoverStateProvider); return discoverState.when( loading: () => const DiscoverSkeleton(), error: (e, _) => DiscoverErrorView(onRetry: () => ref.read(discoverStateProvider.notifier).refresh()), data: (data) => DiscoverContent(data: data), );这是我阶段性的推荐:对于OpenHarmony上这类依赖异步数据较多的页面,AsyncValue三态模型一天省下的代码量相当可观。如果你团队经验集中在Provider,也不是不能做,但要提前想清楚“异步状态多、跨页交互多”这两个水坑,在Provider之上抽象一层统一的加载态处理逻辑。
3.3 卡片组件体系:磁贴、宫格、榜单的复用与抽离
“发现音乐”页面里的卡片形态其实可以归纳为几类:封面在上的磁贴卡(歌单、专辑),横铺的展示条(榜单、新歌)、小封面的播放列表行。同类卡片在不同模块里会反复出现,所以我没有逐页去写Widget,而是抽象了一套通用卡片组件。
class CoverCard extends StatelessWidget { final String title; final String subtitle; final String coverUrl; final VoidCallback onTap; final Widget? cornerBadge; // 角标,比如“新歌”“VIP” final CardShape shape; // 圆角弧度策略 } class PlayRankRow extends StatelessWidget { final int rank; final String name; final String desc; final String playUrl; final VoidCallback onPlay; }组件化的收益在后期运营调整时体现得最明显。比如把“每日推荐”从横滑卡改成双列网格,我只需要在容器层换一个Sliver组件,卡片本体不用动。再比如新增一个“播客”板块,复用CoverCard直接就能拼出来。组件抽象的效果,就是你介入越多页面,越能感受到改动扩散范围被牢牢框住。
4. 核心功能落地:轮播、图片加载与音频播放
4.1 Banner轮播:不只是PageView加Timer
轮播图是所有内容型App的标配,但实现细节里全是坑。最基础的方案是PageView配合定时器:每隔几秒切到下一页。但“无限轮播”和“手势与自动播放的交互”这两个点,十个人有九个人会写错。
我采用的方案是“头尾各冗余一页”的无限循环写法:把总页数设置成count + 2,当用户滑到第count + 1页时,在下一次滚动中把PageController的位置jumpTo回第1页。这里有个关键细节,jumpTo后不能触发滚动的监听回调,否则会死循环。我在监听里做了判断:
void _onPageChanged(int index) { if (index == bannerCount + 1) { Future.microtask(() => _controller.jumpToPage(1)); } else if (index == 0) { Future.microtask(() => _controller.jumpToPage(bannerCount)); } }定时器开停也是一个隐蔽问题。用户手指按住轮播图时,动画应该暂停,松手后再续上。我是在NotificationListener里监听ScrollStartNotification和ScrollEndNotification,前者停Timer,后者重启Timer。没有做这个处理的轮播,体验就是用户想仔细看某张图时,图被强行滑走,非常恼人。
真机上轮播还有一个不能忽略的点:图片异步加载期间页面已经滑过去了,会出现短暂的空白位。我给轮播加了一层FrameBuilder,图片没加载完成时先用占位背景色垫底,宽度固定不变,这样滑动过程中不会出现跳动感。
4.2 图片加载与缓存策略
网络图片如果直接用Image.network,内存和流量都会失控。我用了cached_network_image方案再叠加自定义的CacheManager,将磁盘缓存大小上限调到了80MB,并设置了LRU清理策略。这里面有一个经验:缓存Key不要只用URL,要把“裁剪参数、图片版本号、域名环境”都拼进去。运营经常替换图片,但图片URL可能是同一个(CDN上覆盖),如果不加版本参数会一直命中旧缓存,线上就会出现“改了图用户还是看到旧图”的诡异事故。
static String cacheKey(String url, {String? version}) { return '$url?v=${version ?? defaultImgVersion}'; }在OpenHarmony设备上还遇到过一个特殊问题:部分低内存设备加载大图列表时容易白屏,原因是图片解码后的位图内存没有及时释放。处理办法是把列表里图片的cacheWidth参数按屏幕宽度去设置,相当于提前缩样,避免解码超大原图。一张2000px宽的运营图,在720px宽的屏幕上展示,设置了cacheWidth: 1080后内存占用能降一半以上,视觉上没有任何损失。
4.3 音频播放接入与后台播控
“发现音乐”页最终要响应卡片点击并起播音乐。这是Flutter在OpenHarmony上最折磨人的一环:安卓的音频API在这里不通用,必须走系统桥接。
收歌单卡片后,我先把ID传给播放器模块,由播放器模块通过MethodChannel通知原生层解析并加入播放队列。原生层用OpenHarmony的媒体会话服务创建播放器实例、设置音频源、绑定播放器状态回调。Flutter层则通过EventChannel接收“播放进度、播完切下一首”等事件。
桥接通道的命名和协议要提前定好,我这里贴一个精简版的方法定义和调用:
// Dart 侧 const MethodChannel _playerChannel = MethodChannel('com.example.music/player'); Future<void> playSong(String songId, String playUrl) async { try { await _playerChannel.invokeMethod('play', { 'songId': songId, 'playUrl': playUrl, }); } on PlatformException catch (e) { debugPrint('播放失败: ${e.message}'); rethrow; } } // 原生侧入口示意 void handlePlay(MethodCall call, MethodChannelResult result) { String songId = call.argument('songId'); String playUrl = call.argument('playUrl'); mediaSession.createPlayer(songId, playUrl); result.success(true); }播放器状态在“发现音乐”页的直接体现是:滑动列表时,正在播放的那一行要显示一个转动的音符动画;波浪条;其他行显示播放次数。这个状态不是页面私有的,它来自播放器全局状态。我们用一个全局的PlayerStateProvider来管理,播放进度通过EventChannel流式更新,这样即使播放中切到别的页面,底部播放条也能实时刷新。
后台播放这块,需要在应用级配置里声明媒体后台任务类型。我没有在“发现音乐”页面里做太多后台逻辑,但建议在主板模块里预留好音频焦点处理,否则来电话后播放不会自动暂停,这是音播类应用的标配能力,不能省。
4.4 卡片点击链路的完整串联
卡片点击后的事,比看上去要多得多:
- 点击歌单卡 → 路由到歌单详情页;
- 点击榜单行 → 路由到榜单详情页并自动定位到点击位置;
- 点击歌曲行 → 请求播放地址 → 加入播放队列 → 播放器起播 → 底部播放条更新状态。
我用GoRouter做统一路由,路由名和页面映射是中央登记的。跨模块跳转是“发现音乐”最容易写成意大利面条的地方,所以我在路由层做了强约束:卡片只返回一个RouteModel(包含类型和目标ID),统一交给路由解析器分发,卡片本身不感知任何页面细节。后续不管是跳详情页还是弹起半屏页,改动都只在路由层。
预览链路跑通后,最直观的感受是:播放像一条水管道,从发现页的点击一路通到底,中间任何一个环节断了都能用日志链快速定位。这种可追踪性,是桥接场景里最TM重要的东西。
5. 适配OpenHarmony的实战经验与踩坑记录
5.1 环境搭建与Flutter引擎接入细节
先说版本策略。Flutter for OpenHarmony的适配工作是社区在推进的,版本跟进速度比主流Flutter慢一些,所以我的建议是“锁版本”:Flutter SDK、OpenHarmony SDK、引擎加载库全部按固定版本组合使用,不要随手升到最新。我踩过最大的坑就是Flutter版本升了一小级,结果OpenHarmony侧引擎库不匹配,运行起来直接黑屏。
接入流程大致分四步:
- OpenHarmony工程里加入Flutter引擎依赖,在
module.json5中声明相关服务; - 在应用入口处初始化Flutter容器,加载引擎so库;
- 将Flutter业务模块打包产物和资源文件同步到壳工程;
- 启动Flutter页面时通过路由拉起。
第4步有个细节:OpenHarmony侧的页面生命周期和Flutter侧不算完全对等,页面退到后台时,Flutter容器默认还在跑动画,白白耗电。需要在原生层监听页面显露/退场事件,显式把Flutter引擎切到暂停或恢复状态。
5.2 权限配置与生命周期适配
“发现音乐”页本身不需要太多敏感权限,但播放器需要网络权限,后台播放时还需要在配置里声明后台模式。这些权限没有声明好,出现的问题会比较隐蔽:App在前台播放一切正常,一锁屏就停,而且日志里没有任何明确报错,就是后台服务没被允许。
另外,千万不要把权限申请做成进入App就一股脑全弹。我实际验证下来,音乐类应用在播放第一首歌前弹“允许网络访问”,接受率比启动时弹要高出一截。流程走后,由于各个权限的用途和触达时机对应清楚,用户的困惑感会少很多。
5.3 性能优化:列表掉帧与内存水位控制
“发现音乐”页滚动掉帧的问题主要出在图片解码和列表重建上。
图片方面:运营图尺寸不固定,高分辨率图多了之后,滑动时解码压力陡增。处理方法是分层加载,首次只加载缩略图,滚动停止后再加载高清大图,同时设置ScrollController对滚动状态做判断。实测首屏渲染时间从1.6s降到了0.9s,体验提升非常明显。
列表重建方面:SliverGrid里的卡片在滑出可视区域后会被销毁,但某些卡片里如果注册了控制器(比如播放状态动画),销毁时没有释放会造成内存泄漏。我给卡片组件里所有的AnimationController都使用了addAwareDispose,也就是绑定在组件存活周期上的自动释放,而不是手工再写Dispose逻辑。这一点对长列表和高度动态的页面格外重要。
综合看下来,这个页面在强壮配置的测试机上能稳定跑到60fps,帧率波动不超过5帧。真机上调试的时候,我习惯开着渲染性能浮层,盯着掉帧出现的widget树去定位,而不是靠感觉盲猜。性能优化的本质就是把可能出问题的支路都找到,不然你永远不知道是图片、布局还是动画拖了后腿。
5.4 常见问题与排查速查表
实操中的典型问题,集中整理成一张速查表,方便对照:
| 现象 | 可能原因 | 排查思路 | 处理方案 |
|---|---|---|---|
| OpenHarmony启动黑屏 | Flutter引擎适配版本不匹配 | 查原生日志So加载项 | 锁版本组合,不要混升级 |
| 播放到一半静默中断 | 后台任务未声明或音频焦点丢失 | 查系统日志媒体会话回调 | 补后台模式声明,做焦点恢复 |
| 图片加载白屏 | 内存过大导致回收 | 看内存水位和位图解码日志 | 配置cacheWidth,加缩略图策略 |
| 轮播滑动手感生硬 | Timer没有在手势期间暂停 | 打点Log观察Timer触达 | 监听ScrollStart/End,做开关 |
| 列表滑动时掉帧明显 | 卡片重建频繁或图片解码重 | 打开Flutter性能浮层 | Sliver缓存、图片分步加载 |
| 接口聚合部分模块一直转圈 | 单接口超时拖住Future.wait | 看接口日志和超时配置 | 子请求加独立超时兜底 |
| 切后台回来页面空白 | Flutter引擎被系统回收 | 查生命周期回调 | 配置回收恢复逻辑,重绘页面 |
这张表是我在实际调试中反复沉淀出来的,每一条都真金白银地废过板子。遇到类似问题时,先按表格里的“排查思路”去查,大概率能快速定位,不用再从头一个坑一个坑踩过去。
写在最后的经验
如果这时有人问我:Flutter搞OpenHarmony值不值?我的回答是:UI层太值了,一套代码两个平台不等比例的收效,尤其在这种新系统生态还不成熟的时候;但桥接层的心理准备要做足——你有多少时间花在原生适配,取决于你的通道设计和日志完备度。
我自己这段实操下来,最大的体会是“先通链路,再做UI动效”。一开始我沉迷做卡片阴影、弹出动画这类视觉细节,结果播放链路只通了一半,联调时发现点击卡片后要么播放没响应,要么状态没回传,几经折腾后老实把链路捋顺了再回头打磨动效,反而顺利许多。
最后分享一个更长远的观察方向:OpenHarmony的生态正在以肉眼可见的速度补课,Flutter这类跨端框架会越来越多地在上面出现。如果条件允许,更值得做的是把播放链路、账户体系这些公共能力沉淀成内部可复用的组件。这次时间紧,我只能先把发现页跑通,下一阶段我会把播放页、歌词页、搜索页在“同一套Flutter代码”里整体迁移补齐,到时候再来记录整条体验链路的完整闭环。