利用鸿蒙 Notification API 实现本地通知——提醒用户进行呼吸练习


目录

  1. 为什么情绪健康应用需要通知提醒
  2. 鸿蒙通知系统架构全景
  3. NotificationRequest:通知的"数据包"
  4. NotificationSlot:通知的分类与优先级管理
  5. WantAgent:让通知变成"可点击的"
  6. Platform Channel 双向通信架构设计
  7. 实战:Flutter 端通知服务层实现
  8. 实战:鸿蒙原生端 NotificationPlugin 实现
  9. 定时通知:后台代理提醒(ReminderAgent)方案
  10. 通知点击处理:从锁屏直达呼吸页面
  11. 权限申请策略与用户隐私考量
  12. 通知渠道管理:不同场景不同策略
  13. 完整体验:E-Brufen 每日呼吸提醒配置流程
  14. 鸿蒙通知系统的限制与兼容性说明
  15. 总结:从功能到体验的最后一公里

一、为什么情绪健康应用需要通知提醒

E-Brufen 是一个面向鸿蒙平台的情绪健康应用,核心功能包括白噪音放松、呼吸练习引导和情绪日记记录。在早期版本中,呼吸练习页面已经实现了三种呼吸模式(4-7-8 呼吸法、盒式呼吸、放松呼吸),以及时长选择和动画引导。功能本身是完整的,但使用数据暴露出一个问题:用户很少主动打开呼吸页面

这并非 E-Brufen 独有的困境。根据 Headspace 和 Calm 两份行业报告的数据,冥想类应用的用户 7 日留存率通常在 8% 到 15% 之间,而开启通知提醒的用户,7 日留存率可以提升到 22% 以上。原因很简单:用户并非不想做呼吸练习,而是在忙碌中忘记了。

因此,为 E-Brufen 添加"每日呼吸提醒"功能,就成了提升用户参与度的关键一步。技术选型上,我们面对三个层次的日历提醒方案:

方案 实现方式 保活能力 精确度 适用场景
Flutter Timer 轮询 Dart 端 Timer.periodic 仅前台有效,切后台即失效 秒级 应用内倒计时(如呼吸练习计时)
WorkManager / Background Task 鸿蒙 backgroundTaskManager 长时任务,有一定保活时间 分钟级 播放音频、下载文件
ReminderAgent 系统级代理 鸿蒙 reminderAgentManager 系统托管,完全保活 分钟级 定时提醒、日历事件

我们选择第三种方案——reminderAgentManager。它由鸿蒙系统接管,不依赖应用进程存活,即使设备重启后提醒依然有效。这正是"定时呼吸提醒"所需要的能力。


二、鸿蒙通知系统架构全景

在开始写代码之前,我们需要理解鸿蒙通知系统的整体架构。它由四个核心概念构成,彼此之间形成分层关系:

┌──────────────────────────────────────────────────────────────────┐
│                    鸿蒙通知系统架构                              │
├──────────────────────────────────────────────────────────────────┤
│                                                                  │
│  ┌──────────────┐    ┌──────────────┐    ┌──────────────┐       │
│  │ Notification  │───▶│ Notification │───▶│ Notification │       │
│  │   Request     │    │    Slot      │    │   Subscriber │       │
│  │  (通知请求)   │    │  (通知渠道)  │    │  (通知订阅者) │       │
│  └──────┬───────┘    └──────┬───────┘    └──────┬───────┘       │
│         │                   │                   │                │
│         │          ┌────────▼────────┐          │                │
│         └─────────▶│  WantAgent      │◀─────────┘                │
│                    │  (意图代理)      │                           │
│                    └────────┬────────┘                           │
│                             │                                    │
│                    ┌────────▼────────┐                           │
│                    │  UIAbility      │                           │
│                    │  (目标页面)      │                           │
│                    └─────────────────┘                           │
│                                                                  │
├──────────────────────────────────────────────────────────────────┤
│  数据流:                                                        │
│  1. 应用创建 NotificationRequest(标题、正文、图标、渠道等)     │
│  2. 系统根据 NotificationSlot 决定展示方式(横幅、震动、铃声)   │
│  3. 用户点击通知 → WantAgent 解析跳转目标 → 启动 UIAbility       │
└──────────────────────────────────────────────────────────────────┘

这四个概念的职责划分如下:

  • NotificationRequest:定义通知本身的内容——标题、正文、图标、通知渠道 ID、以及点击后跳转的 WantAgent。
  • NotificationSlot:定义通知渠道的元属性——名称、描述、重要等级、是否震动、是否响铃、是否显示角标。一个应用可以有多个 Slot(例如"呼吸提醒"和"每日心情"各一个),用户可以在系统设置中分别控制。
  • WantAgent:封装了"用户点击通知后做什么"的意图。它既可以启动一个 UIAbility(打开应用),也可以发送一个广播事件。
  • NotificationSubscriber:可选,用于监听通知的状态变化(已发送、已展示、已点击、已移除)。我们的场景中不需要主动订阅,而是通过 WantAgent 的回调来处理点击。

理解了这个架构后,我们分别深入每一个组件。


三、NotificationRequest:通知的"数据包"

NotificationRequest 是鸿蒙通知 API 的核心数据结构。它不是简单的 key-value 对,而是一个结构化的对象,包含了内容、行为、样式三个维度的信息。

在鸿蒙 ArkTS 中,创建一条基础通知的代码如下:

import notificationManager from '@ohos.notificationManager';
import wantAgent from '@ohos.app.ability.wantAgent';

async function publishBreatheReminder(
  wantAgentObj: wantAgent.WantAgent
): Promise<void> {
  const request: notificationManager.NotificationRequest = {
    // ── 唯一标识(重复发布同 ID 会更新而非新增) ──
    id: 1001,

    // ── 通知渠道 SlotType ──
    // 必须与 addSlot 时一致,否则通知会被降级或丢弃
    notificationSlotType: notificationManager.SlotType.SOCIAL_COMMUNICATION,

    // ── 核心内容 ──
    content: {
      // 内容类型:普通文本 or 图片 or 长文本
      contentType: notificationManager.ContentType.NOTIFICATION_CONTENT_BASIC_TEXT,
      // 具体内容数据
      normal: {
        title: '🌿 该做呼吸练习了',
        text: '打开 E-Brufen,来一次 5 分钟的盒式呼吸,让心静下来。',
        additionalText: '每日呼吸提醒'
      }
    },

    // ── 小图标(状态栏 icon)──
    smallIcon: $r('app.media.ic_notification'),

    // ── 点击行为 ──
    wantAgent: wantAgentObj,

    // ── 发布时间 ──
    deliveryTime: Date.now(),

    // ── 其他配置 ──
    isFloatingIcon: true,   // 状态栏显示浮动图标
    tapDismissed: true,     // 点击后自动消失
    color: 0x81D4FA,        // 通知栏颜色(浅蓝,呼应呼吸主题)
  };

  try {
    await notificationManager.publish(request);
    console.info('[E-Brufen] Notification published: id=1001');
  } catch (err) {
    console.error(`[E-Brufen] Publish failed: ${JSON.stringify(err)}`);
  }
}

