在这里插入图片描述

大家好,我是熊猫钓鱼!欢迎大家和我一起探讨技术。希望您能点赞关注,谢谢!

摘要

本文是「Kotlin Multiplatform 三方库鸿蒙化适配」系列的第三篇,围绕 KMPNotifier(一个 Kotlin Multiplatform 本地通知库,Apache-2.0)在 OpenHarmony 上的适配展开。作者放弃「等上游支持 ohosArm64」和「自绘应用内通知 UI」两条路,选择路线 A:用 ArkTS 实现 LocalNotifier 接口、桥接系统 @kit.NotificationKit,把 KMPNotifier「与平台无关的通知模型」接到鸿蒙系统通知服务上。适配沿用系列一致的语义层 / 引擎层 / 验收页三层架构;其中最大的挑战,是鸿蒙通知无法像 Android/iOS 那样直接回调应用——点击与动作必须经由 WantAgent 回灌到 EntryAbility,重建事件模型。文中逐一记录 6 个基于 SDK 真实 .d.ts 签名的 ArkTS 踩坑,并最终在 HarmonyOS SDK 6.0.0(20)(API 20) 下零错误编译出包(HAP 约 529 KB),在模拟器完成「发布 / 取消 / 权限 / 点击回灌」全链路验证。文末将 Decompose、Ktor、Notifier 三篇并置,提炼核心判断:适配之难不在翻译 API,而在翻译平台间对不齐的模型。

目录

  1. 一个比 Ktor 还干脆的决定(路线取舍)
  2. 第一层:语义层,先把契约画对
  3. 第二层:引擎层,真正把通知发到系统栏
  4. 那道绕不过的弯:点击 / 动作怎么回灌
  5. 第三层:验收页,能发能取消能回灌
  6. 怎么知道它真的活了(编译 + 模拟器验证表)
  7. 那些只有踩过才知道的坑(6 个 ArkTS 真签名坑)
  8. 和三篇连起来看,是三种不同的「适配」
  9. 三条我现在认准的判断
  10. 工程信息(工具链 / 版本 / 仓库 / 社区)

写完 Decompose 和 Ktor 两篇之后,我一度以为自己对「适配」这两个字已经摸到一点门道了。Decompose 是复刻一套状态机,Ktor 是给一个真接口补真实现,两篇写完,思路清清楚楚。直到我动手做 KMPNotifier,才发现本地通知这件事,比我想的要多绕一道弯——而且这道弯,正好是鸿蒙和 Android/iOS 最不一样的地方。

KMPNotifier(io.github.mirzemehdi:kmpnotifier,Apache-2.0)是一个 Kotlin Multiplatform 的本地通知库,做的事情很薄也很稳定:发一条本地通知、按 id 取消、取消全部、监听点击和动作按钮。它最值钱的是那套「与平台无关的通知模型」,以及「点击/动作统一回灌」的事件能力。我这次的目标,就是让这套模型在鸿蒙上长出真东西,而不是换个皮。

做完了回头看,这件事,是三篇里「对平台边界判断」要求最高的一篇。

一个比 Ktor 还干脆的决定

动手前我先想清楚了一件事:KMPNotifier 的本地通知,在 Android 上最终也落到系统的 notification manager;在鸿蒙上,这个角色就是 @kit.NotificationKit(notificationManager)。两者语义高度同构——发一条、按 id 取消、取消全部、点击回灌应用。所以这里根本不是「用 ArkTS 重写一套通知逻辑」,而是「把同一套契约接到鸿蒙系统能力上」。

摆在面前的选择,其实只有两条,而且差别比表面大得多。

路线做法为什么没选 / 选了
路线 B:等上游给 ohos 写 expect/actualKMPNotifier 上游自己支持 ohosArm64,我坐等上游目前没有 ohos 目标;而且本地通知本质就是要调系统 API,绕不开鸿蒙这层
路线 B’:自己画个应用内通知 UI 冒充不碰系统通知栏,纯 ArkTS 弹个自定义卡片那东西根本不会出现在系统通知栏,等于把库的核心价值丢了——它叫「通知」,不是「弹窗」
路线 A:ArkTS 实现 LocalNotifier,桥接 NotificationKit(选)真接系统通知服务,点击/动作靠 WantAgent 回灌语义同构,能立刻跑,验证起来也直观

