ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Flutter for OpenHarmony 视力提醒 App 通知设置实践与避坑指南

Flutter for OpenHarmony 视力提醒 App 通知设置实践与避坑指南 做视力保护提醒App是一个挺有意思的项目。大家每天盯屏幕的时间越来越长眼睛其实很需要被刻意地提醒一下“该休息了”。我这次选择 Flutter for OpenHarmony 来落地是因为 OpenHarmony 生态已经能在手机、平板这类设备上跑起来而 Flutter 又能让我们已有的跨端经验直接复用。整个项目里最容易被低估、但恰恰最影响体验的模块就是通知设置它不只是发一条系统通知那么简单还牵扯权限申请、提醒规则、定时调度、平台桥接甚至系统资源回收策略。这篇文章就把我在实际开发中梳理出来的通知设置方案、关键实现和踩坑记录详细讲一遍希望能给正在做类似 App 的同学省点时间。1. 为什么用Flutter来做OpenHarmony的视力保护提醒App1.1 选型既要跨平台又要能触达系统通知开发之前我其实纠结过一阵OpenHarmony 原生开发能用 ArkTS生态内也有自己的状态管理和声明式UI为什么还要绕一圈用 Flutter我的判断是基于团队现状和产品目标。视力提醒这类工具型 App界面不复杂但逻辑穿插很多比如计时、状态展示、设置表单、通知配置入口。Flutter 在处理这类 UI 逻辑密集的场景时效率很高一个StatefulWidget加一个状态管理库就能把页面状态收拾得明明白白。而且团队已经有 Flutter 经验没有必要为了“原生”而原生。更关键的是Flutter for OpenHarmony 并不是一个封闭的玩具项目。官方的 Flutter 引擎适配了 OpenHarmony 的 Ability 和组件生命周期Dart 可以通过MethodChannel调用 OpenHarmony 的 API。这意味着你能继续用 Flutter 写 UI同时还能完整使用系统能力包括通知、提醒代理、后台任务这些原生能力。有个必须想清楚的点OpenHarmony 的通知服务能力来自 NotificationKit 和 ReminderAgent它们不是 Flutter 自带的flutter_local_notifications插件默认支持的平台。你在 Android 上可能一行插件就搞定的事在 OpenHarmony 上就得老老实实走平台通道。所以选型的时候并不只是选框架而是要确认“桥接方案是否可控”。1.2 视力提醒场景对通知设置的需求拆解视力保护提醒和普通的消息推送不一样。普通推送是服务器触达用户被动接收视力提醒是纯本地定时行为用户设置好规则后系统必须按点触发。这就对通知设置提出了几个明确要求到点能提醒无论 App 是否在前台最好在后台甚至应用被清理后也能触发。提醒可感知除了标题和正文最好带声音或振动不然用户看屏幕太专注时根本注意不到。规则可配置用户能自定义提醒间隔、有效时间段、休息时间而不是死板地每20分钟弹一次。避免打扰夜间或用户主动设置的免打扰时段内不要弹通知。可取消可更新用户修改间隔、关掉开关后旧提醒不能继续残留。这五个要求直接决定了通知设置模块的架构。如果只做一个“定时弹通知”的 Demo确实很快但距离一个能日常使用的 App 还差得很远。后面设计数据流的时候我基本就是在围绕这五条需求展开。1.3 通知设置模块在整个 App 中的位置这个模块不是孤立存在的。一个典型的视力保护提醒 App 会有下面几层首页显示当前用眼时长、休息倒计时、疲劳状态。设置页配置提醒间隔、工作时段、声音振动开关。通知调度根据设置生成系统级提醒并在到点后触达用户。状态记录记录今天完成了多少次休息帮助用户调整习惯。通知设置在中间扮演“枢纽”角色设置页产生规则调度层消费规则系统通知负责最终触达。任何一个环节出了问题用户在首页看到的都是“计数器在走但就是不弹提醒”。所以我在开发时把通知设置当作一个独立模块来设计而不是随手写在页面里。这样才能保证规则变更时旧提醒可以被正确清理新提醒能按新规则生成。2. 通知设置模块的设计与数据流2.1 提醒规则与设置项定义我先把设置项收敛成一张清晰的表避免界面和逻辑各做各的。视力提醒 App 的常见设置项包括设置项可选值默认值说明总开关开启/关闭关闭全局控制是否启用提醒提醒间隔20/30/45/60分钟20分钟基于“20-20-20”用眼法则休息时长20秒/1分钟/5分钟20秒只影响文案和休息提示不影响定时精度启用开始时间HH:mm08:00每天生效时段起点启用结束时间HH:mm22:00每天生效时段终点声音提醒开启/关闭开启设置通知响铃振动提醒开启/关闭开启设置通知振动免打扰时段开启/关闭关闭开启后按系统静默规则执行提醒间隔为什么是固定选项而不是自由输入因为视力保护推荐的是每隔一段时间远眺一次固定几个档位能满足绝大多数人还能减少用户输入成本。更重要的是固定档位能方便做提示文案比如“20分钟到了请向远处看20秒”如果用户输入25分钟反而不好设计文案。规则层面的关键逻辑是如果启用了时间段且开始时间在结束时间之后就说明是跨天时段比如 22:00 到 08:00。这段逻辑很基础但很容易被忽略。我一开始没处理跨天结果用户设置夜间模式后提醒一直不触发或者无限触发排查了好久才发现是时间比较写反了。2.2 状态管理与表单校验Flutter 端我用了Provider来做状态管理没有上更重的Riverpod或Bloc。原因很简单这个模块的状态量不大核心就是“当前设置项”和“调度状态”用ChangeNotifier加上Consumer足够干净。设置页的数据流是这样的用户在控件上调整数值控件回调更新EyeProtectionSettings对象同时写本地存储再把变更同步给调度层。这里要注意组件通信的粒度设置项之间会有联动比如关闭总开关时间隔和时间段控件应该变成灰色不能继续操作。这种跨组件状态我放在同一个ChangeNotifier里管理子组件通过context.watchSettingsModel()获取状态避免一层层传回调。表单校验放在保存按钮的提交逻辑里而不是放在每个控件的onChanged里。比如开始时间必须小于结束时间如果不满足提示用户“开始时间不能晚于结束时间”并且不允许保存。校验逻辑看似简单但必须在保存和调度两个入口都执行用户改设置时提示一次调度层读取设置时再防御一次。因为状态机里可能出现旧数据残留二次校验能避免脏数据进入系统。2.3 本地持久化与设置同步视力提醒设置需要跨重启保留最简单可靠的方案是shared_preferences。OpenHarmony 上这个插件已经适配底层会走系统的 Preferences 存储数据和权限声明都还比较稳定。我保存的内容分成两部分基础设置开关、间隔、时段、声音振动都是简单类型直接转成 JSON 字符串存到一个 key 里。调度状态当前已经发布的提醒 ID 列表、下一次提醒触发时间单独存成一份运行时数据。为什么要单独存调度状态因为用户可能修改间隔后退出应用系统里还挂着旧的提醒。下次启动时我需要读取“已发布提醒”列表然后决定是继续保留还是取消重建。如果不存启动时找不到历史提醒就会造成“改了设置但旧的提醒还在”的经典 bug。在数据同步上我遵循一个原则设置页的任何一次保存都会触发一次“全量刷新提醒”。具体逻辑是取消当前所有已发布的提醒。读取最新设置。计算出当天接下来所有提醒的触发时间点。重新发布提醒。这种“先清后建”的策略虽然看起来笨但可预期性很强。只要发布接口和取消接口都走同一个封装的调度类就不会出现新旧参半的脏状态。3. 核心实现Flutter与OpenHarmony通知能力对接3.1 通知权限与 NotificationSlotOpenHarmony 的通知机制里应用要先通过ohos.notificationManager申请通知授权。用户可以在系统设置里关掉某个 App 的通知权限一旦关闭应用内再怎么调接口也无法弹出通知。我从 Flutter 侧封装了一个NotificationService通过MethodChannel调用原生平台代码。原生侧负责三件事检查权限、请求权限、创建通知渠道。channel.invokeMethod(prepare)对应的原生侧伪代码逻辑是这样的调用notificationManager.isNotificationEnabled()检查当前应用的通知总开关。如果未打开调用notificationManager.requestEnableNotification()弹出系统授权框。授权后创建NotificationSlot设置渠道 ID 为eye_guard_reminder重要性级别设为LEVEL_DEFAULT或LEVEL_HIGH并指定是否允许响铃和振动。最后调用notificationManager.addSlot(slot)把渠道注册到系统。很多坑出在渠道上。OpenHarmony 的NotificationSlot和 Android 的 NotificationChannel 概念相似一个应用可以创建多个渠道每个渠道有独立的重要性级别、声音开关和振动设置。如果应用没有先添加渠道就直接发通知通知会被系统静默丢弃而且不会抛异常。我当时排查了快一下午最后才发现是发布通知时忘了先确认渠道已经创建。对比 Android 的做法Android 在首次使用通知时也会要求创建渠道但很多第三方框架会自动帮你建。OpenHarmony 这边我建议在 App 启动时或者进入设置页时先显式prepare()一次把权限和渠道都准备好避免真正到点提醒时才发现没权限。3.2 选 ReminderAgent 而不是自己在 Flutter 里写 Timer这是整个通知设置里最重要的一次技术取舍。刚开始我图省事想用 Flutter 自己的Timer.periodic来计时到点后调用原生接口发通知。这个方案在 App 前台运行、亮屏状态下完全没问题。但视力提醒有个使用场景用户可能开着阅读类或工作类 App甚至把我们的 App 切到后台这时候 Flutter 计时器还能不能准时触发答案是不可靠。OpenHarmony 对后台应用的资源管控很严格应用长时间在后台时Timer.periodic可能被挂起导致通知延迟甚至完全不触发。比这更严重的是用户如果从最近任务里划掉 App应用进程会直接被杀所有 Dart 计时器都会消失。正确方案是使用 OpenHarmony 的ohos.reminderAgentManager提醒代理。这个能力由系统进程统一调度可以创建闹钟提醒、日历提醒和倒计时提醒并且不依赖应用进程是否存活。它非常适合“每个整点/间隔提醒”的场景。我的实现思路是设置页保存后由 Dart 计算出下一次触发时间戳。通过MethodChannel把时间戳、标题、内容传给原生侧。原生侧调用reminderAgentManager.publishReminder()发布一个ReminderRequestAlarm。到点后系统负责弹通知、响铃或振动用户点击通知时再通过 wantAgent 拉起 App。这个方案解决了一个关键问题即使应用被清理提醒依然会由系统发出。应用被清理后用户点通知进入 AppApp 再重新初始化调度这样体验才算完整。3.3 从 Dart 到原生侧的 Channel 调用链我来贴一下 Flutter 侧的核心封装。为了可维护性我把所有跟 OpenHarmony 系统能力相关的调用收敛到一个工具类里class EyeReminderChannel { static const _channel MethodChannel(eye_guard/notification); static Futurebool requestPermission() async { final ok await _channel.invokeMethod(requestPermission); return ok true; } static Futurebool publishReminder({ required int reminderId, required String title, required String content, required int triggerAtMillis, required bool soundEnabled, required bool vibrationEnabled, }) async { final ok await _channel.invokeMethod(publishReminder, { reminderId: reminderId, title: title, content: content, triggerAtMillis: triggerAtMillis, soundEnabled: soundEnabled, vibrationEnabled: vibrationEnabled, }); return ok true; } static Futurevoid cancelReminder(int reminderId) async { await _channel.invokeMethod(cancelReminder, {reminderId: reminderId}); } static FutureListint listPendingReminders() async { final list await _channel.invokeMethod(listPendingReminders); if (list is List) { return list.whereTypeint().toList(); } return []; } }原生侧我在 Ability 的onCreate或入口模块里初始化通道。为了好调试我把方法名设计得和 Flutter 侧一一对应requestPermission内部完成通知权限检查和请求。publishReminder参数传到reminderAgentManager创建提醒对象并发布。cancelReminder通过 ID 取消提醒。这里必须注意提醒 ID 是由系统返回的不是调用方的自定义 ID。我一开始用自定义 ID 去取消结果发现系统根本不认后来改成保存publishReminder返回的真实 ID 才能正确取消。listPendingReminders查询当前正在生效的提醒列表便于启动时恢复调度状态。通道调用本身是异步的原生侧要用AsyncCallback或Promise返回结果。遇到异常时我统一返回失败结果不让异常直接穿透到 UI 层避免用户在设置页看到一串英文报错。3.4 通知的点击行为与联动视力提醒通知的价值不只是“弹出来”用户点击后最好可以直接进入 App 的休息引导页或者今日统计页。这个需求要借助 OpenHarmony 的 wantAgent 实现。原生侧在创建ReminderRequestAlarm时会先构造一个WantAgentInfo指定点击后要启动的 Ability。参数里可以带上restMode或sourcePage这类自定义键Dart 侧再通过getIncomingIntent读取从而决定首页是否切换到休息引导。这里有个体验细节提醒通知的标题和正文要用正向语言。我一开始写的是“长时间看屏幕有害赶紧休息”后来觉得这个语气太像指责用户反而不想点。改成“20分钟到啦起来看看远处让眼睛放松一下”点击率明显提高了。通知文案虽然是产品层面的事但也算是这个功能的一部分不能忽略。另一个联动是免打扰。如果用户已经开启了系统免打扰而 App 还傻乎乎地正常发声音提醒那就打扰到用户休息了。我在发布提醒时会在原生侧判断当前系统是否处于免打扰状态。如果系统接口返回doNotDisturbMode开启就把通知重要性级别降为静默只显示横幅不响铃。这个小功能需要用户授权“访问免打扰状态”授权流程做起来略麻烦但对口碑影响很大。4. 实操过程与调试经验4.1 工程配置与权限声明开发环境我使用的是 DevEco Studio配合 Flutter for OpenHarmony 的 SDK。工程结构上Flutter 层和 OpenHarmony 原生层是分开的原生层负责系统 API 调用和模块配置。通知设置相关的配置主要在module.json5和EntryAbility.ets里。module.json5需要声明通知权限。OpenHarmony 不同版本文档里权限名称不完全一致以我用的 SDK 为例需要声明类似ohos.permission.PUBLISH_AGENT_REMINDER的提醒发布权限同时声明通知管理相关权限。没有声明权限时运行阶段调用会直接报201或401错误很容易判断。还需要在EntryAbility.ets的onWindowStageCreate里读取传入的 want 参数处理点击通知拉起 App 的情况。第一次接入时容易漏掉结果是通知能弹但点击之后 App 无法到达预期页面只能回到默认首页。值得提醒的是真机和模拟器的权限表现不完全一样。模拟器上部分系统能力是模拟实现的reminderAgentManager的调度可能不会真正按时间触发。所以这个项目我从一开始就要求用真机测试哪怕 UI 层在模拟器上看得再多通知调度也一定要在真机上验证。4.2 最常踩的坑通知不响、定时不触发先说通知不响。现象是通知在通知栏里有但没声音也没振动或者在通知栏里根本看不到。排查步骤检查系统设置里“通知 应用 视力保护提醒”是否打开。检查NotificationSlot的重要性级别是否设得太低。LEVEL_NONE直接不显示LEVEL_MIN可能只在通知栏显示且不打扰想响铃至少要到LEVEL_DEFAULT。检查发布通知时是否把sound字段设置成了空。如果系统默认声音被应用层覆盖为空对象就会静默。再说定时不触发。现象是设置好提醒后到点没反应但通知权限和渠道都正常。原因基本都出在“提醒被系统取消”或“发布提醒失败”。你可能不理解为什么提醒会凭空消失其实是因为用户清理任务列表时应用进程被杀系统会顺带清理这个应用发布的部分提醒。这是一种保护机制也提醒了我不能把“发布一次提醒”当成一劳永逸。最后一种很隐蔽应用被杀后如果用户在通知栏看到旧的通知并点击App 被拉起我需要在启动时主动重发提醒。但如果重发逻辑没处理好可能创建了重复提醒。我后来在listPendingReminders()中做了一次“查重”先取消全部旧提醒再按当前设置重新发布才彻底解决重复提醒。4.3 设置页联动测试与旧提醒清理设置页并不是独立界面它和调度层是强联动关系。我测试时把提醒间隔临时改成可调的最小值“1分钟”用来验证到点触发链路。这个做法很有效但坑也不少。最典型的是“修改间隔后旧提醒仍然存在”。假设用户原本设置 20 分钟提醒第一次提醒发布成功系统返回了提醒 IDA。用户把间隔改成 30 分钟新计算出的提醒 ID 变成了B。如果我不在发布B之前取消A那么系统里会有两个提醒一个 20 分钟的旧提醒和一个 30 分钟的新提醒用户会收到额外的一次打扰。我的解决策略是统一封装“全量刷新”。每次保存设置后调用cancelAll即遍历已保存的提醒 ID 逐个取消再重新计算并发布。刚开始这样做会有一点闪烁和延迟但稳定性优先后来优化成只取消“变化后不会再触发的提醒”效果会好一些但坑也更多。普通 App 场景下我更推荐全量刷新没必要为了性能把状态逻辑搞复杂。测试时段逻辑时我用了一个比较取巧的方法在设置页加一个“测试提醒”按钮点击后 3 秒内在当前时间基础上生成一条提醒用来验证发布链路。线上用户从这个入口进来的概率极低但开发调试时非常舒服不需要等 20 分钟才能看到一次提醒。5. 常见问题速查与避坑建议5.1 问题现象与解决方案速查表问题现象可能原因解决方案通知权限弹窗未弹出未在 module.json5 中声明权限检查权限声明重新安装应用有通知但无声音NotificationSlot 重要性级别低将 slot 级别调至 DEFAULT 以上通知直接不显示系统通知权限关闭引导用户在系统设置中打开通知定时提醒到点没反应应用被清理系统提醒被取消使用 ReminderAgent 的 Alarm 提醒并在启动时重新发布修改间隔后收到多次提醒旧提醒未被取消保存系统返回的提醒 ID发布新提醒前先取消旧提醒跨天时段不生效开始/结束时间比较逻辑错误检测到开始时间大于结束时间时按跨天处理点击通知无法进入指定页面wantAgent 未设置或 Ability 未配置在 ReminderRequest 中设置 wantAgent 并处理启动参数这张表是从我真机调试记录里整理出来的基本覆盖了大多数入门阶段会遇到的问题。如果你在做类似功能可以直接对照排查。5.2 绕过通知设置的各种“坑”之后我建议这样做我这里多分享几条从项目里沉淀出来的经验不一定都在官方文档里写明白但实战价值很高。第一尽早把通知设置抽象成一个独立调度类而不是写在 Widget 里。哪怕刚开始只有简单的“每 20 分钟提醒一次”也要把“计算下次时间”“发布提醒”“取消提醒”拆成三个独立方法。到了后面要加入时段限制、跨天逻辑、免打扰判断时你会庆幸早期没有偷懒。第二提醒 ID 要用系统返回的真实 ID。我因为用自定义 ID 取消提醒折腾了很长一段时间。系统返回的reminderId才是取消接口需要的参数这个 ID 一定要持久化保存到本地否则下次启动时我们根本不知道系统里还挂着哪些提醒。第三UI 上要有“通知权限未开启”的提示位。很多用户误以为关掉通知只是不弹声音实际上整个提醒功能都会失效。我在设置页顶部加了一个权限提示条当检测到通知权限未开启时显示“通知未开启提醒可能无法弹出”并提供跳转按钮。这个提示条看似简单却大幅减少了用户的困惑。第四不要把“后台长时间运行”设计成买点。真的在 OpenHarmony 上长时间驻留后台一直是敏感且不可控的。与其研究如何逃过系统清理不如老老实实依赖 ReminderAgent。它的体验更稳定而且用户能理解“到点系统弹通知”这种模式。第五别忘了用户会修改系统时间。如果用户把系统时间往前调ReminderAgent 的提醒时间可能还没到就不会触发如果往后调则可能瞬间触发一批通知。我暂未做完整方案但在计算下一次触发时间时使用了DateTime.now()加偏移量而不是用固定的绝对时间戳能在一定程度上缓解这个问题。最后再分享一个小技巧开发时在原生侧日志里打印发布提醒返回的 ID 和触发时间Dart 侧打印“下一次提醒时间”和间隔设置。用hdc shell hilog拉取日志能非常清楚地看到设置的每一环是否走通而不是靠感觉猜问题。做通知调试最怕的就是“不知道是谁吞掉了提醒”日志能帮你快速定位问题。视力保护提醒这个功能核心其实不是复杂的图形动画而是“用户信任你能在合适的时间轻轻提醒他”。通知设置作为这件事的最后一公里值得多花心思。把权限、渠道、调度、取消、联动想清楚了这个 App 才算真正能落地。
返回列表