今年年初我接到一个需求,要在 OpenHarmony 设备上做一款艺考真题题库App,刷题、模考、错题本都要有,最关键的是要带学习报告功能。团队里没人碰过 Flutter for OpenHarmony 这套组合,网上能搜到的资料要么停留在跑通 Hello World,要么就是官方文档里语焉不详的适配说明,几乎没有一篇能告诉我“题库 + 统计报告”这种完整产品怎么落地。做完之后我觉得很有必要把全过程写下来——从环境搭建、数据模型设计,到学习报告的统计口径和图表实现,再到跨端适配时踩的一堆坑,给后面想用 Flutter 做 OpenHarmony 应用的同行留一份能直接抄作业的参考。这篇文章适合有 Flutter 基础、想拓到 OpenHarmony 平台的客户端开发者,也适合正在评估“题库类产品跨端方案”的产品和技术负责人。
1. 为什么选 Flutter 做 OpenHarmony 题库:需求背后的真实原因
1.1 艺考刷题这个场景,到底特殊在哪
艺考和普通升学考不一样,它不是一个“科目大盘子”,而是覆盖美术、音乐、舞蹈、播音编导等多个大类,每个大类下面还有细分方向的题库需求。我做这个项目时,甲方是某培训机构,他们的学生要刷的理论题主要是文艺常识、美术史、乐理、艺术概论、影视编导基础这几块。题型以单选题、多选题、判断题为主,偶尔掺杂一些填空题。
这带来三个特殊点:
- 知识点标签是刚需。学生刷完题不是只看对错的,教务老师要能看出“这个班文艺常识普遍掌握得差”还是“乐理里面调式判断总是错”。所以题目数据模型里必须有 category 和 knowledgePoint 两个字段,学习报告全靠它们聚合。没有这两个字段,后面做报告就是空中楼阁。
- 碎片化刷题场景多。艺考生的训练时间被素描、形体、发声练习切得很碎,刷题基本发生在通勤路上、候场间隙、睡前十分钟,离线可用是第一优先级,在线同步反而是其次。
- 报告要给机构看。学习报告不只是学生自己的复盘工具,也是机构续课和教学调整的抓手。这意味着报告不能只给一个总正确率,得拆到知识点维度,最好还有时间趋势。
1.2 Flutter + OpenHarmony 的组合逻辑
这个项目的客户端预算是有限的,团队里没有专职的 ArkTS 原生开发。而 OpenHarmony 生态的设备,尤其是机构会统一采购的学习终端和平板,数量在明显增长,产品覆盖范围把 OpenHarmony 划进来了。
Flutter 在这里的价值是:一套 Dart 代码,同时出 Android 和 OpenHarmony 两个安装包。OpenHarmony 的 Flutter 支持来自开源社区的维护分支,纯 Dart 实现的 UI 和业务逻辑基本都能被这层适配接住,前提是别用太多平台插件。
我在技术选型阶段就定了一条原则:能直接搜到 OHOS 适配的插件才用,搜不到就用纯 Dart 方案或者自己写 MethodChannel,绝对不在关键路径上赌一个“据说能用”的插件。这条原则后面帮我们省了非常多排查时间。
2. 环境搭建从 0 到 1:版本选型和第一个 HAP
2.1 工具链和版本选型
Flutter for OpenHarmony 的 SDK 不能用官方 Flutter 发行版,得用 OpenHarmony 社区维护的 flutter_flutter 仓库的 ohos 分支。我落地时用的是基于 Flutter 3.7 系列的适配版本,这套 API 对做题库App完全够用。需要注意:
- 不要混用官方 Flutter SDK,否则 build 时根本找不到 ohos 平台。
- 需要配套的 OpenHarmony SDK,在 IDE 里下载对应 API 版本的 SDK。
- 构建产物是 HAP 包,类似 Android 的 APK。调试期用 Debug 证书没问题,发布必须配置签名,这和 Android 的 keystore 逻辑类似。
工具链清单大致如下:
| 组件 | 作用 | 备注 |
|---|---|---|
| flutter_flutter(ohos分支) | Dart 侧 SDK 与编译工具 | 通过 git 拉取,配置到 PATH |
| OpenHarmony SDK | 原生侧编译所需 | 在 IDE 中配置路径 |
| OpenHarmony 配套 IDE | 管理 ohos 工程、签名、构建 HAP | 打开项目下的 ohos 目录使用 |
| ohpm | OpenHarmony 的包管理器 | 原生侧依赖安装 |
2.2 工程结构长什么样
Flutter 工程创建之后,除了常规的 android、ios 目录,还会多出一个 ohos 目录,这就是 OpenHarmony 的壳工程。核心结构是:
项目根/ ├─ lib/ # Dart 业务代码 ├─ ohos/ # OpenHarmony 壳工程 │ ├─ entry/ │ │ └─ src/main/ets/ # ArkTS 原生侧代码 │ ├─ AppScope/ # 应用级配置 │ └─ oh-package.json5 # 原生依赖声明 ├─ assets/ # 题目 JSON、图片等资源 └─ pubspec.yaml大多数时候我们只需要在 lib/ 下写 Dart,ohos/ 目录里的东西不做大改。只有涉及原生能力调用时才需要动 ets 目录下的文件。
2.3 跑通第一个 HAP
我这边跑通工程的完整路径是这样:
- 把 ohos 分支的 Flutter SDK 下载到本地,配好 PATH,确认
flutter --version能输出版本信息。 - 创建工程后,在项目根目录新建
local.properties,写入ohos.sdk.dir=你的OpenHarmony SDK路径。这一步不写,后面flutter devices识别不到 OpenHarmony 设备。 - 连接设备或启动模拟器,执行
flutter devices,能看到带 ohos 标识的设备条目。 - 直接
flutter run -d 设备ID,首次构建会非常慢,因为要编译引擎相关的 so 文件,耐心等。 - 如果要出可安装的 HAP 包,把 ohos 目录导入配套 IDE,IDE 会跑 ohpm 安装原生依赖,同时帮你处理签名,再触发构建。
2.4 环境上踩过的几个坑
- flutter doctor 一直报警。OpenHarmony 平台的工具链不会被完整识别,doctor 会反复提示 Android toolchain 缺失。别慌,这说明不了什么,只看实际构建能不能过。
- Debug 能跑、Release 装不上。Release 包没配签名,设备上安装直接失败。解决方式是 IDE 里生成证书并配置到工程的 build-profile.json5 里。
- 热重载是半个废的。纯 Dart 代码改动基本能热更新,但动了原生 ArkTS 或者引擎相关的东西,必须整个 HAP 重新构建,不能指望热重载救场。
3. 题库和刷题核心:数据模型、组卷与错题归集
3.1 题目数据模型
题目数据我设计成了一份 JSON 文件放进 assets,而不是一开始就塞进数据库。原因有两个:题目是只读数据,一套题库撑死几千条,全量加载进内存也就几兆;用 JSON + 启动时加载,能绕开 sqflite 这类数据库插件在 OpenHarmony 上适配不稳定的问题。
单题结构如下:
{ "id": "mc_00123", "type": 0, "category": "美术史", "knowledgePoint": "文艺复兴美术", "stem": "下列哪位艺术家被认为是“文艺复兴美术三杰”之一?", "options": ["达·芬奇", "鲁本斯", "伦勃朗", "委拉斯贵兹"], "answer": [0], "analysis": "文艺复兴美术三杰指达·芬奇、米开朗琪罗、拉斐尔。" }type 用 0 单选、1 多选、2 判断,answer 用数组是为了兼容多选题。knowledgePoint 字段在学习报告阶段是聚合维度,所以导入时就必须按一套固定知识点词表清洗,不能随性乱填。这里的教训是:数据清洗比写代码费时间。我们当时拿到的原始题库是 Excel,里面“文艺复兴”一会儿写成“文艺复兴时期美术”,一会儿写成“文艺复兴艺术”,我写了段 Python 脚本按映射表统一替换,才保证了后面报告统计口径不出岔子。
3.2 刷题流程与错题归集
刷题页的状态机很简单:idle -> answering -> answered -> next。用户点选项时记录选中答案,点“下一题”时把这条答题记录写入本地库,同时更新当前会话的统计缓存。
练习记录表是整套报告系统的地基,字段是这样设计的:
CREATE TABLE practice_records ( id INTEGER PRIMARY KEY AUTOINCREMENT, question_id TEXT NOT NULL, category TEXT NOT NULL, knowledge_point TEXT NOT NULL, is_correct INTEGER NOT NULL, selected_answer TEXT NOT NULL, duration_ms INTEGER NOT NULL, created_at INTEGER NOT NULL ); CREATE INDEX idx_records_created ON practice_records(created_at); CREATE INDEX idx_records_kp ON practice_records(knowledge_point);每条记录必须冗余 category 和 knowledge_point,而不是通过 question_id 去题目表关联查。原因很实际:题目 JSON 是内存数据,如果报告聚合时要回表关联,既要跨存储又慢。冗余字段虽然在插入时多写几个字符串,但换来的是报告查询一次本地库就全部拿到。
错题归集我用了单独一张错题表,question_id 做唯一约束,答错自动插入,答对且已在错题表里的记录删掉。这样“错题清零”功能也顺手实现了。
3.3 模拟考试组卷逻辑
模考不是随机抽题那么简单,得按考纲比例来。我用的组卷算法是:按 category 的权重分层抽样,再做全局洗牌,最后用固定随机种子保证同一套卷子在不同设备上结果一致。
List<Question> buildPaper(List<Question> pool, Map<String, double> blueprint, int seed) { final random = Random(seed); final result = <Question>[]; blueprint.forEach((category, weight) { final bucket = pool.where((q) => q.category == category).toList()..shuffle(random); final need = (weight * totalCount).round(); result.addAll(bucket.take(need)); }); result.shuffle(random); return result; }交卷逻辑有个坑:未做答的题在评分时不能漏掉,要按错误处理。模考记录也会写入 practice_records,但单独加一个 source 标识字段区分练习和模考,报告里这两个数据要分开看,混在一起会严重污染知识点正确率。
4. 学习报告实现:从埋点到图表
4.1 数据采集:一次答题记录什么
学习报告能不能做出来,全看埋点定义。我埋的字段就是 3.2 节那张表:question_id、category、knowledge_point、is_correct、duration_ms、created_at。这里要说三个容易漏的点:
- duration_ms 必须逐题记。有了它才能算“平均每题用时”“知识点耗时”,这些指标对机构判断学生熟练度很有价值。
- created_at 用毫秒时间戳,而不是存成“2025-04-01 10:00”这种格式化字符串。日期聚合只有在查询时才做格式化,存时间戳能避免时区问题和格式解析开销。
- 会话时长单独处理。单题时长的总和并不等于用户实际学习时长,因为用户会在两道题之间发呆。我在刷题会话开始和结束时各记一个时间点,App 切后台时用 AppLifecycleState 回调自动结算本段,这个口径更接近真实学习投入。
4.2 统计口径:正确率、学习时长、连续天数怎么算
统计口径是最容易被产品和技术拉扯的地方,我踩过之后强烈建议:先写一份统计口径文档,再写代码。我最终落地的口径是这样的:
- 正确率= 正确题数 ÷ 总题数。但模考中未作答的题一律计错,练习中用户中途放弃的不计。
- 知识点掌握度= 该知识点答对次数 ÷ 该知识点练习次数。练习题量少于 3 条的知识点不展示,避免“练了一题对了就说掌握度 100%”的假象。
- 学习时长= 会话时长累加,不是单题时长累加。
- 连续学习天数= 从今天开始往前扫,只要当天练习记录数大于 0 就算连续,如果今天还没有记录,则从昨天开始扫。这样不会因为“今天还没开始学”就粗暴断掉连续记录。
聚合查询用 SQL 做也行,用 Dart 内存聚合也行。我这边因为已经全量查了 records,直接在内存里分组更顺手:
Map<String, double> knowledgeAccuracy(List<PracticeRecord> records) { final stat = <String, List<int>>{}; for (final r in records) { final s = stat.putIfAbsent(r.knowledgePoint, () => [0, 0]); s[1]++; if (r.isCorrect == 1) s[0]++; } return stat.map((kp, s) => MapEntry(kp, s[0] / s[1])); }4.3 图表落地:fl_chart 折线与雷达图
学习报告页我做了四个可视化块:7日正确率趋势(折线图)、知识点掌握度(雷达图)、薄弱知识点 Top5(列表)、每日刷题量柱状图。
图表库选了 fl_chart,它在 OpenHarmony 上的表现让我意外地放心,因为它是纯 Dart 绘制,不依赖原生画布能力。折线图用 LineChart,雷达图用 RadarChart:
RadarChart( RadarChartData( dataSets: [ RadarDataSet( dataEntries: entries, borderColor: Colors.blue, fillColor: Colors.blue.withOpacity(0.2), ), ], radarShape: RadarShape.polygon, ticksTextStyle: const TextStyle(fontSize: 10), ), )这里有个真实的坑要讲:RadarChart 在部分 OpenHarmony 设备上对触摸事件处理有兼容问题,如果用户手指滑过雷达图偶尔会闪退。我没花时间深究底层,直接把整个报告的图表区域包了一层 IgnorePointer,用户只能看不能点。报告页本来就是展示型页面,交互需求为零,这个取舍非常划算。
4.4 报告缓存与刷新策略
报告页打开时如果每次都全量扫 practice_records,数据量上来后会卡。我加了一张冗余聚合表 daily_stats:
CREATE TABLE daily_stats ( date TEXT PRIMARY KEY, total_count INTEGER DEFAULT 0, correct_count INTEGER DEFAULT 0, duration_ms INTEGER DEFAULT 0 );每次答题完成,就在事务里更新当天这一行。打开报告页时,只需要查:daily_stats 算今日数据和 7 日趋势,practice_records 按 knowledge_point 分组算掌握度。两次小查询,毫秒级响应。
如果用户通过某些后台手段清了练习记录,daily_stats 会和 records 不一致。我在设置页放了一个“重建统计数据”入口,提供全量重建方法,报给用户“修复中”,省了很多客诉。
5. 跨端实坑清单:OpenHarmony 上插件与原生协作
5.1 插件兼容地图
这个项目做完,我对 Flutter 插件在 OpenHarmony 上的兼容性总结了一张表,给同行做个参考:
| 插件/库 | 是否纯 Dart | OpenHarmony 可用性 | 我的处理 |
|---|---|---|---|
| provider | 是 | 正常 | 直接用 |
| fl_chart | 是 | 正常 | 直接用,关闭触摸 |
| shared_preferences | 否 | 有社区适配版,版本需匹配 | 找个固定版本锁死 |
| path_provider | 否 | 没有可用的 | 自写 MethodChannel |
| sqflite | 否 | 不可用 | 改用 JSON + 文件存储 |
注意,插件兼容版本是动态的,今天能用的明天可能因为 SDK 升级挂掉。我的建议是:锁定 flutter_flutter 分支的 commit,锁定所有依赖的精确版本,绝不轻易升级。这个项目从联调到交付,工具链一个没升,就是因为稳定压倒一切。
5.2 自写 MethodChannel 对接 ArkTS
path_provider 用不了之后,我写了一个极简的 MethodChannel 拿应用文件目录。Dart 侧代码:
static const MethodChannel _channel = MethodChannel('app/native'); Future<String> getAppDir() async { return await _channel.invokeMethod('getAppDir'); }原生侧是用 ArkTS 实现的,和 Android 上用 Kotlin 写插件是两套完全不同的 API。我这边只是在壳工程的 ArkTS 入口里注册了 channel 实现,返回文件系统路径。刚开始不熟悉 ArkTS 的模块注册规则,折腾了一天才跑通,原因只是我在错误的生命周期节点执行了注册。后来我把这一小段原生代码写成注释文档放在工程里,交给团队其他人就没再出过问题。
方法论上我想多说一句:自写 MethodChannel 的数量要克制。每多一个 channel,就多一个需要在原生侧验证的边界。这个项目把所有原生依赖收敛到了三个 channel:应用目录获取、轻量 KV 存储、设备信息。剩下的全靠纯 Dart 解决。
5.3 包体积和性能调优
HAP 发布包体积一开始很大,主要成本在 Flutter 引擎的 so 文件。我做三件事把它压了下来:
- 发布构建加
--obfuscate --split-debug-info,混淆的同时剥离符号表,体积减小明显。 - assets 里只打包当前大纲对应考点的题目 JSON,不把全量题库历史版本打进去。
- 题目配图统一压缩到 WebP,单张控制在 100KB 以内。
性能上最大的体感差异是 Debug 模式明显比 Android 卡,上 Release 包之后流畅度恢复正常。题库列表页我用了 itemExtent 固定行高,避免长列表滚动时的反复测量开销。这些在 Android 上可能无感的优化,在 OpenHarmony 的 Flutter 适配层上就是实打实的流畅度差距。
6. 交付之后回头看:这个项目真正的难点不在代码
6.1 时间花在了三个没写代码的地方
回头看整个交付,最花时间的三件事都不是 Flutter 代码本身:第一是题目 Excel 清洗成标准 JSON,几百道题的人工核对;第二是学习报告统计口径的确认,产品和教务来回拉扯了好几轮;第三是插件兼容性验证,每个依赖都要在真机上试一遍。
我自己的感受是:Flutter for OpenHarmony 的 UI 层开发体验已经相当够用,真正的不确定性全在生态层。哪个插件没适配、哪个原生 API 换了签名,这些问题不会出现在报错信息里,而是冷不丁在某个流程上绊你一下。
6.2 给后来者的三个选型标准
如果你也想入坑 Flutter for OpenHarmony,我建议你选依赖时用三条标准过滤:
- 纯 Dart 优先,能查到官方维护的 OHOS 适配记录才考虑原生插件。
- API 简单且可替换,万一插件挂了,自己写 MethodChannel 的成本要可控。
- 版本锁死,不要追新。这套组合下,新版本带来的不确定远大于收益。
6.3 最后一点体会
做完这套题库,如果让我重来一次,我会在第一天就把“学习报告口径文档”和“依赖兼容矩阵”两个东西立起来,而不是边开发边补。艺考题库本身不难,难的是它要求你在一个适配还不成熟的平台上,把数据、统计、可视化完整跑通。但反过来说,正因为平台生态不成熟,先趟过这条路的人,后面再遇到同类的项目,基本就是降维打击了。希望这篇分享能帮你少踩几个我踩过的坑。