有几个容易踩坑的点值得单独说明:

第一,id 的语义。 它不是自增的全局唯一 ID,而是应用内通知的分类标识。如果你用同一个 id 再次调用 publish,系统不会创建新通知,而是更新已有通知。这意味着如果你想同时展示"呼吸提醒"和"心情记录提醒",必须给它们分配不同的 id(例如 1001 和 1002)。

第二,deliveryTime 的作用。 它指定了通知在通知栏中的排序时间,但不等于"定时发送"。真正的定时发送需要借助 reminderAgentManager,我们会在第九章详细讨论。

第三,notificationSlotType 必须与 addSlot 注册的类型匹配。 如果你 addSlot 注册了一个 SOCIAL_COMMUNICATION 类型的 Slot,但 NotificationRequest 中写的是 CONTENT_INFORMATION,系统会丢弃这条通知且不会报错。这是一个静默失败,排查起来非常痛苦。


四、NotificationSlot:通知的分类与优先级管理

鸿蒙的通知系统借鉴了 Android 8.0 引入的通知渠道(Notification Channel)机制。每个通知必须归属到一个 Slot,而每个 Slot 定义了该类通知的展示行为和用户可控性。

鸿蒙预置了五种 SlotType,它们在系统通知设置中的默认行为和用户可见性各不相同:

SlotType 等级 默认横幅 默认震动 默认铃声 用户可关闭 建议用途
SOCIAL_COMMUNICATION HIGH 即时通讯、提醒
SERVICE_INFORMATION DEFAULT 服务状态、更新
CONTENT_INFORMATION LOW 内容推荐、广告
OTHER MIN 不重要的通知
CUSTOM 自定义 自定义 自定义 完全自定义渠道

对于 E-Brufen 的呼吸提醒,我们创建一个自定义类型的 Slot,因为我们需要同时具备横幅展示和震动提醒,但又不需要响铃(情绪健康应用的通知不应该制造紧张感):

import notificationManager from '@ohos.notificationManager';

async function createBreatheReminderSlot(): Promise<void> {
  const slot: notificationManager.NotificationSlot = {
    type: notificationManager.SlotType.SOCIAL_COMMUNICATION,
    level: notificationManager.SlotLevel.LEVEL_HIGH,

    // 用户可见的描述(在系统通知设置中展示)
    desc: '每日呼吸练习提醒,帮助你保持情绪健康习惯',

    // ── 展示行为 ──
    badgeFlag: true,      // 显示桌面角标
    bypassDnd: false,     // 不绕过免打扰(尊重用户的勿扰模式)
    vibrationEnabled: true, // 轻柔震动(不响铃)
    lightEnabled: false,  // 不使用 LED 闪烁灯
    sound: undefined,     // 不播放声音

    // ── 锁屏展示 ──
    lockscreenVisibility:
      notificationManager.NotificationRequest. VISIBILITY_PUBLIC,

    // ── 自定义名称(覆盖系统默认的 "社交通讯") ──
    slotName: 'breath_reminder',
    label: '呼吸提醒',
  };

  try {
    await notificationManager.addSlot(slot);
    console.info('[E-Brufen] Notification slot created: breath_reminder');
  } catch (err) {
    console.error(`[E-Brufen] AddSlot failed: ${JSON.stringify(err)}`);
  }
}

这里有一个设计决策值得展开:为什么呼吸提醒不设置铃声?

我们的用户场景是:用户白天收到一条轻柔的震动提醒,提醒他们花 5 分钟做呼吸练习。如果配上一个尖锐的通知铃声,用户会产生被"催促"的负面情绪——这与我们"放松"的产品理念背道而驰。所以最终方案是:仅震动、不响铃、可被免打扰模式屏蔽。用户可以在系统设置中随时关闭这类通知,也可以调整其优先级。


五、WantAgent:让通知变成"可点击的"

在这里插入图片描述

通知本身只是信息展示,真正让它成为用户入口的是 WantAgent。简单来说,WantAgent 封装了一个"意图"——用户点击通知后,系统按照事先声明的操作(启动 Ability、发送广播等)来执行。

对于 E-Brufen,用户点击呼吸提醒通知后应该直接跳转到呼吸练习页面。我们通过 WantAgent 的 START_ABILITY 操作来实现:

import wantAgent from '@ohos.app.ability.wantAgent';
import common from '@ohos.app.ability.common';

async function createBreatheReminderWantAgent(
  context: common.UIAbilityContext
): Promise<wantAgent.WantAgent> {
  // 构建 Want 对象:定义跳转目标
  const wantInfo: wantAgent.WantAgentInfo = {
    wants: [
      {
        bundleName: context.abilityInfo.bundleName,
        abilityName: context.abilityInfo.name,
        // ★ 携带参数,用于目标页面识别跳转来源
        parameters: {
          targetPage: 'breathe',     // 目标页面标识
          fromNotification: true,    // 标记来自通知
          remindTime: new Date().toISOString(),
        }
      },
    ],
    operationType: wantAgent.OperationType.START_ABILITY,
    requestCode: 2001,              // 同一个 Ability 可以注册多个 WantAgent,通过 requestCode 区分
    wantAgentFlags: [
      wantAgent.WantAgentFlags.CONSTANT_FLAG,
      // 保持 WantAgent 不变(不因系统重启失效)
    ],
  };

  const agent = await wantAgent.getWantAgent(wantAgentInfo);
  console.info('[E-Brufen] WantAgent created for breathe reminder');
  return agent;
}

parameters 中携带的 targetPage: 'breathe' 是关键。当应用通过 WantAgent 启动后,我们在 Ability 的生命周期回调中读取这些参数,决定跳转到哪个 Flutter 页面。这个机制我们会在第十章详细实现。


六、Platform Channel 双向通信架构设计

在 E-Brufen 的已有架构中,我们已经通过 Platform Channel 实现了音频播放的原生桥接(参见 OhosAudioPlayerAudioPlayerPlugin)。通知系统采用同样的模式,但方向是"以 Flutter 端为主控,原生端为执行层"。

下面是通知模块的通信架构:

┌─────────────────────────────────────────────────────────────────┐
│              Flutter ↔ 鸿蒙 通知桥接架构                       │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  ┌──────────────────────┐      MethodChannel       ┌──────────┐ │
│  │  Flutter (Dart)      │                          │ 鸿蒙原生 │ │
│  │                      │                          │ (ArkTS)  │ │
│  │  NotificationService │──── dart → native ──────▶│ Notifi-  │ │
│  │                      │                          │ cation   │ │
│  │  .scheduleReminder() │                          │ Plugin   │ │
│  │  .cancelReminder()   │                          │          │ │
│  │  .requestPermission()│                          │ .publish │ │
│  │  .hasPermission()    │                          │ .schedule│ │
│  │                      │◀──── native → dart ──────│ .cancel  │ │
│  │  onNotificationTap   │                          │          │ │
│  │  (stream)            │     EventChannel/反向调用  │          │ │
│  └──────────┬───────────┘                          └────┬─────┘ │
│             │                                           │       │
│  ┌──────────▼───────────┐                    ┌──────────▼─────┐ │
│  │  AppSettings         │                    │ notification   │ │
│  │  (Hive 持久化)       │                    │ Manager        │ │
│  │                      │                    │ (系统 API)     │ │
│  │  breatheReminder:    │                    │                │ │
│  │  {enabled, time}     │                    │ publish()      │ │
│  └──────────────────────┘                    │ addSlot()      │ │
│                                              └────────────────┘ │
└─────────────────────────────────────────────────────────────────┘

