【Jack实战】如何用 Calendar Kit 把旅行计划同步到系统日历

大家好,我是鸿蒙Jack。本期以我的《时光旅记》APP 为例,聊一下我是怎么把 Calendar Kit 接进旅行计划场景里的。

《时光旅记》里有一个很典型的需求:用户创建一次旅行计划后,会继续拆成每天、每个地点、每段交通或活动安排。比如"去机场""入住酒店""参观博物馆""夜游外滩"。这些内容如果只留在 APP 内部,用户需要不断打开应用查看;如果能同步到系统日历,系统日历、桌面日程、日程提醒这些能力就都能接上。

所以我这里没有把 Calendar Kit 当成一个孤立的"新建日程 Demo",而是把它放进旅行计划的生命周期里:新增子行程时创建系统日程,编辑子行程时更新系统日程,删除子行程时删除系统日程,导入好友分享的旅行计划时批量补写日程。

我在项目里扫到的真实接入点

我先把项目里的 Calendar Kit 调用点扫了一遍,真实使用位置主要是这几处:

entry/src/main/module.json5 声明日历读写权限。

entry/src/main/ets/pages/travel/TravelPlanDetailPage.ets 是旅行计划详情页,也是最核心的同步点。这里负责初始化 Calendar Kit、创建或获取应用自己的日历账户,并在子行程增删改时同步系统日程。

entry/src/main/ets/pages/shell/MainPage.ets 负责处理分享计划导入。用户从旅行广场或分享链接导入计划后,会把导入后的子行程批量写入系统日历。

entry/src/main/ets/model/TimeImprintModels.ets 里,TravelSubPlan.calendarEventId 保存系统日程 ID。没有这个字段,后续更新和删除就只能靠标题、时间去猜,会很不稳。

entry/src/main/ets/utils/PermissionUtil.ets 是项目统一权限工具,Calendar Kit 的读写权限也走这里。

这套能力背后用到的技术栈是:

技术 在本场景里的作用
ArkTS 业务模型、页面逻辑、异步同步逻辑
ArkUI Stage 模型 从页面里拿 HostContext,再调用 Calendar Kit
Calendar Kit / @kit.CalendarKit 创建日历账户、创建日程、更新日程、删除日程
AbilityKit 权限体系 申请 READ_CALENDARWRITE_CALENDAR
本地持久化 保存 calendarEventId,保证日程可追踪
分享导入链路 导入好友旅行计划后,批量同步系统日历
MapKit 位置数据 子行程里的地点、经纬度会写入 Calendar Kit 的 location 字段

这里有一个容易混淆的点:《时光旅记》里还有"出发提醒"的后端推送能力,那是 Push Kit 和服务端定时任务做的;本文讲的是 Calendar Kit,把旅行计划写进系统日历。两者可以并存,但职责不同。

整体架构

我把这套同步逻辑放在业务页内,是因为它和旅行计划的增删改强绑定。Calendar Kit 只负责系统日历的数据写入,什么时候写、写哪条、失败后怎么提示,还是应该由业务决定。
#mermaid-svg-pRZGiR3ml1lmqZpD{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-pRZGiR3ml1lmqZpD .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-pRZGiR3ml1lmqZpD .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-pRZGiR3ml1lmqZpD .error-icon{fill:#552222;}#mermaid-svg-pRZGiR3ml1lmqZpD .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-pRZGiR3ml1lmqZpD .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-pRZGiR3ml1lmqZpD .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-pRZGiR3ml1lmqZpD .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-pRZGiR3ml1lmqZpD .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-pRZGiR3ml1lmqZpD .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-pRZGiR3ml1lmqZpD .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-pRZGiR3ml1lmqZpD .marker{fill:#333333;stroke:#333333;}#mermaid-svg-pRZGiR3ml1lmqZpD .marker.cross{stroke:#333333;}#mermaid-svg-pRZGiR3ml1lmqZpD svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-pRZGiR3ml1lmqZpD p{margin:0;}#mermaid-svg-pRZGiR3ml1lmqZpD .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-pRZGiR3ml1lmqZpD .cluster-label text{fill:#333;}#mermaid-svg-pRZGiR3ml1lmqZpD .cluster-label span{color:#333;}#mermaid-svg-pRZGiR3ml1lmqZpD .cluster-label span p{background-color:transparent;}#mermaid-svg-pRZGiR3ml1lmqZpD .label text,#mermaid-svg-pRZGiR3ml1lmqZpD span{fill:#333;color:#333;}#mermaid-svg-pRZGiR3ml1lmqZpD .node rect,#mermaid-svg-pRZGiR3ml1lmqZpD .node circle,#mermaid-svg-pRZGiR3ml1lmqZpD .node ellipse,#mermaid-svg-pRZGiR3ml1lmqZpD .node polygon,#mermaid-svg-pRZGiR3ml1lmqZpD .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-pRZGiR3ml1lmqZpD .rough-node .label text,#mermaid-svg-pRZGiR3ml1lmqZpD .node .label text,#mermaid-svg-pRZGiR3ml1lmqZpD .image-shape .label,#mermaid-svg-pRZGiR3ml1lmqZpD .icon-shape .label{text-anchor:middle;}#mermaid-svg-pRZGiR3ml1lmqZpD .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-pRZGiR3ml1lmqZpD .rough-node .label,#mermaid-svg-pRZGiR3ml1lmqZpD .node .label,#mermaid-svg-pRZGiR3ml1lmqZpD .image-shape .label,#mermaid-svg-pRZGiR3ml1lmqZpD .icon-shape .label{text-align:center;}#mermaid-svg-pRZGiR3ml1lmqZpD .node.clickable{cursor:pointer;}#mermaid-svg-pRZGiR3ml1lmqZpD .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-pRZGiR3ml1lmqZpD .arrowheadPath{fill:#333333;}#mermaid-svg-pRZGiR3ml1lmqZpD .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-pRZGiR3ml1lmqZpD .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-pRZGiR3ml1lmqZpD .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-pRZGiR3ml1lmqZpD .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-pRZGiR3ml1lmqZpD .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-pRZGiR3ml1lmqZpD .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-pRZGiR3ml1lmqZpD .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-pRZGiR3ml1lmqZpD .cluster text{fill:#333;}#mermaid-svg-pRZGiR3ml1lmqZpD .cluster span{color:#333;}#mermaid-svg-pRZGiR3ml1lmqZpD div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-pRZGiR3ml1lmqZpD .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-pRZGiR3ml1lmqZpD rect.text{fill:none;stroke-width:0;}#mermaid-svg-pRZGiR3ml1lmqZpD .icon-shape,#mermaid-svg-pRZGiR3ml1lmqZpD .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-pRZGiR3ml1lmqZpD .icon-shape p,#mermaid-svg-pRZGiR3ml1lmqZpD .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-pRZGiR3ml1lmqZpD .icon-shape .label rect,#mermaid-svg-pRZGiR3ml1lmqZpD .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-pRZGiR3ml1lmqZpD .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-pRZGiR3ml1lmqZpD .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-pRZGiR3ml1lmqZpD :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 否

