如果你手里刚好有一台OpenHarmony开发板,想用Flutter给它写点实用工具,那喝水提醒这个项目绝对是个不错的起点。我最初的想法很简单:手机上的提醒类App不能直接装到开源鸿蒙设备上,原生开发又得重新学ArkTS,干脆走Flutter跨端路子硬啃。折腾了两周,把喝水提醒完整跑通了——从Flutter侧定时调度,到鸿蒙系统的本地通知,再到App被系统回收之后提醒依然能响。整个过程踩了不少坑,也把Flutter和OpenHarmony之间的通信机制摸了一遍。这篇文章把核心实现思路、关键代码和实际测试结果都记录下来,适合想在OpenHarmony上用Flutter做生活助手、工具类应用的开发者参考。
1. 为什么选Flutter写OpenHarmony应用,以及能写到什么程度
1.1 一套代码跑多端的真实收益
生活助手类App往往不是只在手机上用。我手头这台设备的场景是:OpenHarmony开发板放工位上,旁边偶尔用旧Android手机盯一下数据,回家还想在桌面上开个窗口看统计。如果三端各写一套逻辑,光维护成本就够呛。
Flutter正好把这块吃下来了。它的UI是自己渲染的,不依赖系统原生控件,所以同一套界面在OpenHarmony、Android、Windows上呈现出来的效果基本一致。Dart语言写业务逻辑也很顺手,依赖管理一个pubspec.yaml搞定,不用像原生工程那样为每个平台维护一堆配置文件。
关键是团队技能复用。你如果已经有一批熟悉Flutter的开发者,不需要让他们重新学ArkTS和ArkUI,就能在OpenHarmony上出可用的产品。对我这种个人开发者来说,这套路节省的时间尤其明显。
1.2 Flutter和OpenHarmony原生各管一摊
不过别指望Flutter能包办一切。通知、传感器、蓝牙这类系统能力,最终还是得靠鸿蒙侧的原生能力配合。Flutter负责的是业务编排和UI展示,真正发通知的动作要通过平台通道交给鸿蒙侧完成。
我当时把边界划得很清楚:UI层、状态管理、提醒调度逻辑全放Flutter侧;调用系统通知服务、注册后台代理提醒这一类动作,全部下沉到鸿蒙侧通过MethodChannel暴露给Flutter。这个分层想明白之后,项目结构一下就清晰了,后面加功能也顺手。
1.3 社区支持现状:能做什么,不能做什么
Flutter for OpenHarmony目前主要是社区维护,不是官方主推,这一点要有心理准备。好在我实际用下来,工具类应用完全够用:页面跳转、状态管理、数据存储、平台通道,这些基础能力都跑得稳。社区里的flutter_ohos版本也在持续更新,常用的第三方库大部分能直接编译。
不适合的场景也有,比如重度系统集成、底层渲染性能要求极高的游戏、复杂的原生地图交互,这些在OpenHarmony上的Flutter适配还比较吃紧,不如直接写原生。做生活助手类应用,Flutter完全在舒适区里。
2. 先把手弄脏:工程配置里最容易翻车的权限与通知渠道
2.1 通知权限的声明
很多人第一次在OpenHarmony上集成通知,上来就调接口,然后发现通知死活不弹。原因多半是权限和通知设置没有处理好。在当前我使用的SDK版本中,发布通知一般需要在module.json5里声明相关权限,同时还要保证用户在系统设置里给应用打开了通知开关。
权限配置的示意写法长这样:
{ "module": { "requestPermissions": [ { "name": "ohos.permission.NOTIFICATION_CONTROLLER" } ] } }这里要特别提醒:不同API版本的权限名称和授权方式有差异,有些版本已经把通知权限改成运行时申请了。你开发的时候先查一下目标设备对应的SDK文档,别照抄我的配置。另外,就算权限声明了,用户没在设置里打开通知开关,通知一样不显示,这部分要做引导,后面排错章节我会详细讲。
2.2 通知渠道不是可选项
OpenHarmony的通知机制和Android 8.0之后很像,通知必须要绑定渠道。渠道的作用是让用户能单独控制某类通知的开关、声音和震动。喝水提醒如果和别的通知混在一起,用户在设置里没法定制,体验大打折扣。
我建的通知渠道很简单:
- 渠道ID:water_reminder
- 渠道名称:喝水提醒
- 渠道描述:定时喝水提醒和今日饮水总结
后面发布通知的时候,请求里带上这个渠道ID。用户可以在系统设置里单独关掉喝水提醒的声音,但不会误伤其他通知。这个体验细节在做生活助手类App时一定要保留。
2.3 环境版本匹配:先把底打好
Flutter for OpenHarmony的适配和SDK版本强相关。我用的是社区维护的Flutter 3.44版本SDK,OpenHarmony侧的API版本对应API 12左右。版本对不上,轻则编译警告,重则平台通道直接连不上。
我踩过一次坑:一开始手滑装了旧版本的Flutter SDK,结果鸿蒙侧的MethodChannel注册接口和Flutter侧对不上,查了半天发现是新旧版本接口签名变了。建议开工前先把下面这个对应关系确认好:
| 组件 | 版本 | 说明 |
|---|---|---|
| Flutter SDK | 3.44+(社区ohos版本) | 以你下载的SDK发布说明为准 |
| OpenHarmony API | 10/11/12均可,推荐12 | DevEco Studio中配置 |
| 开发工具 | DevEco Studio 5.x | 鸿蒙侧工程管理 |
| 依赖管理 | pub + ohpm | Flutter侧用pub,鸿蒙侧用ohpm |
版本确认这一步没什么技术含量,但它决定了后面所有代码能不能跑起来,值得花十分钟提前查清楚。
3. 喝水提醒的核心调度设计:轮询校准与后台代理提醒
3.1 纯Timer为什么不行
第一版我图省事,直接用Flutter的Timer.periodic每分钟触发一次,到点就弹通知。结果一测就露馅了:App退到后台几分钟,Timer还能跑;时间长了之后,进程一旦被系统回收,Dart isolate整个消失,定时器也就不存在了。
就算进程没被杀,Dart的Timer在系统休眠状态下也不会准点触发,它不保证实时性,只是把回调放到事件循环里等待执行。App在后台被系统冻结的情况下,Timer回调能拖多久完全看系统脸色。
所以我的结论很明确:Flutter侧的Timer只能做辅助性的状态校准,不能作为提醒的兜底机制。
3.2 我的双层调度方案
最终的调度设计分两层:
第一层是Flutter侧的分钟级轮询校准。App活着的时候,每分钟检查一次当前时间,判断是否到了计划的提醒时间,到了就立即触发通知,并更新下一次提醒时间。这一层解决的是"计划和实际执行之间的偏差",也会在用户打开App时马上同步一次状态。
核心逻辑大概长这样:
Timer.periodic(const Duration(minutes: 1), (timer) async { final now = DateTime.now(); final settings = await ReminderSettings.load(); final nextReminder = settings.nextReminderTime(now); if (now.isAfter(nextReminder)) { await _scheduler.showNotification( title: '喝水时间到', content: '距离上次喝水已过去 ${settings.intervalMinutes} 分钟', ); settings.updateNextReminder( now.add(Duration(minutes: settings.intervalMinutes)), ); await settings.save(); } });第二层才是兜底:使用OpenHarmony的Reminder Agent Kit注册定时提醒。即使App进程被系统回收,到点时系统自己会弹出提醒,完全不需要App活着。这一层才是喝水提醒在锁屏状态下依然能响的真正保障。
3.3 后台代理提醒的注册
OpenHarmony的reminderAgentManager专门负责这种定时提醒场景。我通过Mate的纯Flutter写法是调用不了这个能力的,所以还是老规矩:Flutter侧发起注册请求,鸿蒙侧负责真正的API调用。
鸿蒙侧的注册逻辑用ArkTS写,核心代码是这样的:
import { reminderAgentManager } from '@kit.ReminderAgentKit'; function publishWaterReminder(hour: number, minute: number) { const reminder: reminderAgentManager.ReminderRequestAlarm = { reminderType: reminderAgentManager.ReminderType.REMINDER_TYPE_ALARM, hour: hour, minute: minute, title: '喝水时间到', content: '该喝水啦,起来活动一下', notificationId: 1024, slotType: reminderAgentManager.SlotType.SOCIAL_COMMUNICATION, }; reminderAgentManager.publishReminder(reminder).then((reminderId) => { console.info(`喝水提醒已注册,reminderId: ${reminderId}`); }); }这里提醒一下,slotType要选对,它决定了提醒的优先级和声音策略。SOCIAL_COMMUNICATION适合社交沟通类提醒,比普通通知的打扰级别高一些,喝水提醒用这个比较合适。你要是做用药提醒,级别甚至可以更高。
3.4 今日状态记录
喝水提醒不能只弹通知,还得记录用户今天已经喝了多少杯,设置页面里要能调整间隔。我用的是shared_preferences做轻量持久化,字段就三个:今日已记录杯数、上次提醒时间、提醒间隔分钟数。量不大,没必要上数据库,存个JSON就行。
提醒间隔第一次可以默认60分钟,用户可以在设置页改成40或90分钟。改完之后把下次提醒时间重新计算一遍,保证改动立刻生效。
4. 让Flutter和鸿蒙通知服务握手:MethodChannel与EventChannel的全过程
4.1 平台通道的基本结构
Flutter和鸿蒙侧通信主要靠平台通道。通道本身是个字符串名字,两端保持一致就行,剩下就是按约定好的方法名和参数传数据。
通道名建议做成域名倒写,避免冲突:com.example.reminder/notification。方法名我定义了showNotification和publishWaterReminder两个,一个用于立即通知,一个用于注册后台代理提醒。参数用Map传递,Flutter侧会自动序列化成鸿蒙侧能读的对象。
4.2 Flutter侧调用端代码
Dart侧封装一个调度器,所有平台通道调用都收敛在这个类里,别把MethodChannel散落在页面各处:
class ReminderScheduler { static const _channel = MethodChannel('com.example.reminder/notification'); Future<bool> showNotification({ required String title, required String content, int delaySeconds = 0, }) async { try { final result = await _channel.invokeMethod<bool>('showNotification', { 'title': title, 'content': content, 'delaySeconds': delaySeconds, 'channelId': 'water_reminder', }); return result ?? false; } on PlatformException catch (e) { debugPrint('通知调用失败: ${e.message}'); return false; } } Future<int?> registerReminder({ required int hour, required int minute, }) async { try { final id = await _channel.invokeMethod<int>('publishWaterReminder', { 'hour': hour, 'minute': minute, }); return id; } on PlatformException catch (e) { debugPrint('后台提醒注册失败: ${e.message}'); return null; } } }注意invokeMethod返回值的类型:如果你在鸿蒙侧返回的是整数,Dart侧要对应接收int;返回布尔就对应bool。类型对不上会直接抛类型转换异常,这个我在开发中遇到过,排查起来还挺费劲的。
4.3 鸿蒙侧注册通道与发布通知
鸿蒙侧的核心是在Ability的onConnect生命周期里注册MethodChannel。因为Flutter for OpenHarmony的工程模板和纯鸿蒙工程不太一样,入口类的位置可能不同,但思路都是一样的:拿到能力对象之后,调registerMethodChannel注册通道。
处理MethodChannel调用的示意代码如下:
import { MethodChannel } from '@kit.ArkTS'; import { notificationManager } from '@kit.NotificationKit'; methodChannel.onMethodCall((method, args) => { if (method === 'showNotification') { const content = args['content'] as string; const title = args['title'] as string; const channelId = args['channelId'] as string; const request: notificationManager.NotificationRequest = { id: Date.now(), content: { notificationContentType: notificationManager.ContentType.NOTIFICATION_CONTENT_BASIC_TEXT, normal: { title: title, text: content, }, }, notificationSlotType: notificationManager.SlotType.SOCIAL_COMMUNICATION, }; notificationManager.publish(request, (err) => { if (err) { console.error(`通知发布失败: ${JSON.stringify(err)}`); } }); return true; } });这里几个细节值得注意。一是request.id不能重复,我直接用时间戳做id,简单可靠;二是publish接口的回调一定要处理,不然发通知失败时日志里什么都看不到;三是发布通知时要带上渠道ID,否则系统会默认丢到未分类渠道里,用户想单独控制喝水提醒的声音就找不着。
4.4 EventChannel:什么时候需要它
MethodChannel是Flutter主动调用鸿蒙侧,典型的"请求-响应"模式。但生活助手类App里还有一种场景:鸿蒙侧持续往Flutter侧推数据,比如计步传感器每秒钟上报一次步数,这时候用MethodChannel就得反复拉取,效率低还容易卡UI。
EventChannel就是为这个设计的。鸿蒙侧作为事件源,Flutter侧注册监听。真要用的时候,在鸿蒙侧建立EventChannel并设置sendEvent回调,Flutter侧用receiveBroadcastStream()接收。这里提醒一个容易踩的坑:通道名必须全局唯一,如果MethodChannel和EventChannel用了同一个名字,注册的时候不会报错,但事件会互相污染,数据全串了。
4.5 排错经验:MethodChannel常见的三种翻车方式
我调试过程中最常见的错误有三种,排错顺序基本固定。
第一,通道名对不上。Flutter侧写的通道名和鸿蒙侧注册的名字哪怕差一个字符,调用就直接抛MissingPluginException。解决办法就是复制粘贴,不要手敲,确保两端完全一致。
第二,参数类型不匹配。Dart侧传的Map,到了鸿蒙侧不一定是同一种类型。我试过把布尔值true传过去,鸿蒙侧拿到的可能是个字符串"true",直接用的话逻辑会出错。调试时先打日志看类型,再写处理逻辑。
第三,通知不出现。这个大概率不是平台通道的问题,而是通知权限没开或者渠道ID不对。排查链路是:先确认权限声明有没有在module.json5里,再去系统设置里看应用的通知开关是不是关着,最后检查通知请求里的slotType和渠道ID是否合法。按这个顺序查,一般十分钟内能定位问题。
5. 切后台、锁屏、被杀死:三种场景下的提醒存活测试
5.1 测试矩阵:必须覆盖的四个场景
功能开发完,不能只在App开着的时候测一次就完事。我建了个简单的测试矩阵,覆盖生活场景里最常见的几个状态:
| 场景 | 操作 | 提醒表现 | 结论 |
|---|---|---|---|
| App前台运行 | 正常等待到点 | 准时弹出 | 基本盘,必须稳定 |
| 切到后台 | 按Home键回桌面 | 到点依然弹出 | Timer轮询兜底+后台代理提醒 |
| 锁屏 | 熄屏等待到点 | 锁屏界面显示提醒 | 后台代理提醒的功劳 |
| 杀死进程 | 从最近任务划掉App | 到点依然弹出 | 只有后台代理提醒能覆盖 |
| 设备重启 | 重启后等待到点 | 默认失效,需重新注册 | 需要业务侧做恢复处理 |
我实际测下来,前三个场景都能通过,第四个场景能不能响取决于你注册的是哪种提醒。如果用的是reminderAgentManager,到点系统会自己拉起提醒,App死不死都没关系。但如果只依赖Flutter侧Timer,进程被杀死后提醒就彻底没了。
5.2 设备重启后的恢复机制
有个坑必须单独说一下:设备重启后,之前通过reminderAgentManager注册的提醒可能会失效。这意味着用户重启了一次设备,喝水提醒就悄悄消失了,如果不重新注册,后面不会再响。
解决思路是在App启动时做一次自检:拉起App后,主动读取本地的提醒配置,把今天剩下的提醒全部重新注册一遍。同时注册一个系统事件监听,检测到开机事件后尽可能触发一次自检。不过说实话,开机自启在OpenHarmony上也有权限限制,不是想开就能开,实际做的时候要注意引导用户手动开启App一次来触发重注册。
5.3 实测观察与保活建议
在开发板上测下来,锁屏状态下提醒照常弹出,这一点让人很有信心。但我也发现,不同设备的后台策略差异很大。有些设备对第三方应用的后台限制很严格,App切后台几分钟就被回收;有些设备则宽松得多。
所以我的建议是:开发者能做的都做满——选对后台代理提醒API、引导用户开通知权限、在设置页里加上"提醒失效修复"按钮让用户主动重注册。剩下的就交给系统,不要试图去绕过系统的后台限制,那既不稳定,也不被允许。
6. 喝水提醒只是起点:生活助手功能扩展中的技术选型与坑
6.1 复用调度框架:久坐提醒和用药提醒
喝水提醒跑通之后,我第一时间意识到这套调度框架可以原样复用到其他提醒类功能上。久坐提醒、用药提醒,结构和喝水提醒几乎一模一样,只是提醒内容、间隔、图标不同。
我把提醒配置抽象成了一个模型:提醒名称、提醒内容、间隔分钟、生效时间段、是否启用。页面里配置好之后,统一走同一个调度器。加一个新提醒类型,只需要在设置页加一行配置项,不需要重写任何调度逻辑。
这个重构花了一个晚上,但后续加功能省下的时间远超这个数。做生活助手类App,这种"配置驱动"的思路越早落地越好。
6.2 状态管理和页面交互的取舍
生活助手类App页面不算多,但状态穿插频繁:今日饮水数据、提醒开关状态、健康建议文案,这些数据在多个页面之间共享。我用的是flutter_cubit,轻量又好理解,适合这种中小型App。Bloc太重了,Provider在团队协作时依赖全局注入,后期维护会有点绕。cubit把状态变化收敛在独立的Cubit类里,页面只负责订阅和展示,逻辑清晰,调试也方便。
页面交互上我遇到两个高频问题。一个是TabBar点击切换时,默认会有一段动画过渡,闪一下总感觉不够干脆。去掉动画的办法很简单,给TabController设置一个特别短的动画时长就解决了。另一个是Navigator切换页面后,之前页面的状态偶尔会丢失。这个一般是因为页面没有正确处理keepAlive,用AutomaticKeepAliveClientMixin包一层就能把状态保住。
6.3 平台视图与其他系统能力的接入
再往后扩展,你可能会碰到需要展示地图、摄像头预览这类原生控件的情况。Flutter侧的通用解法是PlatformView,通过视图类型ID把原生View嵌入Flutter页面。这个能力在OpenHarmony上也能用,但性能和绘制效率比纯Flutter控件差一截,非必要不用。
蓝牙和IoT设备控制是另一个热门方向。比如用App连ESP32做个智能水杯灯,Flutter侧负责UI和业务逻辑,蓝牙的扫描、连接、收发数据这些动作通过MethodChannel交给鸿蒙侧做。思路和前面通知模块完全一致,只是方法名换成了scanDevices、connectDevice、sendData。
最后说点实在的。这个项目做完,我心里最深的体会是:提醒类功能的可靠性,比拼功能数量重要得多。我之前一直在调通知样式、加动画效果,结果发现真正决定用户是否愿意把App装进手机里的,是那个提醒在锁屏状态下到底响没响。如果你打算复刻这个项目,建议先跑通最小闭环——定时调度加本地通知,能在切后台、锁屏、杀进程三种场景下稳定触发,再考虑扩展功能。代码整体的结构我做了精简,适合直接拿去改,想体验的朋友可以从喝水提醒这个模块开始摸索。