通信分为两个方向:

  1. Dart → Native(主动调用)scheduleRemindercancelReminderrequestPermissionhasPermission。这些是 Flutter 端主动发起的方法调用,通过 MethodChannel.invokeMethod 发送到原生端执行。
  2. Native → Dart(事件回调)onNotificationTap。当用户点击通知时,原生端通过反向方法调用通知 Flutter 端,Flutter 端据此执行页面跳转。这与 OhosAudioPlayeronPlaybackStateChanged 的通信模式完全一致。

七、实战:Flutter 端通知服务层实现

基于上述架构,我们在 Flutter 端实现 NotificationService。它的职责是:

  • 封装所有与通知相关的方法调用
  • 管理用户的提醒偏好(开关、时间)
  • 提供点击事件的监听流
  • 处理权限检查
// lib/data/notification_service.dart
import 'dart:async';
import 'package:flutter/services.dart';

/// 呼吸提醒通知服务。
///
/// 通过 Platform Channel 与鸿蒙原生端通信,
/// 实现本地通知的发布、定时调度、取消和点击处理。
class NotificationService {
  static const _channel = MethodChannel('com.ebrufen/notification');

  // ── Native → Dart 事件 ──

  static final Stream<NotificationTapEvent> onNotificationTap =
      _tapController.stream;
  static final _tapController =
      StreamController<NotificationTapEvent>.broadcast();

  static bool _handlerRegistered = false;

  /// 注册原生端 → Dart 端的反向方法调用。
  /// 必须在应用启动时调用一次。
  static void init() {
    if (_handlerRegistered) return;
    _handlerRegistered = true;

    _channel.setMethodCallHandler((call) async {
      switch (call.method) {
        case 'onNotificationTap':
          final args = (call.arguments as Map?)?.cast<String, dynamic>();
          final targetPage = args?['targetPage'] as String? ?? '';
          _tapController.add(NotificationTapEvent(
            targetPage: targetPage,
            fromNotification: args?['fromNotification'] == true,
          ));
          break;
        default:
          break;
      }
    });
  }

  // ── 权限 ──

  /// 检查通知权限是否已授权。
  static Future<bool> hasPermission() async {
    final result = await _channel.invokeMethod<bool>('hasPermission');
    return result ?? false;
  }

  /// 请求通知权限。返回 true 表示用户已授权。
  static Future<bool> requestPermission() async {
    final result = await _channel.invokeMethod<bool>('requestPermission');
    return result ?? false;
  }

  // ── 发布即时通知 ──

  /// 发布一条即时通知(通常用于测试或即时提醒)。
  static Future<void> publishImmediate({
    required String title,
    required String body,
    String? targetPage,
  }) async {
    await _channel.invokeMethod('publishImmediate', {
      'title': title,
      'body': body,
      'targetPage': targetPage ?? 'breathe',
    });
  }

  // ── 定时通知 ──

  /// 调度每日呼吸提醒。
  ///
  /// [hour] 和 [minute] 指定每天的提醒时间(24 小时制)。
  /// [reminderId] 用于标识不同的提醒(支持多个提醒)。
  static Future<void> scheduleDailyReminder({
    required int hour,
    required int minute,
    int reminderId = 1001,
  }) async {
    await _channel.invokeMethod('scheduleDailyReminder', {
      'hour': hour,
      'minute': minute,
      'reminderId': reminderId,
      'title': '🌿 该做呼吸练习了',
      'body': '花几分钟深呼吸,让紧绷的神经松弛下来。',
      'targetPage': 'breathe',
    });
  }

  /// 取消指定的每日提醒。
  static Future<void> cancelReminder({int reminderId = 1001}) async {
    await _channel.invokeMethod('cancelReminder', {
      'reminderId': reminderId,
    });
  }

  /// 取消所有呼吸提醒。
  static Future<void> cancelAllReminders() async {
    await _channel.invokeMethod('cancelAllReminders');
  }

  // ── 通知渠道管理 ──

  /// 打开系统通知设置页面(让用户管理通知偏好)。
  static Future<void> openNotificationSettings() async {
    await _channel.invokeMethod('openNotificationSettings');
  }

  /// 销毁资源。
  static void dispose() {
    _tapController.close();
  }
}

/// 通知点击事件。
class NotificationTapEvent {
  final String targetPage;
  final bool fromNotification;

  const NotificationTapEvent({
    required this.targetPage,
    required this.fromNotification,
  });
}

这个 NotificationService 的设计有几个值得展开的点:

为什么用 static 方法而不是实例化? 参考项目中 OhosAudioPlayer 的设计模式,通知服务和音频播放器一样,属于"应用级别的单例服务"。它们不持有 UI 状态,只负责与原生端通信。使用静态方法可以减少不必要的对象创建,同时让调用方不需要依赖注入。

_handlerRegistered 守卫的作用。 setMethodCallHandler 在同一个 MethodChannel 上只能设置一次。如果在多个地方调用 init(),第二次调用会覆盖第一次的处理器,导致事件丢失。通过一个静态标志位来保证只注册一次。

AppSettings 的配合。 NotificationService 本身不存储用户的提醒偏好——它只负责与原生端通信。提醒的开关状态、时间等用户偏好存储在 AppSettings(基于 Hive 的持久化层)中。Flutter UI 从 AppSettings 读取偏好,再通过 NotificationService 下发给原生端。这样的职责分离保持了与现有代码的一致性。


八、实战:鸿蒙原生端 NotificationPlugin 实现

原生端的 NotificationPlugin 负责实际调用鸿蒙 Notification API。它与已有的 AudioPlayerPlugin 遵循相同的插件模式:实现 MethodCallHandlerFlutterPluginAbilityAware 三个接口,通过 MethodChannel 与 Flutter 端通信。

以下是核心实现:

// ohos/entry/src/main/ets/plugins/NotificationPlugin.ets
import {
  MethodChannel,
  MethodCall,
  MethodCallHandler,
  MethodResult,
  FlutterPlugin,
  FlutterPluginBinding,
  AbilityAware,
  AbilityPluginBinding,
  Log
} from '@ohos/flutter_ohos';
import notificationManager from '@ohos.notificationManager';
import reminderAgentManager from '@ohos.reminderAgentManager';
import wantAgent from '@ohos.app.ability.wantAgent';
import common from '@ohos.app.ability.common';
import { BusinessError } from '@ohos.base';

