做了一阵子 Flutter for OpenHarmony 开发,最直观的感受就是:这套组合完全能撑起一个日常向的 App。这个生活助手 App 的项目,核心里做的是习惯打卡功能——每天早上打开设备,看一眼今天的运动、阅读、喝水目标有没有完成,点一下就记录下来,晚上再瞄一眼这个月的打卡日历。一个顺手、稳定的打卡工具,本身就是有价值的产品。
这篇文章不是讲概念,是我实际把一个 Flutter for OpenHarmony 习惯打卡功能从零做出来的全过程。会聊到为什么选这套技术栈、环境怎么搭、数据表怎么设计、打卡热力图怎么做、本地提醒怎么配,最后还会把编译和运行阶段踩过的坑一次性列清楚。适合这几类人看:想在 OpenHarmony 设备上用 Flutter 开发应用但不知道怎么入手的开发者,已经在用 Flutter 想了解 openharmony 平台适配差异的人,以及纯粹对跨端技术方案感兴趣、想看看这套组合真实战斗力如何的同学。
1. 项目定位与方案选型:为什么非要用 Flutter 做 OpenHarmony 应用
1.1 原生 ArkUI 和 Flutter,我为什么选了后者
OpenHarmony 生态有自己的应用开发范式,也就是常说的 ArkUI 声明式开发。它的组件体系和状态管理设计得确实不错,如果只做一个只在 OpenHarmony 上跑的小工具,直接用 ArkUI 没问题。但我这个项目有个前提:同样的代码后续要覆盖移动端和其他平台,团队里也已经有比较成熟的 Flutter 技术积累。用 Flutter for OpenHarmony,等于把原来那套组件库、状态管理方案、第三方包生态整体搬过来,而不是在另一个技术栈里重新踩一遍 UI 和交互的坑。
这里有一个很实际的对比:ArkUI 的工程结构、路由写法、组件生命周期和 Flutter 差异不小,一旦做起来,后续想要复用到别的平台几乎等于重写。而 Flutter 本身的跨端能力放在这里,UI 表现层可以做到一套代码多处运行,底层的 OpenHarmony 能力通过平台通道按需调用。对我这种"先做一个核心功能验证可行性"的项目来说,选择非常明确。
1.2 习惯打卡为什么是第一个落地功能
生活助手 App 的完整形态其实是很大的,待办清单、健康数据、记账、日程安排,每个模块都是一片深水区。我刻意先把习惯打卡拎出来做,原因有三点:
- 功能闭环完整:新建习惯、每日打卡、历史记录、数据统计、周期提醒,一条链路走下来,能把 Flutter 在 OpenHarmony 上涉及的 UI、数据库、异步、通知这几块核心能力全部覆盖一遍。
- 数据模型清晰:习惯主表和打卡记录表的关系非常典型,很适合用来验证本地数据库方案在 OpenHarmony 平台上的表现。
- 交互复杂度适中:列表、日历网格、状态切换、长按编辑,这些交互在 Flutter 里都有成熟写法,既能检验框架能力,又不至于一上来就把自己淹没在过度复杂的需求里。
把第一个版本的范围切到足够小,才能把"Flutter 跑在 OpenHarmony 上"这件事本身验证清楚。项目跑通了,后续再往其他模块扩展就有了稳定基座。
1.3 整体架构设计:模块边界越清楚,适配成本越低
整体架构我分成了四层:表现层、状态层、数据层、平台层。表现层是纯 Flutter Widget,负责页面渲染和交互;状态层用 Provider 管理习惯列表、今日状态、日历数据;数据层负责 SQLite 读写和统计逻辑;平台层是唯一的 OpenHarmony 相关代码,比如获取应用文档目录、发送本地通知,都收拢在这一层。
这么分层有个好处:万一某个第三方包在 OpenHarmony 上不可用,我只需要替换平台层对应的实现,不需要动 UI 和状态层。实际开发中这个设计救了我好几次,后面讲踩坑的时候会详细说。
2. 环境搭建:OpenHarmony 与 Flutter 双端配置要点
2.1 OpenHarmony 侧的环境准备
先说结论:要在 OpenHarmony 上跑 Flutter 应用,你得先有一个能编译 HAP 包的完整工具链环境。我用的流程是先从 OpenHarmony 官方渠道下载 SDK 包,然后安装官方提供的 IDE 工具,把 SDK 路径配置进 IDE 的工程设置里。这一步没什么捷径,SDK 版本和 IDE 版本必须匹配,否则后面编译时会出现一堆莫名其妙的错误。
有一点要特别提醒:OpenHarmony 的 SDK 下载下来之后,环境变量和 IDE 配置要一起检查。我一开始只配了 IDE 里的 SDK 路径,命令行环境变量没有同步,导致 Flutter 端调用编译脚本时找不到 OpenHarmony 的编译工具,卡了半天。建议在系统环境变量里把 SDK 路径和工具链路径都配上,IDE 和命令行两边保持一致。
2.2 Flutter for OpenHarmony SDK 的配置过程
OpenHarmony 官方维护了专门的 Flutter SDK 分支,它的用法和标准 Flutter SDK 类似,但要把它当成一个独立的 SDK 来管理。做法是先把这个分支的代码拉下来,比如放到一个独立的目录,然后把 FLUTTER_ROOT 环境变量指向这个目录,或者在使用时显式指定 SDK 路径。
我当时的做法是下载官方维护分支的稳定版本,然后执行 flutter doctor 确认识别到了 OpenHarmony 平台。这一步的输出里能看到类似 ohos 平台的工具链状态,包括是否找到 OpenHarmony SDK、编译工具是否可用。如果 doctor 里没出现 OpenHarmony 相关的平台项,大概率是环境变量没配对,或者 SDK 版本不在支持列表里。
这里有个经验:不要同时把标准 Flutter SDK 和 OpenHarmony 分支混在一个项目里用,容易把 .dart_tool 和 build 目录搞乱。我后来是建了一个专门的环境切换脚本,用哪个 SDK 就切换对应的 PATH 和 FLUTTER_ROOT,项目之间互不干扰。
2.3 创建项目与依赖治理
项目创建仍然走熟悉的 flutter create,但需要显式声明平台:
flutter create --platforms ohos,android habit_app然后把偏好目录结构调整好,打开 pubspec.yaml 添加依赖。依赖选择是整个项目最关键也最容易翻车的一环,因为 OpenHarmony 的 Flutter 生态还不够成熟,不是所有 pub.dev 上的包都有对应的 OpenHarmony 平台实现。
我总结了一个依赖选型的优先级顺序:优先选纯 Dart 实现的包,其次选社区明确支持 OpenHarmony 的版本,最后才考虑需要自己写平台通道的包。纯 Dart 包完全不依赖原生平台代码,天然跨端,在 OpenHarmony 上几乎没有适配问题。比如后面要用的数据库方案,我没有选常见的 sqflite,而是用了纯 Dart 实现的轻量级数据库,直接操作文件,绕开了平台通道的不确定性。
依赖治理的经验就是:每加一个第三方包之前,先查它的平台支持列表,确认有 OpenHarmony 实现或者纯 Dart 实现再动手。项目里宁可少用几个花哨的包,也要保证编译和三方依赖的稳定性。
3. 习惯打卡核心实现:数据表设计、打卡交互与日历统计
3.1 数据模型设计:两张表打通全部需求
习惯打卡的数据结构其实非常经典。我用了两张表,一张存习惯本身的信息,一张存每天的打卡状态:
CREATE TABLE habits ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, target_days INTEGER DEFAULT 21, created_at TEXT NOT NULL ); CREATE TABLE checkins ( habit_id INTEGER NOT NULL, date TEXT NOT NULL, status INTEGER DEFAULT 1, PRIMARY KEY (habit_id, date), FOREIGN KEY (habit_id) REFERENCES habits(id) ON DELETE CASCADE );habits 表负责习惯的基本信息,比如名称、目标天数、创建时间。checkins 表是打卡记录,以 habit_id 和 date 作为联合主键,这个设计保证了同一天对同一个习惯只能有一条打卡记录,天然去重。
date 字段我用的是 YYYY-MM-DD 格式的字符串,没有用时间戳。原因很简单:打卡业务的粒度是"天",用字符串可以直接比较和索引,做日历统计的时候也不需要做时区转换。跨时区这种场景在这个功能里不存在,没必要引入额外的复杂度。
target_days 是给用户设置习惯周期用的,比如 21 天养成计划。实际统计的时候会用到这个字段来计算进度百分比,也会在连续打卡达到目标天数时给一个完成提示。
3.2 数据库访问层:避免在 UI 线程里碰数据库
数据层我用纯 Dart 的轻量级数据库方案,逻辑上是标准 DAO 模式。核心思路是:所有数据库操作都封装在单独的 Repository 类里,UI 层和状态层只跟 Repository 打交道,不直接拼 SQL。
class HabitRepository { Future<List<Habit>> getAllHabits() async { ... } Future<int> insertHabit(Habit habit) async { ... } Future<void> checkIn(int habitId, String date) async { ... } Future<Map<String, bool>> getCheckinsByMonth(int habitId, String month) async { ... } }在 OpenHarmony 上有一个特别要注意的点:数据库 IO 操作绝对不能直接在 UI isolate 里跑。Flutter 的标准做法是把数据库操作放到单独的 isolate 中,避免阻塞 UI 线程。我在 OpenHarmony 上实测发现,简单的本地读写如果量不大,直接在 async 方法里跑问题也不明显,但一旦按月查询打卡记录、做连续天数统计时,就能感觉到掉帧。后来统一封装了一个带 isolate 调度的工具类,把读写压力从 UI isolate 里挪走,页面滑动才恢复正常。
连续打卡天数的计算是打卡功能的灵魂。逻辑上不复杂:从今天往回逐天检查,只要发现某一天没有打卡记录就停止计数。
int calculateStreak(Set<String> checkoutDates, DateTime today) { var streak = 0; var cursor = today; final formatter = DateFormat('yyyy-MM-dd'); while (checkoutDates.contains(formatter.format(cursor))) { streak++; cursor = cursor.subtract(const Duration(days: 1)); } return streak; }这里有个隐藏的坑:DateTime.now() 拿到的值是带时分秒的,直接用来做日期比较时,Subtract Duration 计算可能出现跨天误差。我踩过一次,晚上十一点打卡后计算连续天数,结果因为时间分秒影响,往回回溯的起点不对,把连续天数算断了。解决方法是先做一次归一化,只保留年月日:
final today = DateTime(now.year, now.month, now.day);所有日期相关的计算都基于这个归一化后的对象,统计结果才稳定。
3.3 打卡主界面与交互实现
主界面我采用的是"今天"优先的设计思路,打开 App 直接看到的是今日打卡卡片和习惯列表。打卡交互上做了两个入口:一个是每个习惯右侧的大圆点按钮,点击即完成当天打卡,状态立即切换;另一个是点击整个卡片进入习惯详情页,能看到历史记录和周期进度。
打卡按钮的状态有三种:未打卡、已打卡、已中断。已中断状态我用了灰色标记,表示这个习惯在今天以前已经有断档,用于提醒用户及时续上。状态切换时加了简短的动画反馈,按钮从空心圆变成带对勾的实心圆,这个过程用 Flutter 自带的 AnimatedContainer 就能实现,不需要额外引入动画库。
列表的滑动删除和长按编辑也是刚需。滑动删除用 Flutter 标准的 Dismissible 组件,但要注意一个问题:Dismissible 在滑动删除时如果直接操作数据源列表,容易出现"删除后列表状态不同步"的问题。我的做法是先把数据库删除操作完成,再把内存列表里的对应数据移除,最后更新状态层,顺序不能反。
长按编辑我跳转到了一个独立的编辑页面,习惯名称和目标天数都可以改。这个页面的表单校验也补上了,比如习惯名称不能为空、目标天数不能小于 1,这些基本功不能省。
3.4 日历视图与连续打卡统计
日历视图是这个功能里视觉上最出效果、也是性能上最容易出问题的地方。我需要在一个月视图上展示:哪些天打卡了、哪些天没打、今天在哪、连续打卡多少天。我的实现方案是用 GridView 渲染一个 6x7 的网格,对应一个完整的日历月。
具体做法是:先计算出当前月第一天的星期偏移量,再补齐本月所有天数,不足 42 格的部分用下个月的日期填充。每个格子根据当天的打卡状态显示不同的背景色和文字颜色,打卡日用一个实心圆标记,今天用一个描边圆标记,未打卡的日期则淡化显示。
性能上有一个关键点:每个日历格子的布局不能太重。我一开始在格子里放了多个 Padding、Container、Text 嵌套,结果月份切换时能明显感觉到卡顿。后来把格子内部的组件结构简化,固定了每个格子的宽高比例,再用 RepaintBoundary 把格子隔离成独立绘制层,性能就稳定了。月份切换时用 PageView 左右滑动切换,加载相邻一个月的数据后再刷新,基本可以达到流畅跟手。
日历下面的统计区放了两个核心指标:累计打卡天数和当前连续打卡天数。累计天数直接查 checkins 表 count 就能拿到,连续天数用前面写的回溯逻辑计算。这两个数字每天都会变动,所以我在状态层里对它们做了缓存,只有当天打卡状态变化时才重新计算,避免频繁遍历数据库。
4. 状态管理、数据流与本地提醒:别让状态流在细节里崩掉
4.1 Provider 为什么够用
状态管理我选的是 Provider,没有上更重型的方案。原因很简单:这个 App 的状态流比较清晰,核心就是习惯列表和打卡记录,Provider 的 ChangeNotifier 机制完全能覆盖,而且分层好理解。项目亚硬上更复杂的框架,反而增加心智负担。
我把状态拆分成了两个 Notifier:HabitListModel 负责习惯列表的增删改查,TodayCheckModel 负责今日打卡状态和日历数据。两个模型之间有交互,比如在详情页修改了习惯名称,返回列表页后列表数据要能刷新。这个我用的是共享 Repository 实例的机制,两个模型持有同一个数据源访问对象,数据变更后手动调用 notifyListeners 通知对应页面重建。
class HabitListModel extends ChangeNotifier { final HabitRepository _repo; List<Habit> _habits = []; Future<void> load() async { _habits = await _repo.getAllHabits(); notifyListeners(); } }4.2 数据流时序:异步操作容易引发的状态错乱
这个模块里最容易出的隐性 Bug 是异步操作的时序问题。比如用户非常快地连续点击两个不同习惯的打卡按钮,如果每次点击都触发一次异步数据库写入,然后立即用返回结果更新内存状态,两个操作的完成先后顺序无法保证,可能出现界面显示和数据库不一致的情况。
我的解法是把打卡操作设计成串行队列模式:每次打卡请求先进入一个待处理队列,上一个操作完全结束后再处理下一个。虽然看起来损失了一点并发效率,但保证了数据一致性。实际体验下,单机本地数据库的写入速度毫秒级,串行化根本感知不到延迟。
另一个关键是状态更新要基于数据库操作的成功回调,而不是在发起操作时乐观更新。乐观更新在标准 Flutter 里很常见,但在 OpenHarmony 的异步环境里,如果写入失败会导致界面状态和真实数据长期不一致,排查起来很痛苦。宁可多等十几毫秒,也要确认写入成功再更新 UI。
4.3 本地提醒通知的实现与权限坑
习惯打卡如果没有提醒,大概率三天后就忘了打开 App。所以本地通知是必须有的能力。我用了 Flutter 的本地通知方案来做每日定时提醒,核心是配置每天固定时间触发一条本地通知,文案类似"今天的阅读目标还没完成,记得打卡"。
配置通知时有个设备差异问题:OpenHarmony 不同设备对通知权限的处理不完全一样。有的设备默认允许应用发送通知,有的需要用户在设置里手动授权。我在首启引导页里加了请求通知权限的流程,并且把权限被拒绝时的情况做了兜底处理——在打卡页显示一个"开启提醒"的横幅,点击后跳转到系统设置页。
定时通知的构建逻辑参考了本地通知插件的标准用法:
await localNotifications.periodicallyShow( id: 1001, title: '打卡提醒', body: '今天的习惯目标还没完成,记得打卡', frequency: DailyPeriodicNotification.dailyAt(8, 30), );每天同一时间触发,对于习惯打卡这种周期性场景非常合适。这里也有一个细节:如果用户把设备时区改了,定时通知的时间可能偏移。目前我的处理是监听时区变化事件并重新调度下一次通知,避免用户跨时区后提醒时间错乱。
5. 常见问题与排查技巧实录
5.1 编译阶段:依赖不匹配与 MissingPluginException
第一类高频问题是编译时提示找不到某个原生符号或者 SDK 版本不匹配。这种问题绝大多数是因为 Flutter for OpenHarmony 分支和 OpenHarmony SDK 的版本对应关系没对齐。OpenHarmony 的 API level 在持续演进,老的 Flutter 分支可能只支持到某个 API 等级,换了更高版本 SDK 后编译就会报错。
我的排查顺序是:先看报错信息里提到的 API 符号,再确认当前 Flutter 分支对应的支持版本,最后调整 SDK 或分支版本到兼容位置。不要一上来就怀疑代码写错了,环境匹配问题占了编译失败原因的六成以上。
第二类高频问题是 MissingPluginException,意思是在调某个 Flutter 插件的时候,OpenHarmony 平台没有对应的原生实现。最典型的场景是拿了一个只支持 Android 和 iOS 的插件直接跑在 OpenHarmony 上。这个问题的解决思路我在选型部分就铺垫过了:插件选型前先确认平台支持,遇到不支持的插件就找纯 Dart 替代品,或者自己在平台层补一个实现。
5.2 运行时崩溃与文件路径问题
习惯数据要持久化存储,就需要拿到一个可靠的永久目录路径。普通 Flutter 里的 path_provider 插件在 OpenHarmony 上需要有对应的平台适配。如果直接调用没有适配的版本,就会遇到空返回值或者路径获取失败。
我最后的方案是走平台通道,在 OpenHarmony 原生侧获取应用沙箱目录,返回到 Dart 侧使用。虽然代码量多了几十行,但换来了路径获取的确定性。这里有个小技巧:把路径获取结果缓存到静态变量里,只在应用启动时获取一次,后续所有数据库和文件操作都复用这个路径,避免频繁调用平台通道。
运行时崩溃另一种常见原因是数据库文件句柄被多处打开。排查下来是多个 Repository 实例各自打开了一次数据库,操作时互相竞争文件锁。统一改成单例模式之后就没再出现过。
5.3 体验优化:日历卡顿与中文字体显示
日历卡顿在 3.4 里提过优化思路,这里补充一个细节排查过程。我首版日历的每个格子是一个半透明的 Container,叠了多层阴影和边框。在低端 OpenHarmony 设备上滑动切换月份时,整页重建的格子太多,掉帧明显。后来把所有静态样式抽出来,用 const 构造函数固定不变量,结合 RepaintBoundary 和 cacheExtent 参数,把当前页前后各一屏的内容预构建,问题就解决了。
中文字体的问题是另一类体验细节。部分 OpenHarmony 设备上默认字体对中文的渲染不够统一,个别系统字体在某些字重下显示发虚。解决方法是打包一个自定义中文字体资源,在 MaterialApp 的 theme 中全局指定 fontFamily。这样虽然让安装包变大了一些,但换来的是所有设备上一致的文字渲染效果,对打卡类这种以文字为主要载体的 App 来说值得。
还有一个我在真机与模拟器之间切换时反复踩的坑:模拟器上默认没有配置通知渠道,调用本地通知会静默失败,看起来像代码没生效。后来我加了统一的渠道初始化方法,在应用启动时确保渠道已创建,问题才消失。真机上一切正常,模拟器上报无声 Bug,这种环境差异要靠系统日志才能定位到。
写在最后的个人体会
这个项目做下来,我对 Flutter for OpenHarmony 的真实感受是:可行,可用,但需要克制。可行的部分在于 Flutter 的上层开发体验完整保留了下来,状态管理、UI 组件、异步模型和标准 Flutter 完全一致,没有学习上的额外成本。可用的部分在于基础数据库、文件读写、本地通知、定时任务这些中轻度应用需要的能力,经过适配后都能稳定工作。需要克制的地方在于第三方生态还不够丰富,每一个依赖都要先想想在 OpenHarmony 上跑不跑得起来。
如果接下来要继续扩展这个生活助手 App,我会优先补两件事:一是把打卡数据做成可导出的格式,后续可以做云备份和跨设备同步;二是加一个每周复盘视图,把一周的完成情况汇总成一张卡片,这种反馈对习惯养成的持续性很有帮助。数据结构和 Repository 层在规划时已经为这两个功能预留了扩展位,到时候改动成本应该可控。
最后分享一个小技巧:如果你也在折腾 Flutter for OpenHarmony,建议把所有平台相关代码收拢到一个目录里,目录之外的代码全部保持纯 Dart。这样项目未来无论迁移到哪个平台,只需要重写薄薄一层平台适配,主体代码一分都不动。这套思路在我这个习惯打卡功能上已经验证过,省下的时间足够你多喝好几杯咖啡。