ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Kotlin Multiplatform for OpenHarmony 实战:为 KMPNotifier 实现 OpenHarmony 本地通知引擎

2026/10/6 7:59:16 拓冰建站 浏览量
Kotlin Multiplatform for OpenHarmony 实战:为 KMPNotifier 实现 OpenHarmony 本地通知引擎 大家好我是熊猫钓鱼欢迎大家和我一起探讨技术。希望您能点赞关注谢谢摘要本文是「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而在翻译平台间对不齐的模型。目录一个比 Ktor 还干脆的决定路线取舍第一层语义层先把契约画对第二层引擎层真正把通知发到系统栏那道绕不过的弯点击 / 动作怎么回灌第三层验收页能发能取消能回灌怎么知道它真的活了编译 模拟器验证表那些只有踩过才知道的坑6 个 ArkTS 真签名坑和三篇连起来看是三种不同的「适配」三条我现在认准的判断工程信息工具链 / 版本 / 仓库 / 社区写完 Decompose 和 Ktor 两篇之后我一度以为自己对「适配」这两个字已经摸到一点门道了。Decompose 是复刻一套状态机Ktor 是给一个真接口补真实现两篇写完思路清清楚楚。直到我动手做 KMPNotifier才发现本地通知这件事比我想的要多绕一道弯——而且这道弯正好是鸿蒙和 Android/iOS 最不一样的地方。KMPNotifierio.github.mirzemehdi:kmpnotifierApache-2.0是一个 Kotlin Multiplatform 的本地通知库做的事情很薄也很稳定发一条本地通知、按 id 取消、取消全部、监听点击和动作按钮。它最值钱的是那套「与平台无关的通知模型」以及「点击/动作统一回灌」的事件能力。我这次的目标就是让这套模型在鸿蒙上长出真东西而不是换个皮。做完了回头看这件事是三篇里「对平台边界判断」要求最高的一篇。一个比 Ktor 还干脆的决定动手前我先想清楚了一件事KMPNotifier 的本地通知在 Android 上最终也落到系统的 notification manager在鸿蒙上这个角色就是kit.NotificationKitnotificationManager。两者语义高度同构——发一条、按 id 取消、取消全部、点击回灌应用。所以这里根本不是「用 ArkTS 重写一套通知逻辑」而是「把同一套契约接到鸿蒙系统能力上」。摆在面前的选择其实只有两条而且差别比表面大得多。路线做法为什么没选 / 选了路线 B等上游给 ohos 写 expect/actualKMPNotifier 上游自己支持 ohosArm64我坐等上游目前没有 ohos 目标而且本地通知本质就是要调系统 API绕不开鸿蒙这层路线 B’自己画个应用内通知 UI 冒充不碰系统通知栏纯 ArkTS 弹个自定义卡片那东西根本不会出现在系统通知栏等于把库的核心价值丢了——它叫「通知」不是「弹窗」路线 AArkTS 实现 LocalNotifier桥接 NotificationKit选真接系统通知服务点击/动作靠 WantAgent 回灌语义同构能立刻跑验证起来也直观选 A 我心里是踏实的通知这件事系统已经做得够好了我没必要在适配阶段自己造一套。但 A 也不是毫无代价——它逼我正面去解决那道鸿蒙独有的弯下面会专门讲。开发界面如下第一层语义层先把契约画对和 Ktor 那篇一样我第一步没写引擎而是先写了个不碰任何kit.*的语义层Notifier.ets。它把 KMPNotifier 的契约原样画一遍/** 点击/动作回调里携带的数据对应上游的 typealias PayloadData Map */exporttypePayloadDataRecordstring,Object;/** 通知动作按钮对应上游 NotificationAction */exportclassNotificationAction{readonlyid:string;readonlytitle:string;readonlyallowsTextInput:boolean;readonlyinputLabel:string|null;constructor(id:string,title:string,allowsTextInput:booleanfalse,inputLabel:string|nullnull){/* ... */}}/** 对应上游 notify { ... } 的 DSL 字段集合 */exportclassLocalNotifierOptions{id:number-1;title:string;body:string;payloadData:PayloadData{};image:NotificationImage|nullnull;actions:NotificationAction[][];constructor(title:string,body:string){this.titletitle;this.bodybody;}}/** 对应上游 LocalNotifier 接口去掉了与鸿蒙无关的调度 API */exportinterfaceLocalNotifier{notify(options:LocalNotifierOptions):Promisenumber;// 返回 idremove(id:number):Promisevoid;removeAll():Promisevoid;requestPermission():Promiseboolean;}/** 对应上游 NotifierManager.Listener点击与动作都通过回灌派发 */exportinterfaceNotifierListener{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}fromkit.NotificationKit;import{common,Want,wantAgent,WantAgent}fromkit.AbilityKit;import{KmpNotifier,LocalNotifier,LocalNotifierOptions,/* ... */}from../notifier/Notifier;exportclassOhosLocalNotifierimplementsLocalNotifier{constructor(context:common.UIAbilityContext){this.contextcontext;// 注意应用标识在 abilityInfo 上不在 applicationInfo 上this.bundleNamecontext.abilityInfo.bundleName;this.abilityNamecontext.abilityInfo.name;}asyncnotify(options:LocalNotifierOptions):Promisenumber{constid:numberoptions.id0?options.id:this.nextId();// 点击通知要回灌到应用用 WantAgent 启动本 Ability 并埋标记位constclickAgent:WantAgentawaitthis.buildWantAgent({notificationClicked:true,notificationId:${id},payloadJson:JSON.stringify(options.payloadData)});// 动作按钮每个按钮一条独立的 WantAgent埋上 actionIdconstactionButtons:ArraynotificationManager.NotificationActionButton[];for(constactionofoptions.actions){constactionAgent:WantAgentawaitthis.buildWantAgent({notificationAction:action.id,notificationId:${id},payloadJson:JSON.stringify(options.payloadData)});constbutton:notificationManager.NotificationActionButton{title:action.title,wantAgent:actionAgent};actionButtons.push(button);}constcontent:notificationManager.NotificationContent{notificationContentType:notificationManager.ContentType.NOTIFICATION_CONTENT_BASIC_TEXT,normal:{title:options.title,text:options.body}};constrequest:notificationManager.NotificationRequest{id:id,content:content,wantAgent:clickAgent};if(actionButtons.length0){request.actionButtonsactionButtons;}awaitnotificationManager.publish(request);// 发到系统通知栏returnid;}asyncremove(id:number):Promisevoid{awaitnotificationManager.cancel(id);}asyncremoveAll():Promisevoid{awaitnotificationManager.cancelAll();}asyncrequestPermission():Promiseboolean{awaitnotificationManager.requestEnableNotification(this.context);// 运行时授权returntrue;}}你看publish / cancel / cancelAll / requestEnableNotification这几个调用名字和 Android 的NotificationManager几乎是镜像的。这就是我开头说的「最舒服的适配」——契约对得上系统能力也对得上中间只隔一层很薄的翻译。那道绕不过的弯点击/动作怎么回灌但「最舒服」是错觉因为鸿蒙通知和 Android/iOS 有一个根本性的不同而且这个不同正好戳在 KMPNotifier 最金贵的能力上。在 Android/iOS 上用户点了通知、点了动作按钮系统能直接回调到你的应用进程。鸿蒙不行。鸿蒙通知不能像 Android/iOS 那样直接回调应用它只能由系统启动或拉起一个 Ability把你想带的信息塞进want.parameters你的 Ability 收到这个 want自己识别出「这是哪条通知的点击 / 哪个动作」再去派发事件。换句话说KMPNotifier 的「统一事件」能力在鸿蒙上得靠WantAgent这个机制来重建。这就是我说的「绕一道弯」也是这一篇比前两篇更考验平台判断的地方——你得先想清楚点击通知这个动作在鸿蒙的世界里本质上是一个「启动 Ability」的事件而不是一个「函数回调」。我是这么接的发通知时给NotificationRequest.wantAgent挂一个 WantAgent它指向本应用的bundleName abilityName并在parameters里埋notificationClicked、notificationId、payloadJson三个标记位。动作按钮同理每条按钮各自挂一个 WantAgent埋notificationAction动作 id和notificationId。应用的EntryAbility在onCreate冷启动和onNewWant后台拉起两条路径上统一读want.parameters识别标记位后转交给KmpNotifier.dispatchClicked / dispatchAction由管理器派发给页面注册的监听者。EntryAbility里的回灌入口长这样节选privatehandleNotificationWant(want:Want):void{constparams:Recordstring,Object|undefinedwant.parameters;if(paramsundefined){return;}constpayloadJson:Object|undefinedparams[payloadJson];letpayload:Recordstring,Object{};if(typeofpayloadJsonstringpayloadJson.length0){payloadJSON.parse(payloadJson)asRecordstring,Object;}if(params[notificationClicked]!undefined){KmpNotifier.dispatchClicked(payload);return;}constactionObj:Object|undefinedparams[notificationAction];if(actionObj!undefined){constactionId:stringString(actionObj);constidObj:Object|undefinedparams[notificationId];constnotificationId:numbertypeofidObjstring?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{consthostthis.getUIContext().getHostContext()ascommon.UIAbilityContext;KmpNotifier.setLocalNotifier(newOhosLocalNotifier(host));// 这一行就是「换引擎」this.listenernewDemoListener((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?: ArrayNotificationActionButton。名字差一个词类型系统一律不认。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要改成titleautoLaunch在这个 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 信息里拿。对象字面量不能直接赋给含Recordstring, Object方法签名的接口。这是最让我意外的一个。我本来写const listener: NotifierListener { onNotificationClicked: (data) {...}, onAction: (...) {...} }编译器报Object literal must correspond to some explicitly declared class or interface。原因大概是NotifierListener的方法参数用到了Recordstring, ObjectArkTS 的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 考验你对平台网络边界的判断别去造 TLSNotifier 考验你对「事件模型」落地的判断Android/iOS 的回调在鸿蒙上得翻译成 Ability 启动 标记位。三篇下来我越来越确信一句话适配的难从来不在「翻译 API」而在「翻译那些平台之间对不齐的模型」。三条我现在认准的判断折腾完这一圈有三件事我想得很清楚先看抽象层再决定写什么。KMPNotifier 把「本地通知」抽成了LocalNotifier这个接口加上NotifierListener这个事件模型所以我的适配说到底是「实现接口 重建事件回灌」。抽象画在哪儿工作量就在哪儿。能复用平台能力就别硬刚平台短板。通知直接交给kit.NotificationKitDNS/TLS 那种「Ktor 式」的坑这里根本没有唯一要自己补的是回灌这件事——而这恰恰是无法交给系统的必须自己接。事件模型对不齐的时候别试图强行 1:1 映射。Android/iOS 的「点击直接回调」在鸿蒙上硬要找一个等价物是找不到的。与其拧巴不如承认「点击 启动 Ability 带参数」这个事实把回灌做成一个清晰的 EntryAbility 入口。承认平台差异反而写得最顺。工程信息Demo 工程E:\huawei\hongmengdev\demo在 Decompose / Ktor demo 基础上新增 Notifier 三文件 EntryAbility 回灌逻辑复用同一工程不破坏原有功能新增代码约 836 行 ArkTSNotifier.ets201 OhosNotifier.ets204 NotifierDemo.ets431另在EntryAbility.ets增加约 40 行回灌入口工具链DevEco Studio 26.0.0 Release · HarmonyOS SDK6.0.0(20)API 20· KMPCMP 鸿蒙社区工具链 v1.1.0Kotlin 2.2.21 / CMP 1.9.2目标设备HarmonyOS 手机 ROM 6.1DevEco 模拟器 / 真机适配路线路线 A自研LocalNotifier桥接kit.NotificationKit点击/动作经 WantAgent 回灌关键权限通知运行时授权notificationManager.requestEnableNotification适配后仓库https://atomgit.com/wdracky/kmp-ohos-adapters/tree/main/notifierKMP/CMP 鸿蒙化社区https://atomgit.com/CPF-KMP-CMP欢迎加入KMPCMP 鸿蒙社区 https://atomgit.com/CPF-KMP-CMP推荐 AtomCodeAI 编程工具专属邀请码https://atomgit.com/dashboard/atomcode?utm_sourceavisLogin99顺手说一句下一步的打算像 KMPNotifier 的「定时通知 / 大图通知」这类能力本 SDK 的NotificationRequest也都支持deliveryTime、largeIcon、NotificationPictureContent等只是字段名和枚举同样要走真实签名核对最值得补的反而是前面提到的「冷启回灌缓存队列」——把点击事件先存住等页面aboutToAppear注册监听后再补派这样冷启动场景也能完整接住。等 kableJuul KableKMP 蓝牙库那篇写完这个系列的第一阶段就算收口了。