const TAG = 'NotificationPlugin';
const CHANNEL_NAME = 'com.ebrufen/notification';

// ── 本地类型声明(ArkTS 严格模式要求) ──

interface ReminderParams {
  hour: number;
  minute: number;
  reminderId: number;
  title: string;
  body: string;
  targetPage: string;
}

interface PublishImmediateParams {
  title: string;
  body: string;
  targetPage: string;
}

interface NotificationTapData {
  targetPage: string;
  fromNotification: boolean;
}

export class NotificationPlugin
  implements MethodCallHandler, FlutterPlugin, AbilityAware {

  private channel: MethodChannel | null = null;
  private abilityContext: common.UIAbilityContext | null = null;

  // ══════════════════════════════════════════
  // 生命周期
  // ══════════════════════════════════════════

  getUniqueClassName(): string {
    return 'NotificationPlugin';
  }

  onAttachedToEngine(binding: FlutterPluginBinding): void {
    this.channel = new MethodChannel(
      binding.getBinaryMessenger(), CHANNEL_NAME
    );
    this.channel.setMethodCallHandler(this);

    // ★ 注册反向调用处理器(已在 Dart 端通过 setMethodCallHandler 完成,
    //    这里确保引擎 attach 时原生端已准备好接收请求)
    Log.i(TAG, 'Plugin attached');

    // ★ 初始化通知渠道
    this.createNotificationSlot();
  }

  onDetachedFromEngine(binding: FlutterPluginBinding): void {
    this.channel?.setMethodCallHandler(null);
    this.channel = null;
  }

  onAttachedToAbility(binding: AbilityPluginBinding): void {
    this.abilityContext =
      binding.getAbility().context as common.UIAbilityContext;
    Log.i(TAG, 'Ability attached');
  }

  onDetachedFromAbility(): void {
    this.abilityContext = null;
  }

  // ══════════════════════════════════════════
  // 方法路由
  // ══════════════════════════════════════════

  onMethodCall(call: MethodCall, result: MethodResult): void {
    Log.i(TAG, `onMethodCall: ${call.method}`);
    switch (call.method) {
      case 'hasPermission':
        this.handleHasPermission(result);
        break;
      case 'requestPermission':
        this.handleRequestPermission(result);
        break;
      case 'publishImmediate':
        this.handlePublishImmediate(
          call.args as PublishImmediateParams, result
        );
        break;
      case 'scheduleDailyReminder':
        this.handleScheduleDailyReminder(
          call.args as ReminderParams, result
        );
        break;
      case 'cancelReminder':
        this.handleCancelReminder(call.args as number, result);
        break;
      case 'cancelAllReminders':
        this.handleCancelAllReminders(result);
        break;
      case 'openNotificationSettings':
        this.handleOpenSettings(result);
        break;
      default:
        result.notImplemented();
    }
  }

  // ══════════════════════════════════════════
  // 通知渠道初始化
  // ══════════════════════════════════════════

  private async createNotificationSlot(): Promise<void> {
    try {
      const slot: notificationManager.NotificationSlot = {
        type: notificationManager.SlotType.SOCIAL_COMMUNICATION,
        level: notificationManager.SlotLevel.LEVEL_HIGH,
        desc: '每日呼吸练习提醒,帮助你保持情绪健康习惯',
        badgeFlag: true,
        bypassDnd: false,
        vibrationEnabled: true,
        lightEnabled: false,
      };
      await notificationManager.addSlot(slot);
      Log.i(TAG, 'Notification slot created ✓');
    } catch (err) {
      const e = err as BusinessError;
      Log.e(TAG,
        `Slot creation failed — code=${e.code}, msg=${e.message}`
      );
    }
  }

  // ══════════════════════════════════════════
  // 权限检查
  // ══════════════════════════════════════════

  private async handleHasPermission(result: MethodResult): Promise<void> {
    try {
      const granted =
        await notificationManager.isNotificationEnabled();
      result.success(granted);
    } catch (err) {
      result.success(false);
    }
  }

  private async handleRequestPermission(
    result: MethodResult
  ): Promise<void> {
    try {
      await notificationManager.requestEnableNotification();
      // 请求完成后再次检查状态
      const granted =
        await notificationManager.isNotificationEnabled();
      result.success(granted);
    } catch (err) {
      const e = err as BusinessError;
      Log.e(TAG,
        `Permission request failed — code=${e.code}, msg=${e.message}`
      );
      result.success(false);
    }
  }

  // ══════════════════════════════════════════
  // 发布即时通知
  // ══════════════════════════════════════════

  private async handlePublishImmediate(
    params: PublishImmediateParams,
    result: MethodResult
  ): Promise<void> {
    try {
      if (!this.abilityContext) {
        result.error('NO_CONTEXT', 'Ability 上下文不可用', null);
        return;
      }

      const agent = await this.createWantAgent(
        params.targetPage
      );

      const request: notificationManager.NotificationRequest = {
        id: Date.now() % 100000, // 使用时间戳避免 ID 冲突
        notificationSlotType:
          notificationManager.SlotType.SOCIAL_COMMUNICATION,
        content: {
          contentType:
            notificationManager.ContentType
              .NOTIFICATION_CONTENT_BASIC_TEXT,
          normal: {
            title: params.title,
            text: params.body,
          },
        },
        wantAgent: agent,
        deliveryTime: Date.now(),
        tapDismissed: true,
      };

      await notificationManager.publish(request);
      result.success(null);
    } catch (err) {
      const e = err as BusinessError;
      result.error(
        'PUBLISH_ERROR',
        e.message ?? '发布通知失败',
        null
      );
    }
  }

  // ══════════════════════════════════════════
  // 创建 WantAgent(被 publish 和 schedule 共用)
  // ══════════════════════════════════════════

  private async createWantAgent(
    targetPage: string
  ): Promise<wantAgent.WantAgent> {
    if (!this.abilityContext) {
      throw new Error('Ability context 不可用');
    }

    const wantAgentInfo: wantAgent.WantAgentInfo = {
      wants: [
        {
          bundleName: this.abilityContext.abilityInfo.bundleName,
          abilityName: this.abilityContext.abilityInfo.name,
          parameters: {
            targetPage: targetPage,
            fromNotification: true,
          },
        },
      ],
      operationType: wantAgent.OperationType.START_ABILITY,
      requestCode: 2001,
      wantAgentFlags: [
        wantAgent.WantAgentFlags.CONSTANT_FLAG,
        wantAgent.WantAgentFlags.UPDATE_PRESENT_FLAG,
      ],
    };

    return await wantAgent.getWantAgent(wantAgentInfo);
  }

  // ══════════════════════════════════════════
  // 向 Dart 端发送点击事件
  // ══════════════════════════════════════════

  private sendTapToDart(targetPage: string): void {
    const data: NotificationTapData = {
      targetPage: targetPage,
      fromNotification: true,
    };
    try {
      this.channel?.invokeMethod('onNotificationTap', data);
    } catch (err) {
      const e = err as BusinessError;
      Log.e(TAG,
        `sendTapToDart failed — code=${e.code}, msg=${e.message}`
      );
    }
  }

  // ══════════════════════════════════════════
  // 定时提醒(ReminderAgent)
  // ══════════════════════════════════════════

  private async handleScheduleDailyReminder(
    params: ReminderParams,
    result: MethodResult
  ): Promise<void> {
    try {
      if (!this.abilityContext) {
        result.error('NO_CONTEXT', 'Ability 上下文不可用', null);
        return;
      }

      const agent = await this.createWantAgent(
        params.targetPage
      );

      // 构建提醒内容
      const reminderRequest: reminderAgentManager.ReminderRequest = {
        reminderType:
          reminderAgentManager.ReminderType.REMINDER_TYPE_TIMER,
        // 每日重复
        actionButton: [
          {
            title: '开始练习',
            type: reminderAgentManager.ActionButtonType
              .ACTION_BUTTON_TYPE_NORMAL,
          },
        ],
        wantAgent: agent,
        // 通知栏展示
        notificationId: params.reminderId,
        title: params.title,
        content: params.body,
        expiredContent: '呼吸提醒已过期',
        snoozeContent: '稍后提醒',
        // ★ 每日重复的触发时间
        triggerTimeInSeconds: this.calculateTriggerTime(
          params.hour, params.minute
        ),
        // 重复间隔(24小时 = 86400秒)
        repeatIntervalInSeconds: 86400,
        ringDuration: 10, // 提醒持续 10 秒
        slotType:
          notificationManager.SlotType.SOCIAL_COMMUNICATION,
      };

      const reminderId =
        await reminderAgentManager.publishReminder(
          reminderRequest
        );
      Log.i(TAG,
        `Reminder scheduled: id=${reminderId}, ` +
        `time=${params.hour}:${params.minute}`
      );
      result.success(reminderId);

    } catch (err) {
      const e = err as BusinessError;
      Log.e(TAG,
        `Schedule failed — code=${e.code}, msg=${e.message}`
      );
      result.error(
        'SCHEDULE_ERROR',
        e.message ?? '定时提醒设置失败',
        null
      );
    }
  }

  /**
   * 计算从当前时间到目标提醒时间的秒数。
   *
   * 如果目标时间已过(今天),则返回明天的同一时间的秒数。
   */
  private calculateTriggerTime(hour: number, minute: number): number {
    const now = new Date();
    const trigger = new Date(
      now.getFullYear(), now.getMonth(), now.getDate(),
      hour, minute, 0, 0
    );

    // 如果今天的目标时间已过,推迟到明天
    if (trigger.getTime() <= now.getTime()) {
      trigger.setDate(trigger.getDate() + 1);
    }

    return Math.floor(
      (trigger.getTime() - now.getTime()) / 1000
    );
  }

  private async handleCancelReminder(
    reminderId: number,
    result: MethodResult
  ): Promise<void> {
    try {
      await reminderAgentManager.cancelReminder(reminderId);
      Log.i(TAG, `Reminder cancelled: id=${reminderId}`);
      result.success(null);
    } catch (err) {
      const e = err as BusinessError;
      result.error(
        'CANCEL_ERROR',
        e.message ?? '取消提醒失败',
        null
      );
    }
  }

  private async handleCancelAllReminders(
    result: MethodResult
  ): Promise<void> {
    try {
      await reminderAgentManager.cancelAllReminders();
      Log.i(TAG, 'All reminders cancelled');
      result.success(null);
    } catch (err) {
      const e = err as BusinessError;
      result.error(
        'CANCEL_ALL_ERROR',
        e.message ?? '取消所有提醒失败',
        null
      );
    }
  }

  private async handleOpenSettings(
    result: MethodResult
  ): Promise<void> {
    try {
      if (!this.abilityContext) {
        result.error(
          'NO_CONTEXT', 'Ability 上下文不可用', null
        );
        return;
      }

      // 打开应用的通知设置页
      await notificationManager.openNotificationSettings(
        this.abilityContext
      );
      result.success(null);
    } catch (err) {
      const e = err as BusinessError;
      result.error(
        'OPEN_SETTINGS_ERROR',
        e.message ?? '打开设置失败',
        null
      );
    }
  }
}