旅行计划 TravelPlan
子行程 TravelSubPlan
是否有有效开始和结束时间
跳过系统日历同步
构造 calendarManager.Event
Calendar Kit
时光旅记旅行计划 Calendar
系统日历 Event
返回 eventId
写回 calendarEventId
本地持久化
好友分享计划导入
生成本地 TravelPlan
批量 addEvent

我这里没有直接写进系统默认日历,而是创建了一个本地日历账户:

ts 复制代码
{
  name: 'TimeImprintTravel',
  type: calendarManager.CalendarType.LOCAL,
  displayName: '时光旅记旅行计划'
}

这样用户在系统日历里能看出这些日程来自《时光旅记》,后续也更容易按账户管理。

调用时序

旅行计划详情页打开后,我会先初始化 Calendar Kit。初始化成功后,如果当前计划里有一些历史子行程还没有 calendarEventId,会顺手补写一次系统日历。
本地数据 时光旅记旅行计划日历 Calendar Kit PermissionUtil TravelPlanDetailPage 用户 本地数据 时光旅记旅行计划日历 Calendar Kit PermissionUtil TravelPlanDetailPage 用户 #mermaid-svg-Ry5tOqpSXUnlrMdH{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-Ry5tOqpSXUnlrMdH .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-Ry5tOqpSXUnlrMdH .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-Ry5tOqpSXUnlrMdH .error-icon{fill:#552222;}#mermaid-svg-Ry5tOqpSXUnlrMdH .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-Ry5tOqpSXUnlrMdH .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-Ry5tOqpSXUnlrMdH .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-Ry5tOqpSXUnlrMdH .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-Ry5tOqpSXUnlrMdH .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-Ry5tOqpSXUnlrMdH .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-Ry5tOqpSXUnlrMdH .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-Ry5tOqpSXUnlrMdH .marker{fill:#333333;stroke:#333333;}#mermaid-svg-Ry5tOqpSXUnlrMdH .marker.cross{stroke:#333333;}#mermaid-svg-Ry5tOqpSXUnlrMdH svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-Ry5tOqpSXUnlrMdH p{margin:0;}#mermaid-svg-Ry5tOqpSXUnlrMdH .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-Ry5tOqpSXUnlrMdH text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-Ry5tOqpSXUnlrMdH .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-Ry5tOqpSXUnlrMdH .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-Ry5tOqpSXUnlrMdH .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-Ry5tOqpSXUnlrMdH .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-Ry5tOqpSXUnlrMdH #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-Ry5tOqpSXUnlrMdH .sequenceNumber{fill:white;}#mermaid-svg-Ry5tOqpSXUnlrMdH #sequencenumber{fill:#333;}#mermaid-svg-Ry5tOqpSXUnlrMdH #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-Ry5tOqpSXUnlrMdH .messageText{fill:#333;stroke:none;}#mermaid-svg-Ry5tOqpSXUnlrMdH .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-Ry5tOqpSXUnlrMdH .labelText,#mermaid-svg-Ry5tOqpSXUnlrMdH .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-Ry5tOqpSXUnlrMdH .loopText,#mermaid-svg-Ry5tOqpSXUnlrMdH .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-Ry5tOqpSXUnlrMdH .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-Ry5tOqpSXUnlrMdH .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-Ry5tOqpSXUnlrMdH .noteText,#mermaid-svg-Ry5tOqpSXUnlrMdH .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-Ry5tOqpSXUnlrMdH .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-Ry5tOqpSXUnlrMdH .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-Ry5tOqpSXUnlrMdH .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-Ry5tOqpSXUnlrMdH .actorPopupMenu{position:absolute;}#mermaid-svg-Ry5tOqpSXUnlrMdH .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-Ry5tOqpSXUnlrMdH .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-Ry5tOqpSXUnlrMdH .actor-man circle,#mermaid-svg-Ry5tOqpSXUnlrMdH line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-Ry5tOqpSXUnlrMdH :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} alt日历账户不存在 打开旅行计划详情申请 READ_CALENDAR / WRITE_CALENDAR返回授权结果getCalendarManager(context)getCalendar(TimeImprintTravel)createCalendar(TimeImprintTravel)Calendar查找未同步子行程addEvent(event)eventId保存 calendarEventId

