做剧本杀组队App这个项目,我给自己选了一条不那么主流的路:用Flutter去开发OpenHarmony应用。说白了就是想验证一件事——这套已经被Android和iOS验证过的跨端框架,真正搬到OpenHarmony生态里能不能干重活、能不能上真机、能不能把剧本库这种典型列表页跑得流畅。整个项目里最有代表性的一块就是剧本库列表:接口请求、分页加载、筛选项、图片缓存、空状态和重试逻辑全都有,麻雀虽小五脏俱全。这篇文章把这一块的完整实现过程摊开讲,环境怎么搭、代码怎么组织、哪些坑必须绕,给正在评估Flutter for OpenHarmony的团队,也给自己研究跨端应用的个人开发者做个参考。
先说结论:OpenHarmony上的Flutter没有网上传闻那么“玩具”,但也绝对算不上“无缝”。只要把SDK版本、插件兼容性和真机调试这三关过了,写一个剧本库列表这种业务页面完全可行。
1. 项目背景与整体设计思路
1.1 为什么选择Flutter来开发OpenHarmony应用
剧本杀组队App的业务核心是让玩家找到剧本、找到搭子、约好车。这类产品多数团队会优先做小程序和Android,其次才是iOS,突然多出一个OpenHarmony渠道,如果用ArkTS单独维护一套代码,人力成本直接翻倍。我们团队当时已经积累了不少Flutter业务代码和组件,决定评估Flutter for OpenHarmony,本质上是想复用这套资产。
另一个考虑是生态。Flutter的生态是跨端框架里最成熟的,Dart语言、包管理、状态管理社区都有大量实践。OpenHarmony这一侧还处于早期,很多系统能力要看厂商适配,但至少路由、网络、基础UI组件这些通过Flutter引擎和社区插件已经能覆盖。相比之下,Compose Multiplatform对OpenHarmony的支持距离可用还很远,React Native在OpenHarmony上也没有一个统一可用的官方形态,所以Flutter算是矮子里面拔将军,也是当前最现实的选择。
不过要提醒一句:OpenHarmony是一个开源操作系统发行版,和手机厂商的商用OS并不完全一样。你在开发时面对的是OpenHarmony的API和SDK,Flutter引擎跑在它提供的ACE框架之上,很多能力最后还是要通过PlatformChannel去调用原生侧。这个前提会影响后面所有技术选型。
1.2 剧本库在组队App里的定位与功能边界
剧本库是整个App的流量入口,用户在首页看到一堆店和车,真正下单前一定会先点开剧本库去确认“这个本子我喜不喜欢、难度合不合适”。所以这个页面不能只是一个简单的列表,它必须承担筛选、检索、详情引导这三件事。
我最初把功能范围划得比较窄:剧本列表按分页加载,顶部支持类型、难度、人数筛选,支持关键词搜索,卡片上展示封面、名称、类型标签、玩家人数、游戏时长、评分和难度。再往后的剧本详情页、收藏、上车操作都不在这一个列表页里做,通过点击卡片跳转即可。
这个边界很重要。OpenHarmony适配期的问题会消耗大量时间,如果一开始就把列表页做成一个“超级页面”,后期排查问题和性能优化都会非常痛苦。现在Spring、扩展性都不差,我宁可先保证核心链路跑通,再加功能。
1.3 状态管理、网络层与缓存选型
状态管理我选了Provider,没有上bloc。原因很简单:剧本库是一个典型的“列表 + 筛选 + 分页”页面,状态无非是列表数据、loading、error、hasMore这几个,用ChangeNotifier就能表达清楚。bloc那套事件流在这里反而增加样板代码,对新人也不友好。如果后续业务复杂度上来了,要同时维护多个页面共享剧本收藏状态,再考虑Riverpod或bloc也不迟。
网络层选Dio,它和OpenHarmony的兼容性很好,因为核心实现是纯Dart,走dart:io的Socket,不依赖平台通道。加上拦截器之后,可以让后端的code、message、data结构自动解包,错误码统一处理。
缓存这块我没有在列表页做完整数据库缓存,只用了一个内存级封面图片缓存。原因有两个:一是剧本数据本身需要实时更新,本地存一份过期数据反而容易让用户产生误解;二是OpenHarmony上第三方缓存插件还没有完全统一适配,依赖太多反而容易踩坑。后面会详细讲图片缓存怎么设计。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 环境准备与工程搭建
2.1 OpenHarmony SDK与Flutter版本对齐
这一步做不好,后面所有报错都会变得莫名其妙。OpenHarmony的Flutter适配仓库通常叫flutter_flutter和flutter_engine,从OpenHarmony SIG维护的分支拉取,不要直接拉Flutter官方stable分支。官方分支里没有ohos平台的工具链,跑了半天发现flutter create根本不认识ohos这个platform。
我当时的做法是先把OpenHarmony SDK装好。用DevEco Studio安装游刃有余,就是注意SDK目录要自己看得懂,后面flutter config要指给它。OpenHarmony SDK目录里会有native、js、ets几个子目录,实际写原生侧时用得上。
Flutter版本和OpenHarmony版本得对齐。社区仓库里通常会有类似3.x-ohos的分支或标签,不同分支对应的OpenHarmony API等级不一样,如果设备是OpenHarmony 4.0时代的,最好找对应时期的Flutter分支,不要拿着最新Flutter去适配老设备。版本对不上最典型的症状是构建时候报“minOSVersion mismatch”或者设备上首帧白屏。
环境变量示例:
bash复制export OHOS_SDK_HOME=/home/you/ohos-sdk
export PATH=$PATH:/home/you/flutter_flutter/bin
flutter config --ohos-sdk=$OHOS_SDK_HOME
flutter doctor -v
flutter doctor里如果能看到ohos相关条目,至少说明工具链的一部分已经通了。
2.2 创建Flutter工程并接入ohos平台
老一套的flutter create命令加上ohos选项就够了:
bash复制flutter create --platforms ohos,android script_team_app --org com.example
cd script_team_app
创建完以后,项目里会额外多出一个ohos目录。这个目录本质上是一个OpenHarmony工程,外面是Flutter业务代码,里面是ACE框架和Flutter引擎的胶水代码。不同版本生成的ohos目录结构会有些差异,但大致都会有entry模块、CMake配置和bridge文件。
有一点值得注意:生成出来的工程默认依赖可能不是你本地的Flutter引擎版本,第一次构建时如果发现Gradle在下载一堆东西,可以检查一下ohos目录里的build脚本是否指向了正确的flutter engine路径。实际工作中我遇到过好多次“构建到最后一步失败”,一查就是引擎构件路径写错。
2.3 用开发板真机跑通第一行代码
OpenHarmony设备我用的是润和DAYU200开发板,RK3568芯片,8GB内存,跑Flutter列表页足够了。连接开发板之后先用hdc确认设备在线:
bash复制hdc list targets
如果设备列表是空的,检查USB调试开关、重启hdc服务、换一根不要只充电的数据线,这三个步骤能解决九成连接问题。
设备识别之后,直接:
bash复制flutter run -d <device_id>
第一次跑会比较久,因为要编译Flutter引擎相关依赖,不是说卡死了,耐心等。我自己的体验是,debug模式下Dart代码改动后,热重载在纯页面调整时挺好用,但一旦动了平台相关的东西或者模型代码,建议直接热重启,否则容易看到陈旧状态。
3. 剧本库列表从接口到UI的完整实现
3.1 脚本数据模型与响应结构设计
列表接口返回的数据结构先定好,后端和前端对齐,避免后面反复改。标准的剧本对象至少包含这些字段:id、名称、类型标签、最少/最多人数、游戏时长、难度、封面地址、简介、评分。考虑到客户端会有筛选需求,类型和难度最好直接以字符串/数组形式返回,不要返回编号让前端去维护映射表。
Dart模型示例:
dart复制class Script {
final String id;
final String name;
final List<String> types;
final int minPlayers;
final int maxPlayers;
final int duration; // 以小时为单位
final String difficulty;
final String coverUrl;
final String summary;
final double score;
const Script({
required this.id,
required this.name,
required this.types,
required this.minPlayers,
required this.maxPlayers,
required this.duration,
required this.difficulty,
required this.coverUrl,
required this.summary,
required this.score,
});
factory Script.fromJson(Map<String, dynamic> json) {
return Script(
id: json['id'] as String,
name: json['name'] as String,
types: List<String>.from(json['types'] ?? const []),
minPlayers: json['minPlayers'] as int? ?? 4,
maxPlayers: json['maxPlayers'] as int? ?? 8,
duration: json['duration'] as int? ?? 3,
difficulty: json['difficulty'] as String? ?? '新手',
coverUrl: json['coverUrl'] as String? ?? '',
summary: json['summary'] as String? ?? '',
score: (json['score'] as num?)?.toDouble() ?? 0.0,
);
}
}
字段加默认值这个习惯很重要。剧本库后端数据不会永远完整,字段暂时缺失时,宁可显示一个兜底值,也不要让整个列表解析抛异常。
响应结构我统一用了一个信封格式:code、message、data。data里再包分页信息,避免列表数据直接裸在顶层。分页结构大致长这样:
json复制{
"code": 0,
"message": "ok",
"data": {
"list": [],
"page": 1,
"pageSize": 10,
"total": 230
}
}
page是当前页,total是总条数,hasMore可以直接用page * pageSize < total算出来,比后端再给个布尔值省心,还能避免分页漏数据。
3.2 网络层封装与分页加载逻辑
Dio的网络封装不复杂,但要把拦截器做好。我在Request拦截器里统一加token和公共参数,Response拦截器只做一件事:解析code,如果code不是0,就直接抛业务异常。列表页调接口时只需要关心“成功拿到ScriptPage”或“失败要提示重试”这两种结局,业务代码清爽很多。
dart复制class ScriptApi {
ScriptApi(this._dio);
final Dio _dio;
Future<ScriptPage> fetchScripts({
required int page,
required int pageSize,
String? type,
String? difficulty,
String? keyword,
}) async {
final res = await _dio.get(
'/api/scripts',
queryParameters: {
'page': page,
'pageSize': pageSize,
if (type != null) 'type': type,
if (difficulty != null) 'difficulty': difficulty,
if (keyword != null) 'keyword': keyword,
},
);
final data = res.data['data'] as Map<String, dynamic>;
return ScriptPage.fromJson(data);
}
}
分页加载逻辑我放在ScriptListController里。核心思路是:refresh和loadMore两套动作,refresh永远从第一页开始,成功后就地清空列表;loadMore只有在当前没有请求、且hasMore为true时才发起。同时用_page和_loading两个变量挡住重复请求。
dart复制class ScriptListController extends ChangeNotifier {
final ScriptApi _api;
final List<Script> scripts = [];
int _page = 1;
int _pageSize = 10;
bool _hasMore = true;
bool _loading = false;
Object? _error;
Future<void> refresh() async {
if (_loading) return;
_loading = true;
_error = null;
notifyListeners();
try {
final result = await _api.fetchScripts(
page: 1,
pageSize: _pageSize,
type: currentType,
difficulty: currentDifficulty,
keyword: keyword,
);
scripts
..clear()
..addAll(result.list);
_page = result.page + 1;
_hasMore = result.list.length < result.total;
} catch (e) {
_error = e;
} finally {
_loading = false;
notifyListeners();
}
}
Future<void> loadMore() async {
if (_loading || !_hasMore) return;
_loading = true;
notifyListeners();
try {
final result = await _api.fetchScripts(
page: _page,
pageSize: _pageSize,
type: currentType,
difficulty: currentDifficulty,
keyword: keyword,
);
scripts.addAll(result.list);
_page += 1;
_hasMore = scripts.length < result.total;
} catch (_) {
// 加载更多失败不弹全屏错误,保留旧数据
} finally {
_loading = false;
notifyListeners();
}
}
}
loadMore失败的时候不要弹大错误页,用户正在往下翻,突然整个列表变成错误视图非常劝退。保留已有数据,底部提示“加载失败,上拉重试”就够了。这是我在几个项目里反复踩过之后的经验。
3.3 列表卡片与筛选搜索组合
列表UI我直接用ListView.builder,没有用CustomScrollView这种重型结构。卡片高度固定,所以给itemExtent一个值,可以让ListView在滚动的时候省掉对卡片高度的大量测量计算。卡片内部布局不复杂:左边封面图,右边名称、标签、时长、人数、评分。
dart复制ListView.builder(
controller: _scrollController,
itemExtent: 148,
itemCount: controller.scripts.length,
itemBuilder: (context, index) {
final script = controller.scripts[index];
return ScriptCard(script: script);
},
)
筛选和搜索这一块,我把它拆成两个部分:顶部的横向FilterBar和顶层的搜索框。FilterBar是一排ActionChip,点击后把选中的值写回controller,然后触发refresh。搜索框不要每敲一个字就请求一次,加一个400ms的debounce,等用户停手再请求。实际操作里这个Debounce既省流量又省OpenHarmony设备上的CPU占用。
dart复制Timer? _searchDebounce;
void onSearchChanged(String value) {
_searchDebounce?.cancel();
_searchDebounce = Timer(const Duration(milliseconds: 400), () {
controller.search(value.trim());
});
}
这里还有个小坑:FloatingActionButton和部分Material组件在OpenHarmony的Flutter引擎上虽然能用,但如果用到了较新的Material 3特性,在老版本引擎上偶尔会渲染异常。倒不是说不能用,而是提醒你别把UI样式绑死在最新Material版本上,否则打磨样式的时间会被无休止的引擎差异吞掉。
3.4 加载中、空数据、失败重试三种状态
一个完整列表页不该只有数据和滚动条。我在页面里用IndexedStack同时挂了三层视图:LoadingView、ErrorView、ListView。根据controller的状态切换显示哪一层。
LoadingView在首次加载时显示,转圈就用普通CircularProgressIndicator即可。ErrorView要包含错误提示和“重试”按钮,点击后调用controller.refresh()。空数据场景往往被忽略,但剧本杀场景太容易出现“当前筛选条件下没有剧本”,这时候给一个友好的空盒子提示,比白屏强一百倍。
从开发节奏来说,我建议先把三态做完再去调卡片样式。很多新手一上来就扣卡片阴影和字体间距,结果错误状态没处理,接口真的失败的时候整个页面白屏,这种基础体验问题比像素级UI难看致命得多。
4. 真机性能优化与图片缓存
4.1 图片加载在OpenHarmony上的特殊处理
OpenHarmony上的图片加载是列表页最容易卡的一环。原因不是Flutter框架本身慢,而是图片的网络请求、解码、上屏链路在真机上比Android复杂。很多Android上开箱即用的图片插件,在OpenHarmony上因为platformChannel没有对应实现,直接运行时报MissingPluginException。
cached_network_image这个插件就是一个典型例子。它在Dart侧依赖flutter_cache_manager,后者底层又依赖path_provider来拿缓存目录。如果path_provider没有为OpenHarmony注册实现,整个缓存图片链路就瘫痪。我当时没有死磕这个插件,直接在列表页里用最朴素的方式实现图片加载:
dart复制Image.network(
script.coverUrl,
fit: BoxFit.cover,
cacheWidth: 100 * MediaQuery.of(context).devicePixelRatio as int,
loadingBuilder: (context, child, progress) {
// 返回一个占位块
},
errorBuilder: (context, error, stack) {
// 返回一个灰色封面
},
)
cacheWidth这里特别关键。它控制图片解码时的目标宽度,封面卡片宽度就100多dp,让引擎解码一张几千像素宽的原图纯属浪费内存和时间。在OpenHarmony开发板上,内存本身紧张,一次列表滚动触发几十张高清封面解码,直接就会看到帧率明显下降。
内存缓存我写了一个简单的单例Map,key是封面URL,value是Completer<ui.Image>。同一批封面URL在短时间内被重复请求时,直接复用已经解码好的图片,这个思路和常见ImageCache原理一样,但避开了插件依赖,在OpenHarmony上自己心里有数。
4.2 ListView滚动性能三板斧
列表页滚动流畅度优化的经验总结下来就三板斧:固定itemExtent、减少build开销、避免不必要的重建。
itemExtent已经说了,固定卡片高度可以让ListView不需要逐个测量子项,滚动性能提升是立竿见影的。减少build开销方面,卡片内部的文字区域不要放复杂的Expanded嵌套,能用Row和Flexible解决的就别套多层Container。避免不必要重建,核心是让ScriptCard的构造函数满足const调用,字段进来后只读不写,这样父级列表重建时它有机会复用。
在OpenHarmony上,RepaintBoundary要慎用。它是双刃剑,能把部分组件的绘制缓存下来,但也意味着多占一层内存。卡片数量不多的时候,我反而不加RepaintBoundary,让系统自然绘制。
还有一个很实际的建议:如果在Release模式下测试性能,务必关掉debug banner。OpenHarmony开发板的GPU能力本身有限,每秒钟多画那个banner和性能统计开销,滚动时体感都会有差别。
4.3 首帧和包体积的实测优化
剧本库列表首帧慢,大部分时间不在Dart业务代码,而在Flutter引擎初始化和第一个接口的返回速度。想要真正感受到“秒开”,得把网络请求提前。我在页面路由跳转的前一个页面就预创建了ScriptListController,利用页面过渡动画的时间去发请求,等用户真正看到列表页时,数据已经回来了。
首帧体验上还有一个容易被忽略的点:封面图不要全部挤在第一帧加载。ListView.builder本身就是懒加载,但如果你在itemBuilder里同时触发多个Image.network,网络差的情况下也会抢占带宽。我给图片加载加了一个小队列,只并行加载当前可视区域内的封面,滑动后新的图片再补进队列。
包体积上,OpenHarmony的Flutter引擎本身就占了一大部分,业务Dart代码对最终HAP体积的影响相对有限。真正需要控制的是把大量本地图片、素材放进assets目录。剧本封面一定要走服务端CDN,本地只留必要的占位图和一两个默认图标。命令行看一下产物体积:
bash复制flutter build hap --release
构建完看output目录里的.hap文件,心里能对交付产物有个数。
5. 常见问题与排查实录
5.1 插件兼容性:哪些能用,哪些要绕
这是OpenHarmony Flutter开发最劝退的一关。我用一张表总结一下剧本库列表这种业务里常见的插件情况,方便大家查。
| 插件 | 可用性 | 原因与替代方案 |
|---|---|---|
| dio | 可用 | 纯Dart实现,网络层不依赖平台通道 |
| http | 可用 | 纯Dart实现,接口调试阶段可以用 |
| provider | 可用 | pure Dart状态管理,没有任何原生依赖 |
| flutter_bloc | 可用 | 依赖关系都在Dart层 |
| shared_preferences | 多数可用 | 老版本需要在ohos侧注册实现,建议先打日志验证 |
| path_provider | 需确认 | 部分版本缺ohos实现,缺了会MissingPluginException |
| cached_network_image | 谨慎使用 | 依赖path_provider,没适配时图片崩,可换自绘缓存 |
| image_picker | 一般是坑 | 基本没有ohos实现,需要写原生Bridge桥接 |
我在实际项目里有一个原则:列表页级的核心链路,尽量不引可能有平台通道依赖的插件。宁可多写两层封装,也不要被一个没适配的插件卡住整个版本。
5.2 编译失败与SDK版本冲突
构建时报错有两类非常高发。第一类是“minOSVersion mismatch”或“compileSdkVersion”这类版本冲突,去ohos目录下的构建配置里改,让minOSVersion低于设备实际系统版本就行。第二类是Gradle相关问题,尤其当你用了和DevEco Studio内置版本不一样的Gradle插件时,整个工程会陷入下载依赖循环。
遇到过几次“明明线上分支能构建,新拉下来的代码构建失败”,最后发现都是flutter engine构件路径或缓存的问题。清理方式很简单:
bash复制hdc shell rm -rf /data/local/tmp/flutter_cache
flutter clean
然后再构建。如果还不行,检查是不是同时跑了好几个DevEco/Flutter进程占用了构建目录。
5.3 真机调试、日志与端口映射
真机连接第一关是hdc识别不到设备,这个前面提了,重启hdc通常是万金油。第二关是能看到设备但flutter run一直等,多半是开发板上的OpenHarmony版本和Flutter引擎需要的接口对不上。
日志排查建议用hilog替代adb logcat那套心智模型:
bash复制hilog | grep -i flutter
Flutter业务侧的print会出现在Dart VM日志里,hilog里按关键字过滤就能看到。如果自定义平台通道打不通,优先看hilog里有没有MissingPluginException,十有八九就是插件没注册。
开发阶段如果接口配的是本机地址,设备访问开发机又走不通,我习惯用端口转发。具体命令每个版本的hdc略有差异,执行前先看帮助:
bash复制hdc fport --help
转发成功后,Dio的baseUrl直接改成localhost对应的端口,省去查局域网IP的麻烦。
5.4 老生常谈:Debug和Release行为不一致
这个坑很狗血但值得专门说。OpenHarmony上Debug模式和Release模式的表现差异比Android还大。Debug模式下有JIT和热重载,一些编译期才暴露的问题在Debug里不爆,一到Release就开始闪退或者列表首帧白屏。
所以剧本库列表这个页面请务必在开发的中间阶段就跑一次Release构建:
bash复制flutter build hap --release
装到开发板上实测一遍,不要等到所有功能写完才做这个动作。越早发现Release专用问题,修起来越省事。
6. 实战体会与后续扩展
6.1 我重新理解了“跨端”这件事
做完剧本库列表,我最大的体会是:Flutter在OpenHarmony上跑通不难,难的是“有意识地为不可用的插件留出替代方案”。跨端从来不是把一套代码到处编译,而是在每个目标平台上都知道哪些能力要靠平台通道补、哪些插件不靠谱、哪些特性只能用基础UI实现。这个列表页教会我的不是ListView怎么写,而是怎么在一套不成熟生态里快速做技术风险排查。
对OpenHarmony的Flutter适配,我现在的态度是:它能覆盖大量的常规业务页面,但如果你需要依赖摄像头、生物识别、复杂传感器这些强系统能力,一定要提前做插件可行性验证。宁可第一个迭代先做一个纯列表页,也不要第一个月就冲复杂交互。
6.2 剧本库列表还能往哪些方向扩
剧本库本身只是一个起点。接下来可以在三个方向继续加东西:一是剧本详情页,列表卡片点击后展示更丰富的剧本信息,这正好能检验Flutter路由和页面转场在OpenHarmony上的表现;二是收藏与本地历史,这里才需要考虑真正的本地存储方案,shared_preferences不够用了就上数据库;三是把筛选条件做成服务端推荐规则,让列表不只依赖显式筛选,还能根据用户历史行为调整排序。
最实在的建议是,如果团队正在评估要不要用Flutter接OpenHarmony,先内部做一个小项目,把“一个带网络请求的列表页”跑通,再决定要不要扩大范围。这条路,剧本库列表就是最好的试金石。
最后说一句真实感受:Flutter for OpenHarmony还远没到和Android比成熟度的阶段,但作为低成本试水方案,它已经能让我在不熟悉ArkTS的情况下短时间交付一个可演示、可测试的剧本库列表。如果你也正在犹豫要不要入坑,别被那些编译报错吓住,赶紧搭个环境,把第一个列表页跑起来,你自然会知道后面怎么走。
