1. 从零开始拆解这个项目:为什么是“记录 + 提醒”这个组合
拿到“Flutter for OpenHarmony 家具购买记录App实战:添加提醒实现”这个标题,我先给你说说我第一眼看到它的感觉:这个项目选得聪明。它没有贪大求全去做一个电商闭环,也没有做花哨的AR摆家具之类的炫技功能,而是切了一个非常具体、非常日常的细分场景——家具购买记录 + 到点提醒,并且把技术栈锁定在了Flutter + OpenHarmony这套组合上。
先说“家具购买记录”这个业务场景。你以为它就是做个备忘录,记一下“我想买个沙发”就完了?实际用起来你会发现,家具采购是一个典型的长周期、多决策点、强时效性的需求。比如你看中了一款餐桌,可能要在几个品牌之间比价,要等促销节点,要预约送货安装,还可能要在装修工期倒排之前做决定。单纯记一笔“想买餐桌”,过几天就忘了,提醒功能就变得至关重要。所以这个项目表面上是“记录”,真正的灵魂在“提醒”——它是把“记录”这个静态行为,升级成“按时间驱动行动”的关键一环。标题里的“添加提醒实现”,是整个App里最能体现工程价值的部分。
再说技术选型。Flutter是跨平台UI框架,OpenHarmony是开源操作系统。这两个东西放到一起,解决了一个非常现实的痛点:你不是只为一个设备写代码。现在OpenHarmony生态的设备形态涵盖了手机、平板、电视、带屏IoT设备等等,如果你用传统的ArkTS原生开发,一套代码往往只能跑一种设备形态,适配成本很高。而借助Flutter的跨平台渲染能力,加上OpenHarmony对Flutter框架移植的支持,你可以用一套Dart代码,同时覆盖OpenHarmony的多个设备形态和屏幕尺寸。这对个人开发者和中小企业来说,意味着更低的开发成本、更快的迭代速度,这是这个组合最核心的价值。
那谁适合参考这篇文章?我建议三类人重点看:
- 刚接触Flutter、想找一个小而完整项目练手的新手,这个项目功能边界清晰,不会让你淹没在过度的复杂工程里。
- 已经会Flutter、但想在OpenHarmony生态里做应用的开发者,你会发现适配层和原生插件调用的思路和常规Android开发不太一样。
- 产品经理或独立开发者,想参考一下“记录 + 提醒”这类经典功能模块该怎么拆解、怎么落地。
我写这篇文章的思路也很简单:不从头教你怎么装Flutter环境,那些官方文档讲得很清楚;我重点讲提醒功能从设计到实现的完整链路,包括数据的存储、时间的调度、通知的触达、状态的同步,以及我实际操作中踩过的一些坑。这些都是网上零散资料里看不到的细节。
2. 整体设计思路:先画好功能区,再动手写代码
2.1 功能模块怎么切:记录区、提醒区、通知区
把一个家具购买记录App拆开来看,核心功能其实就三大块:
第一块是家具记录管理。用户新增一条购买意向,包括家具名称、类别、预算金额、意向品牌、备注等字段。这一块是整个应用的数据地基,所有提醒都挂在某条具体的家具记录上。
第二块是提醒配置与调度。这里要解决的是“什么时候提醒”和“到期了怎么触发”的问题。提醒可以关联到家具记录,作为子表存在;也可以独立配置,比如用户只是单纯想在周末去逛家具城,不需要关联具体某件家具。
第三块是系统通知触达。提醒时间到了以后,要通过系统通知把用户拉回来,告诉用户“你计划去购买那张餐桌了,注意今天可能有满减活动”。通知栏的标题、内容、跳转逻辑都需要设计好。
这三块看起来平淡无奇,但它们的组合方式决定了项目后续的生长空间。比如你在提醒触达后,可以顺手把该家具的记录状态从“计划中”改成“已购买”或“已延期”,这就是“提醒”和“记录”两个模块之间的联动——这也是我最终选择把它们分开建模、再通过外键关联的原因。
2.2 状态管理选型:为什么最终用了 Provider + 本地持久化
Flutter社区的状态管理方案多到让人选择困难:setState、Provider、Riverpod、Bloc、GetX……这个项目规模不大不小,既要处理页面间的数据同步(比如填写提醒时间后返回列表页要能刷新状态),又要考虑后续可能引入的后台网络同步,我最终选了Provider。
原因很直接:Provider的学习曲线平缓,代码侵入性小,逻辑清晰。它既有InheritedWidget的高效性,又用ChangeNotifier解决了数据变更通知的问题,非常适合中小型App。相比之下,Bloc适合大型复杂状态流转,拿到这个项目里容易显得杀鸡用牛刀;GetX虽然省事,但有些魔法成分比较重,你遇到底层问题排查的时候反而不容易看透。
状态管理的职责划分也要提前想清楚。我的做法是拆成三个Store或者叫Model:
- FurnitureModel:管理家具记录列表,负责增删改查和本地存储同步;
- ReminderModel:管理提醒集合,负责按时间排序、过期清理,以及触发通知;
- SettingsModel:管理全局设置,比如默认提醒提前量、通知开关。
这样每个模块的职责单一,后面加了新的设备形态,适配层不会把状态层拖下水。
2.3 提醒功能的设计要点:提前量、重复规则、状态流转
提醒功能好不好用,很大程度上取决于细节设计。在设计阶段,有这么几个点直接决定后面编码的复杂程度:
提前量设置。用户不只是想在“购买日”当天收到通知,而是希望提前一天、提前三天、甚至提前一周收到提醒,方便做好预算和比价准备。这意味着一套提醒规则里可能有多个提醒节点,每个节点之间相隔固定的时间差。我在项目里实现的是“可添加多个提醒点”的模型:一个默认提醒点(购买日前一天),再允许用户自定义添加。
重复规则问题。家具购买往往是单次行为,不太需要每天重复的闹钟式提醒。但万一用户设定的是“每周末逛一次家具城”,就需要支持按周重复。这个场景我在后台schedule时预留了repeatInterval字段,默认null表示单次。这个字段设计好了,后面要扩展月提醒、年提醒都是水到渠成的事。
状态流转闭环。提醒触发后不能只是“响一下通知就结束”。我设计了三个状态:pending(待触发)、notified(已通知)、dismissed(已忽略)。用户点击通知进入App后,要么确认购买(记录状态变为已购买),要么延期购买(自动生成一个新时间的提醒)。这一套闭环让“提醒”真正服务于“行动”,而不是一个孤立的通知。
下面进入实操阶段,我把环境准备和工程初始化的关键步骤走一遍。
3. 环境准备与工程初始化:把跨平台地基打牢
3.1 OpenHarmony适配层:Flutter on OpenHarmony的版本对齐
首先要说一个大家都容易忽略的前提:Flutter默认是不直接支持OpenHarmony的,你需要通过社区或厂商提供的适配框架来运行。很多博主会把这个问题一笔带过,但我在实操中发现,版本对齐这件事做得不好,后面一步一个坑。
我在这个项目里使用的是某适配框架的稳定版本,它对OpenHarmony的API Level有明确要求,建议使用OpenHarmony公共版本的SDK,保持API Level在特定版本之上。为什么强调这个?因为适配框架的核心原理是让Flutter引擎通过系统级对接层去调用OpenHarmony的基础能力,比如窗口管理、输入事件、纹理渲染。OpenHarmony如果升级了系统接口,适配框架跟不上,轻则功能异常,重则直接把应用闪退。
我踩过一个很典型的坑:某次我把OpenHarmony SDK升级到了新版本,结果Flutter应用能启动,但一进页面就开始掉帧,纹理渲染花屏。查了半天,最后发现是适配框架还没有适配新版SDK的图形栈接口,不得不把SDK降回去。从那以后,我的习惯是锁定一个经过验证的三方版本组合,不在开发到一半的时候随意升级系统SDK。
3.2 集成开发工具与模拟器环境
OpenHarmony的开发工具我建议用官方提供的IDE,它支持鸿蒙应用和原子化服务的开发调试,安装好后在设备管理里拉一个模拟器即可。如果你手上没有OpenHarmony真机,模拟器也能覆盖大部分调试场景,尤其是通知和本地存储这类的功能。
具体配置路径是这样的(以某常见版本为例):
- 安装OpenHarmony官方IDE,并配置好SDK路径;
- 在设备管理器中启动模拟器,若拉取镜像较慢,建议切换至国内源;
- 执行
flutter doctor,确认Flutter侧的OpenHarmony环境通过校验; - 在工程目录的
ohos子目录下,补充配置项目ID,并同步签名信息; - 运行
flutter run -d <device-id>,拉起应用。
这套配置流程看着简单,但有个关键细节:IDE内置的模拟器默认分辨率、屏幕密度和真机并不一样,如果你的UI布局用的是硬编码像素,换到真机时很可能会出现布局错乱。所以我在项目里一律用MediaQuery或者Flutter自带的适配方案来计算尺寸,而不是写死width: 300这样的值。
3.3 工程目录结构建议
理清目录很重要,别等代码写到几千行再重构。我的项目结构如下:
lib/ ├── main.dart ├── models/ │ ├── furniture.dart │ └── reminder.dart ├── providers/ │ ├── furniture_provider.dart │ ├── reminder_provider.dart │ └── settings_provider.dart ├── pages/ │ ├── home_page.dart │ ├── detail_page.dart │ └── reminder_page.dart ├── widgets/ │ ├── furniture_card.dart │ └── reminder_picker.dart └── services/ ├── database_service.dart ├── notification_service.dart ├── reminder_scheduler.dart └── openharmony_channel.dartmodels放数据实体;providers放状态管理;pages放页面路由;widgets放可复用组件;services放与平台能力交互的服务。分工明确的好处是,你后面要给OpenHarmony做平台特有的能力(比如系统日历接入、通知渠道设置),只需要在services里扩展,不需要动UI层。这也是跨平台工程最重要的设计思想之一:把平台差异锁在服务层里,而不是让它渗透到UI层。
4. 核心数据模型:给家具和提醒分别建表
4.1 家具记录的字段设计
家具记录是整个系统的根数据。我在设计模型时,没有把它简单做成一个Map了事,而是定义了一个规范的Dart类,方便做类型安全检查:
class FurnitureItem { final int id; final String name; final String category; final double budget; final String brand; final String note; final DateTime createdAt; final DateTime? purchasedAt; FurnitureItem({ required this.id, required this.name, required this.category, required this.budget, required this.brand, required this.note, required this.createdAt, this.purchasedAt, }); }这里的字段有几个是深思熟虑过的:
budget用double而不是int,是因为家具预算经常带小数(比如1899.9元),用整数表示早期没问题,但后面一旦要做统计报表、单位换算,就会浪费时间。category没有用枚举,而是用了字符串。OpenHarmony的数据库存储枚举需要映射层,字符串不仅存储方便,而且添加新类别不需要改代码。purchasedAt允许为空,用来标记“最终是否真的买了”。从“计划购买”到“已购买”只是一字之差,但在业务逻辑上有本质区别,这个空值语义一定要处理好。
4.2 提醒模型的字段设计
提醒模型是记录模型的扩展,它独立存储,通过furnitureId和家具记录关联。稍微有经验的开发者会考虑一个方案:直接把提醒时间字段塞进家具表里,一个家具只有一个提醒时间。听起来省事,但我劝你不要这么干。原因正是前面提到的,一个购买意向可能需要多个提前提醒、多个不同类型的提醒节点。把字段塞进主表,很快表格结构就会变得臃肿,且扩展性极差。
class PurchaseReminder { final int id; final int? furnitureId; final String title; final DateTime remindAt; final int advanceDays; final bool isRepeated; final ReminderStatus status; final String? repeatInterval; PurchaseReminder({ required this.id, this.furnitureId, required this.title, required this.remindAt, required this.advanceDays, required this.isRepeated, required this.status, this.repeatInterval, }); }几个关键字段的说明:
advanceDays表示“提前了几天提醒”,这个字段不是临时算出来的,而是在创建提醒时固化下来的。好处是,就算用户后来修改了购买日期,历史提醒记录依然能看出当时的提醒策略是怎么设计的。status是一个枚举对象,对应我前面说的pending / notified / dismissed三个阶段。furnitureId允许为空,表示用户创建了一条独立提醒(比如“周末逛家具城”),它不关联任何家具。这种灵活性是“可空外键”带来的,不要因为“外键不能为空”这种洁癖,砍掉真实业务场景。
4.3 数据库表结构与建表SQL
存储层面我用的是Sqlite,这在OpenHarmony和Flutter生态里都非常成熟。建表SQL如下:
CREATE TABLE IF NOT EXISTS furniture ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, category TEXT NOT NULL, budget REAL NOT NULL, brand TEXT, note TEXT, created_at TIMESTAMP NOT NULL, purchased_at TIMESTAMP ); CREATE TABLE IF NOT EXISTS reminders ( id INTEGER PRIMARY KEY AUTOINCREMENT, furniture_id INTEGER, title TEXT NOT NULL, remind_at TIMESTAMP NOT NULL, advance_days INTEGER NOT NULL DEFAULT 0, is_repeated INTEGER NOT NULL DEFAULT 0, status TEXT NOT NULL DEFAULT 'pending', repeat_interval TEXT, FOREIGN KEY (furniture_id) REFERENCES furniture(id) ON DELETE CASCADE );注意一个设计细节:ON DELETE CASCADE。当删除一条家具记录时,关联的所有提醒会被自动删除,不会留下悬空引用。这个约束在应用层常常被忽略,结果就是数据库里残留一堆无效提醒,通知服务扫描时还要额外判断。在数据库层面直接干掉,既省心又安全。
5. 提醒调度的实现:定时扫描还是后台任务?
5.1 两种调度方案对比
提醒功能的核心问题是“如何让代码在预定的时间执行”。我在这里调研过两条路线:
方案A:一个定时任务反复扫描数据库。App启动后,注册一个周期性任务(比如每30秒扫描一次),检查有没有到期且状态为pending的提醒,有就发通知。优点是实现简单、可靠,不依赖系统的AlarmManager等复杂机制;缺点是用户必须保持App进程活着,一旦进程被杀,提醒就没法触达了。
方案B:把提醒注册到系统级调度器。类似于Android的AlarmManager,在OpenHarmony系统层面注册一个定时任务,到时间由系统唤起应用并触发通知。优点是即使应用不在前台,只要用户没有强制关机,提醒依然会触发;缺点是实现复杂度上升,不同系统版本的调度规则还有差异。
我在实际项目里选择的策略是A+B混合:应用在前台活跃时,用计时器 + 数据库扫描保证UI状态实时刷新;同时针对“离开应用但进程存活”的场景,注册系统级的一次性闹钟任务。这样兼顾了实现的稳健性和触达的可靠性。
5.2 基于Timer + 数据库扫描的简易调度器
先说方案A的具体实现。我在ReminderScheduler类里写了一个_startPeriodicScan方法:
void _startPeriodicScan() { _timer?.cancel(); _timer = Timer.periodic(Duration(seconds: 30), (timer) async { await _checkDueReminders(); }); } Future<void> _checkDueReminders() async { final dueReminders = await _db.queryDuePendingReminders(DateTime.now()); for (final reminder in dueReminders) { await _notificationService.showImmediateNotification(reminder); await _db.markReminderNotified(reminder.id); } }这段代码逻辑不复杂,但有几个地方要特别提醒:
- 扫描间隔不要设得太短。我之前图省事设了5秒,结果在调试时频繁触发通知,不仅打扰自己,设备资源也白白消耗。30秒或60秒就够。
- 查询SQL要注意把时间参数化,不要拼字符串。Sqlite的日期比较格式和Dart的ISO8601字符串要做对齐,避免时区偏移导致查询永远漏掉到期数据。
Timer是在Dart的单线程事件循环里执行的,不能用它处理耗时任务,好在这里的查询和通知调用都足够轻量。
5.3 OpenHarmony系统闹钟服务的封装
如果要实现方案B,需要用到OpenHarmony系统级的闹钟能力。这里的实现逻辑是这样:
通过特定的平台通道,在创建提醒时获取到系统闹钟服务的代理对象,然后调用接口注册一次性闹钟或周期提醒。到点后,服务会回调回到Flutter侧,再触发通知。
我在代码里做了这样一个服务类:
class AlarmService { static const _channel = MethodChannel('app.com/furniture_reminder'); static Future<void> scheduleAlarm(int id, DateTime time) async { try { await _channel.invokeMethod('addAlarm', { 'alarmId': id, 'timestamp': time.millisecondsSinceEpoch, }); } on PlatformException catch (e) { print('Failed to schedule alarm: ${e.message}'); } } static Future<void> cancelAlarm(int id) async { await _channel.invokeMethod('removeAlarm', {'alarmId': id}); } }这层封装的好处是,Flutter侧不需要关心系统闹钟的底层API长什么样,平台层的差异都被隔离在了MethodChannel的另一端。
但这里有一个特别容易踩的坑:系统闹钟最小间隔限制。OpenHarmony的某些版本对闹钟的触发时间有最小分钟数限制,如果你设置的提醒时间距离当前时间不足该限制值,API会静默失败,不报错、不触发。我第一版集成的时候,反复测试“提前1分钟提醒”都没有响应,一度以为是通知权限的问题。后来查系统日志才发现是闹钟API主动拒绝了过短的间隔。排查这个问题花了我大半个下午。
所以我的建议是:在创建提醒的UI层就做好时间校验,把“距当前时间不足最小间隔”的情况直接提示给用户,不让用户在静默失败中迷失。
6. 通知触达:本地通知从配置到弹出的完整过程
6.1 配置通知渠道并与OpenHarmony对齐
通知不是你想弹就能弹的。在OpenHarmony上,通知发送前必须做两个前置动作:注册通知渠道、向用户申请权限。这两件事分别在App启动时和用户首次触发提醒时完成。
Flutter侧有一套完整的本地通知插件封装,配置方式非常标准:
Future<void> initNotifications() async { await notificationPlugin.initialize( onDidReceiveNotificationResponse: (response) { // 点击通知时,根据payload跳转到对应的家具详情页 _handleNotificationTap(response.payload); }, ); await notificationPlugin.requestPermission(alert: true, badge: true, sound: true); }但OpenHarmony和Android的通知渠道概念略有区别,渠道分组默认打开,如果没有对齐系统的默认渠道ID,可能在部分设备上通知图标不显示或声音不生效。我的做法是在初始化后,统一将渠道细节设置为系统默认值,不自己造奇怪的自定义音效——在跨平台场景下,稳定优先于个性。
6.2 通知内容与跳转逻辑
通知的内容设计直接影响用户体验。我在通知里放了三个要素:
- 标题:尽量带上家具名称,比如“别忘记关注你心仪的餐桌”,而不是干巴巴的“您有一条新提醒”。
- 正文:带上预算和品牌信息,比如“预算5000元,品牌A,记得对比一下同款。”
- Payload:把家具记录的主键ID编码进通知的payload字段,用户点击后可以通过ID直接跳转到详情页。
有个细节值得单独说:payload不能用裸的ID数字,我建议用JSON字符串。比如{"reminderId": 12, "furnitureId": 8}这样。虽然多几行解析代码,但后期万一通知类型多样化(比如新增一个“延迟提醒”的类型),只靠一个int是表达不了的。用JSON能在不破坏接口的前提下自由扩展字段。
6.3 通知点击后的行为闭环
用户点通知进入App以后,不要只跳转一个空白详情页就完事。我在_handleNotificationTap里做了状态联动:
void _handleNotificationTap(String? payload) { if (payload == null) return; final data = jsonDecode(payload); final reminderId = data['reminderId'] as int?; final furnitureId = data['furnitureId'] as int?; if (reminderId != null) { reminderProvider.markNotified(reminderId); } if (furnitureId != null) { Navigator.push( context, MaterialPageRoute( builder: (_) => FurnitureDetailPage(furnitureId: furnitureId), ), ); } }这样做了之后,提醒的状态立刻从“待触发”变成“已通知”,同时用户自动进入了那条家具的记录详情页,可以直接操作“标记已买”或“延期”。整个动作一气呵成,没有任何冗余页面。这是我在产品层面最满意的一个设计点,也是“提醒”这个功能从鸡肋变实用的关键转折。
7. 四个最容易踩的坑和排查方案
7.1 时区不一致,提醒永远不会触发
这是我遇到过最隐蔽的问题。Dart的DateTime.now()返回的是本地时区的当前时间,但Sqlite存储时如果把时间直接toString存储,并按照字符串比较,那么在UTC+8的环境下没有问题,可一旦设备切换时区,内置的UTC时间与本地时间就会错位,导致查询不到“到期”的数据。
我在项目里统一采用的做法是:在存储层把时间一律转为UTC毫秒时间戳(epoch milliseconds),展示层再转换为本地时间。这样数据库比较逻辑彻底绕开了时区概念,无论用户走到哪,查询和比较结果都是一致的。
建议在模型层提供两个方法:
DateTime get localRemindTime => DateTime.fromMillisecondsSinceEpoch(remindAtMillis, isUtc: false); int get utcMillis => remindAtMillis;7.2 权限被拒后静默失败
通知权限如果被拒绝,requestPermission返回false,但系统不会给你抛出异常。你调用showImmediateNotification也不会报错,就是通知不出现。很多开发者在真机调试时反复测试都没看到通知,其实就是因为安装时误点了一次拒绝授权,后面每次都被静默拦截。
排查方法很简单:初始化通知服务后,调用一次checkPermissions,如果返回false,在界面上明确提示用户去设置里打开通知权限,并给出跳转设置的按钮。不要只佛系地在日志里打印一行。
7.3 定时扫描与UI线程冲突
如果你在_startPeriodicScan里直接操作了UI状态,而扫描逻辑在异步函数里执行完毕返回结果时,页面已经销毁或者处于后台,此时更新状态会引发异常。我在开发早期遇到过类似问题,表现为“提醒触发后不崩溃,但偶尔弹红屏”。
解决方式是给状态更新加上生命周期保护:
if (!mounted) return; provider.updateReminderStatus(updated);在State里使用mounted判断,在Provider层使用ChangeNotifier的dispose控制,确保没有在已销毁的对象上继续操作。
7.4 不同OpenHarmony版本的API兼容性
OpenHarmony本身也在快速迭代,不同版本的系统API存在差异。有一次我在新版本SDK上调用闹钟服务的接口,一切正常;但同样的APK包部署到老版本设备上,直接报“找不到接口”。这种问题属于典型的系统API版本不匹配。
我的解决方案是,在平台通道层做了一个版本探测:在调用闹钟服务前,通过系统能力先获取当前系统的API Level,如果低于某个阈值,就降级为纯数据库扫描模式(方案A),只用Timer轮询来触发提醒。虽然触发时机不如系统闹钟那么精确,但至少功能不会直接挂掉。
8. 写完这个提醒模块,我的一点真实体会
说句实在话,这个项目看起来不大,但它把跨平台开发里最容易忽略的几个环节全都过了一遍:数据模型怎么设计才不容易翻车、平台差异怎么隔离才不会拖垮业务层、系统通知怎么接才能保证用户体验不掉线。我做完以后深刻的感受是——在Flutter for OpenHarmony这个组合里,真正的难点从来不是UI怎么写,而是Flutter工程和系统能力之间的这座桥怎么搭得稳。
尤其提醒功能,牵涉到本地存储、时间计算、系统调度、通知权限、进程生命周期等各个层面的协作,任何一个环节掉链子,用户感知到的就是“该提醒的时候静悄悄”。所以我在整个实现过程中始终坚持一个原则:能用数据库约束解决的问题,不丢给应用层判断;能用系统能力解决的触达问题,不依赖应用进程活着。
最后再分享一个小技巧:开发这类提醒功能时,记得在设置页里放一个“测试提醒”的隐藏入口,点击后直接触发一条5秒后的提醒。有了这个入口,你再也不用为了验证通知逻辑反复修改购买日期、反复等待自然提醒时间。这个测试入口我保留了整个项目周期,每次改完通知相关代码,先点一下测试,确认无误再继续。实测下来,这个办法帮我省了不少无效等待时间。