鸿蒙日历服务实践:把应用里的事件写进用户的日程表
引言
很多应用都会产生带时间属性的事件——买了张火车票、预约了一场直播、信用卡该还款了、晚上有节线上课。这些信息散落在各个应用里,用户需要自己记住或者手动添加提醒,难免遗漏。
鸿蒙的 Calendar Kit 提供了一种更好的方式:让应用直接把这些事件写入系统日历。写入后,日程会通过通知中心、桌面卡片、日历应用等多个入口触达用户,还能配上一个**"一键服务"按钮**,用户点一下就能跳回应用完成操作——比如"加入会议"、"马上还款"、"立即观看"。
本文面向希望接入日历服务的鸿蒙开发者,梳理日历日程的创建机制、一键服务的场景设计,并通过两个典型场景的完整实现,帮你快速理解开发要点。
一、日历服务能做什么
1.1 核心能力
简单说就是三件事:
- 写入日程:把应用中带时间属性的事件以标准格式写入系统日历,包括标题、时间、地点、备注等信息。
- 提醒用户:通过系统级的提醒机制,在日程开始前的指定时间通知用户。
- 一键直达:在日程卡片上提供服务按钮,用户点击后通过 DeepLink 跳回应用的对应页面,完成后续操作。
写入日历的日程会出现在多个地方——日历应用内部、桌面日历卡片、通知中心。用户不需要打开你的应用就能看到这些信息,这对提升事件的到达率很有帮助。
1.2 一键服务按钮的显示时机
一键服务按钮不是一直显示的,不同入口的出现时机不一样:
| 入口 | 显示时机 |
|---|---|
| 桌面卡片 / 月视图日程列表卡片 | 日程开始前 15 分钟显示,日程结束后自动隐藏 |
| 日程详情页 | 始终显示 |
| 日程通知 | 通知弹出时显示,点击通知卡片后显示 |
这意味着一键服务按钮是有"时效性"的——它在用户最需要行动的时间窗口出现,而不是一直挂在那里。
1.3 9 种典型服务场景
Calendar Kit 为不同的业务场景预定义了服务类型,每种类型对应一个具体的按钮文案:
| 场景 | ServiceType | 按钮文案 |
|---|---|---|
| 会议 | Meeting |
加入会议 |
| 追剧 | Watching |
立即观看 |
| 还款 | Repayment |
马上还款 |
| 直播 | Live |
开启直播 |
| 购物 | Shopping |
开始选购 |
| 出行 | Trip |
立即查看 |
| 上课 | Class |
开始上课 |
| 赛事 | SportsEvents |
立即观看 |
| 运动 | SportsExercise |
开始运动 |
选择合适的服务类型,按钮文案就会自动匹配,不需要开发者自定义。
二、开发前的准备工作
在写第一行业务代码之前,有三步准备工作需要完成:
第一步,导入依赖。日历管理相关的能力都在 @kit.CalendarKit 中。
第二步,申请权限。日历是用户的私有数据,读写操作需要在 module.json5 中声明两个权限:ohos.permission.READ_CALENDAR 和 ohos.permission.WRITE_CALENDAR。
第三步,获取日程管理器对象。通过上下文获取 calendarMgr 对象,后续所有日历账户和日程的管理操作都通过它来进行。推荐在 EntryAbility.ets 中完成这一步,确保管理器对象在应用生命周期内可用。
三、理解日程的数据结构
在看具体场景之前,先理解日程涉及的几个关键概念,后面写代码时会更清楚为什么要这样配置。
3.1 日历账户
每个写入系统日历的日程都归属于一个日历账户。你可以理解为日历中的一个"分组"——用户打开日历应用时,能看到来自不同应用的日程被归类在各自的账户下。
账户有三个关键属性:
- name:账户标识,供系统内部使用。
-
type:账户类型,一般使用
LOCAL。 - displayName:展示给用户看的名称,建议和应用在应用市场中的名称保持一致,方便用户识别"这个日程是哪个应用写的"。
3.2 日程字段
一条日程的核心字段包括:
- title:标题,出现在日程卡片上最醒目的位置。
- startTime / endTime:起止时间,时间戳格式。
- isAllDay:是否是全天日程。全天日程不会显示具体时刻,适合"入住日""还款日"这类以天为单位的事件。
-
reminderTime:提醒时间,是一个数组,单位是分钟。比如
[0, 10]表示日程开始时和开始前 10 分钟各提醒一次。对于全天日程,0表示当天上午 9 点提醒,1440(即 24 小时)表示前一天上午 9 点提醒。 - description:备注信息,可以补充标题里放不下的细节。
- location:地点信息,包含地址文本和经纬度。
-
service:一键服务配置,包括服务类型(
type)和跳转链接(uri,DeepLink 格式)。
3.3 日程的增删改查
日历服务提供了完整的 CRUD 操作。创建日历账户后,可以在该账户下添加日程、按条件查询日程、更新日程信息、删除日程。后面的场景示例中会展示这些操作的具体写法。
四、典型场景实践
下面通过两个最常见的场景——出行服务和会议——来完整走一遍开发流程。其他场景(直播、购物、还款、课程等)的开发思路完全一致,区别只在于字段内容和 ServiceType 的选择。
4.1 出行服务场景
这大概是最容易理解的场景了:用户在购票应用里买了一张高铁票,应用把行程信息写入日历,出发前提醒用户,用户还能一键跳回应用查看电子客票。
字段设计思路:
标题要一目了然,建议包含车次和起终点,比如"行程信息:G107 上海虹桥-北京南"。备注里可以放检票口和座位号这类到了车站才需要的细节。提醒时间设两个——4 小时前提醒用户该出门了,2 小时前再提醒一次。一键服务类型选 TRIP。
创建日程:
import { calendarMgr } from '../entryability/EntryAbility';
import { calendarManager } from '@kit.CalendarKit';
let tripCalendar: calendarManager.Calendar | undefined = undefined;
let oriEvent: calendarManager.Event | null = null;
let id: number = 0;
async createTripCalendarAndEvent(): Promise<void> {
const calendarAccount: calendarManager.CalendarAccount = {
name: 'TripCalendar',
type: calendarManager.CalendarType.LOCAL,
displayName: '高铁出行'
};
const config: calendarManager.CalendarConfig = {
color: '#aabbcc'
};
const startTime = new Date('2025-10-01T08:17:00').getTime();
const endTime = new Date('2025-10-01T12:51:00').getTime();
const event: calendarManager.Event = {
type: calendarManager.EventType.NORMAL,
title: '行程信息:G107 上海虹桥-北京南',
startTime: startTime,
endTime: endTime,
isAllDay: false,
reminderTime: [120, 240],
description: '检票口:南二楼1口或北广场B2候车室 \n座位号:02车04二等座',
service: {
type: calendarManager.ServiceType.TRIP,
uri: 'demo://mobile/player?params='
}
};
try {
tripCalendar = await calendarMgr?.createCalendar(calendarAccount);
if (!tripCalendar) {
console.error('Failed to create calendar.');
return;
}
await tripCalendar.setConfig(config);
id = await tripCalendar.addEvent(event);
oriEvent = event;
oriEvent.id = id;
console.info(`日程创建成功,ID: ${id}`);
} catch (error) {
console.error(`创建失败: ${error.code}, ${error.message}`);
}
}
这段代码做了三件事:创建日历账户、设置账户配色、添加日程。注意一定要确保日历账户创建成功后再进行日程操作,否则后续调用会失败。
日程的后续管理:
出行场景下,行程变更是常有的事——改签了车次、换了出发时间。这时候需要更新已有日程而不是删掉重建:
async updateTripEvent(): Promise<void> {
if (!tripCalendar || !oriEvent) return;
// 改签后更新起止时间
oriEvent.startTime = new Date('2025-10-01T07:03:00').getTime();
oriEvent.endTime = new Date('2025-10-01T11:51:00').getTime();
try {
await tripCalendar.updateEvent(oriEvent);
console.info('日程更新成功');
} catch (err) {
console.error(`更新失败: ${err.code}, ${err.message}`);
}
}
如果用户退票了,则直接删除日程:
async deleteTripEvent(): Promise<void> {
if (!tripCalendar) return;
try {
await tripCalendar.deleteEvent(id);
oriEvent = null;
console.info('日程已删除');
} catch (err) {
console.error(`删除失败: ${err.code}, ${err.message}`);
}
}
需要查询已有日程时,通过 EventFilter.filterById 按 ID 查询:
async getTripEvent(): Promise<void> {
if (!tripCalendar) return;
try {
const filter = calendarManager.EventFilter.filterById([id]);
let data = await tripCalendar.getEvents(filter,
['title', 'type', 'startTime', 'endTime']);
if (data && data.length > 0) {
oriEvent = data[0];
}
} catch (err) {
console.error(`查询失败: ${err.code}, ${err.message}`);
}
}
4.2 会议场景
会议场景和出行的最大区别在于:它有与会人信息。用户在会议应用中创建或被邀请参加一个会议,应用将其写入日历,到时间时用户看到提醒,点击"加入会议"按钮就能直接进入会议。
字段设计思路:
标题就是会议主题。提醒时间设准时和 15 分钟前——太早没意义,太晚来不及。会议场景特有的是 attendee 字段,用来记录与会人信息,每个与会人有姓名、邮箱、角色(组织者还是参与者)和类型(必选还是可选)。
async createMeetingEvent(): Promise<void> {
const calendarAccount: calendarManager.CalendarAccount = {
name: 'meetingCalendar',
type: calendarManager.CalendarType.LOCAL,
displayName: '会议'
};
const config: calendarManager.CalendarConfig = {
color: '#aabbcc'
};
let attendee: calendarManager.Attendee[] = [
{
name: 'Alice',
email: 'alice@example.com',
role: calendarManager.AttendeeRole.ORGANIZER
},
{
name: 'Jack',
email: 'jack@example.com',
role: calendarManager.AttendeeRole.PARTICIPANT,
type: calendarManager.AttendeeType.REQUIRED
},
{
name: 'Jerry',
email: 'jerry@example.com',
role: calendarManager.AttendeeRole.PARTICIPANT,
type: calendarManager.AttendeeType.REQUIRED
}
];
const startTime = new Date('2025-10-20T09:00:00').getTime();
const endTime = new Date('2025-10-20T10:00:00').getTime();
const event: calendarManager.Event = {
type: calendarManager.EventType.NORMAL,
title: '产品方案评审会议',
startTime: startTime,
endTime: endTime,
isAllDay: false,
reminderTime: [0, 15],
attendee: attendee,
description: 'Q4产品方案评审',
service: {
type: calendarManager.ServiceType.MEETING,
uri: 'demo://mobile/player?params='
}
};
try {
let calendar = await calendarMgr?.createCalendar(calendarAccount);
if (!calendar) return;
await calendar.setConfig(config);
id = await calendar.addEvent(event);
console.info(`会议日程创建成功,ID: ${id}`);
} catch (error) {
console.error(`创建失败: ${error.code}, ${error.message}`);
}
}
与会人信息会展示在日程详情中,帮助用户确认参会人员。对于会议应用来说,service.uri 中的 DeepLink 通常会携带会议室 ID 等参数,用户点击"加入会议"后直接进入对应的会议房间。
五、其他场景速览
前面详细讲了出行和会议两个场景的完整实现,其他场景的代码结构完全一样,差异只在字段内容的填写上。这里简要列出几个场景的要点,帮你快速对照:
酒店住宿:适合设置为全天日程(isAllDay: true),标题包含酒店名称和地址,别忘了填 location 字段(包含经纬度),ServiceType 用 TRIP。提醒建议设前一天上午 9 点(reminderTime: [1440])和当天上午 9 点(reminderTime: [0])。
还款提醒:也是全天日程,毕竟还款日是以"天"为单位的。备注里写上待还款金额,ServiceType 用 REPAYMENT,提醒一次就够了——当天上午 9 点(reminderTime: [0])。
直播 / 抢购 / 课程 / 赛事 / 运动:都是精确到具体时刻的非全天日程,提醒时间一般设准时和开始前 10-30 分钟。区别就是选对 ServiceType,按钮文案就会自动匹配。
六、总结与实践建议
日历服务的接入逻辑并不复杂——创建账户、配置日程、写入系统。但要把体验做好,有几个细节值得注意:
- 标题要有信息量。用户在桌面卡片上看到的可能只有标题,所以"G107 上海虹桥-北京南"远比"火车票行程"有用。
- 提醒时间要合理。出行类提前 2-4 小时,会议和课程提前 10-15 分钟,全天日程用上午 9 点。不要设过多提醒,免得用户觉得被打扰。
- 及时更新和清理。行程改签了就更新日程,退票了就删除。不要让过期或无效的日程留在用户的日历里,这会损害用户对应用的信任。
- displayName 要用应用真名。用户看到一条日程时,会通过日历账户名称判断"这是哪个应用写的"。用正式的应用名称,而不是内部代号或缩写。
- DeepLink 要能真正落地。一键服务按钮点下去后跳转的链接,必须能正确打开应用的对应页面。如果链接失效或跳错位置,这个功能反而会让用户感到困惑。
日历是一个天然的时间管理入口,用户每天都会看。把应用中有价值的时间事件写进去,既帮用户管理好了日程,也为应用争取到了在系统级入口的露出机会。