
从零把一个Flutter应用跑在OpenHarmony上还要做一套能用的提醒功能这中间的路比想象中长但走通了之后你会发现一切其实都是值得的。这篇文章就围绕我在口腔护理类App“刷牙打卡助手”里落地提醒设置模块的完整过程把选型思路、功能拆解、状态管理、MethodChannel原生通道调用以及适配OpenHarmony时踩过的一堆坑原原本本记录下来。1. 立项选型为什么非要用Flutter去碰OpenHarmony1.1 一个让人纠结的起点2024年之前我手里有几个现成的移动端Flutter项目产品形态大多是工具类、提醒类的小应用。当OpenHarmony的商用设备开始多起来管理层的第一反应就是“这也做一个鸿蒙版”。问题在于原生的ArkTS/ArkUI团队当时只排得出来两个人而产品线同时要维护iOS、Android和小程序三套端再来一套原生鸿蒙人力直接崩盘。这时候做技术选型基本就是三条路路线说明我评估下来的感受纯ArkTS重写用OpenHarmony原生语言和框架工期最长组件生态最缺看着最正统但投入最大WebView套壳用H5包一层快是快但交互和系统能力调度受限提醒这种高频功能体验很差Flutter适配分支复用现有Flutter工程跑在OpenHarmony上团队现有技能直接复用社区已经有人趟过路最符合我们“少干活多办事”的诉求最后我选了Flutter而且这个选择在“口腔护理提醒设置”的场景里耐受力相当强。因为这类应用的UI复杂度高自定义控件多刷牙倒计时环形进度、日历打卡表、时间选择器Flutter的自绘渲染优势正好能用上而提醒设置这种系统级能力又可以通过MethodChannel打进OpenHarmony的原生侧两边互补不会有明显的短板。1.2 OpenHarmony上跑Flutter到底靠不靠谱很多人一听到“OpenHarmony上的Flutter”第一反应是“这是不是拿模拟器凑合”。实际上OpenHarmony社区早就在维护一个适配分支仓库在社区的flutter_flutter仓下你可以理解为“Flutter框架针对OpenHarmony的设备能力做了桥接”。基于这个分支你可以用标准的Dart写业务逻辑用Flutter的widget树搭页面最后通过OpenHarmony的构建工具链打包出hap文件跑在开发板和商用设备上。不过必须说清楚它不是100%无缝的。我当时的版本组合是Flutter SDK使用OpenHarmony官方适配分支OpenHarmony SDKAPI 10及以上的版本开发工具DevEco Studio配合flutter命令混合使用该分支对标准Flutter框架的大部分渲染能力已经收敛得比较一致但涉及原生插件像flutter_local_notifications这种典型的pub包就基本没法指望。这意味着提醒功能必须要自己写平台通道。1.3 口腔护理App为什么必须带着提醒模块口腔护理这类工具应用说白了就是跟用户养成习惯打交道每天早晚刷牙、用牙线、漱口水、定期换牙刷。如果最后用户只能靠自觉那产品三天就会被打进冷宫。提醒功能解决的是“用户想起来要用你”的问题它虽然不是业务核心却是留存的核心。我的App里提醒模块拆成了三大块固定时间提醒比如“每天早上07:30刷牙”、“晚上22:00用牙线”依赖系统级定时调度。周期策略提醒每隔6小时提醒一次换牙线或者每周日提醒换牙刷头。智能触发提醒用户手动记录一次刷牙后页面直接引导设置下一回提醒形成一个使用闭环。这些场景全部围绕“提醒设置”这个关键词展开所以项目标题里的“实战提醒设置实现”真正落地的就是这三块。2. 功能拆解与数据模型提醒不是存一个时间那么简单2.1 先把提醒的“实体”定义清楚我一开始犯过错误觉得提醒设置不就是“一个时间一段文字”嘛。真做起用户调研和数据埋点才发现单条提醒至少要满足以下几个需求每天固定时间、每周固定几天、还是每隔几小时周期模型要能支撑提醒内容里最好带上“时长指令”比如“请完成3分钟刷牙”用户需要随时开关某一条提醒而不是只能删除重建如果有多条提醒首页要展示“下一条提醒是什么时候”。于是提醒实体在Dart侧被我定义成下面这样class ReminderItem { final String id; // 唯一ID用于取消、更新 final String title; // 标题如“晚间刷牙” final String content; // 提示内容如“记得刷满3分钟” final int hour; // 时 final int minute; // 分 final Listint repeatDays; // 重复的星期如[1,3,5]空数组每天 final bool enabled; // 是否开启 final ReminderType type; // 枚举固定、周期、智能 ReminderItem({ required this.id, required this.title, required this.content, this.hour 8, this.minute 0, this.repeatDays const [], this.enabled true, this.type ReminderType.fixed, }); MapString, dynamic toJson() { id: id, title: title, content: content, hour: hour, minute: minute, repeatDays: repeatDays, enabled: enabled, type: type.name, }; factory ReminderItem.fromJson(MapString, dynamic json) ReminderItem( id: json[id] as String, title: json[title] as String, content: json[content] as String, hour: json[hour] as int, minute: json[minute] as int, repeatDays: (json[repeatDays] as Listdynamic).castint(), enabled: json[enabled] as bool, type: ReminderType.values.firstWhere( (e) e.name json[type], orElse: () ReminderType.fixed, ), ); }这种设计最重要的地方是把业务对象和系统调度对象分开。设置页操作的是上面这个ReminderItem通知调度层只关心“给我ID和时间规则”。否则后面做时间段修改公测时日志会乱成一锅粥。2.2 持久化为什么不能指望shared_preferences存一切Flutter里最常见的本地持久化方案是shared_preferences适合存开关量、JSON字符串这类轻量数据。提醒列表在数据量不大时直接用JSON字符串存没毛病但要增加两个保险必须做版本字段防止未来schema升级之后旧数据解析失败写盘时机不能依赖UI生命周期应该在Provider里每次变更后立即异步写入同时要防抖否则开关切换频繁时会有磁盘IO抖动。我的实际做法是在Provider里维护一个single-thread的序列化写入队列用了async锁确保最后写入的一定是最新状态。这个细节如果不注意容易出现重启后设置丢回旧状态的诡异问题。2.3 时间与周期计算下一次触发时间才是基本功在做周期触发时如果不能直接依赖系统级“按星期几重复”就要自己在Dart侧算“下一个符合条件的时间戳”。我的工具类长这样DateTime calculateNextTrigger({ required int hour, required int minute, required Listint repeatDays, }) { final now DateTime.now(); var candidate DateTime(now.year, now.month, now.day, hour, minute); final todayWeekday now.weekday; // 先把今天对应的提醒时间算出来如果已经过了就推到明天 if (!candidate.isAfter(now)) { candidate candidate.add(const Duration(days: 1)); } // 如果是每天提醒repeatDays为空直接返回候选时间 if (repeatDays.isEmpty) return candidate; // 遍历未来最多14天找到第一个匹配的星期几 for (var i 0; i 14; i) { final day candidate.add(Duration(days: i)); if (repeatDays.contains(day.weekday)) { return day; } } // 兜底找不到就默认明天 return DateTime(now.year, now.month, now.day, hour, minute) .add(const Duration(days: 1)); }这个函数我放在原生通知通道之前主要作用有两个一是给UI层展示“下条提醒时间”二是当系统那边只能设置“下一次触发时间”而不是“周期规则”时我们就反复计算并重新注册。3. 提醒设置页面实现从UI到状态管理的完整链路3.1 页面结构如何设计一块用户不会反感的设置面板提醒设置页的UI结构我拆成了四个区域依次是基本信息、时间选择、重复周期、总开关。这四个区域从上到下从“要说什么”到“什么时候说”逻辑上是顺的。时间选择部分直接用Flutter的showTimePicker不自己重复造轮子。但有一件事必须做把TimePicker的确认按处理成中文文案并且把返回结果直接聚合到页面ViewModel里而不是让页面自己存临时变量。Futurevoid _pickTime(BuildContext context) async { final picked await showTimePicker( context: context, initialTime: TimeOfDay( hour: _item.hour, minute: _item.minute, ), builder: (context, child) { return MediaQuery( data: MediaQuery.of(context).copyWith(alwaysUse24HourFormat: true), child: child!, ); }, ); if (picked ! null) { _viewModel.updateTime(picked.hour, picked.minute); } }重复周期这里我放弃了复杂的“七选多”横条改成了三个默认组合每天、工作日、自定义。自定义弹一个BottomSheet里面用Wrap生成一排Chip。这个交互收敛之后后续的“重复规则文案”生成也好做了——用户一眼就能看懂“周一、周三、周五”这种描述。3.2 Provider状态管理设定一个全局但不过度的状态整个App里需要跨页面共享的状态其实只有两个提醒列表、当前用户的刷牙记录。所以我没有引入Riverpod或者Bloc那种重量级方案就用flutter_riverpod里的ChangeNotifierProvider足够。为什么不用setState因为“设置页保存提醒”这个动作需要首页的提醒卡片即时刷新、打卡页的智能提醒同步感知这两个页面不在同一个路由上setState根本管不到。我封装了一个ReminderProvider它是全App唯一的提醒数据源class ReminderProvider extends ChangeNotifier { ListReminderItem _reminders []; bool _initialized false; ListReminderItem get reminders List.unmodifiable(_reminders.where((e) e.enabled)); Futurevoid load() async { if (_initialized) return; // 从本地存储恢复 _initialized true; notifyListeners(); } Futurevoid saveReminder(ReminderItem item) async { final index _reminders.indexWhere((e) e.id item.id); if (index 0) { _reminders[index] item; } else { _reminders.add(item); } notifyListeners(); // 写入本地 } Futurevoid toggleReminder(String id, bool enabled) async { final index _reminders.indexWhere((e) e.id id); if (index -1) return; _reminders[index] _reminders[index].copyWith(enabled: enabled); notifyListeners(); // 同步到系统通知通道 } }3.3 组件通信设置页保存后首页为什么能自动变讲到组件通信我顺便把Flutter里几种常见方案的选型逻辑也放在这里因为很多人在“提醒设置”这种多页面联动场景里会纠结。设置页保存提醒之后首页要展示“下一支提醒今天 22:00 刷牙”。这个数据流如果靠路由返回时传递参数很生硬因为用户可能从首页进设置页、保存后继续改第二条这时首页也得跟着变。我的做法是首页用一个Consumer监听ReminderProvider保存操作完成后Provider调用notifyListeners()首页的Consumer自动重建“下一条提醒时间”的计算不放在build里避免每帧都做DateTime运算而是也放进Provider里缓存。final nextReminder ref.watch(reminderProvider).nextTrigger();Provider内部这样计算并缓存DateTime? _cachedNextTrigger; DateTime? _cacheBasedOn; DateTime? nextTrigger() { final now DateTime.now(); if (_cacheBasedOn ! null _cacheBasedOn!.year now.year _cacheBasedOn!.month now.month _cacheBasedOn!.day now.day _cacheBasedOn!.hour now.hour) { return _cachedNextTrigger; } ReminderItem? nearest; DateTime? nearestTime; for (final item in _reminders.where((e) e.enabled)) { final triggerTime calculateNextTrigger( hour: item.hour, minute: item.minute, repeatDays: item.repeatDays, ); if (nearestTime null || triggerTime.isBefore(nearestTime)) { nearestTime triggerTime; nearest item; } } _cachedNextTrigger nearestTime; _cacheBasedOn now; return nearestTime; }还有一个小技巧Provider里不直接暴露_cachedNextTrigger这些字段而是把整个DTO封装成一块这样设置页和首页的代码永远只跟“业务”打交道不直接操作内部的缓存字段。4. 原生侧通知能力接入MethodChannel打通Flutter与OpenHarmony4.1 为什么不能直接使用Flutter的通知插件如果你的目标平台只有Android和iOSflutter_local_notifications完全可以覆盖提醒功能。但OpenHarmony不支持Android的NotificationManager方案也不能用GMS推送通道。在OpenHarmony生态里系统级定时提醒的官方能力是挂载在reminderAgentManager相关的API上的。Flutter侧可以直接用MethodChannel把这个能力包一层做成平台通道供Dart调用。这一步是绕不开的。4.2 Dart侧封装一个ReminderChannel我在lib/services目录下建了一个reminder_channel.dart把所有原生调用收敛到一个文件避免到处散着字符串方法名。方法名拼错导致运行时报MissingPluginException这种事越分散越难查。import package:flutter/services.dart; class ReminderChannel { static const MethodChannel _channel MethodChannel(com.oralcare.reminder); static Futureint schedule(ReminderItem item) async { final id item.id.hashCode; await _channel.invokeMethod(scheduleReminder, { id: id, title: item.title, content: item.content, hour: item.hour, minute: item.minute, repeatDays: item.repeatDays, nextTrigger: calculateNextTrigger( hour: item.hour, minute: item.minute, repeatDays: item.repeatDays, ).millisecondsSinceEpoch, }); return id; } static Futurevoid cancel(int reminderId) async { await _channel.invokeMethod(cancelReminder, {id: reminderId}); } }这里我建议用item.id.hashCode做原生日历ID简单直接但要注意Dart的String.hashCode在跨进程时不能保证长期稳定正式项目里建议自己维护一个从0自增的ID表存到本地。我上线初期就遇到过一次ID冲突。4.3 OpenHarmony侧ArkTS实现基础版提醒调度OpenHarmony侧需要在工程里处理MethodChannel的调用。以ArkTS为例模块导入的路径和API命名在不同版本上有些出入我当时用的方式是查了当前SDK对应的接口文档后再改的。整体逻辑骨架如下import reminderAgentManager from ohos.reminderAgentManager; import wantAgent from ohos.wantAgent; export function registerReminder(event: any): number { const param { reminderType: reminderAgentManager.ReminderType.REMINDER_TYPE_TIMER, title: event.title, content: event.content, dateTime: new Date(event.nextTrigger), repeatDays: event.repeatDays, wantAgent: { pkgName: com.example.oralcare, abilityName: EntryAbility, parameters: { reminderId: event.id } }, ringDuration: 10, notificationId: event.id, slotType: reminderAgentManager.SlotType.SOCIAL_COMMUNICATION }; const reminderId reminderAgentManager.publishReminder(param); return reminderId; }需要单独说明的是wantAgent的作用是在用户点击通知时拉起App的指定Ability。这里我把reminderId放在parameters里传回去App冷启动后就能通过它定位到具体的ReminderItem并直接跳到打卡页面。这套方案能跑通关键在于“提醒的调度责任在系统侧”Flutter被杀了、Dart的isolate挂了提醒依然会响。这比在Flutter里挂一个Timer可靠得多。4.4 周期重复的两种策略我为什么选择了混合方式在OpenHarmony里ReminderRequestTimer其实支持按重复策略设置但不同版本的SDK对repeatDays字段的约束不一样。我在开发板实测发现有的版本可以传[1,3,5]代表周一、周三、周五重复有的版本只能把它当成长整型位掩码。为了不踩版本差异我最终选择了一种“双保险”策略首次创建时调用原生publishReminder注册一次下一次执行的提醒在Dart侧定期检查当前时间如果发现“下一次已过”且目标周期未结束则自动重新schedule。也就是原生侧只保证“那一个时间点”一定响剩下的周期循环由Flutter业务侧根据状态更新。这样即便系统API的周期规则有出入也不会把用户绑死在一套接口版本上。代价是App必须至少比“下一次触发时间”更早地跑起来一次但对于口腔护理这种每天都会至少打开一次的工具类应用现实影响很小。4.5 权限与时效通知权限不是一次申请就完事OpenHarmony上通知权限的行为跟Android有区别它对“通知是否允许打扰”有细分包括铃声、震动、锁屏展示。在设置页测试时我发现一处坑如果用户关掉了系统通知权限应用内设置页的开关依然可以改但保存后 никак不会弹通知。这样用户体验就很割裂。所以我在进入提醒设置页时增加了一个权限状态检查final status await ReminderChannel.checkPermission(); if (status denied) { await ReminderChannel.requestPermission(); }原生侧返回的权限状态只要不是granted设置页顶部就会显示一条代理解释文案并提供“去系统设置”按钮。这种交互上的细节比代码逻辑本身更容易被用户感知值得多花力气。4.6 通知点击跳转冷启动和热启动要分开处理通知点击后要跳转到具体页面这里最难的是冷启动场景。当用户从通知栏点击进入App时Flutter引擎还没起来MethodChannel的返回值不是即时能收到的。我最终的处理方式是原生侧在冷启动的Ability生命周期里把parameters存到本地JSON文件Flutter首帧渲染完成后读取该文件并跳转最多清理一次避免每次启动都弹残留事件。跳转逻辑放在路由初始化之后要确保根页面已挂载Futurevoid handlePendingReminderTap() async { final pending await ReminderChannel.consumePendingTap(); if (pending ! null) { router.push(/brush, arguments: {reminderId: pending}); } }5. 适配OpenHarmony踩过的坑与实测结果5.1 构建环境与版本匹配一半的报错来自版本错位我最初直接在标准Flutter环境下用openHarmony SDK编译第一轮就遇到一堆“undefined symbol”、“could not resolve dependency”之类的报错。排查半天才发现问题根源很朴素Flutter SDK必须用社区适配分支不能用Google主仓的版本。这两者在引擎层的接入点不同混用任何一方都会在构建期或运行期炸开。建议统一使用OpenHarmony推荐的那条SDK分支并配合DevEco Studio的工程向导生成hap壳工程再回头把Dart代码放进来。如果你直接从现有移动端Flutter项目拷过来android/和ios/目录能删就删避免交叉引用出问题。5.2 权限声明不声明等于没有OpenHarmony的权限体系虽然耳熟能详但新手最容易漏掉的是在module.json5里单独声明通知权限。这个声明文件和AndroidManifest里加权限很像但字段名不同。我第一次打包后安装、点击提醒开关发现原生调用总是返回“没有权限”跟权限声明对不上。后来到OpenHarmony开发者文档里查到对应权限名称在模块配置里加上之后才好。5.3 真机与模拟器提醒只能真机验证DevEco Studio自带模拟器对Flutter渲染的支持不错但提醒调度依赖系统的reminderAgentManager这个能力在模拟器上要么缺失、要么时间不准。我的建议是准备一台开发板或者真机专门用来验证通知是否按时弹出。如果实在没有真机至少要验证到“原生方法被调用、没有抛异常”这一层响不响只能等真机了。5.4 电量优化与应用冻结后台进程不是你想活多久就活多久OpenHarmony设备为了续航会有后台应用冻结机制。如果你只在Flutter侧写一个Timer做提醒触发App被冻结后Timer立刻哑火。这也是系统级通知调度不可替代的根本原因。实测数据上说系统级提醒在低电量模式下的表现依然稳定而依赖Dart内Timer的方案在后台冻结情况下大约有30%的概率不触发。这30%不是小数字对“提醒类应用”来说一次不触发就可能丢掉一个日活。5.5 运行期MethodChannel的线程问题原生侧收到invokeMethod后如果用异步回调去处理耗时操作可能会导致Channel返回值在Flutter侧拿不到。我当时在做提醒调度时把publishReminder放在Promise回调里结果Flutter侧一直等不到返回值。解决方案是原生侧同步返回reminderId把耗时操作挪到publishReminder的回调里再处理不在Channel的返回值上等待。这个方法在OpenHarmony API 10/11上验证可用。写在最后的个人体会这套“Flutter OpenHarmony 提醒设置”的链路走通之后我最大的体会是跨端框架的适配难点永远不在UI而在系统能力的对齐上。Flutter解决的是“界面一致”而提醒功能这种和操作系统强关联的能力必须要学会跟原生侧协作。如果有朋友也想做类似的口腔护理应用我的建议是先从“单条提醒的完整闭环”入手设置、保存、触发、点击跳转、再次设置。这一条链路通了后面扩展多提醒、周期策略、智能推荐都只是在这个骨架上加枝叶而已。希望这篇文章能帮你少踩点坑剩下的就交给时间了。