1. 项目概述与选题解析
1.1 为什么偏偏是“歌手列表”
提到跨平台开发,大家第一反应往往是 Android 和 iOS,但现在的技术版图已经悄悄多了一块——OpenHarmony。我接触 Flutter for OpenHarmony 有一段时间了,说实话,一开始并不顺利,光是环境配置和编译链就能劝退不少人。但跨过那道坎之后你会发现,Flutter 那套“一次编写,处处运行”的理念,在 OpenHarmony 上也确实能跑起来,而且跑得还不赖。
这次我想分享一个音乐播放器 App 的实战片段:歌手列表的实现。可能有朋友会问,列表页那么多,为什么要单独揪着“歌手列表”出来说?因为歌手列表太典型了。它既有图文混排的需求,又有列表滚动的性能问题,还要处理头像加载失败、点击跳转、状态切换这些五花八门的细节。说句实话,把歌手列表做明白了,整个 App 的骨架也就立起来了。
音乐播放器这个场景本身很有意思。它不像电商或者社交应用那样追求信息流的复杂嵌套,更多是围绕“播放”这个核心动作组织内容。歌手列表就是用户点进音乐 App 后最先看到的那几个页面之一。作为整个产品的内容导航,歌手列表的加载速度、滑动流畅度、交互反馈,直接决定了用户对 App 的第一印象。
对于想上手 Flutter for OpenHarmony 的开发者来说,从音乐播放器入手也是一个不错的路线。功能边界清晰,涉及的基础组件比较全,又不会像企业级应用那样堆砌大量复杂业务逻辑。而且音乐类 App 的页面形态相对固定:歌手列表、歌曲列表、播放页、搜索页,这些页面在 OpenHarmony 上能不能流畅跑起来,很大程度上就代表了这个跨平台方案的成熟度。
1.2 这一节内容适合谁看
做这个项目前,我建议你至少满足两个条件:一是 Flutter 基础语法还说得过去,知道 Widget 树是怎么组织的,知道 setState 大概是怎么工作的;二是对 OpenHarmony 有那么一点概念,哪怕只是知道它是个开源操作系统也没关系。这两条都具备的话,接下来的内容你应该能轻松跟上。
如果你是零基础想直接上手跨平台开发,这篇内容可能有一点门槛。但也没关系,我尽可能把每一步都讲得透彻,包括环境搭建、依赖引入、组件选型、性能优化,都会给出可以参考的解决方案。遇到没见过的 API,我建议你顺手查一下官方文档,形成自己的知识体系,这比背代码重要得多。
整个项目跑下来,我最大的感受是:Flutter 在 OpenHarmony 上的坑确实不少,但绝大多数坑都是有迹可循的,无非就是插件不兼容、C++ 层编译报错、渲染引擎的细微差异。把这些坑记录下来,后面的人就能少走很多弯路。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 环境准备与工程搭建
2.1 这四样工具一个都不能少
如果你做过 Flutter 开发,那对这套环境应该不陌生:Flutter SDK、Dart SDK、IDE,再加一个模拟器或者真机。但在 OpenHarmony 上做开发,情况稍微特殊一点,因为你还需要 OpenHarmony SDK 以及一个支持 OpenHarmony 的编译工具链。
我用的组合大致是这样:
- Flutter SDK 版本:3.7.x 及以上,建议直接用最新稳定版
- OpenHarmony SDK:从官方渠道获取,版本需要和 Flutter SDK 有对应关系
- IDE:DevEco Studio,这是 OpenHarmony 应用开发的主战场
- 设备环境:模拟器优先,调试方便,后期再换真机
有一件事需要特别提醒:Flutter 和 OpenHarmony 的版本适配不是自动的。有些 Flutter 版本跑在 OpenHarmony 上会有编译错误,这不是你的代码问题,而是底层适配层没有跟上。刚开始别求新,选一个官方验证过的稳定组合,比什么都重要。
2.2 创建项目时的关键抉择
项目创建这一步有不少讲究。我用的是命令行方式,因为这种方式更直观,也更容易排查问题。
bash复制flutter create --platforms ohos music_player_app
注意 --platforms ohos 这个参数。如果创建时没有指定,默认可能只生成 Android 和 iOS 的工程目录,那后面再想加 OpenHarmony 平台支持,就会多出不少手工活。
创建完成后,你会看到项目根目录下有 ohos 文件夹(如果 Flutter 版本支持的话)。这个文件夹就是 OpenHarmony 工程的入口。如果创建时没有自动生成,也不要慌,可以通过 Flutter 官方提供的适配工具手动添加。
接下来要检查 pubspec.yaml,确认 Flutter SDK 的约束条件。我习惯把依赖版本尽量放宽,比如:
yaml复制environment:
sdk: ">=2.19.0 <4.0.0"
原因很简单:OpenHarmony 平台的第三方包生态还不像 Android 那么丰富,有些包的最新版可能会依赖高版本 Dart 特性,如果约束太紧,包冲突会让人崩溃。
2.3 运行到 OpenHarmony 模拟器上的第一次体验
第一次把 Flutter 项目跑上 OpenHarmony 模拟器,这个过程颇具仪式感。有几个注意点我踩过坑,先说给你听。
DevEco Studio 里启动模拟器后,用命令行检查设备是否可见:
bash复制flutter devices
正常会列出 OpenHarmony 设备,如果没有,多半是模拟器没完全启动,或者 OpenHarmony SDK 路径没有配置好。
运行项目:
bash复制flutter run -d <device-id>
如果一切顺利,你会在模拟器上看到一个 Flutter 默认的计数器 Demo。这一步成了,就说明 Flutter 的 Dart 虚拟机已经在 OpenHarmony 上跑起来了,渲染链路也已经打通。这个 Demo 虽然简单,但它背后做的事情非常多:Dart 代码编译、原生工程构建、渲染引擎初始化、平台通道建立,任意一步出错都看不到这个界面。
第一次运行通常会比较慢,因为要编译整个 Flutter 引擎的 OpenHarmony 适配层。如果超过十分钟还在转圈,建议检查一下电脑性能,或者看看是不是第一次构建时下载依赖包卡住了。
3. 歌手列表的功能拆解与 UI 设计思路
3.1 歌手列表页面到底需要承载什么
动手写代码之前,我习惯先问自己一个问题:这个页面在真实场景中,用户会怎么用?
用户点进“歌手”页面,期待看到的是一组歌手的头像和名字,排列方式可能是网格,也可能是列表。点击某个歌手,会进入这个歌手的详情页,看到他的热门歌曲、专辑等。在某些 App 里,歌手列表还承担着“推荐”的功能,比如把最近热门的歌手排在前面。
基于这些使用习惯,我把歌手列表页面的功能定义为三块:
- 歌手信息展示:头像、姓名、作品数或热度等辅助信息
- 列表交互操作:点击歌手进入详情页
- 状态反馈:加载中、加载失败、空数据这三种状态必须有明确的 UI 表现
有了功能定义,接下来才是设计布局。这一步非常关键,因为功能决定页面长什么样,而页面长什么样,又反过来影响代码结构。我见过不少新手一上来就写 Widget,写到最后功能是通了,但代码已经乱到什么都不敢动的程度。
3.2 网格布局还是列表布局
歌手列表最常见的两种形态:一是上下滚动的列表,每行一个歌手信息;二是两列的网格,像瀑布流一样铺开。两种布局各有各的适用场景。
列表布局适合歌手数量多、信息量大的场景。一行可以放头像、姓名、粉丝数、歌手简介摘要,用户扫一眼就能获取足够信息。网格布局则更偏向视觉导向,让封面和头像占据视觉焦点,适合偏运营风格的页面。
我这次选择的是网格布局。原因有两个:一是音乐 App 的歌手页几乎都是宫格形态,用户已经有使用习惯;二是网格布局在 OpenHarmony 上的性能表现,恰好可以作为一个重点来验证。
项目里我用了两层结构:外层是一个 CustomScrollView,内部用 SliverGrid 来实现网格。为什么不用简单的 GridView?因为 CustomScrollView 的扩展性更强,后面如果要加头部的 Banner、搜索框或者分类标签,直接在 slivers 列表里插入对应的 SliverToBoxAdapter 就行,改造成本很低。
3.3 卡片样式里的那些细节
歌手卡片本身,我是这样设计的:
卡片顶部是歌手头像,圆形裁剪,占卡片宽度的 80% 左右,居中显示。头像下方留 8 个逻辑像素的间距,然后是歌手姓名,单行居中显示,字号 14,颜色用深灰。再往下是一行辅助信息,显示“热度 1234”,字号 11,颜色用浅灰。
这里有几个尺寸和约束的细节值得注意。圆形头像的裁剪在 Flutter 里可以用 ClipOval 实现,但如果头像本身是正方形,直接用 CircleAvatar 会更省事。我在项目里采用了 CircleAvatar + backgroundImage 的方式,加载网络图片时,配合 NetworkImage 的 scale 参数,能稍微优化一下内存占用。
卡片间距设置为 4 个逻辑像素。有人说网格间距大一点更好看,但实际体验下来,音乐 App 的歌手卡片间距不宜过大,不然整个页面会显得松散,网格承载的信息密度也降下来了。
3.4 页面状态切换别偷懒
歌手列表从打开到展示,至少会经过三个状态:加载中、加载完成、加载失败。还有一种情况也必须考虑:数据返回了,但是一个歌手都没有。这三种状态,我在代码里用枚举值管理:
dart复制enum ViewState {
loading,
success,
error,
empty,
}
为什么要单独定义一个枚举?因为状态管理一旦用散落的布尔变量表示,代码会越来越难维护。你可能今天加一个 _loading,明天加一个 _isError,后天就分不清 _loading == false 到底表示成功还是失败了。用一个枚举来表达页面状态,逻辑分支会清晰很多。
加载中的 UI 我用了 CircularProgressIndicator,放置在整个页面中央。加载失败则显示一个简明文案和一个重试按钮。空数据页面会稍微用心一点,加了一个简单的图标和“暂无歌手数据”的提示,让用户知道这不是 bug,只是真的没有内容。
4. 数据层设计与本地模拟
4.1 没有后端也能开发的思路
真实项目里,歌手列表的数据一般来自后端 API。但在这个项目阶段,我们完全可以先搭一个本地数据层,模拟接口返回,把 UI 和交互调通,等联调阶段再切换成真实 HTTP 请求。这个思路不仅适用于音乐播放器,任何 App 开发第一阶段都应该这样做。
我采用的方案是:写一个 SingerRepository 类,对外暴露一个获取歌手列表的方法。内部实现先用一个 Future.delayed 加本地 JSON 数据模拟异步加载,模拟网络请求的耗时。后面要接真实网络时,只需要替换这个 Repository 内部实现,UI 层完全不用动。
这样设计的好处,就是数据源与 UI 解耦。你测试下拉刷新、分页加载、异常提示这些功能时,不用依赖后端环境,自己就能模拟各种场景。
4.2 歌手数据模型怎么定义
歌手模型字段不多,但每个字段都有存在的理由:
dart复制class Singer {
final String id;
final String name;
final String avatarUrl;
final int songCount;
final int hotScore;
Singer({
required this.id,
required this.name,
required this.avatarUrl,
required this.songCount,
required this.hotScore,
});
factory Singer.fromJson(Map<String, dynamic> json) {
return Singer(
id: json['id'] as String,
name: json['name'] as String,
avatarUrl: json['avatarUrl'] as String,
songCount: json['songCount'] as int,
hotScore: json['hotScore'] as int,
);
}
}
id 字段容易被忽略,但它必不可少。列表的 key 需要它,点击跳转详情页需要它,数据缓存和去重也要靠它。hotScore 字段在 UI 上可以显示成热度,也可以用来做排序,都属于基础信息。
4.3 本地 JSON 数据的组织方式
我在 assets/data/singers.json 放了一份模拟数据,结构大致如下:
json复制[
{
"id": "s001",
"name": "歌手A",
"avatarUrl": "https://example.com/avatar/s001.png",
"songCount": 128,
"hotScore": 9800
},
{
"id": "s002",
"name": "歌手B",
"avatarUrl": "https://example.com/avatar/s002.png",
"songCount": 86,
"hotScore": 7600
}
]
提示一点:如果你的模拟数据里包含网络图片 URL,在模拟器上测试时要注意网络权限。OpenHarmony 应用默认可能没有开启网络访问权限,需要在 module.json5 里申请 ohos.permission.INTERNET。我一开始没注意到这个,头像一直加载不出来,排查了老半天才发现是权限没开。
pubspec.yaml 里做资源声明:
yaml复制flutter:
assets:
- assets/data/singers.json
这一步漏掉的话,运行时读取 JSON 会直接报错,而且这个错在编译期看不到,只能在日志里发现。
4.4 异步加载与数据解析的代码实现
Repository 类的核心方法长这样:
dart复制class SingerRepository {
Future<List<Singer>> fetchSingerList() async {
await Future.delayed(const Duration(milliseconds: 800));
final rawJson = await rootBundle.loadString('assets/data/singers.json');
final listJson = json.decode(rawJson) as List<dynamic>;
return listJson
.map((item) => Singer.fromJson(item as Map<String, dynamic>))
.toList();
}
}
Future.delayed 是我有意加的,模拟网络延迟。因为如果没有延迟,页面会瞬间从加载态跳到完成态,加载动画根本来不及展示,也就没法验证状态切换逻辑是否写得对。
解析 JSON 时用到了 rootBundle.loadString,这是 Flutter 读取本地资源的标准方式。注意 loadString 返回的是 Future<String>,所以整个方法用了 async/await。
启动时的加载时长可以再加长一点,比如 1.5 秒。我知道有朋友会问,这不是拖慢开发效率吗?其实不然,模拟器和真机上网络环境的差异本来就大,如果本地加载瞬间完成,到了真机连接慢速网络时,你的加载页可能因为没有经过充分测试而出现闪烁或者白屏问题。模拟得稍微真实一点,测试才更有价值。
5. 歌手列表页面的核心实现
5.1 页面脚手架与状态管理
歌手列表页我使用 StatefulWidget,内部持有三个核心对象:Repository 实例、歌手列表数据、页面状态枚举。这种组合方式在这类中小型页面中足够用,不需要引入额外状态管理框架。
initState 里发起数据加载,然后通过 setState 更新状态。这里的 setState 调用要注意一个问题:不要在异步回调之后直接判断 mounted 而跳过,必须在 setState 前做检查,否则页面销毁后再更新 UI 会触发异常。
dart复制if (!mounted) return;
setState(() {
_viewState = ViewState.success;
_singerList = result;
});
你可能在网上看到过很多 Flutter 代码片段,没写 mounted 检查也能跑,但那是运气好。页面在加载过程中被用户退出,异步回调还是会执行的,这时候访问已经不存在的 Element,轻则警告,重则崩溃。养成写 mounted 检查的习惯,能省掉不少线上事故。
5.2 网格卡片 Widget 的搭法
单个歌手卡片我封装成了 SingerCard,好处是这个组件可以复用,后面在搜索结果的歌手列表里还可以再用一次,保持视觉风格统一。
dart复制class SingerCard extends StatelessWidget {
final Singer singer;
final VoidCallback onTap;
const SingerCard({
Key? key,
required this.singer,
required this.onTap,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircleAvatar(
radius: 32,
backgroundImage: NetworkImage(singer.avatarUrl),
onBackgroundImageError: (_, __) {},
),
const SizedBox(height: 8),
Text(
singer.name,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: Color(0xFF333333),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Text(
'热度 ${singer.hotScore}',
style: const TextStyle(
fontSize: 11,
color: Color(0xFF999999),
),
),
],
),
);
}
}
这里有个值得展开说的点:onBackgroundImageError 回调为什么要传一个空实现?如果不传,当网络图片由于某些原因加载失败时,日志里会疯狂输出异常信息,并且在 Debug 模式下可能直接触发红屏。传一个空回调虽然是一种比较粗暴的处理方式,但至少能保证页面不崩溃、不闪红。更完善的方案是提供一个本地占位图当 fallback,这个我后面会讲到。
5.3 网格与滚动容器的组合
页面主体结构:
dart复制CustomScrollView(
slivers: [
SliverPadding(
padding: EdgeInsets.all(12),
sliver: SliverGrid(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
childAspectRatio: 0.72,
),
delegate: SliverChildBuilderDelegate(
(context, index) {
final singer = _singerList[index];
return SingerCard(
singer: singer,
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => SingerDetailPage(singerId: singer.id),
),
);
},
);
},
childCount: _singerList.length,
),
),
),
],
)
网络布局采用的是三列,这个选择是基于手机屏幕宽度和卡片信息密度做出的。三列时,每个卡片的宽度大概在 100 到 120 逻辑像素之间,刚好能看清楚头像和名字。如果改成四列,卡片会变得太窄,歌手名字稍微长一点就会被截断,体验上会打折扣。
childAspectRatio 设成 0.72,表示宽度与高度之比。试过 1.0、0.8、0.75,最终选的 0.72 是基于头像尺寸、间距和文本占位综合算出来的视觉最优值。你可以根据自己的设计稿调整,但不管怎么调,一定要在真机上看效果,模拟器分辨率和真机还是有差异的。
5.4 点击跳转到歌手详情
跳转逻辑不复杂,但有一些页面见了面才想得起来的问题。比如歌手详情页需要接收什么参数?我只传了 singerId,详情页根据这个 id 再加载歌手详情数据。这样设计是合理的,因为列表页的 singer 对象可能只包含摘要信息,详情页需要更完整的数据,如果直接把整个 Singer 对象传过去,数据冗余不说,后面字段更新时还要维护两处。
跳转时我包了一层 MaterialPageRoute。有人会问,OpenHarmony 上不是有自家的路由系统吗?为什么不用?在这个阶段,Flutter 层的 Navigator 已经能完整工作,和原生路由互跳属于更高级的混编话题,后面有需要再单独研究,初版就统一走 Flutter 路由,省心。
6. 性能优化与体验打磨
6.1 图片加载的性能门道
歌手头像列表是典型的网络图片密集场景。一张图几十 KB,几十张图叠在一起,对内存和带宽的消耗就不是小数目了。Flutter 自带的 NetworkImage 能工作,但性能上不够理想,主要体现在两个方面:一是没有缓存策略,退出页面再进入还得重新加载;二是没有缩略图支持,总是按原图解码。
我建议换成 cached_network_image 这个第三方包,用法几乎一样:
dart复制CachedNetworkImage(
imageUrl: singer.avatarUrl,
imageBuilder: (context, imageProvider) => CircleAvatar(
backgroundImage: imageProvider,
radius: 32,
),
placeholder: (context, url) => const CircleAvatar(
radius: 32,
backgroundColor: Color(0xFFF0F0F0),
),
errorWidget: (context, url, error) => const CircleAvatar(
radius: 32,
backgroundColor: Color(0xFFE0E0E0),
child: Icon(Icons.person, color: Color(0xFFAAAAAA)),
),
)
这个包支持磁盘和内存两级缓存,滚动时不会反复发请求,滑动体验会顺滑不少。而且它还自带占位图和错误图,模型上比 onBackgroundImageError 那个空实现优雅得多。
有一点要注意:cached_network_image 在 OpenHarmony 上的适配情况要提前验证。我用的版本可以正常工作,但不代表所有版本都兼容。引入任何一个第三方包前,先去它的 GitHub 仓库看看有没有 OpenHarmony 相关的 issue,能帮你避开很多坑。
6.2 滚动流畅度的优化思路
网格列表滚动掉帧,是 Flutter 开发中的高频问题。结合我自己的排错经验,优先级最高的优化是这几个:
- 让 Item 的构建尽可能轻量,不要在 build 方法里做耗时操作
- 避免在列表中使用
Opacity和ClipRRect成片出现,这些操作会增加合成层的开销 - 使用
const构造函数,减少 Widget 重建时的实例化成本
第一点最关键。列表滚动时,Flutter 会对新进入可视区域的 item 执行 build,如果 build 里做了 json 解析、图片处理这类操作,一秒钟二三十个 item 构建下来,卡顿是必然的。所以在列表的 item 构建过程里,只做 UI 布局相关的操作,数据都在传入之前处理好。
const 构造函数这一点容易被新手忽略,但收益非常明显。核心原理是:如果 Widget 配置没有变化,Flutter 可以跳过 rebuild,直接复用已有的 Element 和 RenderObject。为 Widget 的构造函数加上 const,等于提前告诉框架“我这块不会变”,省掉了运行时的比较成本。
6.3 列表 Item 的缓存复用
Flutter 列表框架内部本身就有缓存复用机制,RenderSliverGrid 会回收滚动出去的子组件,避免无限创建。但这个机制有一个前提,你的 item 必须使用 SliverChildBuilderDelegate 而不是 SliverChildListDelegate。
前者按需构建,后者是一次性构建全部子组件。如果歌手数据有几百条,用 ListDelegate 会导致所有卡片同时被创建,内存直接爆炸。我见过有人把这两种方式混用,结果列表一长就卡成 PPT。记住一条原则:列表数据不确定数量时,一律用 BuilderDelegate。
6.4 真机调试时的性能检查方法
优化有没有效果,不能凭感觉。我通常用两个工具来验证:
- Flutter 自带的 DevTools,查看 Widget 重建频率和渲染耗时
- OpenHarmony 侧的性能分析工具,查看 UI 线程的负载情况
在 DevTools 里重点关注一个指标:Performances 面板的 UI 和 Raster 线程耗时。如果 UI 线程耗时高,说明问题出在 Widget 构建逻辑上;如果 Raster 线程耗时高,说明 GPU 渲染链路上有瓶颈,比如图片解码过多或图层叠加过深。
我自己实测下来,优化前后同一个列表页面在模拟器上的帧率表现差距很明显。优化前滚动时偶发掉帧到 40 多,优化后稳定在 55 到 60。虽然模拟器的数据不能完全代表真机,但方向是对的。
7. 常见问题与排查技巧实录
7.1 编译时报错找不到 ohos 平台
有朋友过来问我,flutter create 之后没有 ohos 目录,是哪里操作不对?
大概率是 Flutter SDK 版本偏老,或者没有安装对应的 OpenHarmony 平台支持插件。解决办法是先升级 Flutter 到适配版本,然后执行:
bash复制flutter create --platforms ohos .
注意后面的点号,表示在当前目录下补充平台文件。还有一种偏门情况:项目是从远端仓库克隆的,原本就没有 OpenHarmony 适配文件,这种情况也需要用同样的命令来补齐。
7.2 图片加载失败但没有报错
这是一个让人非常抓狂的问题。日志没有报错,页面也不崩溃,但头像就是出不来。我最初遇到时,逐行检查了代码,确认 URL 没问题,最后才发现是网络权限没有开启。
OpenHarmony 应用默认的网络策略比较严格,需要在 module.json5 的 requestPermissions 里加上:
json复制{
"name": "ohos.permission.INTERNET"
}
修改完记得重新构建应用,不是热重载能生效的,因为原生权限配置发生在应用安装阶段。
7.3 网格间距不均匀的诡异现象
网格的间距看起来时大时小,不统一。排查半天,最后发现问题出在头像图片本身。当图片的宽高比不一致时,CircleAvatar 的裁剪表现会出现偏差,视觉上看起来像是间距被“撑开”了。
解决办法是在头像外层固定一个尺寸的 SizedBox,让 CircleAvatar 在一个确定的范围内工作:
dart复制SizedBox(
width: 64,
height: 64,
child: CircleAvatar(...),
)
从 UI 设计的角度说,图片显示区域确定了之后,不管原图是什么比例,都能稳定输出统一的视觉效果。头像体系里,这一点要尽早规范化。
7.4 热重载失效的场景
在 OpenHarmony 上跑 Flutter,热重载(Hot Reload)大部分时候是正常的,但如果你修改了原生层的代码,比如改了 module.json5 或者 C++ 桥接代码,热重载是不会生效的,必须完整重启应用。
还有一类容易误判的情况:你改的是数据模型字段,热重载后界面上没有任何变化。这不是热重载坏了,而是页面状态没有被重置。最简单的办法是在代码里临时加一个启动时清空数据的逻辑,或者直接快捷键 R(Hot Restart)而不是 r(Hot Reload)。这两者的区别是:Hot Reload 保留应用状态,Hot Restart 会重新跑一遍启动流程。
7.5 列表加载更多时闪跳
给列表加上下拉加载更多后,新数据插入时列表会突然跳动,用户正在看的位置被顶开。这是因为插入数据后没有保持滚动位置。
解决方案是给 ScrollController 记录当前滚动偏移量,在数据更新后恢复。更简单的方式是用 CustomScrollView 加 SliverGrid,并把 key 设置为列表长度。不过这个方案有一定副作用,会丢失滚动状态。最稳妥的还是维护一个 controller:
dart复制final _scrollController = ScrollController();
void _loadMore() async {
final offset = _scrollController.offset;
setState(() {
_singerList.addAll(newItems);
});
WidgetsBinding.instance.addPostFrameCallback((_) {
_scrollController.jumpTo(offset);
});
}
先记录位置,数据更新后再跳回去,视觉上就没有跳闪的感知了。
7.6 一个关于字体的隐性坑
OpenHarmony 设备上,某些默认字体可能不支持中文显示。Flutter 项目里显示中文文本时,如果出现方块字,不要急着改 Flutter 代码,先检查系统是否安装了中文字体。
有一种规避方式是在应用启动时显式指定 fontFamily,使用系统内置的中文字体名称,但这个具体名称在不同版本上可能有差异。更通用的做法是内置一个开源中文字体文件,打包进 assets,全局设置到主题里:
dart复制theme: ThemeData(
fontFamily: 'Roboto',
fontFamilyFallback: ['PingFang SC', 'Microsoft YaHei', 'Noto Sans CJK SC'],
),
fontFamilyFallback 的作用是当第一个字体找不到某个字符(比如汉字)时,按顺序往后找,直到找到能显示这个字符的字体。这个机制在跨平台开发里非常实用,建议每个 Flutter 项目都顺手加上。
8. 经验总结与后续扩展方向
歌手列表这个页面,单独看是音乐播放器里的一个小模块,但把它整个做下来,实际上覆盖了一条很完整的开发链路:环境搭建、工程创建、数据建模、页面构建、状态管理、图片缓存、列表优化、真机调试。这条链路打通之后,再做专辑页、排行页什么的,就只是换一层皮的事情。
我给自己的下一步规划是,在歌手列表的基础上扩展三个方向。第一个是下拉刷新和上拉加载更多,配合模拟数据把完整的分页逻辑调通。第二个是歌手详情页,承接列表页的点击事件,做成一个包含歌手简介、热门歌曲、专辑列表的复合页面。第三个是搜索页,在歌手列表之上实现搜索过滤,体验一下列表实时刷新的交互模式。
如果你也想把这个项目继续做下去,我的建议是先别急着接真后端。把本地数据层模拟的各种状态玩明白了,包括加载失败重试、空数据、长列表滚动,再接网络请求时就会非常顺。另外,强烈建议把项目放进版本管理,每次改完一个功能就提交一次,这样出了问题可以快速回滚,调试心态也会稳很多。
就分享到这儿。有朋友卡在环境配置或者编译阶段的话,可以把报错日志发给我看看,我踩过的坑大概率比你要多。