这段代码有 300 行出头,但每个方法都遵循同一个模式:接收参数 → 调用系统 API → 返回结果。这种模式的好处是:当鸿蒙 API 升级时,只需要修改这一个文件;Flutter 端的代码完全不需要变化。


九、定时通知:后台代理提醒(ReminderAgent)方案

这是本文中最关键的一节。定时提醒是整个功能的灵魂,而 reminderAgentManager 是鸿蒙提供的系统级定时提醒 API。

为什么不用 Timer 或 WorkManager?

我们在第一章的对比表格中已经提到,Timer 在应用进入后台后会暂停(或被系统挂起),backgroundTaskManager 虽然有一定保活能力,但鸿蒙对后台任务有严格的时间限制:音频播放类任务在锁屏后约 30 分钟内会被终止,普通任务更短。

reminderAgentManager 的不同之处在于,它是系统服务。一旦你发布了一条提醒,它就注册到了系统的提醒代理中,由系统进程(而非你的应用进程)负责计时和触发。即使你的应用被杀死、设备重启,提醒依然有效。

ReminderAgent 的三种提醒类型:

类型 说明 适用场景 可重复
REMINDER_TYPE_TIMER 倒计时提醒 N 分钟/小时后提醒 是(设置 interval)
REMINDER_TYPE_CALENDAR 日历提醒 指定日期时间 否(单次)
REMINDER_TYPE_ALARM 闹钟提醒 指定小时分钟 是(每日重复)

我们使用 REMINDER_TYPE_ALARM,因为它天然支持"每天几点几分"的语义。triggerTimeInSeconds 的计算逻辑在上一节的 calculateTriggerTime 方法中:如果当前时间已过今天的提醒时间点,则推到明天。

一个重要的细节:repeatIntervalInSeconds: 86400。一旦第一条提醒触发,系统会根据这个间隔自动安排下一条。86400 秒正好是 24 小时,实现每天一次的效果。

跨设备重启的持久性。 reminderAgentManager.publishReminder 创建的提醒是持久化的——写入系统的提醒数据库中。即使设备关机重启,重启后系统会重新加载所有待触发的提醒。这意味着用户不需要每天重新设置提醒。


十、通知点击处理:从锁屏直达呼吸页面

用户点击通知后,系统通过 WantAgent 启动应用的 UIAbility,并携带 parameters 参数。我们需要在 Flutter 端接收这些参数并跳转到对应页面。

在鸿蒙端,EntryAbility 的生命周期中会收到 Intent 参数,我们需要把它们传递给 Flutter 引擎:

// ohos/entry/src/main/ets/entryability/EntryAbility.ets
// 在 EntryAbility 中新增 onNewWant 回调

import { Want } from '@ohos.app.ability.Want';

