ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Flutter for OpenHarmony:家具提醒App的平台通道与系统调度实践

Flutter for OpenHarmony:家具提醒App的平台通道与系统调度实践

买家具这件事,噼里啪啦下完单只是一个开始。更常见的情况是:床垫说好45天内送货,沙发要等门店通知再约安装,床头柜的保修期一年后哪天到期你根本想不起来。这些时间点本身没多复杂,但凭人脑记多了肯定会漏。我做的这个“家具购买记录App”,核心就是把每笔采购的单据、金额、到货状态记下来,再配上提醒能力,到点主动通知你。技术选型上,我用了Flutter for OpenHarmony这套社区维护的SDK分支,既保住了Flutter跨端写界面的效率,又能把通知调度这类系统能力交给OpenHarmony原生侧去做。

这个项目的受众很明确:已经熟悉Flutter基础、又想往OpenHarmony生态上迁移的开发者。你不需要懂整套鸿蒙原生开发,但需要理解“平台通道”这个关键衔接点。下面我会从需求拆解、工程搭建、提醒调度实现、稳定性排查四个维度,把整个功能怎么落地讲透。代码都来自我实际跑过的Demo,你可以直接照搬。

1. 项目拆解与方案设计:先搞清楚提醒服务要长什么样

1.1 家具购买场景里到底谁需要提醒

做提醒功能之前,我先列了一遍真实使用场景。家具购买和其他网购最大的区别是:下单后不是马上发货,而是有一长串时间节点。举个我自己的例子,去年买了一套餐桌,商家说7天内发货,结果发货后物流在中转站停了三天,我每天都要点开App查一次。等收到货,又约了师傅上门安装,安装师傅迟到两小时,我那天下午全程不敢出门。

这些体验痛点对应到提醒功能,至少分成四类:

  • 物流到货提醒:下单时商家给出预计送达区间,我们希望提前一天收到通知,而不是临时抱佛脚。
  • 安装/配送预约提醒:送货和安装往往是两个环节,相隔几天甚至一周。提醒要落在“预约时间”上,而不是下单时间。
  • 保修期到期提醒:床垫、沙发这类大件,保修期一两年,到期前一个月提醒一次,方便决定要不要续保或报修。
  • 尾款/分期提醒:如果走了分期付款,每期还款日也得提醒,虽然这块更偏账务,但可以和购买记录统一管理。

所以提醒功能不是简单弹一个“明天要送货”,而是按业务事件建模。每条提醒应该包含:关联的购买记录ID、提醒类型、触发时间、重复规则(一次性/每周/每月)、附加说明,以及完成后标记状态的能力。这个建模直接影响后面表结构设计和通道协议设计。

1.2 提醒能力的技术现状与选型思路

Flutter生态里做通知提醒,大多数人第一个想到的是flutter_local_notifications插件。这个插件在Android和iOS上确实很成熟,但放到OpenHarmony上就变得尴尬了:社区适配版本存在,但API映射不完整,有些通知渠道的行为和原生预期不一致。我在早期调研时试过一个第三方适配包,结果通知弹出样式和点击跳转都不可控,调试成本很高。

OpenHarmony系统侧本身提供了一个“后台代理提醒”机制,叫ReminderAgentManager。它不依赖应用进程是否存活,到点由系统统一调度并弹出通知,甚至支持闹钟、日历、倒计时三种类型。这套能力和我要做的提醒需求非常匹配。所以最终方案很清晰:Flutter侧负责业务逻辑和UI交互,OpenHarmony原生侧负责实际提醒调度,中间通过平台通道(MethodChannel)连接。

1.3 三种提醒实现方案对比

我梳理了三种可行路线,各有适用场景:

方案实现思路优势劣势适用场景
A. 纯Flutter定时器在Dart层用Timer轮询,结合本地通知插件弹提示实现简单,跨端一致App进程被杀或冻结后失效,后台被清理后完全不触发应用前台运行时的轻提醒
B. 数据库轮询兜底把提醒时间存本地库,每次打开App扫描过期记录并主动弹出数据不丢,可补发漏掉的提醒做不到“到点主动推送”,依赖用户打开App作为其他方案的兜底机制
C. 系统ReminderAgent调度原生侧创建系统级提醒,由系统到点弹出进程冻结也能触发,可靠性高每个平台需要适配,通道协议要自己设计生产环境首选方案

