做这个项目的起因其实很朴素:我一直想验证 Flutter 这套跨端方案,在脱离 Android 和 iOS 这两个“舒适区”之后,到底能不能扛住真实业务。正好手头有一台 OpenHarmony 开发板,又接到一个“逆向思维训练 App + 学习日历”的练习需求,两者一凑,就有了这个 flutter_for_openharmony 实战项目。这篇文章不是教程搬运,而是我完整走完“需求拆解 → 环境适配 → 功能实现 → 真机验证 → 踩坑复盘”全流程的记录,涉及逆向思维训练的题库设计与防剧透交互、学习日历的自绘实现与打卡统计,以及 Flutter 移植到 OpenHarmony 平台时绕不开的环境配置和插件适配问题。无论你是想试试 Flutter 的多端能力,还是准备在自己的设备上跑一个带日历、带训练记录的应用,这篇都能给你一份能直接动手的参考。
1. 项目背景与整体设计思路
1.1 为什么把“逆向思维训练”和“学习日历”放在同一个 App
很多人第一反应是这两个功能不太搭:一个偏脑力游戏,一个偏学习管理。但实际做下来,它们是非常合拍的组合。逆向思维训练的核心是“高频、短时、持续”——每天花三五分钟做一道反直觉的逻辑题,比一次性刷二十道更有效。而学习日历的职责恰好是承接这种节奏:把每次训练的时间、题目类型、思考耗时、答题结果记录下来,在日历上形成可视化的时间轴。用户看到一周的格子都是满的、连续打卡天数在涨,自然会有继续练下去的驱动力。
从产品逻辑上拆,这个 App 实际上只有两条主线:
- 训练主线:每日一题 → 限时思考 → 提交答案 → 查看逆向解析 → 自评难度与收获
- 记录主线:日历视图展示每日训练量、正确率、连续天数,点击某一天可回看当天做过的题目
两条主线共享同一个数据层,训练模块产生记录,日历模块消费记录,不需要复杂的服务端支持,纯本地存储就能跑通。对 Flutter 在 OpenHarmony 上的首战来说,这个体量刚刚好,既能覆盖路由、状态管理、持久化、自定义绘制、平台通道这些关键能力,又不会因为业务太重导致排错困难。
1.2 技术路线选型:为什么是 Flutter,而不是 ArkTS 原生
如果只针对 OpenHarmony 一个平台,老实说用 ArkTS 原生写更省事,IDE 自带脚手架,组件和系统能力贴合也最紧密。但我选 Flutter 有一个明确的前提:同一个 App 后续要同时跑在 Android、iOS、PC 和 OpenHarmony 设备上。逆向思维训练这种逻辑型应用几乎没有复杂系统调用,UI 层完全可以跨端复用,用 Flutter 写一套 UI 逻辑,再为 OpenHarmony 做一次壳工程适配,比维护两套代码划算得多。
Flutter 在 OpenHarmony 上能跑起来,靠的是社区维护的适配分支——它把 Flutter 引擎编译成 OpenHarmony 的动态库,UI 层通过 Skia 渲染,平台能力通过 Platform Channel 桥接到 OpenHarmony 的 Ability 框架。实际体验下来,普通页面的流畅度接近 Android,启动速度略慢,冷启动大概多 300 到 500 毫秒,但可以接受。需要特别说明的是,这个适配分支不是官方主线,每隔一段时间要跟着上游 Flutter 版本同步合并,所以选版本时我特意挑了稳定分支,而不是追最新版。
1.3 功能清单与核心需求拆解
动手写代码之前,我把需求列成了表,每一项都明确“要做什么”和“做到什么程度算完”:
| 模块 | 核心需求 | 验收标准 |
|---|---|---|
| 题库管理 | 内置不少于 60 道逆向思维题,支持分类与难度标记 | 本地 JSON 加载,无网络依赖 |
| 每日一题 | 按日期生成当日题目,同一天内重复进入仍是同一题 | 日期种子算法决定题目 ID |
| 思考计时 | 记录用户从看到题到提交答案的耗时 | 后台计时,退出页面自动暂停 |
| 答案与解析 | 提交后先展示“逆向点”,再展示完整解析 | 二级揭示,避免秒看答案 |
| 自评反馈 | 用户对题目难度、启发程度打星 | 数据落入本地数据库 |
| 学习日历 | 月视图展示每日训练数与正确率,支持按日查看历史 | 自定义 GridView 绘制 |
| 统计概览 | 总训练天数、连续打卡天数、累计做题数 | 首页卡片展示 |
表格里每一项我都对应到具体的 Flutter 页面和数据结构,后面实现时就顺着这张表走,不会出现做着做着不知道下一步干什么的情况。
2. 环境搭建与工程初始化
2.1 OpenHarmony 开发环境准备
先说我踩的第一个坑:很多人以为装个 Flutter SDK 就能直接编译 OpenHarmony 工程,实际上需要同时具备 Android 工具链和 OpenHarmony SDK 两套环境,因为 Flutter 适配工程在构建时既要调 Dart 编译,又要通过 OpenHarmony 的编译工具链生成 hap 包。
我用的环境版本记录如下,照着这套组合基本不会出大问题:
- Flutter SDK:社区适配的
ohos稳定分支(对应 Flutter 3.x 版本线) - Dart SDK:随 Flutter 分支自动携带
- OpenHarmony SDK:API 11 及以上
- IDE:官方 IDE 工具链,用于打开壳工程和签名打包
- 命令行工具:准备
ohpm,用于安装鸿蒙侧的依赖包
安装时有个细节:适配分支的 Flutter SDK 需要单独解压,不能用包管理器覆盖系统已装的 Flutter,否则会把 Android 构建链搞坏。我的做法是在用户目录下建一个flutter_ohos文件夹,单独维护这份 SDK,环境变量里用flutter_ohos命令区分:
export PATH="$HOME/flutter_ohos/bin:$PATH" flutter_ohos doctordoctor命令会检查 OpenHarmony SDK 路径是否配置正确,如果报找不到 SDK,需要在 IDE 的 SDK 管理里确认 API 版本,然后再在环境变量里补上:
export OHOS_SDK_HOME="/path/to/ohos-sdk"2.2 创建 Flutter 工程并接入 OpenHarmony 平台
环境就绪后,创建工程和普通 Flutter 没什么区别,命令如下:
flutter_ohos create --platforms ohos,android reverse_train_app cd reverse_train_app这里要留意--platforms参数,默认模板不会生成ohos目录,必须显式指定。创建成功后工程目录里会出现一个ohos文件夹,里面是 OpenHarmony 壳工程,用 IDE 打开它就能看到entry模块。这个壳工程的作用相当于 Android 工程的宿主 Activity,Flutter 引擎会被嵌入到这个模块里运行。
首次编译建议先跑一遍:
flutter_ohos build hap --debug这条命令会完成 Dart 侧编译、生成 Flutter 引擎动态库、再调用 OpenHarmony 工具链打成一个调试版 hap 包。整个过程在普通配置的电脑上大约需要 10 到 15 分钟,大部分时间花在下载依赖和编译引擎上,耐心等就行。
2.3 工程结构规划与状态管理选型
工程创建完毕后,我先把 lib 目录按业务模块重新组织了一下:
lib/ ├── main.dart # 入口,负责初始化 ├── app.dart # 根 Widget,配置主题与路由 ├── models/ # 数据模型 │ ├── question.dart # 题目模型 │ └── study_record.dart # 学习记录模型 ├── data/ # 数据层 │ ├── question_bank.dart # 本地题库加载 │ └── record_store.dart # 本地数据库读写 ├── pages/ # 页面 │ ├── home_page.dart # 首页:入口与统计卡片 │ ├── train_page.dart # 训练页:题目展示与计时 │ ├── result_page.dart # 结果页:解析与自评 │ └── calendar_page.dart # 日历页:月视图打卡 └── utils/ ├── date_utils.dart # 日期与打卡计算 └── daily_question.dart # 每日一题算法状态管理我选了 Provider,没有上 Riverpod 或 Bloc。原因很简单:这个项目的共享状态只有“当前用户、今日题目 ID、今日是否已练过”这几个轻量级数据,Provider 的ChangeNotifier足够覆盖,代码量最少,排查链路也短。日历页的数据完全可以从数据库里读,不需要全局缓存。
3. 逆向思维训练模块实战
3.1 数据模型设计:题库不只是“问题+答案”
逆向思维训练题和我们常见的选择题不一样,它更像一道“思维谜题”:题干先描述一个看似矛盾的场景,用户需要跳出惯性思路找到合理解释,然后系统再揭示“逆向点”。所以题目模型不能只存 question 和 answer,还要有引导层。
我最终敲定的字段结构:
class Question { final int id; final String category; // 分类:逻辑、社科、经济、生活 final int difficulty; // 1-3,1简单 3困难 final String title; // 题干 final String hint; // 第一层引导,可空 final String reversePoint; // 逆向解析点:答案的核心逻辑 final String answer; // 完整解析 final bool isClassic; // 是否经典题,经典题优先出现在推荐位 }为什么要单独拆reversePoint和answer两层?因为训练的核心机制是“先启发、再揭示”。用户提交答案后,先看到简短的一句话点破思路方向,此时如果已经想通了,可以不用看完整解析,减少剧透感;如果还没想通,再展开详细答案。这个二级揭示机制是逆向思维训练体验的关键,数据结构上一开始就得支持。
题库我采用 JSON 文件内置的方式,放在assets/questions.json里,而不是硬编码在 Dart 代码中。60 道题的量不算大,JSON 的好处是后续增加题目不需要重新编译 Dart 代码,只需要替换资源文件。加载逻辑用一个异步方法:
Future<List<Question>> loadQuestions() async { final jsonString = await rootBundle.loadString('assets/questions.json'); final data = jsonDecode(jsonString) as List; return data.map((e) => Question.fromJson(e)).toList(); }实际测试中,60 道题的 JSON 解析耗时约 20 毫秒,完全不需要做缓存优化。
3.2 每日一题算法:日期种子保证“同日同题”
每日一题最直接的实现是存一个“今天显示第几题”的计数,每天切一条。但这样有一个问题:如果用户改了系统日期,或者第二天再进应用,状态会错乱。我改用了一个更稳的方案——日期种子随机算法。
核心思路是把日期字符串(如2025-01-15)转成一个稳定整数种子,再用它从题库里选出一道题:
int questionIdForDate(DateTime date, int bankSize) { final seed = date.year * 10000 + date.month * 100 + date.day; final random = Random(seed); return random.nextInt(bankSize); }同一个日期无论调用多少次,返回的随机序列都相同,所以选中的题目 ID 也稳定。这样做的好处很明显:用户当天反复进出训练页,看到的都是同一道题,符合“每日一题”的预期;第二天日期变化,题目自然切换,不需要任何数据库记录“今天做到哪了”。
不过这个简单算法有个小问题:如果题库数量较小,可能连续几天会抽到同一道题。为了改善体验,我把经典题做了一次权重修正,让随机数有 30% 的概率落在经典题集合内,保证翻来覆去抽到的都是好题。另外,我在训练记录表里加了question_id字段,日历回看历史时可以查出来某天具体做了哪一题。
3.3 思考页面与防剧透交互
训练页是整个 App 交互最重的部分,布局上分三块:题目卡片、计时区、提交区。这里有一个交互细节要重点说:思考计时不是用Timer倒计时,而是记录开始时间戳,计算当前时间差。这样做的好处是不受页面重建影响,用户切后台再回来,时间依然是连续的。
void _startTiming() { _startTime = DateTime.now(); } String currentElapsed() { final diff = DateTime.now().difference(_startTime!); final minutes = diff.inMinutes; final seconds = diff.inSeconds % 60; return '$minutes分$seconds秒'; }防剧透交互是训练体验的灵魂。我踩过一版坑:一开始把题目、提示、答案全放在同一页面底部,用户手指一滑就能看到解析,完全起不到训练效果。后来改成“三阶段页面”:
- 题目阶段:只展示题干和“想好了,提交我的答案”按钮,页面没有滚动到底部的可能。
- 揭示阶段:提交后展示“逆向点”,此时用户可以选择“需要完整解析”或“我已想通,结束”。
- 复盘阶段:完整解析 + 难度自评 + 启发自评,数据落库。
关键实现点在于路由控制,三个阶段用三个独立页面管理,中间不共享滚动容器。提交按钮在第二阶段被替换为两个新按钮,彻底从 UI 上杜绝偷看。
3.4 提交、解析与自评闭环
提交阶段用户输入的是“我的推理”,这个输入框我限制了最长 200 字,不做语义校验,因为本地应用没有服务端能力去判断答案对错,更像是一个自我确认的仪式感动作。真正有价值的是提交行为本身——它标记了用户已经完成思考,系统才允许进入解析阶段。
自评数据设计为一个独立的StudyRecord模型:
class StudyRecord { final int questionId; final DateTime date; final int elapsedSeconds; final int difficultyRating; // 1-5 final int insightRating; // 1-5 final String userAnswer; }落库时我遇到一个选择:用shared_preferences存 JSON 数组,还是用sqflite存表。60 道题每天一条记录,一年也就 365 条,量很小,但日历需要按月查询、按日期聚合,这种分组统计用数据库更顺手。所以我选了sqflite,建一张study_records表,并用date字段加索引。实际写下来发现查询逻辑很简单:
SELECT date, COUNT(*), AVG(elapsed_seconds) FROM study_records WHERE date BETWEEN ? AND ? GROUP BY date;这个聚合查询直接对接日历页的每日数据展示,一次查询搞定,不需要在 Dart 层做二次统计。
4. 学习日历模块实战
4.1 日历需求边界:到底要展示什么
学习日历最常见的坑是“什么都想放”,结果格子又挤又乱。我一开始也试过在每个日期格里同时放做题数、正确率、训练时长三组数据,小屏上一团糊。后来重新梳理了用户场景,发现日历页核心就三个诉求:
- 今天练没练、这个月练了几天
- 哪些天练得多,哪些天断档了
- 点击某一天能看到当天详细列表
围绕这三个诉求,我把日历格子的视觉元素收敛为两种:一种是一个带颜色的圆点,表示当天有训练记录,颜色深浅代表训练量;另一种是连续打卡的角标数字,显示当前连续天数。这样格子保持干净,信息却一点不少。正确率这种次要数据放到了点击某天后弹出的详情面板里,而不是直接铺在格子上。
4.2 插件选型:自带控件还是自定义 GridView
Flutter 生态里有现成的日历插件,比如table_calendar,功能很全,支持月视图、周视图、事件标记。我在项目里实际测过它,在 Android 上跑得很顺,但换成 OpenHarmony 适配分支后出了两个问题:
- 插件内部用到了
MaterialLocalizations的某些本地化能力,在适配分支上偶尔抛异常 - 我需要高度定制日期格子的样式,
table_calendar的calendarBuilders虽然能改,但每改一处都要和插件的内部布局逻辑做斗争,效率很低
所以最终我选择弃用插件,自己用GridView.builder手写一个月度日历。日子格子的核心计算其实只有两步:算出这个月第一天是周几,算出这个月有多少天。剩余的就是纯 UI 布局。代码骨架如下:
GridView.builder( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 7, mainAxisSpacing: 4, childAspectRatio: 0.9, ), itemCount: _daysInMonth + _leadingEmptyCount, itemBuilder: (context, index) { if (index < _leadingEmptyCount) { return const SizedBox.shrink(); } final day = index - _leadingEmptyCount + 1; // 根据 day 构造 DateTime,查询记录 return _buildDayCell(date, record); }, )_leadingEmptyCount的计算方式是:
final firstDay = DateTime(year, month, 1); final leadingEmptyCount = firstDay.weekday - 1; // 周一为起始列这里我特意让周一作为一周的第一天,符合国内用户对日历的直觉习惯。
4.3 月度网格与每日数据填充
日期数据填充我设计了一个高效的查询方案:进入日历页时,先按当前月的起止日期一次性查出所有记录,按日期分组存入Map<String, List<StudyRecord>>,然后每个格子从 Map 里取数据。这种写法避免了在itemBuilder中逐日出击数据库,整页 30 个格子只需要一次查询。
final start = DateTime(now.year, now.month, 1); final end = DateTime(now.year, now.month + 1, 0); final records = await store.queryBetween(start, end); final groupByDay = groupRecordsByDay(records);格子颜色深浅我用训练量做映射:1 题浅色,2 到 3 题中色,4 题以上深色。这个映射逻辑在_buildDayCell里用一个简单的层级判断实现。实验下来,三档视觉区分在 OLED 屏幕上观感很好,一眼就能看出这个月哪些天在坚持训练。
连续打卡天数的计算我单独做了处理。它不是简单数“这个月有记录的天数”,而是从今天开始往前倒推,找到第一个没有记录的日子,掐断点之前的天数就是连续打卡。这个逻辑写成一个独立函数放到date_utils.dart里:
int continuousDays(List<DateTime> practicedDays, DateTime today) { var count = 0; var cursor = today; while (practicedDays.contains(cursor)) { count++; cursor = cursor.subtract(const Duration(days: 1)); } return count; }注意DateTime.contains在这里需要先归一化到只比较年月日,否则时分秒不同会导致判断失败。
4.4 点击日期查看明细与复盘弹层
日历上的每一天如果有点击态,就能查看当天详细训练记录。这个交互我用showModalBottomSheet实现:点击日期格子后,底部弹出一个半高面板,展示当天的题目清单、每题耗时、自评星级。面板内的数据就是之前 group 好的那批记录,直接从 Map 里取,无需再次查库。
这里有一个小细节:日历页的“今天”要高亮,但用户上个月或下个月切换时,高亮不能消失。我的处理是用一个currentMonth状态记录当前展示的月份,再用一个独立的isToday判断对比日期,不依赖展示月份状态。
切换月的时候,日历页需要重新查库。我在didUpdateWidget和手势切换回调里都调用了同一个_loadMonthData方法,保证月份切换后数据同步刷新。因为一次查询只有几十条记录,所以没有任何加载动画,体感是秒切。
5. 真机调试、打包与性能适配
5.1 模拟器与真机的取舍
OpenHarmony 开发最常见的传闻是“必须用真机,模拟器不好用”。我在项目初期用模拟器试了几轮,结论是:模拟器在调试 UI 布局阶段完全够用,跑 Flutter 页面正常,但有两个硬伤——模拟器不支持某些传感器能力,而且启动 Flutter 引擎的耗时明显更长。我的建议是:前两周的 UI 开发可以用模拟器快速迭代,到了测试计时、后台切换、数据持久化这些有真实状态的场景,必须换真机验证。
真机调试时,我优先使用无线调试方式,避免 USB 线在反复插拔中接触不良导致设备列表丢失。在 IDE 里打开ohos工程后,连接设备会在运行配置里直接显示设备名称,选择后点击 Run 就能看到 hap 包安装进度和 Flutter 侧日志同步输出。
5.2 权限配置与本地存储适配
这个应用全程不需要网络和数据访问权限,目录结构相对干净。唯一和权限相关的是 OpenHarmony 侧对本地文件读写的要求:如果数据库文件要存在应用私有目录,什么权限都不用申请;只有需要读写公共存储时才需要在module.json5里声明ohos.permission.WRITE_MEDIA等权限。我选择了应用私有目录,理由很简单:学习记录是本应用自产自销的数据,没必要暴露给用户文件管理器,私有目录还免去了一堆权限弹窗的麻烦。
sqflite 在 OpenHarmony 上使用时需要确认适配插件是否存在支持版本。社区维护者针对这个场景移植了一个兼容包,用法保持一致,但底层存储路径在 OpenHarmony 上会略有不同,打开数据库时不要硬编码绝对路径,用getDatabasesPath()动态获取:
final dbPath = await getDatabasesPath(); final path = '$dbPath/reverse_train.db';5.3 视觉细节与性能优化
Flutter 在 OpenHarmony 上的渲染性能整体可接受,但我在真机上发现了两个值得注意的点:
第一个是页面切换动画掉帧。在训练页进入结果页时,系统默认的MaterialPageRoute过渡动画偶发跳帧。排查后确认是动画期间引擎需要处理页面重绘和数据库写入并发执行导致的。解决办法简单粗暴:路由切换前先异步落库,落库完成后再 push 路由,避免动画期间发生 IO 操作。
await store.insert(record); if (!mounted) return; Navigator.of(context).push(...);第二个是字体渲染差异。OpenHarmony 自带的系统字体和 Android 的 Roboto 不同,中文默认渲染稍细,部分加粗标题看起来不够“实”。我在全局主题里显式指定了字体族和粗体权重:
ThemeData( fontFamily: 'HarmonyOS Sans', textTheme: ..., )实际效果是中文观感回归正常,标题层次感一下子就出来了。
5.4 打包签名与安装验证
打包发布时,需要在 IDE 中配置签名证书。OpenHarmony 的签名体系和 Android 不同,它要求每个应用有一个.p12证书文件和.cer配置文件,打包时在 IDE 的签名配置里选择对应的证书文件,系统会自动完成签名。我最初打包总是失败,报的错是“证书未配置”,原因是 IDE 默认用的自动签名需要先登录开发者账号才能申请临时证书。我换成了本地手动生成证书,步骤是:在 IDE 的证书管理中新建密钥库 → 生成 CSR → 获取证书 → 配置到工程签名项。这套流程走通后,命令行下也可以复用同一套证书配置,保证后续 CI 打包一致。
真机安装验证时,我走了三个步骤:先确认 hap 包是否正确安装到设备,再确认 Flutter 引擎动态库随包一起部署成功,最后确认数据库在应用重启后数据保留。最后一步最容易出问题,如果数据库文件没有正确关闭或路径配置有问题,重启后数据会直接丢失。我的验证方法是:训练一条记录 → 彻底杀掉应用进程 → 重启 → 打开日历页看那条记录还在不在。
6. 踩坑记录与排查实战
6.1 插件不兼容:从“缺一个方法”到“自己写通道”
整个开发过程中最耗时的坑出在插件兼容上。某个本地存储插件在 Android 上一切正常,换成 OpenHarmony 适配分支后直接报MissingPluginException。这个异常的本质是 Dart 层调用了平台通道,但 OpenHarmony 壳工程里没有对应的方法处理器。
我的排查思路如下:
- 打开日志,定位到具体是哪个方法名缺失。
- 去插件源码里找它的实现,确认它使用的是
MethodChannel还是EventChannel。 - 如果没有官方适配版,就在
ohos的壳工程里自己实现一个同名方法回调。
这个方案说起来简单,实际写却绕了一些弯路。OpenHarmony 侧的平台通道实现语法和 Android 不同,但逻辑一致:拿到MethodCall,解析方法名,执行原生逻辑,回调Result。我在壳工程的entry/src/main/ets下新建了一个StudyRecordChannel.ets,用 ArkTS 实现了数据库写入的通道方法,测试后稳定运行。
这类问题在 Flutter 适配非标准平台时非常典型。我给出的建议是:选插件前先看它的底层依赖,凡是依赖系统传感器、摄像头硬件能力、或用了大量自定义 PlatformView 的插件,大概率要自己适配;纯 Dart 实现的库(如http、dio、provider)则不存在兼容问题。
6.2 中文乱码与字体渲染问题
项目做过一轮中文显示优化后,我在真机上又遇到一个局部乱码问题:题目内容里包含特殊标点(比如长破折号和省略号)时,部分字符显示成方框。查了一圈,定位到是字体 fallback 机制在 OpenHarmony 上不够完善,某些 Unicode 字符没有对应的字形。
解决思路分两层。第一层,换用支持更广的字体文件,把HarmonyOS Sans的字形加载改成系统默认字体策略,优先使用系统字体,不强制指定;第二层,对题库文本做一次字符清理,把不合规字符统一替换为标准标点。这两层处理完,乱码问题彻底消失。
6.3 页面重建导致状态丢失
训练页有个鸡肋问题:用户在看题时切到其他应用,再回来,如果页面被系统回收重建,计时会出现归零或跳变。我最初把_startTime存在 Widget 属性里,页面重建后必然丢失。修复方案是把计时开始时间持久化到本地数据库,在页面initState时读取:
final storedStart = await store.getStartTime(questionId); if (storedStart != null) { _startTime = storedStart; } else { _startTime = DateTime.now(); await store.saveStartTime(questionId, _startTime!); }这样页面无论重建多少次,计时始终从同一个起点计算。配合didChangeAppLifecycleState监听,切后台时写一次时间,回前台时先做容差校验,避免系统时间被修改导致计时异常。
6.4 时间格式与时区导致的日期错位
日历统计里出现过一个非常隐晦的 bug:用户晚上 23:50 做题,记录里存的日期却是第二天。原因是DateTime.now()返回的是带时区的时间,我存库时直接用了完整时间,而按“年月日”分组时用的是本地时区转换后的日期,两者一旦跨天就会产生错位。
修正方案是所有日期统一用“当天零时”作为存储基准,也就是只存年月日,不存时分秒。ElapsedSeconds 单独一列存在同一个记录里,日历分组按这个归一化的date字段操作,彻底避免了时区坑:
DateTime normalizeDate(DateTime dt) => DateTime(dt.year, dt.month, dt.day);6.5 常见问题速查表
| 问题现象 | 根因 | 解决方式 |
|---|---|---|
| 编译报错找不到 OpenHarmony SDK | 环境变量未配置或 SDK 路径错误 | 检查OHOS_SDK_HOME,确认 IDE SDK 已安装 |
| 运行时报 MissingPluginException | 插件未适配 OpenHarmony 平台 | 查找官方适配版,或自行实现平台通道 |
| 日历数据不刷新 | 月份切换后未重新查询 | 统一走_loadMonthData方法 |
| 连续打卡数字不对 | 日期比较含时分秒 | 统一用normalizeDate归一化日期 |
| 冷启动白屏偏久 | 引擎初始化开销 | 启动页显示品牌动画,掩盖引擎加载时间 |
7. 实战总结与经验沉淀
这个项目从零到跑通,前后大约两周,核心开发时间其实只有四五天,其余时间全花在环境适配和插件排查上。回过头看,有三条经验是最值钱的。
第一条,Flutter 跨平台能力在 OpenHarmony 上是真实可用的,但要用“有边界的心态”去用。纯 UI 逻辑、数据模型、状态管理这部分,迁移成本接近零;一旦涉及平台能力、系统 API、第三方插件,就要提前做兼容性调研,不能默认“Android 能用就等于 OpenHarmony 能用”。
第二条,每日一题的“日期种子”算法是逆向思维训练类应用的通用套路。它不需要服务端生成题目,不需要本地状态维护,稳定、可复现、可离线,强烈推荐给所有做每日打卡类功能的朋友。
第三条,日历功能看似是一个“小而常见”的模块,但它对数据模型的要求很苛刻。凡是涉及按日聚合的统计功能,数据结构从第一天就要把“日期归一化”和“唯一记录 ID”设计好,否则后面查 bug 会查到怀疑人生。
我个人最满意的是学习日历里“点击日期弹层看题目详情”这个设计,它让训练模块和日历模块形成了正循环——今天练得怎么样,回看日历一目了然;日历上连续一周的密集点,又会激励你下周继续练。这就是为什么这个 App 不叫“题库工具”,而叫“训练 App”:日历把训练变成了一种看得见的习惯。
最后分享一个后续扩展方向:目前题库是内置的,后续完全可以接一个服务端 API,每日一题变成服务端下发,日历统计也能升级为多端云同步。数据模型和模块边界我已经按这个方向预留好了,改动成本不会太大。如果你正准备在自己的 OpenHarmony 设备上做类似功能,建议先把我这份结构跑通,再往自己的业务方向延伸,会比从零开始搭顺畅很多。