选 A 我心里是踏实的:通知这件事,系统已经做得够好了,我没必要在适配阶段自己造一套。但 A 也不是毫无代价——它逼我正面去解决那道鸿蒙独有的弯,下面会专门讲。
开发界面如下:
在这里插入图片描述

第一层:语义层,先把契约画对

和 Ktor 那篇一样,我第一步没写引擎,而是先写了个不碰任何 @kit.* 的语义层 Notifier.ets。它把 KMPNotifier 的契约原样画一遍:

/** 点击/动作回调里携带的数据,对应上游的 typealias PayloadData = Map */
export type PayloadData = Record<string, Object>;

/** 通知动作按钮(对应上游 NotificationAction) */
export class NotificationAction {
  readonly id: string;
  readonly title: string;
  readonly allowsTextInput: boolean;
  readonly inputLabel: string | null;
  constructor(id: string, title: string, allowsTextInput: boolean = false, inputLabel: string | null = null) { /* ... */ }
}

/** 对应上游 notify { ... } 的 DSL 字段集合 */
export class LocalNotifierOptions {
  id: number = -1;
  title: string = '';
  body: string = '';
  payloadData: PayloadData = {};
  image: NotificationImage | null = null;
  actions: NotificationAction[] = [];
  constructor(title: string, body: string) { this.title = title; this.body = body; }
}

/** 对应上游 LocalNotifier 接口(去掉了与鸿蒙无关的调度 API) */
export interface LocalNotifier {
  notify(options: LocalNotifierOptions): Promise<number>;   // 返回 id
  remove(id: number): Promise<void>;
  removeAll(): Promise<void>;
  requestPermission(): Promise<boolean>;
}

/** 对应上游 NotifierManager.Listener:点击与动作都通过回灌派发 */
export interface NotifierListener {
  onNotificationClicked?(data: PayloadData): void;
  onAction?(actionId: string, notificationId: number, payload: PayloadData): void;
}

这一层一共 201 行,它不 import 任何平台 API,将来想挪到 Node 里做离线测试都行。我特别在意这一点:KMPNotifier 最值钱的不是它的平台实现,是这套「与平台无关的通知模型」。只要模型画对了,底层换成 @kit.NotificationKit 就是顺理成章的事。

第二层:引擎层,真正把通知发到系统栏

骨架画好,引擎层 OhosNotifier.ets 就是把它接到 notificationManager 上。核心逻辑长这样(已经过 SDK 真实签名校正):

import { notificationManager } from '@kit.NotificationKit';
import { common, Want, wantAgent, WantAgent } from '@kit.AbilityKit';
import { KmpNotifier, LocalNotifier, LocalNotifierOptions, /* ... */ } from '../notifier/Notifier';

export class OhosLocalNotifier implements LocalNotifier {
  constructor(context: common.UIAbilityContext) {
    this.context = context;
    // 注意:应用标识在 abilityInfo 上,不在 applicationInfo 上
    this.bundleName = context.abilityInfo.bundleName;
    this.abilityName = context.abilityInfo.name;
  }