子行程增删改时,时序更直接:
本地数据 Calendar TravelPlanDetailPage 用户 本地数据 Calendar TravelPlanDetailPage 用户 #mermaid-svg-jUlljDIvtvrANEXZ{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-jUlljDIvtvrANEXZ .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-jUlljDIvtvrANEXZ .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-jUlljDIvtvrANEXZ .error-icon{fill:#552222;}#mermaid-svg-jUlljDIvtvrANEXZ .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-jUlljDIvtvrANEXZ .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-jUlljDIvtvrANEXZ .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-jUlljDIvtvrANEXZ .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-jUlljDIvtvrANEXZ .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-jUlljDIvtvrANEXZ .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-jUlljDIvtvrANEXZ .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-jUlljDIvtvrANEXZ .marker{fill:#333333;stroke:#333333;}#mermaid-svg-jUlljDIvtvrANEXZ .marker.cross{stroke:#333333;}#mermaid-svg-jUlljDIvtvrANEXZ svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-jUlljDIvtvrANEXZ p{margin:0;}#mermaid-svg-jUlljDIvtvrANEXZ .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-jUlljDIvtvrANEXZ text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-jUlljDIvtvrANEXZ .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-jUlljDIvtvrANEXZ .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-jUlljDIvtvrANEXZ .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-jUlljDIvtvrANEXZ .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-jUlljDIvtvrANEXZ #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-jUlljDIvtvrANEXZ .sequenceNumber{fill:white;}#mermaid-svg-jUlljDIvtvrANEXZ #sequencenumber{fill:#333;}#mermaid-svg-jUlljDIvtvrANEXZ #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-jUlljDIvtvrANEXZ .messageText{fill:#333;stroke:none;}#mermaid-svg-jUlljDIvtvrANEXZ .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-jUlljDIvtvrANEXZ .labelText,#mermaid-svg-jUlljDIvtvrANEXZ .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-jUlljDIvtvrANEXZ .loopText,#mermaid-svg-jUlljDIvtvrANEXZ .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-jUlljDIvtvrANEXZ .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-jUlljDIvtvrANEXZ .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-jUlljDIvtvrANEXZ .noteText,#mermaid-svg-jUlljDIvtvrANEXZ .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-jUlljDIvtvrANEXZ .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-jUlljDIvtvrANEXZ .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-jUlljDIvtvrANEXZ .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-jUlljDIvtvrANEXZ .actorPopupMenu{position:absolute;}#mermaid-svg-jUlljDIvtvrANEXZ .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-jUlljDIvtvrANEXZ .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-jUlljDIvtvrANEXZ .actor-man circle,#mermaid-svg-jUlljDIvtvrANEXZ line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-jUlljDIvtvrANEXZ :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} alt已有 calendarEventId没有 calendarEventId 新增子行程addEvent(event)eventId保存子行程和 calendarEventId编辑子行程updateEvent(event with id)addEvent(event)eventId保存更新后的子行程删除子行程deleteEvent(calendarEventId)删除本地子行程

权限配置

Calendar Kit 读日程需要 ohos.permission.READ_CALENDAR,写日程需要 ohos.permission.WRITE_CALENDAR。我的场景既要读取应用自己的日历账户,也要创建、更新、删除日程,所以两个权限都声明。

entry/src/main/module.json5

json5 复制代码
{
  "name": "ohos.permission.READ_CALENDAR",
  "reason": "$string:permission_calendar_reason",
  "usedScene": {
    "abilities": [
      "EntryAbility"
    ],
    "when": "inuse"
  }
},
{
  "name": "ohos.permission.WRITE_CALENDAR",
  "reason": "$string:permission_calendar_reason",
  "usedScene": {
    "abilities": [
      "EntryAbility"
    ],
    "when": "inuse"
  }
}

项目里统一用 PermissionUtil 申请权限,核心代码如下:

ts 复制代码
import { abilityAccessCtrl, bundleManager, Context, Permissions } from '@kit.AbilityKit';

let cachedAccessTokenId: number = -1;

async function getSelfAccessTokenId(): Promise<number> {
  if (cachedAccessTokenId > 0) {
    return cachedAccessTokenId;
  }
  const bundleInfo = await bundleManager.getBundleInfoForSelf(
    bundleManager.BundleFlag.GET_BUNDLE_INFO_WITH_APPLICATION
  );
  cachedAccessTokenId = bundleInfo.appInfo.accessTokenId;
  return cachedAccessTokenId;
}

