HarmonyOS 本地存储实战:记账本案例改造实现日历联动
-
- 一、动手前我们一起来缕一缕思路
- [二、把发薪日和还款日存进 KVStore](#二、把发薪日和还款日存进 KVStore)
- 三、由存下日期改为对接系统日历
- [四、日历同步的核心实现,不是 addEvent,而是"先删旧,再建新"](#四、日历同步的核心实现,不是 addEvent,而是“先删旧,再建新”)
- 五、为什么还要多建一张同步记录表,这其实是整套方案的稳定器
- 六、最后别忽略页面交互,因为用户感知到的永远是"能不能改、改完有没有反馈"
- 写在最后:这套案例最适合继续扩成什么
上一期我们的 Demo 停留在"存一条数据,再读出来"这一步。代码能跑,但离业务还是太远。
这次我想把场景再往前推一步。

想象一下:某打工人每个月 15 号工资到账,20 号贷款自动扣款。用户不是不记得这些日子,而是工作一忙,就很容易"忘了处理"。这时如果我们的记账本只能把日期存在本地,其实价值只做了一半。不妨我们再拓展一下:
- 用
KVStore保存发薪日、还款日这类轻量偏好 - 用鸿蒙
CalendarKit把这些日期同步到系统日历 - 再用一张本地同步记录表,保证后续修改日期时能删旧建新
这篇文章就基于我们之前的工程代码,带你把"本地偏好设置"升级成"本地配置 + 系统日历联动"的完整案例。
一、动手前我们一起来缕一缕思路
这次改造里,我们处理的其实是三类完全不同的数据:
- 发薪日、还款日:配置型数据,字段少、查询简单,适合放
KVStore - 日历事件:系统数据,不归我们直接持久化,适合通过
CalendarKit写入系统日历 - 同步记录:为了记住"我们曾经往日历里写过哪些事件",适合放本地
RDB
这个拆分非常关键,如果你把所有东西都硬塞进一个存储方案里,看完只会记住 API 名字;但如果你把"哪类数据适合哪种存储"理解,以后做待办、喝水提醒、纪念日提醒、会员续费提醒,思路都会很顺。
我们这次的数据模型很简单,直接看代码:
ts
export class AppPreference {
salaryDay: number;
loanRepaymentDay: number;
constructor(salaryDay: number, loanRepaymentDay: number) {
this.salaryDay = salaryDay;
this.loanRepaymentDay = loanRepaymentDay;
}
}
这段代码它看起来朴素,但已经把这个页面最核心的业务表达出来了:我们真正关心的不是复杂表结构,而是两个"每月重复发生"的关键日期。
二、把发薪日和还款日存进 KVStore

对于偏好设置页来说,最常见的错误不是"不会存",而是"明明只有两个字段,也要把自己写成半个数据库系统"。
这类数据最适合 KVStore,原因很简单:
- 字段少
- 读写直接
- 不需要复杂筛选
- 更像"配置",不像"记录集合"
ts
const BUNDLE_NAME: string = 'com.example.localdatabase';
const STORE_ID: string = 'ledger_preference_store';
const KEY_SALARY_DAY: string = 'pref_salary_day';
const KEY_LOAN_DAY: string = 'pref_loan_day';
class PreferenceKvStore {
private kvStore: distributedKVStore.SingleKVStore | undefined = undefined;
private kvManager: distributedKVStore.KVManager | undefined = undefined;
init(context: common.UIAbilityContext, onReady: () => void): void {
if (this.kvStore !== undefined) {
onReady();
return;
}
const kvManagerConfig: distributedKVStore.KVManagerConfig = {
context: context,
bundleName: BUNDLE_NAME
};
try {
this.kvManager = distributedKVStore.createKVManager(kvManagerConfig);
const options: distributedKVStore.Options = {
createIfMissing: true,
encrypt: false,
backup: true,
autoSync: false,
kvStoreType: distributedKVStore.KVStoreType.SINGLE_VERSION,
securityLevel: distributedKVStore.SecurityLevel.S1
};
this.kvManager.getKVStore<distributedKVStore.SingleKVStore>(STORE_ID, options, (err, store) => {
if (err) {
console.error(`Failed to get KVStore. Code:${err.code}, message:${err.message}`);
return;
}
this.kvStore = store;
onReady();
});
} catch (error) {
const businessError = error as BusinessError;
console.error(`Failed to create KVManager. Code:${businessError.code}, message:${businessError.message}`);
}
}
}

这段代码有两个值得强调的点:
createIfMissing: true很适合 Demo 和业务首版,第一次运行时不需要额外建仓逻辑bundleName一定要和工程实际包名一致,这类问题不复杂,但非常高频
继续看读写逻辑:
ts
loadPreferences(onResult: (preference: AppPreference) => void): void {
if (this.kvStore === undefined) {
onResult(this.defaultPreference());
return;
}
const query = new distributedKVStore.Query();
this.kvStore.getResultSet(query, (err, resultSet) => {
if (err) {
console.error(`Failed to query preferences. Code:${err.code}, message:${err.message}`);
onResult(this.defaultPreference());
return;
}
let salaryDay: number = 15;
let loanRepaymentDay: number = 20;
if (resultSet !== null) {
resultSet.moveToFirst();
while (!resultSet.isAfterLast()) {
const entry = resultSet.getEntry();
if (entry.key === KEY_SALARY_DAY) {
salaryDay = Number(entry.value.value.toString());
} else if (entry.key === KEY_LOAN_DAY) {
loanRepaymentDay = Number(entry.value.value.toString());
}
resultSet.moveToNext();
}
}
onResult(new AppPreference(this.normalizeDay(salaryDay), this.normalizeDay(loanRepaymentDay)));
});
}
savePreferences(salaryDay: number, loanRepaymentDay: number, onComplete: (success: boolean) => void): void {
this.putValue(KEY_SALARY_DAY, this.normalizeDay(salaryDay).toString(), (salarySaved: boolean) => {
if (!salarySaved) {
onComplete(false);
return;
}
this.putValue(KEY_LOAN_DAY, this.normalizeDay(loanRepaymentDay).toString(), (loanSaved: boolean) => {
onComplete(loanSaved);
});
});
}
这里我们做了 normalizeDay() 这个处理。因为用户输入不会永远完美,如果能顺手把"数据归一化"带进去,体验会明显更高。比如:
- 小于
1的日期归到1 - 大于
31的日期归到31 - 浮点数取整
这已经不只是"会调用 KVStore",而是如何把输入变成可靠数据。
三、由存下日期改为对接系统日历

如果只把还款日留在本地页面里,用户只有"打开 App 时才会看见它"。
但实际场景不是这样的。用户在地铁上、在开会、在写周报的时候,未必会主动打开你的记账本。这个时候,把关键日期同步到系统日历,价值就立刻起来了。

先看权限声明。我们在 entry/src/main/module.json5 里加了两项权限:
json
"requestPermissions": [
{
"name": "ohos.permission.READ_CALENDAR",
"reason": "$string:reason_read_calendar",
"usedScene": {
"abilities": [
"EntryAbility"
],
"when": "inuse"
}
},
{
"name": "ohos.permission.WRITE_CALENDAR",
"reason": "$string:reason_write_calendar",
"usedScene": {
"abilities": [
"EntryAbility"
],
"when": "inuse"
}
}
]
然后在运行时授权:
ts
export async function requestCalendarPermission(uiContext: UIContext): Promise<boolean> {
const permissions: Permissions[] = ['ohos.permission.READ_CALENDAR', 'ohos.permission.WRITE_CALENDAR'];
let isFirstTime: boolean = true;
let userGrant: boolean = true;
const atManager = abilityAccessCtrl.createAtManager();
const grantStatus = await atManager.requestPermissionsFromUser(uiContext.getHostContext() as Context, permissions);
for (let element of grantStatus.authResults) {
if (element !== 0) {
userGrant = false;
break;
}
}
if (grantStatus.dialogShownResults) {
for (let element of grantStatus.dialogShownResults) {
if (!element) {
isFirstTime = false;
break;
}
}
}
if (!isFirstTime && !userGrant) {
const data: Array<abilityAccessCtrl.GrantStatus> =
await atManager.requestPermissionOnSetting(uiContext.getHostContext() as Context, permissions);
userGrant = true;
for (let element of data) {
if (element !== 0) {
userGrant = false;
}
}
}
return userGrant;
}
它不是"弹一次权限框就结束",而是把两个真实分支都考虑进去了:
- 用户第一次授权
- 用户第一次拒绝后,后续从设置页重新引导授权
很多文章只写第一段,开发者落地时经常卡在第二段。
四、日历同步的核心实现,不是 addEvent,而是"先删旧,再建新"

这次日历联动里,最容易被低估的不是 CalendarKit 本身,而是同步策略。
比如用户原来把还款日设为 20 号,后来改成 18 号。如果你只是继续新增事件,系统日历里很快就会同时出现两套提醒。对用户来说,这不是提醒,是打扰。
所以我们当前项目采用的是一个非常适合教学案例的策略:每次同步前,先删除之前写入的提醒,再按最新配置重建未来 12 个月事件。
代码在 entry/src/main/ets/store/CalendarReminderService.ets:
ts
class CalendarReminderService {
async syncReminderSchedules(preference: AppPreference, uiContext: UIContext): Promise<CalendarSyncResult> {
const granted = await requestCalendarPermission(uiContext);
if (!granted) {
return {
success: false,
granted: false,
syncedCount: 0,
message: '已保存本地设置,但未获得日历权限'
};
}
const context = uiContext.getHostContext() as common.UIAbilityContext;
await reminderSyncRdbStore.init(context);
const records = await reminderSyncRdbStore.queryAll();
const calendarMgr = calendarManager.getCalendarManager(context);
const calendar = await calendarMgr.getCalendar();
for (let record of records) {
try {
await calendar.deleteEvent(record.calendarEventId);
} catch (error) {
console.error(`Failed to delete calendar event ${record.calendarEventId}`);
}
}
await reminderSyncRdbStore.clearAll();
const events = this.buildReminderEvents(preference);
let syncedCount: number = 0;
for (let item of events) {
const eventId = await calendar.addEvent(item.event);
await reminderSyncRdbStore.insertRecord(item.reminderType, item.eventDate, eventId, item.event.title ?? '');
syncedCount++;
}
return {
success: true,
granted: true,
syncedCount: syncedCount,
message: `已同步 ${syncedCount} 条提醒到系统日历`
};
}
}
这段代码里面,真正应该让大家记住的是这条同步链路:
- 先要权限
- 再读取历史同步记录
- 删除旧日历事件
- 清空本地同步记录
- 根据最新偏好生成新事件
- 新事件写入成功后,再把事件 ID 记回本地
这比单纯展示 calendar.addEvent() 更接近业务。
顺着往下看,未来 12 个月提醒是怎么生成的:
ts
private buildUpcomingDates(day: number, count: number): Date[] {
const today = new Date();
const todayOnly = new Date(today.getFullYear(), today.getMonth(), today.getDate());
const dates: Date[] = [];
let monthOffset = 0;
while (dates.length < count) {
const current = new Date(today.getFullYear(), today.getMonth() + monthOffset, 1);
const year = current.getFullYear();
const month = current.getMonth();
const lastDay = new Date(year, month + 1, 0).getDate();
const safeDay = day > lastDay ? lastDay : day;
const target = new Date(year, month, safeDay);
if (target.getTime() >= todayOnly.getTime()) {
dates.push(target);
}
monthOffset++;
}
return dates;
}
所以你会看到我们用了 safeDay。这意味着:
- 用户把发薪日设置为
31 - 到了 2 月时,提醒会自动落到当月最后一天
这个细节非常生活化,也非常适合作为一个练手小功能。
五、为什么还要多建一张同步记录表,这其实是整套方案的稳定器
如果你第一次看这套方案,可能会问一句:都已经写进系统日历了,为什么还要在本地再存一遍同步记录?
原因很简单:系统日历是结果,本地记录是控制面。
我们需要知道:
- 以前同步过哪些事件
- 这些事件对应的是发薪提醒还是还款提醒
- 事件在系统日历里的 ID 是多少
否则你下次修改配置时,就没有办法精准删掉旧事件。
ts
const REMINDER_SYNC_TABLE_NAME: string = 'reminder_sync_records';
const REMINDER_SYNC_COLUMNS: string[] = ['id', 'reminderType', 'eventDate', 'calendarEventId', 'eventTitle'];
class ReminderSyncRdbStore {
private rdbStore: relationalStore.RdbStore | null = null;
async init(context: common.UIAbilityContext): Promise<void> {
if (this.rdbStore !== null) {
return;
}
const storeConfig: relationalStore.StoreConfig = {
name: 'ledger_book.db',
securityLevel: relationalStore.SecurityLevel.S1
};
await new Promise<void>((resolve, reject) => {
relationalStore.getRdbStore(context, storeConfig, (err, store) => {
if (err) {
reject(err);
return;
}
this.rdbStore = store;
try {
this.rdbStore.executeSql(
`CREATE TABLE IF NOT EXISTS ${REMINDER_SYNC_TABLE_NAME} (` +
'id INTEGER PRIMARY KEY AUTOINCREMENT, ' +
'reminderType TEXT NOT NULL, ' +
'eventDate TEXT NOT NULL, ' +
'calendarEventId INTEGER NOT NULL, ' +
'eventTitle TEXT NOT NULL)'
);
resolve();
} catch (error) {
reject(error);
}
});
});
}
}
这张表不是给用户看的,它最大的意义是把"系统能力调用"变成"可追踪、可回滚、可重建"的流程。
顺便说一句,这里还有一个很适合在面试或实战里继续展开的知识点:
- 简单 Demo 可以直接"全删全建"
- 如果后续追求性能和更细粒度更新,可以进一步做"差量同步"
也就是说,今天这套方案已经足够教学和业务首版,未来还天然有演进空间。
六、最后别忽略页面交互,因为用户感知到的永远是"能不能改、改完有没有反馈"
存储和系统能力都接好了,如果设置页做得像个半成品,整篇案例还是会掉分。
这次我们把偏好页改成了一个更像产品原型的交互:
- 页面先展示当前发薪日、还款日
- 点击卡片或底部按钮都能进入编辑
- 输入框限制数字输入
- 保存成功后立即同步系统日历
- 页面上同步状态和 Toast 都会实时反馈
ts
Button('编辑提醒日')
.width(Constants.FULL_PERCENT)
.height(Constants.LARGE_BUTTON_HEIGHT)
.backgroundColor(Constants.PRIMARY_COLOR)
.fontColor(Color.White)
.borderRadius(Constants.BORDER_RADIUS_PILL)
.margin({ top: Constants.SPACE_12, bottom: Constants.SPACE_4 })
.onClick(() => {
this.openEditor();
});
TextInput({ placeholder: '发薪日,输入 1 - 31', text: this.draftSalaryDay })
.type(InputType.Number)
.height(Constants.LARGE_BUTTON_HEIGHT)
.onChange((value) => {
this.draftSalaryDay = value;
});
TextInput({ placeholder: '还款日,输入 1 - 31', text: this.draftLoanDay })
.type(InputType.Number)
.height(Constants.LARGE_BUTTON_HEIGHT)
.onChange((value) => {
this.draftLoanDay = value;
});
保存动作则把"本地保存"和"日历同步"串成了一条完整链路:
ts
private savePreference(): void {
const salaryDay = Number(this.draftSalaryDay.trim());
const loanDay = Number(this.draftLoanDay.trim());
if (isNaN(salaryDay) || salaryDay < 1 || salaryDay > 31) {
this.validationMessage = '请输入正确的发薪日';
return;
}
if (isNaN(loanDay) || loanDay < 1 || loanDay > 31) {
this.validationMessage = '请输入正确的还款日';
return;
}
preferenceKvStore.savePreferences(salaryDay, loanDay, async (success: boolean) => {
if (!success) {
this.validationMessage = '本地保存失败,请稍后重试';
return;
}
this.salaryDay = salaryDay;
this.loanRepaymentDay = loanDay;
this.refreshItems();
const result = await calendarReminderService.syncReminderSchedules(
new AppPreference(this.salaryDay, this.loanRepaymentDay),
this.getUIContext()
);
this.syncStatusText = result.message;
this.getUIContext().getPromptAction().showToast({
message: result.message,
duration: 2500
});
this.isShowSheet = false;
});
}
为什么我觉得这段代码特别适合教学?
因为它刚好把一条业务链路串完整了:
- 表单校验
- 本地持久化
- 页面状态刷新
- 系统能力调用
- 用户反馈回写
大家看完不是只学会一个 API,而是能把"一个设置页如何从输入走到系统联动"真正想明白。
写在最后:这套案例最适合继续扩成什么
如果你正在学习鸿蒙本地数据库或系统能力联动,我很建议你把这套案例继续往下扩展。因为它已经具备了非常好的思路骨架:
KVStore负责轻量偏好RDB负责同步控制记录CalendarKit负责系统日历联动- 页面层负责状态反馈
如果上一篇文章解决的是"鸿蒙本地存储怎么选",那这篇文章更想解决的是另一件事:
本地存储不是把数据放进去就结束了,真正有业务价值的,是把本地配置继续推到系统能力里。
而"发薪日 + 还款日 + 系统日历提醒"这个案例,恰好就是一个很适合我们教学、又足够贴近生活的落点。