1. 从零搭建首页:先想清楚再动手
Flutter for OpenHarmony开发音乐播放器,做到首页这一步,意味着环境、依赖和基本工程结构已经打通了。在开始堆代码之前,我先把首页的定位想了一遍:它不只是展示歌单和推荐位,更是整个App的门面,是用户打开应用后的第一印象,也是后续播放页、搜索页、个人中心这些功能的入口中枢。
我见过不少开发者一上来就急着写UI,结果做到一半发现状态管理没想清楚、数据流乱成一团,回头重构的成本比重新写还高。所以在动手之前,先花一点时间把首页的结构、状态和数据来源理清楚,后面会顺畅很多。
首页核心要解决这么几件事:一是信息展示,把推荐歌单、热门歌曲、轮播图这些内容按优先级排列;二是交互入口,用户能从首页点进播放页、歌单详情、搜索页;三是状态同步,比如页面加载状态、下拉刷新、播放状态的角标更新,这些都需要一个清晰的状态管理方案。把这三件事想明白,首页就成功了一半。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 项目结构与技术选型:为什么这样搭
2.1 目录组织:按模块拆,别按页面拆
很多Flutter项目的目录是按页面堆的,一个page目录下面全是首页、搜索、我的这样平铺的文件。小项目能跑,一旦页面变多、业务变复杂,文件之间的依赖关系就会变得很乱。
这套音乐播放器项目采用的是按功能模块拆分的结构。首页相关的代码放在feature/home目录下,里面有三个子文件:home_page.dart负责页面UI组装,home_view_model.dart负责业务逻辑和状态,home_feed_item.dart负责单条内容的展示组件。这样一个模块内聚的东西都放一起,查找和修改都很方便,后续要加歌单详情页、播放页,各自独立成模块也不会互相干扰。
如果团队有多人协作,这种按模块拆的方式还有一个好处:每个人负责一个feature目录,代码冲突的概率会小很多。
2.2 状态管理:没有选Riverpod也没有选Bloc
这次实战选择了Provider作为状态管理方案。原因是这套项目的复杂度还没有到非Bloc不可的程度,Provider的API简单直接、社区内教程多、调试也容易,配合ChangeNotifier用起来非常顺手。
具体到首页,页面本身有一些轻量的UI状态——加载中、加载失败、数据就绪,这些状态放在StatefulWidget内部管理就够了。但歌单数据、用户信息这类会被多个页面共享的数据,就必须放到全局Provider里,避免每进一次页面就重复请求数据。
状态管理选型的一个经验是:不要为了框架而框架,简单场景用简单方案,等复杂度真实提升了再升级,在实战项目里更容易推进。
2.3 为什么首页要有独立的View层
Flutter for OpenHarmony开发中,UI代码很直观,很容易把所有逻辑都堆进build方法里。但在这个项目里,首页的UI层只负责渲染和回调用户操作,所有业务逻辑都挪到ViewModel里处理,包括加载数据的回调、刷新状态的触发、点击事件的转发。
这样设计的好处是:调试时能非常快地定位问题是出在界面上还是出在逻辑上;写自动化测试时可以只测ViewModel,不用启动页面;后续要增加缓存、换数据源,UI层完全不用动。
3. 首页核心细节解析:每一块区域的设计逻辑
3.1 整体布局:Tab式导航与Feed流的结合
首页整体结构采用CustomScrollView承载内容区域,底部的BottomNavigationBar负责切换首页、发现、我的三个标签页。CustomScrollView的优势在于可以自由组合Sliver家族的组件,比如SliverAppBar、SliverList、SliverGrid,做滚动手势的联动效果非常灵活。
首页内容区的排列顺序是:顶部搜索入口,中央轮播Banner,接着是“推荐歌单”的横向列表,再往下是“热门歌曲”的大列表。这个顺序遵循了“先吸睛、再推荐、后扩展”的逻辑——轮播图放最上面引导用户看运营位,推荐歌单一屏内能快速浏览,热门歌曲则提供持续下滑的理由。
如果你上手做类似布局,推荐用Widget Inspector多调试几次嵌套层级,会发现比起无脑套Column+ListView,CustomScrollView在滚动回弹、下拉刷新和置顶效果上顺手很多。
3.2 卡片设计:信息密度与视觉呼吸感
推荐歌单的卡片我采用的是2:3比例的竖图卡片,下方的文字只保留歌单名和播放量,超长文本自动省略号处理。卡片本身用ClipRRect切了8像素圆角,点击时有轻微缩放反馈,用的是GestureDetector的onTapDown和onTapUp监听。
热门歌曲的行项目则是左侧封面图、中间歌名加歌手、右侧“播放”按钮。这里有个很容易忽略的细节:歌曲行的右滑删除或更多操作虽然在音乐App里很常见,但如果首页的主要流量入口格直接加右滑手势,容易和页面级返回手势冲突,所以当期实现中只放了点击跳转按钮。
卡片间距我统一用了12像素,圆角是8像素,深色背景下的阴影用黑灰两层叠加而不是简单模糊。在设计调色板时,我尽量保证音乐App最常出现的黑色背景和白色文字之间有足够的亮度反差,避免界面发灰。
3.3 轮播Banner:自动播放与手动滑动的平滑切换
轮播Banner在Flutter里最常用的方案是PageView配合Timer定时器。这个方案本身不复杂,但有个老问题:用户在手动滑动时,自动播放的定时器如果还在跑,会出现“刚划走又被拉回去”的情况。
这次解决了这个问题:监听PageView的手势事件,按下时取消定时器,抬起后重新启动并且倒计时归零。实现方式是在NotificationListener里监听ScrollStartNotification和ScrollEndNotification,判断是不是拖拽行为,再做对应处理。
另外一个轮播的细节是无限循环。这里的处理不是把list转成无限长,而是在最后一页时用animateToPage跳回到第一页的同时关掉动画。这样用户感知上是循环的,但代价小、代码简单。
3.4 下拉刷新与占位态
这套代码用RefreshIndicator包住CustomScrollView,触发刷新后调用ViewModel里的fetchHomeData。这里需要注意:RefreshIndicator要求Child必须有滚动视图,且Scrollable的physics必须为AlwaysScrollableScrollPhysics,否则数据不满一屏时拉不动。
加载失败时的占位态也不能随便糊弄。这个项目里做了三个占位态:加载中显示居中的CircularProgressIndicator;加载失败显示重试按钮;数据为空显示空图片插画。经验是,占位态不仅要“有”,还要“准”,不然用户很容易以为App崩溃了。
4. 核心代码实现:一步步把首页写出来
4.1 数据层:先mock后接真实接口
在这里我先建立了数据结构模型,首页里用到的是HomeFeedData和SongItem。前端开发时,最忌讳的就是等后端接口出好再动手,因此我先把接口调用的服务类抽了出来,定义一个抽象接口,并给出了一个返回固定JSON的Mock实现,这样界面的开发完全不用等待远程接口,等联调时只需要替换实现类就够了。
dart复制class SongItem {
final String id;
final String title;
final String artist;
final String coverUrl;
final String duration;
SongItem({
required this.id,
required this.title,
required this.artist,
required this.coverUrl,
required this.duration,
});
factory SongItem.fromJson(Map<String, dynamic> json) {
return SongItem(
id: json['id'] as String,
title: json['title'] as String,
artist: json['artist'] as String,
coverUrl: json['coverUrl'] as String,
duration: json['duration'] as String,
);
}
}
这段代码本身没什么特别的,但我想说的是一个细节:duration字段用的不是秒数而是字符串。因为在首页只需要展示“03:25”这样的格式,直接把格式化的结果传给前端,省掉了在前端不必要的时间转换逻辑。当然如果后续要做播放进度,这个字段需要重新建模型处理。
服务类的抽象是这个样子的,定义HomeFeedService接口,提供fetchHomeFeed方法,Mock实现返回固定数据,之后有真实接口时写RemoteHomeFeedService替换即可。
dart复制abstract class HomeFeedService {
Future<List<SongItem>> fetchHomeFeed();
}
class MockHomeFeedService implements HomeFeedService {
@override
Future<List<SongItem>> fetchHomeFeed() async {
await Future.delayed(const Duration(milliseconds: 600));
return [
SongItem(id: '1', title: '夜风中的城市', artist: '林晚', coverUrl: 'asset://images/song_1.jpg', duration: '03:25'),
SongItem(id: '2', title: '海岸线', artist: '陈默', coverUrl: 'asset://images/song_2.jpg', duration: '04:02'),
];
}
}
这种mock方式在团队里特别好用,接口联调不再阻塞前端进度,而且接口返回的数据结构可以在mock阶段就定下来,校验字段约束。实测下来,接真实接口时改动很小。
4.2 ViewModel:让页面状态可控
首页的ViewModel继承ChangeNotifier,里面有三块数据:加载状态、歌曲列表、异常信息。对外只暴露load()方法和一个清理方法,页面通过addListener来监听状态变化。
dart复制class HomeViewModel extends ChangeNotifier {
final HomeFeedService _feedService;
HomeViewModel(this._feedService);
ViewState _state = ViewState.loading;
ViewState get state => _state;
List<SongItem> _songList = [];
List<SongItem> get songList => _songList;
String? _errorMessage;
String? get errorMessage => _errorMessage;
Future<void> load() async {
_state = ViewState.loading;
notifyListeners();
try {
_songList = await _feedService.fetchHomeFeed();
_state = ViewState.success;
} catch (e) {
_errorMessage = e.toString();
_state = ViewState.failed;
}
notifyListeners();
}
}
用ViewModel的好处是页面里的代码干净,build方法里只需要根据state分支出不同的widget,不用处理任何网络请求细节。这个模式在团队项目里尤其推荐,因为页面一旦复杂,哪怕只有两三个异步回调,全部写在State里就会开始出现状态不同步的问题。
4.3 页面骨架实现
核心页面骨架并不复杂,主体是Provider里取到ViewModel,根据状态切换展示内容,用CustomScrollView把各个区块串起来。
dart复制class HomePage extends StatelessWidget {
@override
Widget build(BuildContext context) {
final viewModel = context.watch<HomeViewModel>();
return Scaffold(
backgroundColor: const Color(0xFF121212),
body: SafeArea(
child: RefreshIndicator(
onRefresh: () => viewModel.load(),
child: CustomScrollView(
physics: const AlwaysScrollableScrollPhysics(),
slivers: [
SliverAppBar(
title: const Text('音乐'),
backgroundColor: const Color(0xFF121212),
elevation: 0,
),
if (viewModel.state == ViewState.loading)
const SliverToBoxAdapter(
child: SizedBox(height: 300, child: Center(child: CircularProgressIndicator())),
)
else if (viewModel.state == ViewState.failed)
SliverToBoxAdapter(
child: _buildErrorWidget(viewModel.errorMessage, viewModel.load),
)
else
SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
if (index == 0) return const HomeBannerSection();
if (index == 1) return const SongRecommendHeader();
return SongRowItem(song: viewModel.songList[index - 2]);
}, childCount: viewModel.songList.length + 2),
),
],
),
),
),
);
}
}
这段代码可以体现Sliver系组件的高自由度:SliverList的childCount可以手动控制,这样Banner和列表头在滚动时拥有了跟歌曲行一样的滚动联动,而不是固定在页面上方。第一次用SliverList的时候可能不太习惯这种“只管内容不管位置”的感觉,但熟悉之后会觉得比嵌套ListView顺手多了。
一个注意点:首屏加载Banner页签时,如果Banner是asset图,最好放到SliverToBoxAdapter里给一个固定高度,否则在Viewport里不可见的部分会延迟构建,容易造成闪现。
4.4 封装细节:行组件、空态、错误态
行组件SongRowItem复用在多个页面,因此单独放到了一个文件里。左图右文,右侧圆形播放按钮。点击后会把当前歌曲信息抛给上层,由上层路由去播放页。
dart复制class SongRowItem extends StatelessWidget {
final SongItem song;
final VoidCallback? onTap;
SongRowItem({required this.song, this.onTap});
@override
Widget build(BuildContext context) {
return ListTile(
leading: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.network(song.coverUrl,
width: 48, height: 48, fit: BoxFit.cover,
errorBuilder: (_, __, ___) => Container(
color: const Color(0xFF2A2A2A),
child: const Icon(Icons.music_note, color: Colors.white54),
),
),
),
title: Text(song.title, maxLines: 1, overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.white, fontSize: 16)),
subtitle: Text(song.artist, maxLines: 1, overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.white70, fontSize: 13)),
trailing: IconButton(
icon: const Icon(Icons.play_circle_outline, color: Colors.white70),
onPressed: () {
if (onTap != null) {
onTap();
}
},
),
);
}
}
错误态组件也单独提取了,包含一个icon、一段文案、重试按钮。这里比较重要的是错误文案不要只写“加载失败”,应该带一个可操作的引导,比如“网络好像出了点问题,请检查后重试”,不然用户会误以为应用坏了。
4.5 底部导航与路由跳转
首页的底部导航用的是BottomNavigationBar,三个tab上分别是首页、发现、我的。这个模块因为后续三个tab都要常驻,所以单独封装了个RootScaffold,用IndexedStack把三个页面都保存住。
为什么要用IndexedStack而不是直接切换child?因为切换child时,如果页面有滚动位置或播放状态,会全部丢失。IndexedStack会让三个页面同时存活在widget树里,切换tab时不会重新加载,读取导航位置、滚动位置的体验顺畅很多。
首页点击热门歌曲行时,通过Navigator.push去播放页。如果你还没接路由管理的库,直接用Navigator.push就够用,减少不必要的依赖。
5. 常见问题与排查技巧:实测中还真遇到几个坑
5.1 图片加载失败时的白屏处理
第一个坑是图片加载失败显示白屏。在Flutter for OpenHarmony中,如果你的图片是远端URL,且开发阶段网络权限没有配置到位,Image.network会直接抛异常,但默认只会在控制台打日志,界面上就是一片空白。
解决方式是给Image.network加errorBuilder,加载失败展示一个带音乐图标的占位块。这个处理不复杂,但可以让页面在弱网环境下也不会显得破损。
经验是:只要是加载网络图片的地方,一律都要写errorBuilder,这是我踩过一次白屏后给自己定的规矩。另外,如果图片在列表里特别多,建议加缓存策略,否则每次滚动都要重新加载一遍,滚动到卡顿基本就是这个问题。
5.2 RefreshIndicator拉不动
第二个坑是RefreshIndicator需要滚动区域满足physics条件。CustomScrollView默认的physics在内容不满一屏时,是没法下拉的,必须指定AlwaysScrollableScrollPhysics才能让RefreshIndicator在任意状态下都能工作。
这个坑看起来不起眼,但排查起来有点绕,因为代码层面没有任何报错,就是事件不触发。我当时的排查思路是先看RefreshIndicator的onRefresh有没有被调用,确认没被调用后才去查child的属性,最后发现是physics的问题。建议到手新设备或新项目时先跑一个最小集测试,确认基础组件行为正常,再往上叠功能。
5.3 切换Tab时首页状态丢失
第三个是IndexedStack带来的问题。这个方案保住了页面,但同时也意味着三个tab的页面一旦初始化就不会再销毁,如果首页数据有变更,不会自动刷新。实际项目里我的处理是监听App生命周期回调,从前台恢复到后台超过半小时后就主动触发一次首页刷新,避免用户下次进来数据太旧。
还有一点,如果历史版本里曾经用的是普通切换,后来换成IndexedStack,需要注意页面里的滚动位置会被保留,但有些组件内部的state不会自动重建。遇到这种情况,可以在切换tab时手动调用某个reload方法。
5.4 OneMoreThing:真实渲染下的字体大小
OpenHarmony设备普遍对系统字体大小很敏感。如果你的列表用了大量固定宽度的布局,用户一旦系统设置了特大字体,标题这一行很容易被截断。
我建议列表类文本全部用maxLines+TextOverflow.ellipsis,并且给标题设置fontSize时不要写死太大,尽量用bodyLarge、titleMedium这类官方定义的文本样式,在字体缩放时自适应效果会好很多。实测下来,用主题文字的样式表比手写fontSize省心很多,但在OpenHarmony上的字体渲染引擎和Android略有区别,所以每个页面做适配预览时确认一次实际效果就稳妥了。
6. 一些个人体会
首页这个阶段做完,整个项目的骨架就算立住了。我个人的习惯是:每个模块的代码写完,先跑一遍widget测试,再在模拟器和真机上各过一遍主要流程,最后再补一下页面滚动的性能观察。
这三个步骤看着繁琐,但能确保后面接播放器内核、做播放队列的时候,不用回头再改UI基础。开发OpenHarmony应用,最大的不同是它既要兼顾平台特性,又要看到Flutter跨端的统一性,两者结合起来,踩坑点其实比单端开发要多一层,所以多加测试不是浪费时间。
如果你也在做这个系列,建议做完首页后把三个tab的页面都按同一套ViewModel模式搭好,后续的实战重心就放在播放逻辑和状态同步上。首页代码本身不难,难的是为后续的扩展留出空间。而这个空间,在架构设计阶段就已经定好了。