export async function arePermissionsGranted(permissions: Array<Permissions>): Promise<boolean> {
  if (permissions.length === 0) {
    return true;
  }
  try {
    const atManager: abilityAccessCtrl.AtManager = abilityAccessCtrl.createAtManager();
    const accessTokenId: number = await getSelfAccessTokenId();
    for (let i: number = 0; i < permissions.length; i++) {
      const grantStatus: abilityAccessCtrl.GrantStatus = await atManager.checkAccessToken(accessTokenId, permissions[i]);
      if (grantStatus !== abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED) {
        return false;
      }
    }
    return true;
  } catch (_error) {
    return false;
  }
}

export async function ensurePermissionsGranted(context: Context, permissions: Array<Permissions>): Promise<boolean> {
  if (await arePermissionsGranted(permissions)) {
    return true;
  }
  try {
    await abilityAccessCtrl.createAtManager().requestPermissionsFromUser(context, permissions);
  } catch (_error) {
  }
  return await arePermissionsGranted(permissions);
}

我没有在应用启动时就强行弹日历权限,而是在进入旅行计划详情、导入旅行计划并需要同步日历时再申请。这样用户更容易理解为什么要授权。

业务数据怎么和系统日程对应

Calendar Kit 的 addEvent 会返回一个系统日程 ID。这个 ID 必须保存到业务数据里,不然后面编辑子行程时无法稳定调用 updateEvent,删除子行程时也无法稳定调用 deleteEvent

entry/src/main/ets/model/TimeImprintModels.ets

ts 复制代码
export class TravelSubPlan {
  id: string = '';
  planId: string = '';
  sortOrder: number = 0;
  title: string = '';
  content: string = '';
  location: string = '';
  latitude: number = 0;
  longitude: number = 0;
  fullAddress: string = '';
  startTime: string = '';
  endTime: string = '';
  transportModeToNext: string = '未设置';
  transportNoteToNext: string = '';
  isCompleted: boolean = false;
  calendarEventId: number = -1;
  createdAt: string = '';
  updatedAt: string = '';
}

export class TravelPlan {
  id: string = '';
  title: string = '';
  description: string = '';
  startDate: string = '';
  endDate: string = '';
  departureStartTime: string = '';
  departureReminderEnabled: boolean = false;
  departureReminderDeviceId: string = '';
  departureReminderUpdatedAt: string = '';
  coverUri: string = '';
  coverPresetId: string = '';
  isTimePrecisionEnabled: boolean = true;
  isDailyGroupingEnabled: boolean = true;
  subPlans: Array<TravelSubPlan> = [];
}

本地持久化时,我也把 calendar_event_id 写进子行程表。下面是项目里的读写片段:

ts 复制代码
subPlan.calendarEventId = resultSet.getLong(resultSet.getColumnIndex('calendar_event_id'));
ts 复制代码
subPlanValues.push({
  id: subPlan.id,
  plan_id: subPlan.planId,
  sort_order: subPlan.sortOrder,
  title: subPlan.title,
  content: subPlan.content,
  location: subPlan.location,
  latitude: subPlan.latitude,
  longitude: subPlan.longitude,
  full_address: subPlan.fullAddress,
  start_time: subPlan.startTime,
  end_time: subPlan.endTime,
  transport_mode_to_next: subPlan.transportModeToNext,
  transport_note_to_next: subPlan.transportNoteToNext,
  is_completed: subPlan.isCompleted ? 1 : 0,
  calendar_event_id: subPlan.calendarEventId,
  created_at: subPlan.createdAt,
  updated_at: subPlan.updatedAt
});

在详情页初始化 Calendar Kit

TravelPlanDetailPage 进入页面时调用 initCalendarKit()。这里做了四件事:拿页面上下文、申请日历权限、获取 CalendarManager、获取或创建应用自己的日历账户。

ts 复制代码
import { common, Context, Permissions } from '@kit.AbilityKit';
import { calendarManager } from '@kit.CalendarKit';
import { ensurePermissionsGranted } from '../../utils/PermissionUtil';

@Component
export struct TravelPlanDetailPage {
  @Prop plan: TravelPlan = new TravelPlan();
  onSavePlan: (plan: TravelPlan) => void = () => {};
  onPersistPlanMeta: (plan: TravelPlan) => void = () => {};

  @State isLoading: boolean = true;

  private calendarMgr: calendarManager.CalendarManager | null = null;
  private appCalendar: calendarManager.Calendar | null = null;

  aboutToAppear() {
    this.initCalendarKit();
  }

  private async requestCalendarPermissions(context: Context): Promise<boolean> {
    return ensurePermissionsGranted(
      context,
      ['ohos.permission.READ_CALENDAR', 'ohos.permission.WRITE_CALENDAR'] as Array<Permissions>
    );
  }

  private async initCalendarKit() {
    try {
      let context = this.getUIContext().getHostContext();
      if (!context) {
        this.isLoading = false;
        return;
      }

      let granted = await this.requestCalendarPermissions(context);
      if (!granted) {
        this.isLoading = false;
        this.showToast('未授予日历权限,行程不会同步到系统日历');
        return;
      }

      this.calendarMgr = calendarManager.getCalendarManager(context);

      let calendarAccount: calendarManager.CalendarAccount = {
        name: 'TimeImprintTravel',
        type: calendarManager.CalendarType.LOCAL,
        displayName: '时光旅记旅行计划'
      };

      try {
        this.appCalendar = await this.calendarMgr.getCalendar(calendarAccount);
      } catch (_error) {
        this.appCalendar = await this.calendarMgr.createCalendar(calendarAccount);
      }

      await this.syncMissingCalendarEventsForCurrentPlan();
      this.isLoading = false;
    } catch (error) {
      console.error(`Failed to init CalendarKit: ${JSON.stringify(error)}`);
      this.isLoading = false;
    }
  }
}