export default class EntryAbility extends FlutterAbility {
  configureFlutterEngine(flutterEngine: FlutterEngine) {
    super.configureFlutterEngine(flutterEngine);
    GeneratedPluginRegistrant.registerWith(flutterEngine);
    flutterEngine.getPlugins()?.add(new AudioPlayerPlugin());
    // ★ 注册通知插件
    flutterEngine.getPlugins()?.add(new NotificationPlugin());
  }

  /**
   * 当应用已启动时,用户点击通知触发 onNewWant。
   * 我们将 Want 参数传递给 Flutter 端。
   */
  onNewWant(want: Want): void {
    super.onNewWant(want);
    const params = want.parameters as Record<string, Object> | undefined;
    const targetPage = params?.['targetPage'] as string ?? '';
    const fromNotif = params?.['fromNotification'] == true;

    // 通过 MethodChannel 通知 Flutter 端
    // 此处复用 NotificationPlugin 中的 channel
    if (targetPage) {
      // 延迟发送,确保 Flutter 端 handler 已注册
      setTimeout(() => {
        this.notifyFlutterTap(targetPage, fromNotif);
      }, 500);
    }
  }

  private notifyFlutterTap(targetPage: string, fromNotif: boolean): void {
    // 调用 NotificationPlugin 的 sendTapToDart 逻辑
    // 实际实现中可以通过 getPlugin 获取实例
  }
}

在 Flutter 端,我们在应用启动时注册监听:

// lib/main.dart 中新增初始化代码
import 'data/notification_service.dart';

void main() async {
  WidgetsFlutterBinding.ensureInitialized();

  // ... 现有初始化代码 ...

  // ★ 初始化通知服务(注册 Native → Dart 回调)
  NotificationService.init();

  // ★ 监听通知点击事件
  NotificationService.onNotificationTap.listen((event) {
    if (event.targetPage == 'breathe') {
      // 使用全局 Navigator Key 进行页面跳转
      final context = navigatorKey.currentContext;
      if (context != null) {
        Navigator.of(context).push(
          MaterialPageRoute(
            builder: (_) => BreathePage(settings: settings),
          ),
        );
      }
    }
  });

  runApp(EBrufenApp(
    settings: settings,
    moodStorage: moodStorage,
  ));
}

这里有两个关键细节:

全局 Navigator Key。 当用户从通知进入应用时,应用可能处于任何页面(首页、白噪音页、甚至锁屏状态的冷启动)。为了让 Navigator.push 在任何页面上下文中都能工作,我们需要在 MaterialApp 上设置 navigatorKey

// lib/main.dart — EBrufenApp 中
final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();

class EBrufenApp extends StatelessWidget {
  // ...
  
  Widget build(BuildContext context) {
    return MaterialApp(
      navigatorKey: navigatorKey, // ★ 全局导航键
      title: 'E-Brufen',
      debugShowCheckedModeBanner: false,
      theme: AppTheme.materialTheme,
      home: HomePage(moodStorage: moodStorage, settings: settings),
    );
  }
}

冷启动与热启动的区别。 如果应用未运行,用户点击通知后系统会创建新的应用进程。此时 onNotificationTap 事件通过 EntryAbilityonCreate(want) 传入。如果应用已在后台运行,则通过 onNewWant(want) 传入。这两种情况在 Flutter 端需要统一处理:延迟发送事件,确保 Flutter 端的 handler 已经注册完毕。


十一、权限申请策略与用户隐私考量

鸿蒙的通知权限设计遵循"用户主动控制"原则。应用不能悄悄开启通知——必须经过用户明确授权。

权限的几种状态:

状态 isNotificationEnabled() 返回值 用户体验
未询问 false 首次请求时弹出系统授权弹窗
已授权 true 通知正常展示
已拒绝 false 引导用户去系统设置中手动开启
已关闭(系统设置) false 与"已拒绝"相同

一个好的权限申请策略不是"一上来就要权限",而是在合适的时机向用户解释"为什么需要这个权限"。对于 E-Brufen 来说,时机就是用户主动开启呼吸提醒功能的那一刻。

以下是在呼吸提醒设置页面中的权限申请流程:

// lib/pages/breathe/breathe_reminder_setting.dart(新页面)

class BreatheReminderSetting extends StatefulWidget {
  final AppSettings settings;
  const BreatheReminderSetting({super.key, required this.settings});
  
  State<BreatheReminderSetting> createState() =>
      _BreatheReminderSettingState();
}

class _BreatheReminderSettingState
    extends State<BreatheReminderSetting> {

  bool _reminderEnabled = false;
  TimeOfDay _reminderTime = const TimeOfDay(hour: 8, minute: 0);
  bool _permissionGranted = false;
  bool _isLoading = false;

  
  void initState() {
    super.initState();
    _checkPermission();
    _loadSettings();
  }

  Future<void> _checkPermission() async {
    final granted = await NotificationService.hasPermission();
    if (mounted) {
      setState(() => _permissionGranted = granted);
    }
  }

  void _loadSettings() {
    setState(() {
      _reminderEnabled = widget.settings.breatheReminderEnabled;
      final savedHour = widget.settings.breatheReminderHour;
      final savedMinute = widget.settings.breatheReminderMinute;
      _reminderTime =
          TimeOfDay(hour: savedHour, minute: savedMinute);
    });
  }

  Future<void> _toggleReminder(bool enabled) async {
    if (enabled && !_permissionGranted) {
      // ★ 先请求权限,并向用户解释原因
      final shouldRequest = await showDialog<bool>(
        context: context,
        builder: (ctx) => AlertDialog(
          title: const Text('开启通知权限'),
          content: const Text(
            'E-Brufen 需要在每天指定时间发送一条通知,'
            '提醒你做呼吸练习。\n\n'
            '我们不会发送任何其他无关通知。'
            '你可以随时在系统设置中关闭此权限。',
          ),
          actions: [
            TextButton(
              onPressed: () => Navigator.pop(ctx, false),
              child: const Text('暂不开启'),
            ),
            ElevatedButton(
              onPressed: () => Navigator.pop(ctx, true),
              child: const Text('允许'),
            ),
          ],
        ),
      );

      if (shouldRequest != true) return;

      final granted =
          await NotificationService.requestPermission();
      setState(() => _permissionGranted = granted);

      if (!granted) {
        // 用户拒绝了,提供去设置的入口
        if (mounted) {
          _showPermissionDeniedDialog();
        }
        return;
      }
    }

    // 授权成功后,调度/取消提醒
    setState(() => _isLoading = true);
    try {
      if (enabled) {
        await NotificationService.scheduleDailyReminder(
          hour: _reminderTime.hour,
          minute: _reminderTime.minute,
        );
      } else {
        await NotificationService.cancelReminder();
      }
      setState(() => _reminderEnabled = enabled);
      // 持久化到 AppSettings
      widget.settings.breatheReminderEnabled = enabled;
      widget.settings.breatheReminderHour = _reminderTime.hour;
      widget.settings.breatheReminderMinute =
          _reminderTime.minute;
    } catch (e) {
      if (mounted) {
        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(
            content: Text('提醒设置失败: $e'),
            backgroundColor: Colors.orange.shade700,
          ),
        );
      }
    } finally {
      if (mounted) setState(() => _isLoading = false);
    }
  }

  void _showPermissionDeniedDialog() {
    showDialog(
      context: context,
      builder: (ctx) => AlertDialog(
        title: const Text('通知权限未开启'),
        content: const Text(
          '你拒绝了通知权限。如果你之后改变主意,'
          '可以在系统设置中手动开启。',
        ),
        actions: [
          TextButton(
            onPressed: () => Navigator.pop(ctx),
            child: const Text('知道了'),
          ),
          ElevatedButton(
            onPressed: () {
              Navigator.pop(ctx);
              NotificationService.openNotificationSettings();
            },
            child: const Text('去设置'),
          ),
        ],
      ),
    );
  }

  // ... UI build 方法 ...
}