我最后选择的是C为主、B为辅的混合架构。主链路走ReminderAgentManager,把提醒真正托管给系统;同时每次App冷启动时扫一遍数据库,发现已过期但还没标记完成的记录,补发一次通知。这样两条腿走路,可靠性会高很多。

2. 环境搭建与工程初始化:Flutter for OpenHarmony怎么跑起来

2.1 SDK环境准备

Flutter for OpenHarmony不是Flutter官方主线直接支持的,而是OpenHarmony社区维护的一条SDK分支,项目名也叫flutter_flutter。所以环境搭建第一步不是flutter doctor,而是把这条分支拉下来,并且切换到和OpenHarmony SDK版本匹配的分支号。这个匹配关系在社区README里写得很清楚,我的经验是:不要用最新代码,挑一个明确标注“稳定”的发布tag,否则编译到一半可能遇到SDK接口对不齐的报错。

接着配置OpenHarmony SDK路径。我用的是DevEco Studio自带的SDK,路径通常在/Applications/DevEco-Studio.app/Contents/sdk或Windows下的安装目录。需要把local.properties里的sdk.dir指过去,同时在Flutter侧的flutter_ohos_config.yaml里登记SDK版本。这个文件是适配分支特有的,官方主线没有,我第一次用的时候找了好久。

配置好之后,终端里执行flutter doctor,输出里会多出一个OpenHarmony相关的检查项。看到“OpenHarmony toolchain is available”这一项变绿,说明基础环境已经通了。这里有一个容易忽略的点:默认的环境检查可能还会扫描Android工具链,如果本机之前装过Android Studio但没装完整,flutter doctor会有红叉,但它不会影响OpenHarmony构建,别被它吓到,重点看OpenHarmony那一栏。

2.2 工程目录结构

环境就绪后,创建工程的方式和传统Flutter不太一样。因为OpenHarmony的构建系统是Hvigor,它需要一个完整的HarmonyOS工程外壳来承载Flutter模块。我的做法是:

  1. 先用DevEco Studio新建一个Empty Ability工程,包名设为com.example.furniture_app。
  2. 在工程根目录执行flutter create --org com.example --project-name furniture_app .,把Flutter模块生成到同一个目录下。
  3. Flutter模块会生成ohos目录,里面预置了适配层代码和CMake配置。

完成后目录大致长这样:

  • entry/:OpenHarmony应用入口,包含MainAbility和module.json5
  • ohos/:Flutter插件的平台适配层,会有flutter_ohos_config.yaml
  • lib/:Dart源码目录
  • entry/src/main/ets/:ArkTS页面与原生逻辑所在位置

需要特别说明的是,entry工程里会被引入Flutter的依赖,这是在build-profile.json5里配置的。如果直接把Flutter模块塞进已有工程,记得检查这个文件里的dependencies是否包含flutter_ohos相关项。我一开始漏了这步,构建时一直提示找不到Flutter引擎,排查了半天才发现是依赖声明缺失。

2.3 权限声明与系统能力配置

提醒功能不是随手就能调的,OpenHarmony对这类敏感能力有权限管控。在entry/src/main/module.json5里,需要申请以下权限:

{ "module": { "name": "entry", "requestPermissions": [ { "name": "ohos.permission.PUBLISH_AGENT_REMINDER", "reason": "需要创建购买记录提醒", "usedScene": { "ability": ["MainAbility"], "when": "inuse" } } ] } }

注意PUBLISH_AGENT_REMINDER是system_basic级别权限,普通的normal权限通过requestPermissions声明后就能用。如果你的开发设备是标准鸿蒙设备,一般有系统签名或者通过授权弹窗就能正常申请;如果提示权限拒绝,要先检查设备的系统授权策略,这属于环境问题而不是代码问题。

除了权限,还得让MainAbility支持后台任务分发,因为当用户点击提醒通知拉起App时,系统会通过路由跳转回到对应页面。这个我放在后面的“点击处理”里细说,但工程配置上需要确保Ability实例化方式支持拉起到前台,否则通知点击无效。