getCalendar(calendarAccount) 找不到时会进入 catch,再调用 createCalendar(calendarAccount)。这比每次都创建账户更稳,也避免系统日历里出现重复的应用日历。

把子行程转换成系统日程

Calendar Kit 的 Event 需要 13 位毫秒时间戳。我的业务模型里 startTimeendTime 存的是 ISO 字符串,所以写入前统一转换。

ts 复制代码
private canSyncSubPlanToCalendar(subPlan: TravelSubPlan): boolean {
  if (subPlan.startTime.trim().length === 0 || subPlan.endTime.trim().length === 0) {
    return false;
  }
  let startTime: number = new Date(subPlan.startTime).getTime();
  let endTime: number = new Date(subPlan.endTime).getTime();
  return !isNaN(startTime) && !isNaN(endTime) && endTime >= startTime;
}

private buildCalendarEvent(plan: TravelPlan, subPlan: TravelSubPlan): calendarManager.Event {
  return {
    type: calendarManager.EventType.NORMAL,
    title: subPlan.title,
    description: subPlan.content,
    location: {
      location: subPlan.location,
      latitude: subPlan.latitude,
      longitude: subPlan.longitude
    },
    startTime: new Date(subPlan.startTime).getTime(),
    endTime: new Date(subPlan.endTime).getTime(),
    isAllDay: !plan.isTimePrecisionEnabled
  };
}

isAllDay 是我在这个场景里比较重视的一个字段。《时光旅记》的旅行计划支持"精确到时间"和"仅日期安排"两种模式。如果用户只想按天规划,我会把系统日程写成全天日程;如果用户开启精确时间,就按具体开始和结束时间写入。

新增、编辑、删除子行程时同步日历

新增子行程时,先调用 addEvent,拿到 eventId 后写回 subPlan.calendarEventId,最后再保存整个计划。

ts 复制代码
private async addSubPlan(subPlan: TravelSubPlan) {
  if (this.plan.id.length === 0) {
    return;
  }

  subPlan.planId = this.plan.id;
  let maxSortOrder: number = -1;
  for (let i: number = 0; i < this.plan.subPlans.length; i++) {
    maxSortOrder = Math.max(maxSortOrder, this.plan.subPlans[i].sortOrder);
  }
  subPlan.sortOrder = maxSortOrder + 1;
  if (subPlan.createdAt.length === 0) {
    subPlan.createdAt = new Date().toISOString();
  }
  subPlan.updatedAt = new Date().toISOString();

  if (this.appCalendar) {
    try {
      let event: calendarManager.Event = this.buildCalendarEvent(this.clonePlan(this.plan), subPlan);
      let eventId = await this.appCalendar.addEvent(event);
      subPlan.calendarEventId = eventId;
    } catch (error) {
      console.error(`Failed to add event to calendar: ${JSON.stringify(error)}`);
    }
  }

  let nextPlan: TravelPlan = this.clonePlan(this.plan);
  nextPlan.updatedAt = new Date().toISOString();
  nextPlan.subPlans = [...nextPlan.subPlans, this.cloneSubPlan(subPlan)];
  this.syncPlanSchedule(nextPlan);
  this.onSavePlan(this.clonePlan(nextPlan, false));
}

编辑子行程时,关键是判断有没有 calendarEventId。有 ID 就更新,没有 ID 就补建。这种写法能兼容老数据,也能兼容用户之前拒绝权限、后来又授权的情况。

ts 复制代码
private async updateSubPlan(subPlan: TravelSubPlan) {
  if (this.plan.id.length === 0) {
    return;
  }

  let nextPlan: TravelPlan = this.clonePlan(this.plan);
  let targetIndex: number = nextPlan.subPlans.findIndex((item: TravelSubPlan) => item.id === subPlan.id);
  if (targetIndex < 0) {
    return;
  }

  let previousSubPlan: TravelSubPlan = nextPlan.subPlans[targetIndex];
  let nextSubPlan: TravelSubPlan = this.cloneSubPlan(subPlan);
  nextSubPlan.planId = this.plan.id;
  nextSubPlan.sortOrder = previousSubPlan.sortOrder;
  nextSubPlan.createdAt = previousSubPlan.createdAt.length > 0 ? previousSubPlan.createdAt : subPlan.createdAt;
  nextSubPlan.updatedAt = new Date().toISOString();
  nextSubPlan.calendarEventId = previousSubPlan.calendarEventId;
  nextSubPlan.isCompleted = previousSubPlan.isCompleted;
  nextSubPlan.fullAddress = subPlan.fullAddress.length > 0 ? subPlan.fullAddress : previousSubPlan.fullAddress;

  if (this.appCalendar) {
    try {
      let event: calendarManager.Event = this.buildCalendarEvent(nextPlan, nextSubPlan);
      if (nextSubPlan.calendarEventId > 0) {
        event.id = nextSubPlan.calendarEventId;
        await this.appCalendar.updateEvent(event);
      } else {
        nextSubPlan.calendarEventId = await this.appCalendar.addEvent(event);
      }
    } catch (error) {
      console.error(`Failed to update event in calendar: ${JSON.stringify(error)}`);
    }
  }

  nextPlan.updatedAt = nextSubPlan.updatedAt;
  nextPlan.subPlans[targetIndex] = nextSubPlan;
  this.syncPlanSchedule(nextPlan);
  this.onSavePlan(this.clonePlan(nextPlan, false));
}

