大家好,我是鸿蒙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_CALENDAR 和 WRITE_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 位毫秒时间戳。我的业务模型里 startTime 和 endTime 存的是 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,最后才能对这个 Calendar 调 addEvent、updateEvent、deleteEvent。
startTime 和 endTime 要传 13 位毫秒时间戳,不要把 ISO 字符串直接塞进去。
calendarEventId 要本地保存。只要你的业务支持编辑和删除,这个字段就不是可选项。
导入别人的计划时,不能沿用对方的 calendarEventId。那个 ID 只对分享方设备上的系统日历有意义,导入到当前设备后必须重新 addEvent。
权限被拒绝时,不要让主流程失败。《时光旅记》里用户即使拒绝日历权限,也仍然可以正常管理旅行计划,只是暂时不同步系统日历。
小结
Calendar Kit 接入本身并不复杂,复杂的是它要和真实业务状态对齐。
在《时光旅记》里,我最后把关键点收敛成三条:用独立的"时光旅记旅行计划"日历账户承载 APP 日程;用 calendarEventId 把业务子行程和系统日程绑定起来;在新增、编辑、删除、导入、时间精度切换这些业务动作里同步更新系统日历。
这样做完以后,旅行计划不再只是 APP 内的一份清单,而是能进入用户的系统时间管理入口。用户在系统日历里看到行程,在《时光旅记》里继续维护旅行内容,两边的数据关系是清楚的。
参考文档:
- Calendar Kit(日历服务):https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/calendar-kit
@ohos.calendarManagerAPI:https://developer.huawei.com/consumer/cn/doc/harmonyos-references/js-apis-calendarmanager