  async notify(options: LocalNotifierOptions): Promise<number> {
    const id: number = options.id >= 0 ? options.id : this.nextId();

    // 点击通知要回灌到应用:用 WantAgent 启动本 Ability 并埋标记位
    const clickAgent: WantAgent = await this.buildWantAgent({
      notificationClicked: 'true', notificationId: `${id}`,
      payloadJson: JSON.stringify(options.payloadData)
    });

    // 动作按钮:每个按钮一条独立的 WantAgent,埋上 actionId
    const actionButtons: Array<notificationManager.NotificationActionButton> = [];
    for (const action of options.actions) {
      const actionAgent: WantAgent = await this.buildWantAgent({
        notificationAction: action.id, notificationId: `${id}`,
        payloadJson: JSON.stringify(options.payloadData)
      });
      const button: notificationManager.NotificationActionButton = { title: action.title, wantAgent: actionAgent };
      actionButtons.push(button);
    }

    const content: notificationManager.NotificationContent = {
      notificationContentType: notificationManager.ContentType.NOTIFICATION_CONTENT_BASIC_TEXT,
      normal: { title: options.title, text: options.body }
    };
    const request: notificationManager.NotificationRequest = { id: id, content: content, wantAgent: clickAgent };
    if (actionButtons.length > 0) { request.actionButtons = actionButtons; }

    await notificationManager.publish(request);   // 发到系统通知栏
    return id;
  }

  async remove(id: number): Promise<void> { await notificationManager.cancel(id); }
  async removeAll(): Promise<void> { await notificationManager.cancelAll(); }
  async requestPermission(): Promise<boolean> {
    await notificationManager.requestEnableNotification(this.context);   // 运行时授权
    return true;
  }
}

你看 publish / cancel / cancelAll / requestEnableNotification 这几个调用,名字和 Android 的 NotificationManager 几乎是镜像的。这就是我开头说的「最舒服的适配」——契约对得上,系统能力也对得上,中间只隔一层很薄的翻译。

那道绕不过的弯:点击/动作怎么回灌

但「最舒服」是错觉,因为鸿蒙通知和 Android/iOS 有一个根本性的不同,而且这个不同正好戳在 KMPNotifier 最金贵的能力上。

在 Android/iOS 上,用户点了通知、点了动作按钮,系统能直接回调到你的应用进程。鸿蒙不行。鸿蒙通知不能像 Android/iOS 那样直接回调应用,它只能由系统启动(或拉起)一个 Ability,把你想带的信息塞进 want.parameters;你的 Ability 收到这个 want,自己识别出「这是哪条通知的点击 / 哪个动作」,再去派发事件。

换句话说,KMPNotifier 的「统一事件」能力,在鸿蒙上得靠 WantAgent 这个机制来重建。这就是我说的「绕一道弯」,也是这一篇比前两篇更考验平台判断的地方——你得先想清楚:点击通知这个动作,在鸿蒙的世界里,本质上是一个「启动 Ability」的事件,而不是一个「函数回调」。

我是这么接的:

  1. 发通知时,给 NotificationRequest.wantAgent 挂一个 WantAgent,它指向本应用的 bundleName + abilityName,并在 parameters 里埋 notificationClicked、notificationId、payloadJson 三个标记位。
  2. 动作按钮同理,每条按钮各自挂一个 WantAgent,埋 notificationAction(动作 id)和 notificationId。
  3. 应用的 EntryAbility 在 onCreate(冷启动)和 onNewWant(后台拉起)两条路径上,统一读 want.parameters,识别标记位后转交给 KmpNotifier.dispatchClicked / dispatchAction,由管理器派发给页面注册的监听者。

EntryAbility 里的回灌入口长这样(节选):

private handleNotificationWant(want: Want): void {
  const params: Record<string, Object> | undefined = want.parameters;
  if (params === undefined) { return; }
  const payloadJson: Object | undefined = params['payloadJson'];
  let payload: Record<string, Object> = {};
  if (typeof payloadJson === 'string' && payloadJson.length > 0) {
    payload = JSON.parse(payloadJson) as Record<string, Object>;
  }
  if (params['notificationClicked'] !== undefined) {
    KmpNotifier.dispatchClicked(payload);
    return;
  }
  const actionObj: Object | undefined = params['notificationAction'];
  if (actionObj !== undefined) {
    const actionId: string = String(actionObj);
    const idObj: Object | undefined = params['notificationId'];
    const notificationId: number = typeof idObj === 'string' ? parseInt(idObj, 10) : -1;
    KmpNotifier.dispatchAction(actionId, notificationId, payload);
  }
}