删除子行程时,如果本地保存过 calendarEventId,就先删系统日程,再删本地数据。

ts 复制代码
private async deleteSubPlan(subPlan: TravelSubPlan) {
  if (this.plan.id.length === 0) {
    return;
  }

  if (this.appCalendar && subPlan.calendarEventId > 0) {
    try {
      await this.appCalendar.deleteEvent(subPlan.calendarEventId);
    } catch (error) {
      console.error(`Failed to delete event: ${JSON.stringify(error)}`);
    }
  }

  let nextPlan: TravelPlan = this.clonePlan(this.plan);
  nextPlan.updatedAt = new Date().toISOString();
  nextPlan.subPlans = nextPlan.subPlans.filter((plan: TravelSubPlan) => plan.id !== subPlan.id);
  this.syncPlanSchedule(nextPlan);
  this.onSavePlan(this.clonePlan(nextPlan, false));
}

这里我没有因为系统日历删除失败就阻断本地删除。原因很现实:用户已经确认删除 APP 内的行程,本地数据应该按用户操作完成;系统日历失败只记录日志。更严格的做法是补一个"日历同步异常"提示或后台修复队列,这个可以看应用对一致性的要求。

页面打开时补齐历史行程

如果用户先前没有授权日历,或者老版本数据还没有 calendarEventId,详情页打开后会执行一次补齐。

ts 复制代码
private async syncMissingCalendarEventsForCurrentPlan(): Promise<void> {
  if (!this.appCalendar || this.plan.id.length === 0) {
    return;
  }

  let nextPlan: TravelPlan = this.clonePlan(this.plan);
  let hasChanged: boolean = false;

  for (let i: number = 0; i < nextPlan.subPlans.length; i++) {
    let subPlan: TravelSubPlan = nextPlan.subPlans[i];
    if (subPlan.calendarEventId > 0 || !this.canSyncSubPlanToCalendar(subPlan)) {
      continue;
    }
    try {
      subPlan.calendarEventId = await this.appCalendar.addEvent(this.buildCalendarEvent(nextPlan, subPlan));
      hasChanged = true;
    } catch (error) {
      console.error(`Failed to backfill calendar event: ${JSON.stringify(error)}`);
    }
  }

  if (hasChanged) {
    this.onPersistPlanMeta(this.clonePlan(nextPlan, false));
  }
}

这段逻辑只保存元数据,不改变用户可见的行程内容。它的目的就是把系统日程 ID 补回来。

切换"精确到时间"时更新系统日程

《时光旅记》的行程有一个开关:可以按具体时间安排,也可以只按日期安排。切换这个开关后,系统日历里的 isAllDay 也要跟着变化。

ts 复制代码
private async applyTimePrecisionChange(nextValue: boolean): Promise<void> {
  let currentOrderedIds: Array<string> = this.getSortedSubPlans(this.plan).map((subPlan: TravelSubPlan) => subPlan.id);
  let nextPlan: TravelPlan = this.clonePlan(this.plan);
  nextPlan.isTimePrecisionEnabled = nextValue;
  nextPlan.updatedAt = new Date().toISOString();

  this.applySubPlanOrder(nextPlan, currentOrderedIds);
  for (let i: number = 0; i < nextPlan.subPlans.length; i++) {
    this.normalizeDateOnlySubPlanTime(nextPlan.subPlans[i]);
  }

  if (this.appCalendar) {
    for (let i: number = 0; i < nextPlan.subPlans.length; i++) {
      let subPlan: TravelSubPlan = nextPlan.subPlans[i];
      if (!this.canSyncSubPlanToCalendar(subPlan)) {
        continue;
      }
      try {
        let event: calendarManager.Event = this.buildCalendarEvent(nextPlan, subPlan);
        if (subPlan.calendarEventId > 0) {
          event.id = subPlan.calendarEventId;
          await this.appCalendar.updateEvent(event);
        }
      } catch (error) {
        console.error(`Failed to sync calendar event after precision change: ${JSON.stringify(error)}`);
      }
    }
  }

  this.isTimePrecisionEnabled = nextValue;
  this.syncPlanSchedule(nextPlan);
  this.onSavePlan(this.clonePlan(nextPlan, false));
}

这一段的重点不是 Calendar Kit 本身,而是业务状态和系统日历状态要一起变。否则用户在 APP 里看到的是全天行程,系统日历里却还是具体时间,会很割裂。

导入分享计划时批量同步系统日历

《时光旅记》支持好友分享旅行计划。导入时,我会先把分享数据转换成新的本地 TravelPlan,并把每个导入子行程的 calendarEventId 重置为 -1,因为分享方的系统日程 ID 对当前设备没有意义。

