难度等级:⭐⭐⭐☆☆
前置知识:Flutter 联合插件(federated plugin)结构、ArkTS 基础、鸿蒙权限机制
本文对应适配仓库:https://atomgit.com/oh-flutter/device_calendar_plus(上游:bullet-to/device_calendar_plus 0.12.0)
文章目录
-
- 一、为什么要做这次适配
- 二、适配架构:一个新包,两行注册
- 三、踩坑实录:四个必须跨过去的坎
-
- [1:权限弹窗永远不弹,requestPermissions 静默返回 denied](#1:权限弹窗永远不弹,requestPermissions 静默返回 denied)
- [2:hasPermissions 永远区分不出"从未询问"](#2:hasPermissions 永远区分不出"从未询问")
- [3:createCalendar 真机失败,所有错误长得一模一样](#3:createCalendar 真机失败,所有错误长得一模一样)
- [4:循环日程查询不展开,一个 RRULE 只返回一条](#4:循环日程查询不展开,一个 RRULE 只返回一条)
- 附带修正:提醒的单位陷阱
- 四、运行效果与功能验证
- 五、能力边界:适配后仍做不到的事
- 六、接入总结与后续计划
一、为什么要做这次适配
团队的项目需要在鸿蒙设备上读写系统日历------创建日程、查询某段时间的安排、处理每周重复的例会。社区里的 device_calendar_plus 是一个联合插件,Android 走 Kotlin、iOS 走 Swift,但没有任何鸿蒙实现,在鸿蒙设备上调用会直接抛 MissingPluginException。
选库前先在 AtomGit 的 oh-flutter 组织里查重,确认尚无该库的适配版本,于是立项自适配。本文记录这次适配的完整过程:架构怎么搭、权限弹窗为什么拉不起来、循环日程怎么展开、错误码为什么全部长一样,以及每一类问题最终怎么验证。
环境与版本(先交代清楚,后文结论都基于此):
| 项 | 版本 |
|---|---|
| 鸿蒙 SDK | HarmonyOS 26.0.0(API 26),compatibleSdkVersion / targetSdkVersion 均为 26.0.0 |
| Flutter | 3.47.5-ohos-1.0.0(Flutter OH 版) |
| Dart | 3.13.x(workspace 约束 sdk: ^3.12.0) |
| IDE | DevEco Studio 26.0 |
| 上游基线 | device_calendar_plus 0.12.0 |
二、适配架构:一个新包,两行注册
联合插件的适配原则是"不动上游、独立成包"。Android 和 iOS 实现一个字不改,所有鸿蒙代码收敛进新增的 device_calendar_plus_ohos 包:
text
device_calendar_plus/
├── packages/
│ ├── device_calendar_plus/ # 主包(只加 ohos 平台声明)
│ ├── device_calendar_plus_platform_interface/
│ ├── device_calendar_plus_android/ # 未改动
│ ├── device_calendar_plus_ios/ # 未改动
│ ├── device_calendar_plus_ohos/ # 新增:鸿蒙实现
│ │ ├── lib/ # Dart 层,MethodChannel 转发
│ │ └── ohos/ # ArkTS 原生层(HAR)
│ │ └── src/main/ets/pages/DeviceCalendarPlusOhosPlugin.ets
│ └── device_calendar_plus/example/ohos/ # 新增:鸿蒙示例工程
Dart 层实现平台接口,所有调用经 device_calendar_plus_ohos 方法通道转发;原生层用 @kit.CalendarKit 的 calendarManager 做日历/日程 CRUD,用 abilityAccessCtrl 申请权限。整个原生插件约 1376 行 ArkTS。
dart
/// 鸿蒙实现包入口:注册为平台默认实例,主包无感知切换
class DeviceCalendarPlusOhos extends DeviceCalendarPlusPlatform {
@visibleForTesting
final methodChannel = const MethodChannel('device_calendar_plus_ohos');
static void registerWith() {
DeviceCalendarPlusPlatform.instance = DeviceCalendarPlusOhos();
}
}
整条调用链路如下:

主包改动只有两处:pubspec.yaml 增加 ohos 平台声明,workspace 根加入新包参与统一依赖解析。业务侧引用时不发布到 pub.dev 的实现包,用 dependency_overrides 指到 Git 仓库:
yaml
dependencies:
device_calendar_plus:
git:
url: https://atomgit.com/oh-flutter/device_calendar_plus.git
path: packages/device_calendar_plus
ref: 0.12.0-ohos-1.0.0
dependency_overrides:
device_calendar_plus_ohos:
git:
url: https://atomgit.com/oh-flutter/device_calendar_plus.git
path: packages/device_calendar_plus_ohos
ref: device_calendar_plus_ohos-0.12.0-ohos-1.0.0
两个日历权限都是 user_grant 类型,reason 和 usedScene 必须声明,缺一个安装期就过不了校验:
json5
{
"module": {
"requestPermissions": [
{
"name": "ohos.permission.READ_CALENDAR",
"reason": "$string:read_calendar_reason",
"usedScene": { "abilities": ["EntryAbility"], "when": "inuse" }
},
{
"name": "ohos.permission.WRITE_CALENDAR",
"reason": "$string:write_calendar_reason",
"usedScene": { "abilities": ["EntryAbility"], "when": "inuse" }
}
]
}
}
三、踩坑实录:四个必须跨过去的坎
适配第一版跑通主流程后,真机验证阶段接连暴露问题。以下每个坑都来自实际提交(e6b1cf5、5a4cc8a 两个 fix 提交),附现象、根因、解决和验证方式。
1:权限弹窗永远不弹,requestPermissions 静默返回 denied
现象 :第一版在真机上点"Request Calendar Permissions",界面毫无反应,返回值直接是 denied。不是用户点了拒绝------是系统弹窗根本没拉起来。
根因 :插件初始化时拿到的是 ApplicationContext,而 requestPermissionsFromUser 要求 UIAbility Context 才能拉起系统弹窗。用 ApplicationContext 调用,弹窗不报错也不弹出,直接走失败分支。
解决 :插件实现 AbilityAware 接口,在 onAttachedToAbility 里持有 UIAbility Context;同时 CalendarManager 也用这个 Context 重建------只用 ApplicationContext 时还有个隐藏症状"能读日历、不能建账户"。
typescript
// 弹窗与 CalendarManager 都依赖 UIAbility Context
if (!this.abilityContext) {
result.success('denied');
return;
}
const atManager = abilityAccessCtrl.createAtManager();
const permissions: Permissions[] = writeOnly
? [PERMISSION_WRITE_CALENDAR]
: [PERMISSION_READ_CALENDAR, PERMISSION_WRITE_CALENDAR];
atManager.requestPermissionsFromUser(this.abilityContext, permissions)
.then((res): void => {
const allGranted = res.authResults.every(
(r: number): boolean => r === abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED);
result.success(allGranted ? 'granted' : 'denied');
});
验证 :真机重跑 demo,系统弹窗正常拉起,读写两个权限依次询问,授权后回读状态为 granted。



2:hasPermissions 永远区分不出"从未询问"
现象 :修复坑 1 后,hasPermissions 只能返回"已授权"和"已拒绝"两种状态。主包的 AutoPermissionMode 逻辑依赖 notDetermined(从未询问过)来自动发起申请,鸿蒙上这个分支永远不触发。
根因 :用 checkSelfPermission 做布尔判断,无法区分"用户明确拒绝"和"还没问过"。
解决 :改用 abilityAccessCtrl.getSelfPermissionStatus(API 20+),把权限状态三分类完整映射回平台接口契约。
验证 :卸载重装后首次调用返回 notDetermined,拒绝后返回 denied,与 Android/iOS 行为对齐。
3:createCalendar 真机失败,所有错误长得一模一样
这是排查时间最长的一个坑,也是改动收益最大的一个。
现象 :真机上创建日历偶发失败,demo 弹窗只有一句 CreateCalendar failed!。同一个失败文案背后,可能权限没给、参数非法、能力不支持、也可能是 CalendarKit 内部错误------完全无法定位。
根因有两层:
- 原生侧只回传了
BusinessError.message,丢弃了err.code。201(权限拒绝)、401(参数错误)、801(能力不支持)、23900004(dataShare 内部错误)全部坍缩成同一句话; - 参数解析写在
try块之外,缺参时异常逃逸且不回包------Dart 侧的 Future 永远挂起。
解决 :错误统一按 code: message 格式回传;demo 弹窗同时展示 errorCode 和 details:
typescript
/// BusinessError 统一渲染为 "code: message"
/// code 才能区分:201 权限 / 401 参数 / 801 能力 / 23900004 内部错误
private describeError(err: BusinessError): string {
if (!err) return 'Unknown error';
const message: string = err.message ? err.message : '';
const code = err.code;
if (typeof code !== 'number') {
return message !== '' ? message : String(err);
}
return message !== '' ? `${code}: ${message}` : `Error ${code}`;
}
顺带修掉了另一个隐患:CalendarKit 对重复创建同名日历会直接拒绝,改成先按名称查找、复用已有日历的 get-or-create 语义,避免失败后留下不可见的重名账户。
验证:真机复现失败路径时,弹窗能给出具体错误码,可按码定位(权限类走设置页引导,参数类修调用方)。错误分文与修复前对比:
| 场景 | 修复前 | 修复后 |
|---|---|---|
| 权限被拒 | CreateCalendar failed! | 201: Permission denied(引导去设置页) |
| 参数非法 | CreateCalendar failed! | 401: Parameter error(修调用方) |
| 设备不支持 | CreateCalendar failed! | 801: Capability not supported |

4:循环日程查询不展开,一个 RRULE 只返回一条
现象 :每周重复的例会,用 listEvents 查一周区间,只返回起点那一天的一条记录,后续发生全部丢失。
根因 :CalendarKit 的 queryEvents 返回的是日程主记录;循环日程的"每次发生"要用 queryEventInstances(API 18+)展开。同时 RRULE 的 UNTIL 字段双向解析格式不一致------原生生成的带 THHMMSSZ 后缀,主包 Dart 解析器按纯日期解析,来回一趟就解析失败。
解决 :listEvents 与 getEvent(带 timestamp 时)全部改走 queryEventInstances;实例 ID 按 eventId@instanceStartTime 编码,单发事件的实例 ID 等于事件 ID。UNTIL 统一为 YYYYMMDD[THHMMSSZ](UTC)格式,与主包解析器对齐。
验证 :创建 FREQ=WEEKLY、BYDAY=MO,WE 的日程,查询一周区间返回全部发生;单次修改某一 occurrence 后该次被 excludedDates 排除、生成独立日程,系列其余不变。
循环日程"本次及以后"的拆分是近似实现,逻辑如下:

附带修正:提醒的单位陷阱
文档与实现曾按 Alarm.alarmOffset(毫秒)映射提醒,实测核对后修正为 Event.reminderTime------CalendarKit 该字段单位是整分钟,传毫秒会导致提醒时间扩大 60000 倍。跨平台字段单位差异这类问题,靠读 API 文档容易漏,靠真机设一次提醒看触发时间才能抓到。
四、运行效果与功能验证
示例工程在真机上完整跑通:权限申请、日历创建(支持选色、同名 get-or-create)、日程创建(含提醒)、循环日程的单次/系列操作、系统表单跳转。

验证方式分三层:
- Dart 单元测试 :覆盖方法通道参数序列化、
Patch清除字段语义、Android 专属参数忽略与空写入短路; - 真机手工验证:示例工程的 Manual Checks 页按平台区分文案,逐项核对权限层级、系统表单、设置页往返;
- 错误路径验证:构造权限拒绝、非法 calendarId、无日历设备等场景,核对错误码回传。
按 ID 定位日程的读写操作统一改用 EventFilter.filterById 串行查询,消除了对同一 MethodResult 重复回调的隐患,也避免遍历全量日程。
五、能力边界:适配后仍做不到的事
如实列出,供使用前评估:
| 上游能力 | 鸿蒙现状 | 原因 |
|---|---|---|
| 日历改名 | 不支持,明确返回 OPERATION_FAILED |
CalendarKit 无改名 API(改色可用) |
| 事件的 url / availability 字段 | 不持久化 | CalendarKit 事件模型无对应字段 |
| 参会人列表 | 最多读回 1 条 | 模型只有单个 attendee 字段 |
| thisAndFollowing 拆分 | 近似实现 | 极端情况下新系列创建成功但截断失败,会残留一条重复 |
| excludedDates 修改单次 | 必须精确到秒 | 需使用插件返回的实例 ID 时间戳 |
API 覆盖面上,showEventModal 依赖 API 26+ 的 openEventEditPage,实例展开依赖 API 18+,权限三态依赖 API 20+------整包因此要求 HarmonyOS 26.0.0(API 26)起。设备侧要求真机或带 CalendarKit 能力的模拟器。
六、接入总结与后续计划
回顾整个适配,收益最大的三个决策:
- 独立成包、不动上游------后续跟进上游版本只需 rebase,Android/iOS 用户零影响;
- 错误码优先透传 ------没有
code: message这条改造,坑 3 类问题每次都要靠猜测定位; - CHANGELOG 按坑记录------每次"现象/根因/解决/验证"四要素齐全,本文一半内容直接从中来。
后续计划:向社区反馈 CalendarKit 缺失的改名 API 与多参会人诉求;跟进上游新版本;thisAndFollowing 拆分失败时的自动清理策略。
适配仓库:https://atomgit.com/oh-flutter/device_calendar_plus(Issue 入口同仓库,欢迎反馈)。本文参与 AtomGit 开源鸿蒙跨平台技术征文(方向一:三方库适配),代码均已在真机验证可运行。
如果本文对你有帮助,欢迎点赞、收藏、转发!有任何问题或建议,请在评论区留言交流。