3. 核心实现:提醒到底怎么从Flutter侧走到OpenHarmony原生侧

3.1 数据模型与本地存储设计

提醒是业务数据的一部分,必须先设计好存放结构。我用的本地数据库是sqflite,Flutter端直接操作,建了两张表:

CREATE TABLE purchase ( id TEXT PRIMARY KEY, name TEXT NOT NULL, price REAL NOT NULL, purchase_date INTEGER NOT NULL, supplier TEXT, status TEXT DEFAULT 'pending' ); CREATE TABLE reminder ( id TEXT PRIMARY KEY, purchase_id TEXT NOT NULL, type TEXT NOT NULL, -- delivery / install / warranty / payment remind_at INTEGER NOT NULL, repeat_rule TEXT, -- once / weekly / monthly note TEXT, is_done INTEGER DEFAULT 0, external_reminder_id INTEGER, FOREIGN KEY (purchase_id) REFERENCES purchase(id) );

external_reminder_id这列非常重要,它记录的是OpenHarmony原生侧在成功创建系统提醒后返回的ID。为什么要把这个ID存下来?因为取消或修改提醒时必须拿着这个ID去原生侧删掉旧提醒,否则你会出现“改了一次时间,结果到点弹了两次通知”的坑。

在Dart侧对应建两个数据类,这里只展示Reminder的核心字段:

class Reminder { final String id; final String purchaseId; final ReminderType type; final DateTime remindAt; final String repeatRule; final String note; final bool isDone; final int? externalReminderId; }

时间字段统一存毫秒时间戳,避免不同时区序列化时出现偏移。后面创建系统提醒时,需要把时间戳转成本地时间的年、月、日、时、分,这一步容易出错,我单独说一下。

3.2 MethodChannel通道协议:唯一的数据交换标准

Flutter和原生侧的交互必须预先定义好通道名和方法签名,否则协作起来到处都是魔法字符串。我在Flutter侧定义了一个ReminderService类来统一封装:

class ReminderService { static const MethodChannel _channel = MethodChannel('com.example.furniture_app/reminder'); // 创建或更新提醒 static Future<int> scheduleReminder({ required String id, required int timestamp, required String title, required String content, String repeatRule = 'once', }) async { final int? result = await _channel.invokeMethod('scheduleReminder', { 'id': id, 'timestamp': timestamp, 'title': title, 'content': content, 'repeatRule': repeatRule, }); return result ?? -1; } // 取消提醒 static Future<bool> cancelReminder(int externalReminderId) async { final bool? result = await _channel.invokeMethod('cancelReminder', { 'reminderId': externalReminderId, }); return result ?? false; } // 查询某条提醒的当前系统状态 static Future<bool> isReminderActive(int externalReminderId) async { final bool? result = await _channel.invokeMethod('isReminderActive', { 'reminderId': externalReminderId, }); return result ?? false; } }

通道协议的方法声明遵循“动词命名”风格,每个方法都有明确的入参和返回约定。我在开发中坚持一个原则:Flutter侧永远不直接拼原生API的参数结构,而是用Map传入可读字段;原生侧也不关心Flutter业务逻辑,只管执行提醒调度。这样两边解耦,以后换提醒实现方式时,只要通道协议不变,Flutter侧完全不用动。

3.3 OpenHarmony原生侧的提醒调度实现

原生侧的入口需要一个自定义的MethodChannel处理器,我用ArkTS实现。OpenHarmony的提醒API是reminderAgentManager,可以创建ReminderType.ALARM类型的提醒。核心逻辑是这样:

import reminderAgentManager from '@ohos.reminderAgentManager'; import { BusinessError } from '@ohos.base'; export class ReminderMethodHandler { scheduleReminder(args: Map<string, Object>): Promise<number> { return new Promise((resolve, reject) => { // 解析参数 const bizId = args.get('id') as string; const timestamp = args.get('timestamp') as number; const title = args.get('title') as string; const content = args.get('content') as string; const repeatRule = args.get('repeatRule') as string; // 把毫秒时间戳转换成本地时间的各部件 const date = new Date(timestamp); const year = date.getFullYear(); const month = date.getMonth() + 1; const day = date.getDate(); const hour = date.getHours(); const minute = date.getMinutes(); // 构造系统提醒 const reminder = { reminderType: reminderAgentManager.ReminderType.ALARM, hour: hour, minute: minute, title: title, content: content, daysOfWeek: repeatRule === 'weekly' ? [1,2,3,4,5,6,7] : [], wantAgent: { pkgName: 'com.example.furniture_app', abilityName: 'MainAbility', parameters: { 'bizId': bizId } } }; reminderAgentManager.publishReminder(reminder, (err: BusinessError, reminderId: number) => { if (err) { reject(new Error(`publish failed: ${err.code}`)); return; } resolve(reminderId); }); }); } cancelReminder(reminderId: number): Promise<void> { return new Promise((resolve, reject) => { reminderAgentManager.cancelReminder(reminderId, (err: BusinessError) => { if (err) { reject(new Error(`cancel failed: ${err.code}`)); return; } resolve(); }); }); } }

这段代码有几个细节值得展开:

第一,daysOfWeek必须显式传空数组表示一次性提醒,如果不传或传undefined,系统可能把它当成每天重复的闹钟。我一开始就没传,结果测试时每天早上8点收到测试通知,一度怀疑是缓存问题。

第二,wantAgent是通知点击后的跳转配置。当用户点击系统弹出的提醒条,系统会通过这个配置拉起MainAbility,并带上bizId参数,这个参数就是提醒表里的业务ID,用于后续跳转到对应的购买记录详情页。这里能达到“通知即入口”的效果。

第三,每次发布提醒前,要检查这个业务ID下是否已有旧提醒。如果用户把“明天送货”改成“后天送货”,直接发布新提醒而不取消旧提醒,系统到点会弹两条。所以我在Flutter侧的业务逻辑里规定:修改提醒必须先cancel后schedule,cancel需要用旧提醒的external_reminder_id。

3.4 Flutter侧交互面板与状态展示

原生侧已经具备调度能力,Flutter侧要解决的是“怎么录入、怎么展示、怎么修改”。我设计了一个提醒管理面板,以卡片形式汇总当前记录下的所有提醒。核心交互流程是:

  • 在购买记录详情页点击“添加提醒”,弹出底部表单。
  • 表单里选择提醒类型(到货/安装/保修/尾款),对应不同的默认标题文案。
  • 选择日期时间,使用showDatePicker和showTimePicker组合。
  • 提交后,先写入本地数据库,再调用ReminderService.scheduleReminder,拿到externalReminderId后回填数据库。

状态展示上,每条提醒卡片显示三态:待触发、已完成、已取消。待触发状态显示倒计时天数,这个倒计时用StreamBuilder每秒刷一次,到点自动变红高亮。已完成状态在用户点击“标记完成”后更新,颜色变灰。这里有一个产品层面的细节:用户标记完成后,Flutter侧要同步取消系统侧提醒,避免通知继续弹出造成困惑。

关键UI代码:

Widget buildReminderCard(Reminder reminder) { return Card( child: ListTile( leading: Icon(_typeIcon(reminder.type)), title: Text(_typeTitle(reminder.type)), subtitle: Text(_formatRemindAt(reminder.remindAt)), trailing: reminder.isDone ? Icon(Icons.check_circle, color: Colors.green) : TextButton( onPressed: () => _markDone(reminder), child: Text('标记完成'), ), ), ); }

这里我踩过的一个坑是:当用户点击“标记完成”时,UI线程直接调用MethodChannel可能耗时过长,尤其在真机上偶尔会出现卡顿。后期我把MethodChannel调用统一放进了compute或者异步队列里,UI层只负责乐观更新状态,回包失败再回滚。这个对提醒类功能尤其重要,因为用户操作非常低频,一旦卡顿会很显眼。

4. 常见问题排查与稳定性加固:真机上都是这样踩坑的

4.1 提醒到点不触发?按这五个环节逐层排查

提醒功能上线后,我收到最多的问题是“设置了提醒但到点没有任何反应”。这种问题排查难度其实不大,关键是按链路顺序查。我把自己的排查清单整理成了一张表,按照从系统到业务的顺序排:

排查环节检查要点常见原因
系统权限是否声明PUBLISH_AGENT_REMINDER权限并授权权限声明位置错误,或设备未授权
提醒创建返回publishReminder是否返回合法ID回调没执行、参数非法(如时间早于当前)
时间区转换时间戳转小时/分钟是否和用户预期一致误用UTC时间导致提醒偏移8小时
重复规则daysOfWeek是否误传了重复值传了空数组之外的值,变成每日重复
应用状态应用是否被强制停止原生侧回调依赖应用进程时,冻结后失效

其中时间区转换问题最隐蔽。Dart侧传给我的是毫秒时间戳,原生侧我用new Date(timestamp)取本地时间,这在大多数设备上没问题。但如果设备时区设置的是非东八区,且你在测试时用了UTC时间的服务器时间戳,就会提前或延后触发。解决方案很粗暴:时间戳在Dart侧统一用DateTime.now().millisecondsSinceEpoch生成,原生侧一律转本地时间,不要在链路中间做时区换算。

4.2 应用被冻结后提醒还能响吗?

很多开发者被Android的进程存活问题搞怕了,做OpenHarmony提醒时本能地担心:应用被用户从后台划掉,提醒是不是就没了?

答案是分情况。系统级提醒(ReminderAgentManager)是独立于应用进程运行的,进程被冻结不影响它在到点时刻弹出通知。但有一种例外:如果你没有使用系统级提醒,而是自己用Flutter侧的Timer实现了到点弹通知,那应用进程被冻结后定时器就失效了。所以我在方案选型环节才那么坚持用系统级能力。

但系统级提醒也有边界。它依赖系统服务正常运行,如果设备重启后提醒数据没有持久化,部分设备会丢失未触发的提醒。为了兜底,我在MainAbility冷启动时执行了一次“过期提醒重新注册”:

Future<void> _rescheduleMissedReminders() async { final reminders = await ReminderRepository.loadPendingReminders(); for (final reminder in reminders) { if (reminder.remindAt.isBefore(DateTime.now())) { // 已经过期但未完成,补发一次通知 await ReminderService.scheduleReminder( id: reminder.id, timestamp: reminder.remindAt.millisecondsSinceEpoch, title: _typeTitle(reminder.type), content: reminder.note, ); } } }

这段兜底逻辑有一层额外的产品考虑:如果用户好几周没打开App,冷启动时补发所有过期提醒会把用户淹没在通知堆里。我最后实现的策略是只补发最近24小时内的过期提醒,超过24小时的就自动标记为“错过不补”,用户可以在记录页里看到历史状态。这个策略可以根据产品需求调,但一定要有,否则就是体验事故。

4.3 提醒重复弹窗:修改时间后旧提醒没清干净

前面提过,修改提醒必须先取消旧的再创建新的。但有些场景下用户并不走“修改”入口:比如直接把某条提醒删除,或者在数据库层面手动清了记录。这些操作如果没联动调用cancelReminder,系统侧就会残留一个“幽灵提醒”,它依然会到点弹出,点进去却是无效记录。

我的解决思路是在几个业务动作里统一维护一个“原生提醒生命周期”:

  • 创建提醒 → 发布到系统,记录externalReminderId
  • 修改提醒 → 按externalReminderId取消旧的,再发布新的
  • 删除提醒 → 按externalReminderId取消
  • 清空购买记录 → 批量取消该记录下的所有提醒
Future<void> deleteReminder(Reminder reminder) async { if (reminder.externalReminderId != null) { await ReminderService.cancelReminder(reminder.externalReminderId!); } await ReminderRepository.delete(reminder.id); }

同时我会在发布新提醒前,先根据业务提醒ID查询一次系统状态。如果系统侧已经存在一个同ID的提醒,就先把旧的取消掉。这个“先清理再写入”的惯性动作,可以挡掉大部分重复弹窗问题。

4.4 测试提醒功能的建议方法

提醒功能的最大特点是时间敏感,直接测试会让人干等。我的测试方法是分三层推进:

第一层,用5分钟后的时间做冒烟测试。每次开发版本里支持填写倒计时分钟数而非绝对时间,这样调试时设置1分钟就能快速验证链路通不通。

第二层,用假时间戳测试边界。在原生侧写一个Debug模式入口,直接喂一个过去的时间戳,验证系统返回的是“非法参数”错误还是正常发布。如果你发现过去的时间戳还能发布成功,说明参数校验没做好,要及时加逻辑拦截。

第三层,真机关机重启验证。因为ReminderAgentManager在不同设备上的持久化策略有差异,关机重启是暴露丢失问题的最佳手段。我会在重启后检查数据库里的externalReminderId对应的系统提醒是否还在,如果不在,就要触发兜底补发逻辑。

使用模拟器测试时要注意,模拟器上系统级提醒的弹出方式和真机有区别,偶尔会延迟甚至不显示,别基于模拟器结论直接上线,真机测过才算数。

5. 从Demo到可用产品:扩展方向与个人体会

5.1 提醒模块还能怎么扩展

当前实现已经把基础提醒跑通,但它还有明显的演进空间。我列几个实际产品中大概率会遇到的扩展需求:

  • 多端同步:购买记录如果以后要做云同步,提醒作为一个时间敏感数据,跨设备同步时需要处理时区冲突和重复提醒去重。最简单的做法是服务端下发提醒计划,客户端只负责展示和执行。
  • 日历联动:把提醒接入系统日历,用户在系统日历中也能看到家具相关的到货和安装计划。这需要从ReminderAgentManager切到日历提醒类型,同时要处理用户手动在日历里删改提醒的回传。
  • 智能推荐提醒时间:根据商家发货周期、物流路程、安装预约窗口,自动计算最佳提醒时间,而不是让用户自己选。这属于业务侧的算法接入,但提醒调度层已经把端口留好了。
  • 批量录入:很多用户一次性买多件家具,如果每件都要单独设置提醒,操作成本很高。可以设计一个“批量设置提醒”的交互,一次性给多个购买记录设置统一的提醒模板。

这些扩展方向都不需要推翻现有通道协议,只需要在方法签名里增加可选参数,或者在业务层增加调度逻辑。这正是我当时花时间规范通道协议的原因,它带来的长期收益值得投入。

5.2 我在这个项目里真正学到的三件事

说实话,这个项目功能量不大,但它让我把Flutter跨端开发和OpenHarmony系统能力的关系理清了。第一件事是:在使用类似ReminderAgentManager这种系统能力前,一定要先确认平台的权限模型和API行为。我在开发中期才发现PUBLISH_AGENT_REMINDER权限级别和普通权限不同,导致前期调试时走了一些弯路。如果一开始就认真读权限文档,能省下至少半天时间。

第二件事是:平台通道的设计要像定义接口一样严谨。Flutter侧和原生侧各写各的,如果不从一开始约定好通道名、方法名、参数类型、返回值类型,联调时就会四处对不上。我自己吃过亏,后来在工程里加了一份CHANNEL_PROTOCOL.md文档,把每个方法的手写签名维护起来,协作效率明显提升。

第三件事是:提醒功能要当作“可靠性功能”来做,而不是普通页面功能。普通页面功能崩了用户刷新一下就好,提醒错一次可能直接导致用户错过一个重要安排。所以在兜底逻辑、异常处理、状态补偿上投入的精力,再怎么多都不为过。

最后分享一个我踩过的坑:开发阶段我在模拟器上测试,模拟器的时间有时会和应用层的时间戳不一致,导致提醒提前触发。上真机后发现一切正常,但当时为了排查这个问题,我把日志翻了好几遍。后来我养成了习惯,所有时间相关功能都以真机为准,模拟器只用来跑正常流程,不用来验证边界时间。

这个项目目前的版本已经能满足日常家具采购管理的需求。后续我计划把提醒类型再细分,加入“质保到期前30天自动提醒”这类规则型提醒,让它从一个工具App逐步变成一个更懂用户的家居管理助手。如果你正在做Flutter与OpenHarmony结合的项目,希望这篇分享能帮你省下一些调试时间。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进