做音乐播放器 App 时,歌手列表这个模块几乎躲不开——用户要按歌手追歌,产品要在这里做流量分发,开发则要面对列表、图片、状态、跳转这一整套基本功。最近我在 Flutter for OpenHarmony 上把一款音乐播放器从零搭到可运行,歌手列表是第一个完整落地的业务页面。这篇文章把从数据模型到 UI 渲染,再到性能优化和真机排坑的完整过程拆开讲,适合第一次在 OpenHarmony 上跑 Flutter 的开发者,也适合准备把现有 Flutter 项目迁移到新平台、想提前知道哪里有坑的客户端工程师。
先给一个总览:歌手列表在业务上只是“歌手库”标签页的一块内容,但它集中了一个工程里最典型的四个问题——数据模型怎么抽象、长列表怎么渲染不卡、图片资源怎么加载稳定、页面跳转和状态更新怎么不写成一团乱麻。把这四个问题解决掉,后面再写专辑列表、歌单列表、排行榜,基本就是换皮加字段。
1. 项目背景与整体方案选型
1.1 这个项目为什么选 Flutter for OpenHarmony
先说项目由来。团队里已有的音乐 App 是用 Flutter 写的,UI 层、播放器逻辑、歌单数据结构都沉淀了两三年。现在要往 OpenHarmony 平台扩展,摆在面前的两个选择是:用平台原生声明式 UI 重新写一遍,或者让 Flutter 直接跑在 OpenHarmony 上。
重写 UI 的代价往往被低估。一个成熟音乐客户端的页面数量少说几十个,每个页面里还有各种自定义组件、动画、状态流转。平移到另一套 UI 描述语言里,等于把几年积累的组件库全部重做一遍。而 Flutter for OpenHarmony 提供的是一条更务实的路:Dart 代码和 Widget 树不动,只解决“引擎怎么在新平台上跑起来”的问题。
实际跑下来,Flutter for OpenHarmony 的适配层会把 Flutter Engine 承接在 OpenHarmony 的图形能力之上,Dart 层写好的页面能在设备上正常渲染和交互。不过这里要清醒一点:这套适配不是零成本的,一些深度调用原生能力的插件在 OpenHarmony 上不一定有现成实现。所以我在项目里把模块分成两类——纯 UI 模块和强平台模块。歌手列表这种纯 UI + 普通数据请求的模块,最适合作为第一个吃螃蟹的业务页面。
1.2 歌手列表在该 App 里的准确业务定位
产品侧定义的入口是底部 Tab 里的“歌手库”。进入之后顶部是可横向滚动的分类栏,包含“全部、华语、欧美、日韩”等分类;下方是当前分类下的歌手列表。用户点进某个歌手后,进入歌手详情页,里面展示歌手简介、热门歌曲和专辑。
这个定位很关键,它直接影响下面的实现取舍。因为歌手列表只是一个入口页,列表项就不需要承载过重信息——一个圆形头像、歌手名、歌曲数量就够,最多加一个“关注”按钮。不需要在列表项里放什么磁力贴、热门金曲标签或者复杂的渐变背景,信息密度过高不仅拖慢列表构建速度,视觉上也显得很堵。
路由设计上也因为这个定位选了“列表只传 id”的方案。点击歌手后,详情页通过 service 根据 id 拉取完整数据。这样列表页的数据模型不用为了详情页的需求而膨胀,两个页面各拿各的数据,边界很清爽。
2. 数据层设计:先把模型和数据通道定下来
2.1 歌手模型字段怎么定才能少返工
很多新手写列表是先去堆 UI,写到一半发现字段不够,又回来改模型。我的习惯是先把数据模型敲定。这个项目的歌手模型长这样:
dart复制class Artist {
final String id; // 歌手唯一标识
final String name; // 歌手名
final String avatarUrl; // 头像,本地资源或网络 URL
final int songCount; // 歌曲数量
final String category; // 分类:华语/欧美/日韩
final String initial; // 姓名首字母,用于索引
final List<String> hotSongNames; // 预取的热门歌曲名,用于详情页快速展示
const Artist({
required this.id,
required this.name,
required this.avatarUrl,
this.songCount = 0,
this.category = '全部',
this.initial = '#',
this.hotSongNames = const [],
});
}
这里每条字段都有它的用途,不是随手写的。id 用于路由传参;name 用于展示和搜索;avatarUrl 决定了图片加载方案;songCount 是列表项副标题的核心文案;category 支撑分类栏的过滤;initial 给字母索引功能留了扩展位;hotSongNames 是我故意预取的一小部分数据——歌手详情页打开时,需要能秒出一屏“热门歌曲”概要,如果等详情页再去拉全量数据,用户会看到明显的白屏缓冲,预取几条热门歌曲名可以把这个时间差抹平。
模型用不可变对象也是一个刻意的选择。final 字段能防止列表数据在渲染过程中被意外修改,也在配合 Flutter 的 const 优化时更友好。
2.2 开发期用本地 JSON 起步,别等后端联调
项目初期后端接口还没稳定,歌手数据结构可能一天一变。这时候直接接网络接口,会花大量时间在处理异常、同步字段上。我的做法是先做本地数据源。
在 assets/data/artists.json 里放一份模拟数据,结构如下:
json复制[
{
"id": "a001",
"name": "林声",
"avatarUrl": "assets/images/artists/a001.png",
"songCount": 36,
"category": "华语",
"initial": "L",
"hotSongNames": ["山海", "夜航", "白昼梦"]
},
{
"id": "a002",
"name": "Elena",
"avatarUrl": "assets/images/artists/a002.png",
"songCount": 24,
"category": "欧美",
"initial": "E",
"hotSongNames": ["Midnight", "Harbor"]
}
]
读取本地 JSON 在 Flutter 里是标准操作:
dart复制class ArtistDataSource {
static Future<List<Artist>> loadFromAssets() async {
final raw = await rootBundle.loadString('assets/data/artists.json');
final List<dynamic> list = jsonDecode(raw);
return list
.map((e) => Artist.fromJson(e as Map<String, dynamic>))
.toList();
}
}
这里有个容易踩的坑:jsonDecode 返回的是 List<dynamic>,直接 .map((e) => e['name']) 在编译期不会报错,运行时才崩。一定要先 e as Map<String, dynamic> 做类型收窄。另外,JSON 文件务必用 UTF-8 编码保存,否则中文名字在真机上可能显示成乱码。
本地数据源的好处是开发 UI 时完全不受网络和联调进度牵制,列表页的加载态、空态、失败态都能稳定复现。等真接口稳定了,再一步步换掉数据源实现。
2.3 留好 Service 层,接口说换就换
本地起步不等于把 JSON 解析逻辑散落在页面里。我定义了一个抽象的数据入口——ArtistService:
dart复制abstract class ArtistService {
Future<List<Artist>> fetchArtists({String category = '全部', int page = 1});
}
class LocalArtistService implements ArtistService {
@override
Future<List<Artist>> fetchArtists({String category = '全部', int page = 1}) async {
// 从 JSON 加载后按分类过滤,模拟分页
}
}
class RemoteArtistService implements ArtistService {
@override
Future<List<Artist>> fetchArtists({String category = '全部', int page = 1}) async {
// 真实网络请求实现
}
}
UI 层只依赖 ArtistService 这个抽象,开发期注入 LocalArtistService,上线前替换成 RemoteArtistService。这样做的直接好处是:后端接口变更时,我只需要改 RemoteArtistService 一个类,页面、状态管理、列表渲染完全不动。
分页参数从第一天就设计进去。虽然本地 JSON 数据量小用不上,但真实接口一定是分页的,字段晚加会导致后面要动很多层代码。提前把 page 参数放在方法签名里,成本几乎为零。
3. 歌手列表 UI 实现:页面骨架与列表项细节
3.1 页面整体布局:分类栏 + 长列表
歌手列表页的结构并不复杂,核心就是一块可滚动区域。我用的布局方案是:
Scaffold提供导航栏和背景;- 导航栏下方放一个横向滚动的分类栏;
- 主体部分用
RefreshIndicator包裹ListView.builder。
为什么没用更复杂的 CustomScrollView?因为当前页面没有需要悬停的复杂头部,也没有多个滚动区域联动的需求。分类栏是独立的横向滚动,和主列表的纵向滚动互不干扰,拆开用简单组件搞定即可,不需要为了炫技引入复杂度。
主体代码大体长这样:
dart复制@override
Widget build(BuildContext context) {
final provider = context.watch<ArtistProvider>();
return Scaffold(
appBar: AppBar(title: const Text('歌手库')),
body: Column(
children: [
CategoryBar(
categories: provider.categories,
selected: provider.currentCategory,
onSelected: provider.switchCategory,
),
Expanded(
child: RefreshIndicator(
onRefresh: provider.refresh,
child: ListView.builder(
physics: const AlwaysScrollableScrollPhysics(),
itemExtent: 72,
itemCount: provider.artists.length,
itemBuilder: (context, index) {
final artist = provider.artists[index];
return ArtistListItem(
key: ValueKey(artist.id),
artist: artist,
onTap: () => _openDetail(context, artist.id),
);
},
),
),
),
],
),
);
}
这里有两个可以立刻抄走的细节。一是 itemExtent: 72,这是列表项固定高度的显式声明。二是 ValueKey(artist.id),让 Flutter 在列表更新时能精确复用对应元素的 State,而不是靠位置猜测,避免滚动位置错乱、复用状态错位的问题。
3.2 列表项组件:一个 72 高的“格子”里能藏多少细节
列表项我抽成了一个独立组件 ArtistListItem,它直接决定了用户对信息密度的体感。高度固定在 72,内部结构是:左侧圆形头像,中间姓名加歌曲数,右侧可选的关注按钮。
dart复制class ArtistListItem extends StatelessWidget {
final Artist artist;
final VoidCallback onTap;
const ArtistListItem({
super.key,
required this.artist,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
ClipOval(
child: SizedBox(
width: 48,
height: 48,
child: AppNetworkImage(
url: artist.avatarUrl,
placeholder: const Icon(Icons.person),
),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
artist.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 4),
Text(
'${artist.songCount} 首歌曲',
style: TextStyle(
fontSize: 12,
color: Theme.of(context).hintColor,
),
),
],
),
),
],
),
),
);
}
}
头像这块我特意没用 CircleAvatar,而是用 ClipOval + SizedBox。原因是 CircleAvatar 在图片未加载出来时会默认显示一个背景色块,视觉上比较突兀,而 ClipOval 配合自定义的占位组件,可以精确控制“加载中是什么效果”“失败时是什么效果”。在实际体验里,这个细节决定了列表滚动时是“一张张图片闪进来”,还是“灰块跳一下”,观感差别很大。
关注按钮在这个版本里我决定先不放。连续两次产品评审都提出要这个功能,但从数据层看,关注状态需要独立的持久化结构,而当前版本用不上真实账号体系。强行加一个“假按钮”,用户点了没有任何反馈,反而伤害体验。砍掉之后,列表项简化到了最舒服的视觉密度。
3.3 点击跳转歌手详情:传 id 而不是传对象
列表项的 onTap 最终执行的是这一行:
dart复制Navigator.push(
context,
MaterialPageRoute(
builder: (_) => ArtistDetailPage(artistId: artist.id, initial: artist),
),
);
注意这里我同时传了 artistId 和 initial。artistId 是详情页拉取全量数据的主键,initial 是列表页已经拿到的概览数据,用来做首屏秒开。详情页接到 initial 后,先用它渲染出一个基础界面——头像、名字、热门歌曲名的草稿,然后立刻通过 service 拉完整数据来覆盖。
这个“缓存概览 + 全量异步拉取”的模式,比只传 id 再白屏等接口要流畅得多。但如果只传对象不传 id,问题更大:列表页的数据可能是不完整的旧数据,详情页如果直接依赖这个对象,会出现“歌手歌曲数对不上”“简介缺失”等诡异 bug。id 是真相,对象只是缓存,这个原则贯穿了整个项目。
3.4 空状态、加载态和“没有更多了”
列表页的三态处理,不是加个 if 就完事,我整理成了标准结构:
- 加载中:首次进入时展示居中
CircularProgressIndicator; - 加载失败:展示错误文案和“点击重试”按钮,点击后重新走加载流程;
- 数据为空:展示一个占位图标加“该分类下暂无歌手”;
- 数据非空:正常渲染
ListView,末尾追加一个“加载更多”尾巴。
实现的时候,我用了一个枚举加各分支的 Widget 构建。要注意的是:加载更多按钮不应该用传统按钮组件,因为它嵌在列表里,点击区域和滚动手势容易互相干扰。我把它做成了一个纯展示组件,加载中显示小进度条,到底了显示“没有更多了”,点击加载逻辑则放在滚动到底的监听里。
4. 性能优化和体验细节:列表卡不卡,差别都在这些位置
4.1 ListView.builder 懒加载:列表数据再多也不要怕
歌手列表的数据量不大,但“不大”不等于可以随便写。我最常见到的错误写法是:
dart复制// 反面教材:一次性构建所有 item
ListView(
children: artists.map((e) => ArtistListItem(artist: e)).toList(),
);
这种写法会把所有歌手组件全部实例化,哪怕用户只看到屏幕上的七八个。Flutter 的 ListView(children: [...]) 虽然内部也是懒加载渲染,但 Widget 对象本身全部被创建了一遍,列表一旦过千,构建时间指数级上升。
正确做法从第一版就固定下来:一律用 ListView.builder。它只会为当前视口附近的 item 调用 itemBuilder,滚动时再按需创建、回收、复用。这个机制对 OpenHarmony 上的适配层同样生效,实测下来的滚动性能与原生列表已经比较接近。
4.2 itemExtent 是长列表性能的关键伏笔
给 ListView.builder 加上 itemExtent: 72,是我在这个项目里最推荐的一个单一优化动作。它的含义是:告诉 Flutter 每个 item 的高度都是固定 72,不需要逐个测量。
内存和计算上的收益是:滚动位置计算从“先布局再测量”变成“直接用乘法算出 offset”,省掉大量 layout 工作。这在长列表滚动时是数量级的性能差异。对于复杂列表项,时间能差出 2 到 5 倍。代价是列表项高度必须真的一致,如果某些 item 内容膨胀导致高度变化,会出现滚动跳动或元素复用的视觉闪烁。我的列表项设计成固定高度,所以这个优化直接生效。
4.3 图片加载与缓存:滚动白块的头号凶手
歌手列表的头像是网络资源,直接使用 Image.network(artist.avatarUrl) 会带来两个问题:一是每次滚动到图片区域都会重新发起网络请求;二是加载过程中会出现白块闪烁。
我在项目里封装了一个 AppNetworkImage 组件,底层基于 CachedNetworkImage 做磁盘与内存缓存。实际展示逻辑是三分支:加载中显示浅灰占位;加载成功显示图片;加载失败显示默认用户图标。其中一个容易踩的坑是图片尺寸——列表头像只有 48 像素,但如果服务端返回的是原图 1000 像素,解码成本会白白消耗在列表滚动路径上。我的处理是用 cacheWidth: 96,让图像解码时按目标尺寸缩小,内存占用和耗时都会显著下降。
dart复制CachedNetworkImage(
imageUrl: url,
width: 48,
height: 48,
fit: BoxFit.cover,
memCacheWidth: 96,
placeholder: (_, __) => Container(color: Colors.black12),
errorWidget: (_, __, ___) => const Icon(Icons.person, color: Colors.grey),
);
有一个和 OpenHarmony 平台更相关的坑:图片缓存目录如果写死成固定路径,在沙箱环境下可能因权限或目录不存在而失败,表现为图片加载缓存失效、滚动时反复请求。建议通过 channel 从原生侧获取应用缓存目录,再交给图片缓存库使用。纯开发期如果不想碰原生代码,可以把缓存目录设置为临时目录,至少保证不会闪退。
4.4 中文字体和数字对齐的适配问题
OpenHarmony 默认字体对中文的覆盖没有问题,但如果你在 MaterialApp.theme 里指定了一个字体族,而真机上没有安装该字体,中文就会变成“豆腐块”。我的做法是不做全局字体指定,让系统默认字体做事。如果项目设计稿要求特定字体,比如数字要等宽对齐,我的建议是用局部 TextStyle 配合 fontFeatures 来做,不要改全局。
dart复制Text(
'${artist.songCount}',
style: const TextStyle(
fontSize: 14,
fontFeatures: [FontFeature.tabularFigures()],
),
)
FontFeature.tabularFigures() 能让数字宽度统一,用于歌曲数量这种可能需要频繁变化的文本,可以避免滚动时数字跳来跳去导致文本宽度抖动。但如果目标字体不支持这个特性,会导致渲染异常,使用前需要先在真机上验证一遍。
4.5 平板和大屏适配:不要让列表拉满全屏
Flutter 默认布局在平板上会直接把列表拉伸到屏幕宽度,文本的可读性是灾难级的。我的处理是给列表内容加一个最大宽度约束:
dart复制Align(
alignment: Alignment.topCenter,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 560),
child: ListView.builder(...),
),
)
这样在手机和平板上,列表内容都保持在舒适阅读宽度。这个约束只作用于列表区域上,背景和导航栏不受影响。这个方案相比复杂的分栏布局,成本低且效果可预期。初次上架阶段先用它兜底,后续如果要针对折叠屏放大屏布局,可以再引入 LayoutBuilder 做设备断点。
5. 状态管理和业务逻辑组织:别被框架绑架,但也别硬扛
5.1 为什么选了 ChangeNotifier + Provider,而不是 Bloc
歌手列表看起来简单,但状态流转不少:启动加载、分类切换、下拉刷新、加载更多。如果全用 setState 写在页面的 State 里,面对这些场景很快就会变成一团乱线。分类切换时,页面可能要清空列表、重新拉数据,只在页面里写这些逻辑,没法被独立测试,也很难在多个页面间复用同一条数据。
我选择的是 ChangeNotifier + Provider。理由很简单:这个项目的状态规模处于中间地带,需要一个“可监听、可注入、可测试”的容器,但还不到需要用事件流、reducer 那套重型方案的复杂度。Bloc 的样板代码量和概念门槛对单人维护的小项目是负收益。Riverpod 更现代,但它引入了编译期代码生成和依赖容器概念,团队里其他人上手成本偏高。Provider 作为社区事实标准,学习曲线平滑,写起来直接,而且完全够用。
5.2 ArtistProvider 到底管了哪些活
dart复制class ArtistProvider extends ChangeNotifier {
final ArtistService _service;
List<Artist> _artists = [];
bool _loading = false;
String _currentCategory = '全部';
int _currentPage = 1;
bool _hasMore = true;
bool _loadingMore = false;
ArtistProvider(this._service);
List<Artist> get artists => _artists;
bool get loading => _loading;
String get currentCategory => _currentCategory;
Future<void> switchCategory(String category) async {
if (category == _currentCategory) return;
_currentCategory = category;
_currentPage = 1;
_hasMore = true;
notifyListeners();
await _reload();
}
Future<void> refresh() async {
_currentPage = 1;
_hasMore = true;
await _reload();
}
Future<void> loadMore() async {
if (_loadingMore || !_hasMore || _loading) return;
_loadingMore = true;
notifyListeners();
final page = _currentPage + 1;
final more = await _service.fetchArtists(
category: _currentCategory,
page: page,
);
if (more.isNotEmpty) {
_currentPage = page;
_artists.addAll(more);
} else {
_hasMore = false;
}
_loadingMore = false;
notifyListeners();
}
Future<void> _reload() async {
_loading = true;
notifyListeners();
try {
final result = await _service.fetchArtists(
category: _currentCategory,
page: 1,
);
_artists = result;
_hasMore = result.length >= 20;
} finally {
_loading = false;
notifyListeners();
}
}
}
几个容易被忽略的细节。switchCategory 里先 notifyListeners() 再 await _reload(),是为了让分类栏的选中态立刻更新,用户操作有即时反馈,而不是等网络返回才动。loadMore 里的 _loadingMore 标志是防止滚动监听器在底部反复触发重复请求的“保险丝”。_reload 里用 finally 保证异常时也能退出加载态,否则一旦请求出错,页面会永远停在转圈状态,这是新手最容易碰到的坑。
5.3 页面和 Provider 的绑定:watch 的粒度控制
页面的 build 方法里,我用了 context.watch<ArtistProvider>() 来获取整个 Provider。它的问题是:Provider 里任何一个字段变化,包括 _loading、_hasMore,都会触发页面重建。列表项本身是轻量的,重建成本尚可接受。但如果以后列表项变重,比如加上了复杂的歌单封面、折叠组件,就要用 Selector 做细粒度选择。
dart复制final artists = context.select<ArtistProvider, List<Artist>>(
(p) => p.artists,
);
这样只有 artists 引用发生变化时,页面才会重建列表。分类切换时的 _loading 变化不会导致整个列表重建。这是我在这个项目里性能预算的一个储备方案,代码结构从一开始就支持这种切换,后面真出现卡顿,直接替换即可。
5.4 下拉刷新与滚动到底加载更多
RefreshIndicator 需要列表在内容不满一屏时也能下拉,所以 ListView 的 physics 必须设为 AlwaysScrollableScrollPhysics。这个细节不写上去,数据量小时页面根本拉不动,会让人误以为刷新功能坏了。
加载更多的触发用滚动监听实现了一版:
dart复制_scrollController.addListener(() {
if (_scrollController.position.pixels >=
_scrollController.position.maxScrollExtent - 200) {
provider.loadMore();
}
});
-200 是提前量,让加载发生在距离底部 200 像素时触发,而不是死等到完全到底,体感上会更流畅。监听器里要依赖 Provider 的 _loadingMore 标志做防重,这个前面已经提到。整个分页逻辑在本地 JSON 阶段几乎是空转的,但我从第一天就把它接好,因为真实接口接进来时,我不需要再改页面层代码。
6. 常见问题与排查实录:这些坑我替你先踩了
6.1 OpenHarmony 环境下 Flutter 工程的初始化配置问题
用 Flutter for OpenHarmony 开发,最容易被卡住的往往不是 UI 代码,而是环境。我遇到过三个典型的:
一是 SDK 路径识别不到。构建时提示找不到 OpenHarmony SDK,但开发工具里明明装了。原因是当前 shell 环境没有导入 SDK 相关环境变量,导致构建脚本无法定位到 SDK 路径。解决方式是确认环境变量配置正确,然后重新打开终端让配置生效。排查时先保证命令行里能执行相关的 SDK 工具命令,再回过来看 Flutter 工程。
二是工程里多出来的 ohos 平台目录。Flutter 工程默认有 android 和 ios 目录,为 OpenHarmony 加适配层后会多出 ohos 目录。有同事不了解这个差异,在清理“无用目录”时把它删了,结果构建直接失败。这个目录是对接原生能力的边界,删不得。
三是三方库兼容性。Flutter 生态里大部分 UI 相关的包在 OpenHarmony 上表现正常,但凡是依赖原生具体功能的,比如定位、相机、传感器,都可能没有对应实现。歌手列表这种模块用的库很少,不会踩雷;如果要用到复杂原生能力,尽量在选库之前就去仓库确认支持情况。
6.2 列表滑动卡顿的完整排查路径
一次真机上性能测试,我发现歌手列表快速滑动掉帧明显。当时没有直接怀疑列表代码,而是用性能分析工具抓帧耗时,发现单帧构建超过了 16ms。逐步排查的次序是:
- 先确认使用
ListView.builder,而不是ListView(children: ...)。 - 检查列表项是否是
const构造。如果组件的构造函数没有const,即便参数没变,Flutter 也无法复用旧的 Widget 实例。 - 检查图片解码大小。这个列表的头像用的是 1000 像素原图,加上
cacheWidth约束后掉帧明显缓解。 - 检查是否误用了
BoxShadow和透明度动画。列表项里一旦出现阴影,渲染时就要额外做一次模糊计算,在滚动路径上很贵。我把阴影效果换成了纯色分割线,视觉效果相差不大,性能提升明显。 - 检查是否有
context.watch粒度过粗的问题。这个项目暂时可接受,但代码留了Selector的优化口。
6.3 图片加载失败或白屏的排查
OpenHarmony 上的网络图片加载失败,控制台常见两类报错。
一类是网络权限问题。开发期如果接口是 http 明文请求,需要在 module 配置文件里声明网络权限,否则请求会被拦截,表现就是图片加载失败、日志里出现网络异常。排查方法很简单:看信息栏里有没有权限相关的过滤词,再把网络权限加上试一次。
另一类是缓存目录问题。图片缓存库默认把缓存写到系统临时目录或者指定目录,但在 OpenHarmony 的沙箱隔离下,某些固定路径可能不存在或不可写,导致缓存一直写不进去,每次都重新网络请求。遇到这种情况,需要通过 channel 从原生侧拿应用缓存目录,再传给图片缓存库。这也解释了为什么很多 Flutter 组件在模拟器上好好的,一到真机就出问题——真机沙箱管控比模拟器严得多。
6.4 中文名称和 UTF-8 转义问题
我在解析本地 JSON 时遇到过歌手名显示成类似 \u4E2D\u6587 的原始转义字符串。第一反应是数据文件写错了,排查后发现是从某后端导出 JSON 时,把中文转义成了 \u 形式,而解析的代码直接把这个转义序列当作普通字符串用了。
解决方式有两种:一是保持 JSON 文件里的中文为可读文本,用 UTF-8 读取;二是不改源数据,用能正确解析转义字符的读取路径读取。这里要提醒一句:jsonDecode 本身是能正确还原 \u 转义的,问题往往出在“先用别的逻辑读取文件、又手动拼接字符串”这种路径上。所以读取资源文件时,统一用 rootBundle.loadString 配合 UTF-8,不要自己写一层字节读取逻辑。
另一个相关细节:某些字体不支持生僻汉字,会导致个别歌手名显示为“豆腐块”。这属于字体覆盖问题,常规做法是把默认字体族设置为系统默认字体,或者引入覆盖范围更广的字体文件。歌手名这种数据不可控的文本,尤其要留个心眼。
6.5 Hot Reload 在 OpenHarmony 上的体验差异
在 OpenHarmony 真机上调试时,我发现 Flutter 的 hot reload 并不总是灵。改 Dart 代码后按热重载键,有时页面没有变化,需要再执行一次 hot restart 才能生效。这与适配层的热更新机制有关,不同版本表现还不一致。
实操中我的习惯是:改纯 UI、纯 Dart 逻辑时优先用 hot restart,别为了省那几秒跟热重载死磕;如果改动了涉及原生侧的配置或代码,比如权限配置、channel 调用,那就直接冷启动,因为这类变更热重载根本覆盖不了。花点时间搞清楚“什么东西在什么平台上能热”,能省掉很多“改了毫无反应”的无效等待。
6.6 列表渲染时的资源缺失崩溃
最后一个坑很隐蔽。本地开发阶段,我给某个歌手配置的头像资源路径在 assets 目录里漏掉了对应图片文件。列表滚动到那个歌手时,Image.asset 直接抛出异常,表现是页面突然闪退,或者整个列表区域空白,控制台没有很明显的业务错误。
排查时,我先把异常处理挂到了全局错误监听上,记录下崩溃堆栈,才定位到是资源文件缺失。这个问题给我的教训是:本地资源引用不能只靠眼检查,最好在下载数据加载完成后统一校验一遍,或者在图片组件里加一层 errorWidget 兜底。这比把异常留给框架默认处理要友好得多。
从数据模型到真机调试,歌手列表这个模块把 Flutter for OpenHarmony 开发里最有代表性的问题都过了一遍。我个人的体会是,它真正考验的不是 Widget 写得好不好,而是对平台边界的判断——哪些能力可以放心用,哪些要绕路,哪些要提前留退路。列表性能、图片缓存、状态管理、路由传参,这些在任何 Flutter 项目里都是基本功,但叠加 OpenHarmony 这个新环境后,每个基本功都会冒出新的幺蛾子。把这些问题记下来,后面再做歌单列表和排行榜时,心里就有底了。