写这一段的时候我有点感慨:KMPNotifier 把「点击」「动作」抽象成两个干净的回调,到鸿蒙上,这两个回调被拆成了一圈 WantAgent + 一个 Ability 入口 + 一堆标记位。抽象没变,但「抽象落到平台」的那一步,辛苦程度完全不一样。适配的含金量,往往就藏在这种「抽象和平台对不齐」的缝隙里。

(顺带说一个已知的小遗憾:如果应用是完全冷启动(被通知直接拉起),这时候 NotifierDemo 页面还没 aboutToAppear,监听者还没注册,点击事件会「派发」但没人接。对于 demo 的主路径——应用已经在前台、用户点通知走 onNewWant——这件事是稳的。要彻底解决,可以加一个「回灌事件缓存队列」,冷启后补派。这篇先不展开,留个口子。)

第三层:验收页,能发能取消能回灌

为了让这个引擎「看得见摸得着」,我写了 NotifierDemo.ets 验收页,和 Ktor 页平级(首页点按钮切换)。这一页要证明四件事:自研 LocalNotifier 真能调 @kit.NotificationKit 把通知发到系统栏;按 id 取消、取消全部都能生效;通知权限是运行时授权——没授权时发不出去;点击通知 / 点动作按钮能回灌到应用,触发 KmpNotifier 的监听者。

页面上「换引擎」同样只要一行:

aboutToAppear(): void {
  const host = this.getUIContext().getHostContext() as common.UIAbilityContext;
  KmpNotifier.setLocalNotifier(new OhosLocalNotifier(host));   // 这一行就是「换引擎」
  this.listener = new DemoListener(
    (data) => { this.appendEvent('clicked', `通知被点击,payload=${JSON.stringify(data)}`); },
    (actionId, notificationId, payload) => { this.appendEvent('action', `动作「${actionId}」被触发,通知#${notificationId}`); }
  );
  KmpNotifier.addListener(this.listener);
}

DemoListener 是用一个具体 class 实现 NotifierListener 的——原因下面「ArkTS 坑」里会讲,直接把对象字面量赋给这个接口在 ArkTS 下会踩红线。

怎么知道它真的活了

验证分两层,和前两篇一致。

第一层是编译。我在 HarmonyOS SDK 6.0.0(20)(API 20)下对工程做了编译验证,BUILD SUCCESSFUL,零 ArkTS error(仅余若干 router.pushUrl / router.back 的废弃告警,属已知噪音)。产出的 entry-default-unsigned.hap 约 529 KB,未签名,模拟器直接能装。这一版 HAP 比 Ktor 那篇的 444 KB 大了一些,因为多了通知这层适配代码,符合预期。
编译如下:

请添加图片描述

第二层才是真刀真枪:首页点「KMPNotifier 本地通知适配 Demo(系统通知栏)→」,进去点按钮。
运行效果如下:
初始界面:
请添加图片描述
点击按钮触发对应效果:
请添加图片描述
可以观察到日志:
请添加图片描述
部署如下:
请添加图片描述
可以看到会要求授权允许:
请添加图片描述
查看系统通知界面,Ok,确实完成了!
请添加图片描述
展开消息,都发送成功了!
在这里插入图片描述

点击通知消息即可跳转回程序界面。

各按钮的预期我列一下,方便你对着查:

按钮你该看到什么
发布基础通知系统通知栏出现一条通知,标题/正文是你在 LocalNotifierOptions 里填的
发布带动作通知通知带一个「打开演示页」按钮
按 id 取消该条通知从通知栏消失
取消全部通知栏里本应用的通知被清空
请求通知权限首次会弹出系统授权弹窗
点击通知应用回到前台,页面日志卡出现 clicked 事件,并带回 payloadJson
点「打开演示页」动作页面日志卡出现 action 事件,带 actionId 和 notificationId

说句心里话,当我在模拟器上点开通知栏、看到自己发的那条通知安安静静躺在那儿、再点一下、页面日志里跳出 clicked 的时候,那种「它真的和系统通知服务通了」的踏实感,和当初 Ktor 点出第一个 200 是同一种。只不过这次,我还得多确认一件事:点的动作能原路回到我的监听者——那才是 KMPNotifier 的魂。

