1. 项目背景与整体设计
再说一次最近的实战主题:用 Flutter for OpenHarmony 做一款剧本杀组队 App。市面上剧本杀组队的需求其实非常集中,玩家要找人、要选本、要确认场次和人数。我先把需求最重、交互最密集的“剧本库列表”做完,核心就是让玩家在一屏幕里快速浏览几十上百个剧本,再通过搜索、筛选和分页找到合适的本。这篇文章把我整个实现过程,从环境搭建、数据层、列表 UI、交互到 OpenHarmony 真机适配,全部拆开讲一遍。
这个标题里真正有门槛的不是“剧本库列表”本身,而是“Flutter for OpenHarmony”这层组合。Flutter 在 Android、iOS、Web、桌面上的生态已经很成熟,但在 OpenHarmony 设备上跑 Flutter 仍然是一个典型的移植适配场景,SDK 要用专门的 fork 版本,工程约束和权限配置也跟普通 Flutter 工程不一样。把这些前置问题解决干净之后,列表实现的价值才能完全释放出来。
1.1 为什么用 Flutter for OpenHarmony 做剧本杀组队App
先聊选型。剧本杀组队 App 的使用场景很特殊,它不像工具类 App 那样追求极致的系统交互,而是强依赖信息展示、卡片列表、图片封面、评分标签,以及后续的聊天、组队、支付。这种业务形态对 UI 的开发效率要求远高于对系统底层能力的依赖。
如果用纯 OpenHarmony 原生开发(ArkTS 加 ArkUI),当然可以做到很好的体验,但问题是两个:第一,团队里如果原本是 Flutter 团队,重新学 ArkTS 的开发成本不低;第二,后续 App 大概率要同时上 Android、iOS 以及 OpenHarmony 生态设备,维护三套原生代码对一个小团队来说太重了。
Flutter for OpenHarmony 解决的就是这个痛点。它是 OpenHarmony 社区维护的 Flutter 适配版本,核心的渲染能力、Widget 体系、Dart 语言生态都保留了,开发层面几乎跟标准 Flutter 一致。我用 Flutter 写一套业务代码,可以同时覆盖 Android、iOS、OpenHarmony 三个平台。实测做剧本库列表这种以列表、卡片、图片为主的页面,代码复用率能到百分之九十以上,只有少量需要平台通道的地方要单独处理。
1.2 剧本库列表到底要做什么
很多人一听到“剧本库列表”,第一反应就是“ListView.builder 加几个商品卡片”,实际上剧本杀业务对列表的要求比普通商城列表更细。在剧本杀组队场景里,玩家选本时脑子里通常带着几个条件:这个本是什么类型(情感、硬核、欢乐、恐怖、机制),适合几个人玩,玩多久,难度怎么样,评分高不高。
所以剧本库列表的核心职责可以拆成这么几块:
| 功能点 | 具体说明 |
|---|---|
| 剧本卡片展示 | 封面、标题、类型标签、人数范围、游戏时长、难度、评分 |
| 文本搜索 | 按剧本名称或简介关键词检索 |
| 类型筛选 | 按情感、硬核、欢乐、恐怖、机制等标签过滤 |
| 下拉刷新 | 重新拉取最新剧本数据 |
| 上拉加载更多 | 分页机制,避免一次性渲染大量数据 |
| 空状态/错误状态 | 搜索无结果、网络异常时的兜底界面 |
| 跳转详情 | 点击卡片进入剧本详情页 |
这些需求分开看不难,但组合在一起对状态管理和列表性能就有要求了。尤其是搜索和筛选同时生效时,页面的数据源就不再是“一次性拉全量”,而是多个条件叠加后经过内存过滤,再加上分页语义。我在设计时就明确了一点:数据层一定要跟 UI 层解耦,列表页不直接操作数据源集合,而是通过 Repository 层拿到结果,否则后面加接口联调时会改得很痛苦。
1.3 技术选型与状态管理取舍
状态管理我这次没有直接上 Bloc 或者 Riverpod,而是先用 Flutter 自带的 StatefulWidget 加 setState。理由很直接:剧本库列表页的状态管理其实是“单页面状态”,只有数据列表、当前页码、有没有更多数据、加载状态、搜索关键词、筛选条件这几项。setState 完全能撑起来,而且对这个阶段的代码来说最好理解。
当然,这不代表项目里就不引入状态管理框架。组队 App 后续一旦扩展到用户登录态、组队房间实时状态、消息未读数这些跨页面共享的数据,setState 就会显得很吃力。正确的做法是先把列表页做成“单页自治”的形态,等数据流复杂了再平滑迁移到 Riverpod,而不是一上来就为所有页面套上重型框架。
列表本身选择 ListView.builder,这是生成大量同构列表项的标准方案。它只渲染可视区域内的 item,不会一次性把所有剧本卡片都 build 出来。配合后续要讲的 itemExtent 和 const 优化,在 OpenHarmony 中低端设备上也能保持流畅滑动。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 环境准备与工程搭建
2.1 Flutter for OpenHarmony SDK 下载与配置
Flutter for OpenHarmony 不能直接用官方 flutter.dev 下载的 SDK,要用 OpenHarmony 社区维护的适配版本。我用的方式是直接拉取 ohos 分支的 flutter_flutter 仓库,把它单独放到一个目录,比如 D:\ohos-flutter\flutter,然后用这个 SDK 作为项目的基础工具链。
这里有一个特别容易踩坑的点:机器上如果同时装有官方 Flutter SDK 和 OpenHarmony 适配版 Flutter SDK,很容易出现命令行用的还是旧版本的情况。我的解决方法是做环境隔离,单独开一个终端窗口,在这个窗口里先执行:
bash复制set PATH=D:\ohos-flutter\flutter\bin;%PATH%
flutter --version
确认版本号是适配版之后再继续。另外 OpenHarmony 构建还需要 DevEco Studio 的 SDK 环境变量,一般要指定 DEVECO_SDK_HOME 指向 DevEco Studio 的 SDK 目录,这一步在官方适配文档里有明确说明。
配置完成后运行 flutter doctor,如果环境正常,会看到本地的 Flutter、Dart 和 DevEco 相关的字段都识别到位。实际开发时我建议大家把“适配版 SDK 的 flutter”和“DevEco 的 hvigor”这两条链路都搭配好,因为它们分别负责 Dart 侧编译和鸿蒙应用的打包。
2.2 创建支持 ohos 的 Flutter 工程
环境就绪后,创建工程我推荐用 flutter create 命令直接生成,但要在 --platforms 参数里带上 ohos。命令如下:
bash复制flutter create --template app --platforms ohos,android,ios script_game_app
生成出来的工程目录跟标准 Flutter 工程相比,会多出一个 ohos 目录,这是 OpenHarmony 应用的宿主工程。lib 目录下的 Dart 代码和普通 Flutter 工程完全一样,真正的差异都在 ohos 目录里,包括 entry/src/main/module.json5、entry/src/main/ets 这些鸿蒙侧的配置与入口代码。
我的习惯是创建完工程后,先用 DevEco Studio 打开 ohos 目录,跑一次空工程到模拟器或者真机上,确认整套链路是通的。这一步很重要,因为很多问题出在 SDK 版本、构建工具链的匹配上,跟业务代码没有关系。空工程能跑通,后面加列表代码出问题时就很好定位。
如果需要在 Android 和 OpenHarmony 之间切换运行,直接用同一个 Flutter 工程从 IDE 里选择不同设备即可。实际调试中我在 Android 上开发列表 UI,在 Ohos 真机上做最终验证,效率会高很多。
2.3 权限配置与产物构建
剧本库列表如果用的是网络图片和真实的 API,就必须在鸿蒙侧配置网络权限。OpenHarmony 应用默认是断网的,在 ohos/entry/src/main/module.json5 的 requestPermissions 里要显式声明:
json5复制{
"module": {
"name": "entry",
"requestPermissions": [
{
"name": "ohos.permission.INTERNET"
}
]
}
}
如果只是用本地 asset 图片,这个权限可以不配,但为了后续接口联调,我建议从一开始就加上。
构建和安装也有固定的流程。调试阶段用 flutter build hap --debug,产物会在 build/ohos 目录下生成 HAP 包。安装到真机用 hdc 命令:
bash复制hdc install build/ohos/apps/.../entry-default-signed.hap
真机调试还需要在 DevEco 里完成签名配置。调试签名可以自动生成,但不要把 Debug 签名用于 Release 包,发布到应用市场前需要申请正式的签名证书,这块每个版本的要求会有差别,以开发时拿到的 DevEco Studio 和 SDK 文档为准。
3. 数据层设计:剧本实体与 Mock 数据源
3.1 剧本实体类设计
剧本库列表的所有 UI 展示和筛选逻辑,都建立在剧本实体类上。字段设计不能只考虑当前列表页,还要兼顾后续的详情页和组队流程。我把实体设计成:
dart复制class Script {
final String id;
final String title;
final String genre; // 情感 / 硬核推理 / 欢乐 / 恐怖 / 机制
final int minPlayers;
final int maxPlayers;
final int durationMinutes;
final double difficulty; // 1.0 - 5.0
final double rating; // 0.0 - 10.0
final String coverUrl; // 空字符串表示用本地占位图
final String summary;
const Script({
required this.id,
required this.title,
required this.genre,
required this.minPlayers,
required this.maxPlayers,
required this.durationMinutes,
required this.difficulty,
required this.rating,
required this.coverUrl,
required this.summary,
});
factory Script.fromJson(Map<String, dynamic> json) {
return Script(
id: json['id'] as String,
title: json['title'] as String,
genre: json['genre'] as String,
minPlayers: json['minPlayers'] as int,
maxPlayers: json['maxPlayers'] as int,
durationMinutes: json['durationMinutes'] as int,
difficulty: (json['difficulty'] as num).toDouble(),
rating: (json['rating'] as num).toDouble(),
coverUrl: json['coverUrl'] as String? ?? '',
summary: json['summary'] as String? ?? '',
);
}
int get playerRangeText => minPlayers == maxPlayers
? minPlayers.toString()
: '$minPlayers-$maxPlayers人';
}
这里要注意两个细节。第一,rating 我用的是十分制,因为剧本杀行业里很多平台的评分体系是 10 分制,列表页展示小数位也比较自然。第二,durationMinutes 偏长,可能超过 60 分钟,直接用分钟数存,展示时再格式化成“5h30min”或者“90min”,数据层不要提前拼接成展示字符串,否则后面换展示形式还要去动实体类。
3.2 Repository 模式与分页语义
数据入口我封装了一个 ScriptRepository,页面上不直接依赖具体的 Mock 数据或者网络实现。这样做的核心原因是:OpenHarmony 平台的网络库适配还不像 Android 那么成熟,后期联调时很可能要换请求库,如果 UI 层直接操作数据源,更换底层的成本会很高。
分页语义我设计得非常明确:页面上滚动加载时,向 Repository 传 page 和 pageSize,Repository 负责返回当前页数据,以及“是否还有更多数据”。Mock 实现里我用 Future.delayed 模拟网络延迟,这样后续接真实接口时,UI 层代码一行都不用改。
dart复制class ScriptRepository {
static const int pageSize = 10;
static const Duration _mockDelay = Duration(milliseconds: 600);
Future<ScriptPage> fetchScripts({
required int page,
String keyword = '',
String genre = '全部',
}) async {
await Future.delayed(_mockDelay);
final filtered = _allScripts.where((s) {
final matchKeyword = keyword.isEmpty ||
s.title.contains(keyword) ||
s.summary.contains(keyword);
final matchGenre = genre == '全部' || s.genre == genre;
return matchKeyword && matchGenre;
}).toList();
final startIndex = (page - 1) * pageSize;
if (startIndex >= filtered.length) {
return ScriptPage(items: [], hasMore: false);
}
final endIndex = (startIndex + pageSize).clamp(0, filtered.length);
return ScriptPage(
items: filtered.sublist(startIndex, endIndex),
hasMore: endIndex < filtered.length,
);
}
}
ScriptPage 是一个轻量结果对象,包含 items 和 hasMore 两个字段。之所以不直接返回 List<Script>,是为了明确告诉列表页“本次请求还有没有下一页”,避免列表页自己去猜。
3.3 Mock 数据准备
Mock 数据我准备了 30 多个剧本,覆盖了不同类型、人数和难度。写 Mock 时有一件事一定要做,就是保证每种类型都有足够的样本,不然测试筛选效果时很容易出现“点一下全是空状态”的假象,反而干扰 UI 验证。
dart复制const List<Script> _allScripts = [
Script(
id: 's001',
title: '雾都谜案',
genre: '硬核推理',
minPlayers: 4,
maxPlayers: 7,
durationMinutes: 210,
difficulty: 4.2,
rating: 8.7,
coverUrl: '',
summary: '民国背景本格推理,封闭环境连环案件,线索链严密。',
),
// ...后续数据按同样结构补充
];
真实项目里,这块数据会被替换成 HTTP 请求,通过网络层拿到 List<Map<String, dynamic>>,再通过 Script.fromJson 转换成实体对象。在没接后端之前,Mock 数据唯一的缺点是要手写大量字段,但相比反复联调接口,这部分的成本完全值得。
4. 剧本库列表 UI 实现
4.1 列表项卡片:封面、标签、详情三区布局
剧本卡片我采用了左图右文的经典布局。左侧是封面图区域,固定宽高比,右侧是标题、类型标签、人数时长、评分难度。整体用 Card 包裹,圆角 16,背景色比页面底色浅一号。OpenHarmony 上 Flutter 的 Material 组件适配得比较好,Card、Ripple、圆角这些都能正常显示。
dart复制class ScriptCard extends StatelessWidget {
final Script script;
const ScriptCard({super.key, required this.script});
@override
Widget build(BuildContext context) {
return Card(
elevation: 0,
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
color: const Color(0xFFF7F4F0),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(10),
child: SizedBox(
width: 88,
height: 120,
child: _CoverImage(url: script.coverUrl),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
script.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 17,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 6),
Row(
children: [
_GenreTag(genre: script.genre),
const SizedBox(width: 8),
Expanded(
child: Text(
'${script.minPlayers}-${script.maxPlayers}人 · ${_formatDuration(script.durationMinutes)}',
style: const TextStyle(fontSize: 13, color: Colors.grey),
),
),
],
),
const SizedBox(height: 8),
Row(
children: [
_DifficultyStars(difficulty: script.difficulty),
const SizedBox(width: 6),
Text(
'${script.difficulty.toStringAsFixed(1)}',
style: const TextStyle(fontSize: 12, color: Colors.brown),
),
const Spacer(),
Text(
'${script.rating.toStringAsFixed(1)}分',
style: const TextStyle(
fontSize: 15,
color: Color(0xFFE6A23C),
fontWeight: FontWeight.w600,
),
),
],
),
],
),
),
],
),
),
);
}
}
封面区域好多人容易踩坑的地方是忘了加 ClipRRect。如果图片本来就有圆角,不加这个组件,图片角落会直接戳出去,跟 Card 的圆角冲突,看着非常粗糙。此外图片区域如果有加载网络图的需求,建议加宽高约束,避免列表滚动时因为图片尺寸不稳定产生跳动。
4.2 列表页主体与加载状态管理
列表页主体用的是 StatefulWidget,内部维护数据列表、页码、加载状态和筛选条件。页面结构是:顶部搜索框加类型标签筛选区,下面是一个可刷新的 ListView.builder,最后一个 item 根据状态显示“加载中”或“没有更多了”。
dart复制class ScriptListPage extends StatefulWidget {
const ScriptListPage({super.key});
@override
State<ScriptListPage> createState() => _ScriptListPageState();
}
class _ScriptListPageState extends State<ScriptListPage> {
final ScrollController _scrollController = ScrollController();
final ScriptRepository _repository = ScriptRepository();
final List<Script> _scripts = [];
int _page = 1;
bool _hasMore = true;
bool _loading = false;
String _keyword = '';
String _genre = '全部';
static const List<String> _genres = ['全部', '情感', '硬核推理', '欢乐', '恐怖', '机制'];
@override
void initState() {
super.initState();
_scrollController.addListener(_onScroll);
_loadFirstPage();
}
Future<void> _loadFirstPage() async {
setState(() {
_page = 1;
_hasMore = true;
_loading = true;
});
final result = await _repository.fetchScripts(
page: _page,
keyword: _keyword,
genre: _genre,
);
if (!mounted) return;
setState(() {
_scripts
..clear()
..addAll(result.items);
_hasMore = result.hasMore;
_loading = false;
});
}
Future<void> _loadMore() async {
if (_loading || !_hasMore) return;
setState(() => _loading = true);
final nextPage = _page + 1;
final result = await _repository.fetchScripts(
page: nextPage,
keyword: _keyword,
genre: _genre,
);
if (!mounted) return;
setState(() {
_page = nextPage;
_hasMore = result.hasMore;
_scripts.addAll(result.items);
_loading = false;
});
}
void _onScroll() {
if (_scrollController.position.pixels >=
_scrollController.position.maxScrollExtent - 200) {
_loadMore();
}
}
}
这个状态设计里有一个我特别注意的地方:_loading 同时承担了“首次加载”“加载更多”“防抖”三个职责。因为 loadMore 的触发条件是滚动位置到达底部,而滚动事件会高频触发,如果不加 _loading 判断,一次滚动到底部可能连续发十几个请求。
4.3 封面图的加载与占位处理
封面图我用了 Image.network 和 AssetImage 的组合策略,在处理 OpenHarmony 平台时,网络图片需要确保权限和网络环境都正常,否则会直接报错。为了避免图片未加载时出现灰块,我做了占位处理:
dart复制class _CoverImage extends StatelessWidget {
final String url;
const _CoverImage({required this.url});
@override
Widget build(BuildContext context) {
final placeholder = Container(
color: const Color(0xFFE8E3DC),
alignment: Alignment.center,
child: const Text(
'暂无封面',
style: TextStyle(color: Colors.grey, fontSize: 12),
),
);
if (url.isEmpty) return placeholder;
return Image.network(
url,
fit: BoxFit.cover,
loadingBuilder: (context, child, progress) {
if (progress == null) return child;
return placeholder;
},
errorBuilder: (context, error, stackTrace) => placeholder,
);
}
}
占位图不是花哨的动画,而是简单的灰底加“暂无封面”四个字。这套方案的好处是稳定,不会因为图片管理库冲突导致整个列表崩掉。如果后续项目需要更高级的缓存和渐进加载,再替换成 cached_network_image 也不迟,但要先确认它的 Flutter for OpenHarmony 分支兼容性。
5. 交互细节:搜索、筛选、刷新与加载更多
5.1 搜索与类型筛选:组合条件下的过滤逻辑
搜索和筛选我分别放在两个控件里:搜索框是 TextField,类型筛选是一排 ChoiceChip。它们都只是修改列表页的 _keyword 和 _genre,具体过滤逻辑在 Repository 内部完成,UI 层不直接写过滤代码。
dart复制Widget _buildSearchBar() {
return Padding(
padding: const EdgeInsets.fromLTRB(12, 12, 12, 8),
child: TextField(
onChanged: (value) {
_keyword = value.trim();
_loadFirstPage();
},
textInputAction: TextInputAction.search,
decoration: InputDecoration(
hintText: '搜索剧本名称或简介',
prefixIcon: const Icon(Icons.search),
filled: true,
fillColor: const Color(0xFFF0EDE8),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(24),
borderSide: BorderSide.none,
),
isDense: true,
contentPadding: const EdgeInsets.symmetric(vertical: 12),
),
),
);
}
Widget _buildGenreFilter() {
return SizedBox(
height: 48,
child: ListView.separated(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12),
itemCount: _genres.length,
separatorBuilder: (_, __) => const SizedBox(width: 8),
itemBuilder: (context, index) {
final genre = _genres[index];
return ChoiceChip(
label: Text(genre),
selected: _genre == genre,
onSelected: (_) {
if (_genre == genre) return;
_genre = genre;
_loadFirstPage();
},
);
},
),
);
}
这里的关键设计是“每次输入或点击都重新拉第一页”。很多新手会直接在原列表上过滤,这样做会出现一个很尴尬的问题:你搜索“情感”时,列表显示的是从已有数据里筛出来的结果,一旦触底加载更多,后面的数据不一定满足当前关键词,新旧数据混在一起,逻辑特别乱。每次筛选都重置到第一页,是分页场景下最稳的做法,代价只是多触发一次请求,但现在接口延迟基本都是几十到几百毫秒,体验上完全能接受。
5.2 下拉刷新与上拉加载更多
下拉刷新我用 RefreshIndicator 包裹 ListView.builder,onRefresh 调用的就是 _loadFirstPage。这里要注意,RefreshIndicator 的 onRefresh 必须返回一个 Future,等数据加载完成、setState 执行之后,刷新动画才会收起。
上拉加载更多我走的是 ScrollController 监听方案。在 _onScroll 里判断滚动位置是否接近底部,接近就触发 _loadMore。判断阈值我设了 200,这个数值可以根据实际滚动体验调整,太大会导致列表还没滑到底就开始加载,太小则在低端机上会有“明显停顿后数据才出来”的感觉。
列表底部我额外加了一个状态 item:
dart复制Widget _buildFooter() {
if (!_hasMore) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 20),
child: Center(
child: Text('已经到底啦', style: TextStyle(color: Colors.grey, fontSize: 13)),
),
);
}
if (_loading) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 20),
child: Center(child: CircularProgressIndicator(strokeWidth: 2)),
);
}
return const SizedBox(height: 16);
}
“已经到底啦”这个文案虽然简单,但非常重要。没有它,很多用户会反复尝试往下滑,还以为接口有问题。这个 footer 在数据没加载完、数据为空、全部加载完成三种状态下分别显示加载中、空占位、到底文案,逻辑非常清晰。
5.3 空状态与错误兜底
列表页在搜索无结果时会出现 _scripts 为空的情况,这时候如果只显示一个白屏,用户会以为页面崩了。我做了一个轻量级的空状态:
dart复制if (_scripts.isEmpty && !_loading) {
return const Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.search_off, size: 48, color: Colors.grey),
SizedBox(height: 12),
Text(
'没有找到符合条件的剧本',
style: TextStyle(color: Colors.grey, fontSize: 14),
),
],
),
);
}
错误兜底这次没有做得很复杂,因为 Mock 数据基本不会失败。但真实接口联调时,建议把 Repository 的请求包一层 try-catch,在返回异常时抛出自定义异常,然后页面上用一个 _error 字段承接,展示“加载失败,点击重试”。这个扩展点现在就要留好,否则接接口时又要大改页面逻辑。
6. 性能优化与 OpenHarmony 真机适配
6.1 列表性能三板斧:itemExtent、const、图片缓存
剧本库列表在数据量少的时候怎么跑都流畅,但一旦剧本数量上到几百个,列表性能问题就会暴露。我这次从三个方向做了优化。
第一,给 ListView 设置 itemExtent。它在列表项高度固定的情况下能够显著提升滚动性能,因为 Flutter 不需要为每个 item 重新计算布局高度。剧本卡片高度其实是固定的,封面 120 高度加上 padding 之后总高度稳定在 144 左右,所以我直接把 itemExtent 设为 144。
dart复制ListView.builder(
controller: _scrollController,
itemExtent: 144,
itemCount: _scripts.length + 1,
itemBuilder: (context, index) {
if (index == _scripts.length) return _buildFooter();
return ScriptCard(script: _scripts[index]);
},
)
第二,能加 const 的 Widget 尽量加。ScriptCard 构造里 script 不是常量,但内部很多文本样式、padding、间距都是常量,把这些提取成 const 可以减少 Widget 重建时的对象分配。最大的收益在 _buildFooter 这种完全不依赖外部状态的组件上,直接整棵子树都声明为 const。
第三,图片区域注意 cacheWidth 或 cacheHeight。网络图如果不做尺寸限制,Flutter 加载的时候会按原图尺寸解码,一张 2000x3000 的图片解码后占的内存非常夸张。列表里的封面图实际显示只有 88x120,我在 Image.network 里加 cacheWidth: 176(2 倍清晰度),解码出来的位图小很多,内存占用直线下降。
6.2 OpenHarmony 真机联调与适配心得
在 OpenHarmony 真机上联调,最大的体会是:不要等全部开发完再上真机,尽量从空工程开始就坚持“Android 开发、Ohos 真机验证”的双轨模式。Flutter 适配版在 OpenHarmony 上的渲染、触摸事件、平台通道跟标准 Flutter 还是有一些差异,越早暴露越好。
我遇到的最典型的问题是文字渲染在某些 OpenHarmony 设备上显得发虚,尤其是小字体。排查了一圈,发现是设备字体渲染策略跟 Flutter 的 Skia 引擎适配不完全匹配。这个问题的规避方式比较简单:列表卡片里的正文文字不要用低于 12sp 的字号,同时避免把字重设置成 w300 以下,细字重更容易显得糊。
还有一个建议是不要在 OpenHarmony 项目里堆太多 Flutter 插件。很多 pub.dev 上的插件没有针对 ohos 平台做适配,flutter pub get 能通过,但跑起来会直接报 MissingPluginException。我的做法是先把纯 Dart 的逻辑和 UI 搭好,插件类的功能延后到确认有 ohos 支持版本后再集成。
6.3 权限、网络图片与平台差异细节
网络图片在 OpenHarmony 上能不能显示,取决于两件事:一是前面说的 module.json5 里是否加了 INTERNET 权限,二是网络环境是否允许直接访问图片地址。真机调试时如果发现图片一直走 errorBuilder,优先怀疑这两个点。
另外,OpenHarmony 的 Flutter 适配版里,部分手势细节和标准 Flutter 略有不同。比如我实测发现,在部分鸿蒙设备上,SingleChildScrollView 配合 RefreshIndicator 的下拉阻力感比 Android 上更“硬”一些,用户会感觉不容易触发刷新。这个问题在标准列表场景下不明显,但如果做了嵌套滚动,建议直接用 CustomScrollView 配 SliverAppBar 和 SliverList,结构更清晰,手势冲突也更少。
7. 常见问题与排查实录
这块我整理成一张速查表,都是实现剧本库列表时很可能踩到的坑,按问题、原因、解决方案三列说明。
| 常见问题 | 可能原因 | 排查与解决 |
|---|---|---|
| flutter 命令还是官方版本 | 环境变量里适配版 SDK 路径没排在最前面 | 单独开终端设置 PATH,确认 flutter --version 是 ohos 适配版本 |
| 空工程构建 HAP 失败 | DevEco SDK 路径或版本不匹配 | 检查 DEVECO_SDK_HOME,尽量用 DevEco Studio 直接打开 ohos 目录构建一次 |
| 真机安装 HAP 提示签名错误 | 用了调试包但没有自动签名 | 在 DevEco 里配置自动签名,或重新生成调试证书 |
| 列表滚动时出现白屏跳动 | 图片没有固定宽高,或未设占位 | 给封面固定尺寸,加 loadingBuilder,并用 itemExtent 固定 item 高度 |
| 筛选后列表数据混乱 | 在旧列表基础上直接过滤 | 每次筛选重置 page=1 并重新拉取第一页 |
| 上拉加载时重复请求 | ScrollController 监听触底事件高频触发 | 用 _loading 状态防抖,加载中不再发起新请求 |
| 网络图片一直加载失败 | 缺少网络权限或网络异常 | 检查 module.json5 的 INTERNET 权限,用错误占位观察异常 |
| 部分插件在 ohos 上运行报错 | 插件没有 ohos 平台实现 | 先查插件是否支持 ohos,不支持的改用纯 Dart 方案或自行适配 |
| 小字号文字显示模糊 | 设备字体渲染与 Flutter 引擎适配问题 | 正文字号不低于 12sp,避免过细字重 |
| 搜索结果为空时不知如何提示 | 没有处理空状态 | 在 itemCount 为空且非加载中时展示空状态 Widget |
这组问题里,我感触最深的是“筛选后数据混乱”这条。我在第一版代码里确实犯过这个错误,搜索时只对当前 _scripts 做了 where 过滤,后来发现加载更多之后,新拿回来的数据根本不管关键词是什么,全部 append 进来,列表瞬间出现“关键词之外的剧本”。后来强制改成“每次条件变化就重新拉第一页”,问题彻底消失。
还有一个大家容易忽略的是 ScrollController 的销毁。页面 dispose 的时候一定要调用 _scrollController.dispose(),否则在 OpenHarmony 上退出页面再进入,经常出现滚动位置异常甚至崩溃。
最后再分享一个我个人的习惯:列表页开发完成之后,我会特意把模拟延迟改成 1.5 秒跑一遍。这个操作可以直观暴露加载状态、空状态、下拉刷新动画之间的衔接问题。很多 bug 在 200 毫秒延迟下看不出任何毛病,一旦延迟拉长,状态机设计的好坏立刻见分晓。剧本库列表看似简单,但把数据流、状态、交互、性能都理顺,整个组队 App 的核心地基也就稳了。