隐私考量清单:

  1. 只申请最小必要权限。 E-Brufen 只需要通知权限,不需要定位、通讯录、相册等。在 module.json5 中只声明 ohos.permission.NOTIFICATION_CONTROLLER(如果需要高级通知控制)或依赖用户授权即可。
  2. 首次申请前解释用途。 上面的代码示例中,在调用 requestPermission() 之前,我们弹出了一个自定义对话框,用平实的语言向用户解释"我们会做什么"和"我们不会做什么"。这种透明性能显著提升授权率。
  3. 拒绝后提供补救路径。 用户拒绝后,我们不反复骚扰,而是提供"去设置"的入口。鸿蒙的 openNotificationSettings 可以直接跳转到应用的通知设置页面,非常方便。
  4. 数据最小化。 提醒的配置(时间、开关状态)存储在本地 Hive 数据库中,不上传任何服务器。通知内容本身也是本地生成的,不经过网络。

十二、通知渠道管理:不同场景不同策略

随着应用功能的增长,通知的类型也会增多。E-Brufen 目前有两个通知场景:呼吸提醒和心情记录提醒(计划中)。为每个场景创建独立的通知渠道,让用户可以分别控制:

通知渠道 ID 场景 SlotType 震动 铃声 用户可控
breath_reminder 每日呼吸提醒 SOCIAL_COMMUNICATION
mood_reminder 每晚心情记录提醒 SERVICE_INFORMATION

分别管理的好处是:如果用户觉得呼吸提醒有用但心情记录提醒多余,她可以在系统设置中只关闭后者而不影响前者。

在原生端,我们分别为每个渠道调用 addSlot

// NotificationPlugin 中新增
private async createAllSlots(): Promise<void> {
  const slots: notificationManager.NotificationSlot[] = [
    {
      type: notificationManager.SlotType.SOCIAL_COMMUNICATION,
      level: notificationManager.SlotLevel.LEVEL_HIGH,
      desc: '每日呼吸练习提醒',
      badgeFlag: true,
      bypassDnd: false,
      vibrationEnabled: true,
      lightEnabled: false,
    },
    {
      type: notificationManager.SlotType.SERVICE_INFORMATION,
      level: notificationManager.SlotLevel.LEVEL_DEFAULT,
      desc: '每晚心情记录提醒,帮你养成记录心情的习惯',
      badgeFlag: false,
      bypassDnd: true,  // 心情记录提醒不打扰
      vibrationEnabled: false,
      lightEnabled: false,
    },
  ];

  for (const slot of slots) {
    try {
      await notificationManager.addSlot(slot);
    } catch (err) {
      // 如果 Slot 已存在,addSlot 会报错,忽略即可
      Log.w(TAG,
        `Slot add maybe duplicate: ${(err as BusinessError).message}`
      );
    }
  }
}

十三、完整体验:E-Brufen 每日呼吸提醒配置流程

让我们把以上的所有组件串联起来,描绘用户从设置提醒到收到的完整体验流程:

┌─────────────────────────────────────────────────────────────────┐
│                E-Brufen 每日呼吸提醒 —— 用户流程               │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  ┌──────────────────────┐                                       │
│  │ 1. 用户进入呼吸练习页 │                                      │
│  │    点击"设置提醒"     │                                      │
│  └──────────┬───────────┘                                       │
│             │                                                    │
│             ▼                                                    │
│  ┌──────────────────────┐                                       │
│  │ 2. 查看权限说明弹窗   │                                      │
│  │    "我们会每天提醒你  │                                      │
│  │     做呼吸练习"      │                                      │
│  └──────────┬───────────┘                                       │
│             │                                                    │
│        用户点击"允许"                                              │
│             │                                                    │
│             ▼                                                    │
│  ┌──────────────────────┐                                       │
│  │ 3. 选择提醒时间       │                                      │
│  │    08:00 / 12:30 /   │                                      │
│  │    20:00 / 自定义    │                                      │
│  └──────────┬───────────┘                                       │
│             │                                                    │
│             ▼                                                    │
│  ┌──────────────────────┐                                       │
│  │ 4. Flutter 端调用    │                                      │
│  │    NotificationSvc   │                                      │
│  │    .scheduleDaily    │                                      │
│  │    Reminder()        │  ── MethodChannel ──▶                │
│  │                      │                    原生端              │
│  │  AppSettings 持久化  │                    ReminderAgent      │
│  └──────────────────────┘                                       │
│                                                                 │
│  ══════════ 第二天 8:00 ══════════                              │
│                                                                 │
│  ┌──────────────────────┐                                       │
│  │ 5. 系统触发提醒       │                                      │
│  │    通知栏弹出"该做    │                                      │
│  │    呼吸练习了"        │                                      │
│  └──────────┬───────────┘                                       │
│             │                                                    │
│        用户点击通知                                               │
│             │                                                    │
│             ▼                                                    │
│  ┌──────────────────────┐                                       │
│  │ 6. WantAgent 启动     │                                      │
│  │    EntryAbility       │                                      │
│  │    (携带 targetPage:  │                                      │
│  │     'breathe')       │                                      │
│  └──────────┬───────────┘                                       │
│             │                                                    │
│             ▼                                                    │
│  ┌──────────────────────┐                                       │
│  │ 7. Flutter 端收到    │                                      │
│  │    onNotificationTap │                                      │
│  │    → Navigator.push  │                                      │
│  │    → BreathePage     │                                      │
│  └──────────────────────┘                                       │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

整个流程涉及的代码文件:

文件 角色
lib/data/notification_service.dart Flutter 端通知 API 封装
lib/pages/breathe/breathe_reminder_setting.dart 提醒设置 UI
lib/data/settings.dart 提醒偏好持久化(需新增字段)
lib/main.dart 初始化 NotificationService + 监听点击事件
ohos/.../NotificationPlugin.ets 原生端通知与提醒调度
ohos/.../EntryAbility.ets 接收 Want 参数并传递到 Flutter

AppSettings 新增的字段如下:

// lib/data/settings.dart 中新增