那些只有踩过才知道的坑

这一节留给想照着做的朋友。下面每一个,都是我在 hvigor 的红字里一个个认出来的,没有一个是我提前知道的。

NotificationRequest 的动作按钮字段叫 actionButtons,不是 notificationActionButton。 我第一版照着记忆写成了 request.notificationActionButton = ...,编译器当场不认。去翻 SDK 的 notificationRequest.d.ts 才发现,字段名是 actionButtons?: Array<NotificationActionButton>。名字差一个词,类型系统一律不认。

NotificationContent 的枚举字段叫 notificationContentType,不是 contentType。 这个坑更隐蔽:contentType 这个字段存在,但它接收的是 deprecated 的 @ohos.notification.ContentType(一个比 notificationManager.ContentType 多了 SYSTEM_LIVE_VIEW 的旧枚举)。你写 contentType: notificationManager.ContentType.NOTIFICATION_CONTENT_BASIC_TEXT,类型对不上,编译器照样拦。正确写法是 notificationContentType: notificationManager.ContentType.NOTIFICATION_CONTENT_BASIC_TEXT。一句话:deprecated 的字段和新的字段,连枚举类型都不是同一套,别混用。

NotificationActionButton 只有 title 和 wantAgent,没有 text 也没有 autoLaunch。 我第一版写的是 { text: action.title, wantAgent, autoLaunch: !action.allowsTextInput },三个字段错了两个。text 要改成 title,autoLaunch 在这个 SDK 版本里压根不存在。文本输入型按钮(allowsTextInput)我暂时没有在引擎层接线——按钮照样展示、照样能点回灌,只是不弹输入框。这点我在代码注释和这里都如实记下了,没藏着。

@kit.NotificationKit 不导出 NotificationRequest / NotificationActionButton 这些具名成员。 你不能 import { NotificationRequest } from '@kit.NotificationKit'。得走命名空间:notificationManager.NotificationRequest、notificationManager.NotificationActionButton。类型照样用,只是路径换一下。

应用标识在 abilityInfo,不在 applicationInfo。 我一开始写 context.applicationInfo.bundleName,编译器报错说 ApplicationInfo 没有 bundleName。正确的取法是 context.abilityInfo.bundleName / context.abilityInfo.name——WantAgent 要指向的「本应用哪个 Ability」,本来就该从 ability 信息里拿。

对象字面量不能直接赋给含 Record<string, Object> 方法签名的接口。 这是最让我意外的一个。我本来写 const listener: NotifierListener = { onNotificationClicked: (data) => {...}, onAction: (...) => {...} },编译器报 Object literal must correspond to some explicitly declared class or interface。原因大概是 NotifierListener 的方法参数用到了 Record<string, Object>,ArkTS 的 arkts-no-untyped-obj-literals 规则对这个组合特别敏感。解决办法是用一个具体 class 实现接口(像上面的 DemoListener),通过构造函数把闭包注进去——class 实现接口不受这条字面量规则约束。

通知是运行时授权,没授权时「静默发不出」。 鸿蒙和 Android 13+ 一样,通知权限要用户运行时给。没授权时调 publish 不报错、也不弹窗,但通知就是不会出现在通知栏。这件事和 Ktor 的 INTERNET 权限一样,属于「编译期不拦你、运行时才咬你」的那种。所以验收页上我专门留了「请求通知权限」按钮,并且把权限状态显示出来,提醒开发者:通知发不出去,先看权限。

和三篇连起来看,是三种不同的「适配」

写到这里,我想把 Decompose、Ktor、Notifier 三篇连起来,因为我觉得这才是整个系列最值得讲清楚的一点。