ts 复制代码
private buildLocalTravelPlanFromShare(sharedPlan: TravelPlanShareResolveResult): TravelPlan {
  let importedPlan: TravelPlan = new TravelPlan();
  let now: string = new Date().toISOString();

  importedPlan.id = createIdentifier('plan');
  importedPlan.title = sharedPlan.title.trim().length > 0 ? sharedPlan.title.trim() : '共享旅行计划';
  importedPlan.description = sharedPlan.description ? sharedPlan.description.trim() : '';
  importedPlan.startDate = sharedPlan.startDate ? sharedPlan.startDate : '';
  importedPlan.endDate = sharedPlan.endDate ? sharedPlan.endDate : '';
  importedPlan.coverUri = this.normalizeSharedTravelPlanCoverUri(sharedPlan.coverUri);
  importedPlan.coverPresetId = sharedPlan.coverPresetId ? sharedPlan.coverPresetId : '';
  importedPlan.isTimePrecisionEnabled = sharedPlan.isTimePrecisionEnabled !== false;
  importedPlan.createdAt = now;
  importedPlan.updatedAt = now;

  importedPlan.subPlans = sharedPlan.subPlans.map((subPlan: SharedTravelSubPlanPayload, index: number) => {
    let importedSubPlan: TravelSubPlan = new TravelSubPlan();
    importedSubPlan.id = createIdentifier('subplan');
    importedSubPlan.planId = importedPlan.id;
    importedSubPlan.sortOrder = index;
    importedSubPlan.title = subPlan.title;
    importedSubPlan.content = subPlan.content;
    importedSubPlan.location = subPlan.location;
    importedSubPlan.latitude = subPlan.latitude;
    importedSubPlan.longitude = subPlan.longitude;
    importedSubPlan.fullAddress = subPlan.fullAddress;
    importedSubPlan.startTime = subPlan.startTime;
    importedSubPlan.endTime = subPlan.endTime;
    importedSubPlan.isCompleted = subPlan.isCompleted;
    importedSubPlan.calendarEventId = -1;
    importedSubPlan.createdAt = now;
    importedSubPlan.updatedAt = now;
    return importedSubPlan;
  });

  return importedPlan;
}

导入后再批量同步系统日历:

ts 复制代码
class TravelPlanCalendarSyncResult {
  attemptedCount: number = 0;
  syncedCount: number = 0;
  permissionGranted: boolean = false;
}

private canSyncTravelSubPlanToCalendar(subPlan: TravelSubPlan): boolean {
  if (subPlan.startTime.trim().length === 0 || subPlan.endTime.trim().length === 0) {
    return false;
  }
  let startTime: number = new Date(subPlan.startTime).getTime();
  let endTime: number = new Date(subPlan.endTime).getTime();
  return !isNaN(startTime) && !isNaN(endTime) && endTime >= startTime;
}

private buildTravelCalendarEvent(plan: TravelPlan, subPlan: TravelSubPlan): calendarManager.Event {
  return {
    type: calendarManager.EventType.NORMAL,
    title: subPlan.title,
    description: subPlan.content,
    location: {
      location: subPlan.location,
      latitude: subPlan.latitude,
      longitude: subPlan.longitude
    },
    startTime: new Date(subPlan.startTime).getTime(),
    endTime: new Date(subPlan.endTime).getTime(),
    isAllDay: !plan.isTimePrecisionEnabled
  };
}

private async requestTravelCalendarPermissions(context: Context): Promise<boolean> {
  return ensurePermissionsGranted(
    context,
    ['ohos.permission.READ_CALENDAR', 'ohos.permission.WRITE_CALENDAR'] as Array<Permissions>
  );
}

private async getTravelPlanCalendar(): Promise<calendarManager.Calendar | null> {
  let hostContext: Context | undefined = this.getUIContext().getHostContext() as Context | undefined;
  if (hostContext === undefined) {
    return null;
  }

  let granted: boolean = await this.requestTravelCalendarPermissions(hostContext);
  if (!granted) {
    return null;
  }

  try {
    let calendarMgr: calendarManager.CalendarManager = calendarManager.getCalendarManager(hostContext);
    let calendarAccount: calendarManager.CalendarAccount = {
      name: 'TimeImprintTravel',
      type: calendarManager.CalendarType.LOCAL,
      displayName: '时光旅记旅行计划'
    };
    try {
      return await calendarMgr.getCalendar(calendarAccount);
    } catch (_error) {
      return await calendarMgr.createCalendar(calendarAccount);
    }
  } catch (error) {
    console.error(`Failed to get travel plan calendar: ${JSON.stringify(error)}`);
    return null;
  }
}

private async syncImportedTravelPlanToCalendar(plan: TravelPlan): Promise<TravelPlanCalendarSyncResult> {
  let result: TravelPlanCalendarSyncResult = new TravelPlanCalendarSyncResult();

  for (let i: number = 0; i < plan.subPlans.length; i++) {
    if (this.canSyncTravelSubPlanToCalendar(plan.subPlans[i])) {
      result.attemptedCount = result.attemptedCount + 1;
    }
  }
  if (result.attemptedCount === 0) {
    return result;
  }

  let appCalendar: calendarManager.Calendar | null = await this.getTravelPlanCalendar();
  if (appCalendar === null) {
    return result;
  }

  result.permissionGranted = true;
  for (let i: number = 0; i < plan.subPlans.length; i++) {
    let subPlan: TravelSubPlan = plan.subPlans[i];
    if (!this.canSyncTravelSubPlanToCalendar(subPlan)) {
      continue;
    }
    try {
      subPlan.calendarEventId = await appCalendar.addEvent(this.buildTravelCalendarEvent(plan, subPlan));
      result.syncedCount = result.syncedCount + 1;
    } catch (error) {
      console.error(`Failed to sync imported sub plan to calendar: ${JSON.stringify(error)}`);
    }
  }

  return result;
}