static const String _keyBreatheReminderEnabled = 'breatheReminderEnabled';
static const String _keyBreatheReminderHour = 'breatheReminderHour';
static const String _keyBreatheReminderMinute = 'breatheReminderMinute';

bool get breatheReminderEnabled {
  if (_box == null || !_box!.isOpen) return false;
  return _box!.get(_keyBreatheReminderEnabled, defaultValue: false);
}
set breatheReminderEnabled(bool v) {
  if (_box != null && _box!.isOpen) _box!.put(_keyBreatheReminderEnabled, v);
}

int get breatheReminderHour {
  if (_box == null || !_box!.isOpen) return 8;
  return _box!.get(_keyBreatheReminderHour, defaultValue: 8);
}
set breatheReminderHour(int v) {
  if (_box != null && _box!.isOpen) _box!.put(_keyBreatheReminderHour, v);
}

int get breatheReminderMinute {
  if (_box == null || !_box!.isOpen) return 0;
  return _box!.get(_keyBreatheReminderMinute, defaultValue: 0);
}
set breatheReminderMinute(int v) {
  if (_box != null && _box!.isOpen) _box!.put(_keyBreatheReminderMinute, v);
}

十四、鸿蒙通知系统的限制与兼容性说明

作为一篇实战导向的技术文章,有必要诚实记录我们在开发过程中遇到的实际限制和兼容性问题。

限制 1:ReminderAgent 的提醒数量上限。 鸿蒙系统对单个应用能够注册的 reminderAgent 提醒数量有上限——通常不超过 30 个(具体取决于设备厂商的配置)。对于 E-Brufen 这种只注册 1-2 个每日提醒的场景来说不是问题,但如果你计划实现"每周三下午 3 点提醒"或"隔天提醒"这种复杂调度,需要注意数量的累积。建议在每次 scheduleDailyReminder 之前先调用 cancelReminder 清理旧提醒,避免重复累积。

限制 2:WantAgent 参数大小限制。 WantAgentparameters 字段有大小限制——约 8KB。在携带参数时不要传入大段 JSON 或 Base64 编码的图片数据。我们的使用方式(targetPage + fromNotification 两个字符串)远低于这个限制。

限制 3:设备型号差异。 不同鸿蒙设备厂商对通知渠道的默认行为有细微差异。例如,某些设备默认关闭了新应用的通知权限(需要用户手动开启),某些设备对 SOCIAL_COMMUNICATION 类型的通知展示为静默。建议在 addSlot 后通过 getSlot 检查渠道的实际配置。

限制 4:冷启动时的通知点击延迟。 如果应用完全未运行,用户点击通知后系统需要先创建进程、初始化 Flutter 引擎、注册 Platform Channel Handler,这一过程在低端设备上可能需要 2-3 秒。在这段时间内,通知点击事件已经触发,如果 Flutter 端的 handler 还没注册完毕,事件就会被丢弃。我们在原生端通过 setTimeout(fn, 500) 做了一个保守的 500 毫秒延迟,但这个值可能需要在更多设备上测试调优。

兼容性矩阵:

API 鸿蒙 3.0+ 鸿蒙 4.0+ 鸿蒙 NEXT 备注
notificationManager.publish 支持 支持 支持 API 9+
notificationManager.addSlot 支持 支持 支持 API 9+
reminderAgentManager.publishReminder 支持 支持 支持 API 9+,参数略有变化
reminderAgentManager.cancelReminder 支持 支持 支持 API 9+
notificationManager.requestEnableNotification 支持 支持 API 10+
notificationManager.openNotificationSettings 支持 支持 API 10+
wantAgent.getWantAgent 支持 支持 支持 API 9+

E-Brufen 的最低支持版本是鸿蒙 4.0,所以我们可以安全使用上述所有 API。


十五、总结:从功能到体验的最后一公里

在本文中,我们完整地走过了一条从需求分析到技术选型、从架构设计到代码实现、从权限策略到兼容性考量的路径。核心结论可以归纳为四点:

第一,系统级 API 的选择决定了功能的可靠性。 对于定时提醒这种"应用不在前台时也需要触发"的场景,reminderAgentManager 是唯一的正确选择。它由系统进程托管,不依赖应用保活,也不需要用户手动设置省电白名单。代价是需要编写原生代码——但对于鸿蒙 Flutter 应用来说,这已经是日常开发的一部分了。

第二,Platform Channel 的架构模式具有可复制性。 E-Brufen 从音频播放模块开始就采用了"Dart 端服务层 + ArkTS 端 Plugin"的双层架构。通知模块完全复用了这个模式:Dart 端封装方法调用和事件流,ArkTS 端实现系统 API 调用。如果你需要为应用添加第三个原生功能(比如蓝牙连接、传感器读取),只需要增加一个新的 Plugin 文件和对应的 Dart Service 文件即可。架构是统一的,不需要重新设计。

第三,通知是"触达用户"的手段,不是目的。 从产品设计的角度来看,通知的价值在于"在合适的时机把用户带回应用"。如果通知的内容是机械的"请打开应用",用户会被烦到关闭通知权限。如果通知的内容是"该做呼吸练习了——来一次 5 分钟的盒式呼吸,让心静下来",用户会感受到关怀。我们花了很多笔墨讨论权限策略和隐私考量,因为信任一旦失去,就再也回不来了。

第四,鸿蒙平台的通知系统已相当成熟。 从 API 设计的角度来看,鸿蒙的 NotificationSlot + NotificationRequest + WantAgent 三位一体模型已经非常接近 Android 的成熟度。相比 iOS 的通知系统(需要 APNs + UserNotifications 两套体系),鸿蒙的方案更加自洽——本地通知和推送通知使用同一套 API,只是发布渠道不同。

最后,附上 E-Brufen 通知模块的完整文件清单和后续迭代方向:

代码文件清单:

  • lib/data/notification_service.dart(本文第七章)
  • lib/pages/breathe/breathe_reminder_setting.dart(本文第十一章)
  • lib/data/settings.dart(新增 3 个持久化字段)
  • lib/main.dart(初始化监听)
  • ohos/entry/src/main/ets/plugins/NotificationPlugin.ets(本文第八章)
  • ohos/entry/src/main/ets/entryability/EntryAbility.ets(新增 onNewWant 处理)

后续迭代方向:

  • 支持多个提醒时间点(晨间、午间、晚间各一次)
  • 基于用户情绪记录的智能提醒时机(心情低落时增加提醒频率)
  • 通知内容的个性化(结合当天的心情数据,生成有温度的提醒文案)
  • 通知与白噪音功能的联动(提醒点击后自动播放白噪音)

作者简介

E-Brufen Dev,鸿蒙 Flutter 开发者,AtomGit Flutter 鸿蒙客户端项目维护者。专注于跨平台移动应用开发,尤其关注 Flutter 在鸿蒙生态中的落地实践。系列文章"鸿蒙 Flutter 实战"持续更新中,覆盖音频播放、通知系统、数据持久化、UI 动画等主题。
AtomGithttps://atomgit.com/e-brufen/firstproject


Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