维度DecomposeKtorKMPNotifier
本质纯状态管理(组件树 / 生命周期 / 状态)真实网络能力(发请求、收响应、处理错误)真实系统能力(系统通知栏 / 权限 / 事件回灌)
鸿蒙上怎么做的ArkTS 等价复刻(语义对上,不是真上游)ArkTS 真实现 HttpClientEngineArkTS 真实现 LocalNotifier
最难的弯组件树根必须在宿主创建一次DNS/TLS 交给系统(别硬刚)点击/动作不能直接回调,必须走 WantAgent 回灌
.so 就绪后替换桥接层替换引擎层替换引擎层(换 Kotlin 侧实现)
验证难度相对容易(无外部依赖)难(真联网、权限、DNS/TLS 要通)中(不用联网,但权限和回灌链路要通)

Decompose 考验耐心(把状态机重写一遍),Ktor 考验你对平台网络边界的判断(别去造 TLS),Notifier 考验你对「事件模型」落地的判断(Android/iOS 的回调,在鸿蒙上得翻译成 Ability 启动 + 标记位)。三篇下来,我越来越确信一句话:适配的难,从来不在「翻译 API」,而在「翻译那些平台之间对不齐的模型」。

三条我现在认准的判断

折腾完这一圈,有三件事我想得很清楚:

先看抽象层,再决定写什么。 KMPNotifier 把「本地通知」抽成了 LocalNotifier 这个接口,加上 NotifierListener 这个事件模型,所以我的适配,说到底是「实现接口 + 重建事件回灌」。抽象画在哪儿,工作量就在哪儿。

能复用平台能力,就别硬刚平台短板。 通知直接交给 @kit.NotificationKit,DNS/TLS 那种「Ktor 式」的坑这里根本没有;唯一要自己补的,是回灌这件事——而这恰恰是无法交给系统的,必须自己接。

事件模型对不齐的时候,别试图强行 1:1 映射。 Android/iOS 的「点击直接回调」,在鸿蒙上硬要找一个等价物是找不到的。与其拧巴,不如承认「点击 = 启动 Ability + 带参数」这个事实,把回灌做成一个清晰的 EntryAbility 入口。承认平台差异,反而写得最顺。


工程信息

  • Demo 工程:E:\huawei\hongmengdev\demo(在 Decompose / Ktor demo 基础上新增 Notifier 三文件 + EntryAbility 回灌逻辑,复用同一工程,不破坏原有功能)
  • 新增代码:约 836 行 ArkTS(Notifier.ets 201 + OhosNotifier.ets 204 + NotifierDemo.ets 431),另在 EntryAbility.ets 增加约 40 行回灌入口
  • 工具链:DevEco Studio 26.0.0 Release · HarmonyOS SDK 6.0.0(20)(API 20)· KMP&CMP 鸿蒙社区工具链 v1.1.0(Kotlin 2.2.21 / CMP 1.9.2)
  • 目标设备:HarmonyOS 手机 ROM 6.1+(DevEco 模拟器 / 真机)
  • 适配路线:路线 A(自研 LocalNotifier 桥接 @kit.NotificationKit,点击/动作经 WantAgent 回灌)
  • 关键权限:通知运行时授权(notificationManager.requestEnableNotification)
  • 适配后仓库:https://atomgit.com/wdracky/kmp-ohos-adapters/tree/main/notifier
  • KMP/CMP 鸿蒙化社区:https://atomgit.com/CPF-KMP-CMP

欢迎加入KMP&CMP 鸿蒙社区: https://atomgit.com/CPF-KMP-CMP

推荐 AtomCode(AI 编程工具,专属邀请码):https://atomgit.com/dashboard/atomcode?utm_source=av&isLogin=99

顺手说一句下一步的打算:像 KMPNotifier 的「定时通知 / 大图通知」这类能力,本 SDK 的 NotificationRequest 也都支持(deliveryTime、largeIcon、NotificationPictureContent 等),只是字段名和枚举同样要走真实签名核对;最值得补的反而是前面提到的「冷启回灌缓存队列」——把点击事件先存住,等页面 aboutToAppear 注册监听后再补派,这样冷启动场景也能完整接住。等 kable(Juul Kable,KMP 蓝牙库)那篇写完,这个系列的第一阶段就算收口了。

Logo

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

更多推荐