最后根据同步结果给用户一个清楚的反馈:

ts 复制代码
private buildSharedTravelPlanImportToast(
  sharedPlan: TravelPlanShareResolveResult,
  calendarSyncResult: TravelPlanCalendarSyncResult
): string {
  let sharerLabel: string = sharedPlan.sharerName.trim().length > 0 ? sharedPlan.sharerName.trim() : '好友';
  if (calendarSyncResult.attemptedCount === 0) {
    return `已导入 ${sharerLabel} 分享的旅行计划`;
  }
  if (!calendarSyncResult.permissionGranted) {
    return `已导入 ${sharerLabel} 分享的旅行计划,未授予日历权限,暂未同步系统日历`;
  }
  if (calendarSyncResult.syncedCount === calendarSyncResult.attemptedCount) {
    return `已导入 ${sharerLabel} 分享的旅行计划,并同步到系统日历`;
  }
  if (calendarSyncResult.syncedCount > 0) {
    return `已导入 ${sharerLabel} 分享的旅行计划,${calendarSyncResult.syncedCount} 项已同步到系统日历`;
  }
  return `已导入 ${sharerLabel} 分享的旅行计划,但未能同步到系统日历`;
}

private async importSharedTravelPlan(sharedPlan: TravelPlanShareResolveResult): Promise<void> {
  let importedPlan: TravelPlan = this.buildLocalTravelPlanFromShare(sharedPlan);
  let calendarSyncResult: TravelPlanCalendarSyncResult = await this.syncImportedTravelPlanToCalendar(importedPlan);
  this.store.travelPlans = [importedPlan, ...this.store.travelPlans];
  this.switchTab(MainTab.TIMELINE);
  this.openTravelPlanDetail(importedPlan.id);
  this.commitStore();
  this.showToast(this.buildSharedTravelPlanImportToast(sharedPlan, calendarSyncResult));
}

一个独立的新建日程弹窗示例

项目里还有一个 CreatePlanDialog.ets,它演示了如何从表单直接构造 calendarManager.Event。这个弹窗更像是最小化的事件创建入口,适合用来理解 Event 的字段。

ts 复制代码
import { ThemePalette } from "../../utils/ThemePalette";
import { calendarManager } from '@kit.CalendarKit';

@CustomDialog
export struct CreatePlanDialog {
  controller: CustomDialogController;
  @Link selectedDate: Date;

  onConfirm: (event: calendarManager.Event) => void = () => {};

  @State title: string = '';
  @State location: string = '';
  @State startTime: Date = new Date();
  @State endTime: Date = new Date();
  @State todos: string[] = [''];

  aboutToAppear() {
    this.startTime = new Date(
      this.selectedDate.getFullYear(),
      this.selectedDate.getMonth(),
      this.selectedDate.getDate(),
      9,
      0,
      0
    );
    this.endTime = new Date(
      this.selectedDate.getFullYear(),
      this.selectedDate.getMonth(),
      this.selectedDate.getDate(),
      10,
      0,
      0
    );
  }

  private handleConfirm() {
    if (this.title.trim() === '') {
      return;
    }

    const validTodos = this.todos.filter(t => t.trim() !== '');
    const description = validTodos.length > 0
      ? '待办事项:\n' + validTodos.map(t => '- [ ] ' + t).join('\n')
      : '';

    const event: calendarManager.Event = {
      type: calendarManager.EventType.NORMAL,
      title: this.title,
      location: { location: this.location },
      description: description,
      startTime: this.startTime.getTime(),
      endTime: this.endTime.getTime(),
      isAllDay: false
    };

    this.onConfirm(event);
    this.controller.close();
  }
}

如果是简单应用,这段代码已经够用;但像《时光旅记》这种有长期维护、编辑、删除、分享导入的场景,还是要把系统日程 ID 保存到业务模型里。

我踩过的几个点

Calendar Kit 的调用顺序别反。要先有 CalendarManager,再拿到或创建 Calendar,最后才能对这个 CalendaraddEventupdateEventdeleteEvent

startTimeendTime 要传 13 位毫秒时间戳,不要把 ISO 字符串直接塞进去。

calendarEventId 要本地保存。只要你的业务支持编辑和删除,这个字段就不是可选项。

导入别人的计划时,不能沿用对方的 calendarEventId。那个 ID 只对分享方设备上的系统日历有意义,导入到当前设备后必须重新 addEvent

权限被拒绝时,不要让主流程失败。《时光旅记》里用户即使拒绝日历权限,也仍然可以正常管理旅行计划,只是暂时不同步系统日历。

小结

Calendar Kit 接入本身并不复杂,复杂的是它要和真实业务状态对齐。

在《时光旅记》里,我最后把关键点收敛成三条:用独立的"时光旅记旅行计划"日历账户承载 APP 日程;用 calendarEventId 把业务子行程和系统日程绑定起来;在新增、编辑、删除、导入、时间精度切换这些业务动作里同步更新系统日历。

这样做完以后,旅行计划不再只是 APP 内的一份清单,而是能进入用户的系统时间管理入口。用户在系统日历里看到行程,在《时光旅记》里继续维护旅行内容,两边的数据关系是清楚的。

参考文档: