HarmonyOS 本地存储实战:记账本案例改造实现日历联动

HarmonyOS 本地存储实战:记账本案例改造实现日历联动

上一期我们的 Demo 停留在"存一条数据,再读出来"这一步。代码能跑,但离业务还是太远。

这次我想把场景再往前推一步。

想象一下:某打工人每个月 15 号工资到账,20 号贷款自动扣款。用户不是不记得这些日子,而是工作一忙,就很容易"忘了处理"。这时如果我们的记账本只能把日期存在本地,其实价值只做了一半。不妨我们再拓展一下:

  • KVStore 保存发薪日、还款日这类轻量偏好
  • 用鸿蒙 CalendarKit 把这些日期同步到系统日历
  • 再用一张本地同步记录表,保证后续修改日期时能删旧建新

这篇文章就基于我们之前的工程代码,带你把"本地偏好设置"升级成"本地配置 + 系统日历联动"的完整案例。


一、动手前我们一起来缕一缕思路

这次改造里,我们处理的其实是三类完全不同的数据:

  1. 发薪日、还款日:配置型数据,字段少、查询简单,适合放 KVStore
  2. 日历事件:系统数据,不归我们直接持久化,适合通过 CalendarKit 写入系统日历
  3. 同步记录:为了记住"我们曾经往日历里写过哪些事件",适合放本地 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} 条提醒到系统日历`
    };
  }
}

这段代码里面,真正应该让大家记住的是这条同步链路:

  1. 先要权限
  2. 再读取历史同步记录
  3. 删除旧日历事件
  4. 清空本地同步记录
  5. 根据最新偏好生成新事件
  6. 新事件写入成功后,再把事件 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 负责系统日历联动
  • 页面层负责状态反馈

如果上一篇文章解决的是"鸿蒙本地存储怎么选",那这篇文章更想解决的是另一件事:

本地存储不是把数据放进去就结束了,真正有业务价值的,是把本地配置继续推到系统能力里。

而"发薪日 + 还款日 + 系统日历提醒"这个案例,恰好就是一个很适合我们教学、又足够贴近生活的落点。

相关推荐
李游Leo2 小时前
别让一张 12MB 的照片拖垮页面:ImageSource / PixelMap / ImagePacker 的工程化处理链路
harmonyos
nashane2 小时前
HarmonyOS 6学习:画中画(PiP)状态同步与场景化实战指南
学习·pip·harmonyos·harmonyos 5
@不误正业3 小时前
鸿蒙小艺智能体开放平台实战-接入系统级AI-Agent能力
人工智能·华为·harmonyos
IntMainJhy6 小时前
「Flutter三方库sqflite的鸿蒙化适配与实战指南:从入门到踩坑的本地数据库开发全记录」
数据库·flutter·华为·信息可视化·数据库开发·harmonyos
前端技术8 小时前
HarmonyOS开发:鸿蒙应用开发发展史
华为·harmonyos
忡黑梨8 小时前
eNSP_路由策略
运维·服务器·网络·华为·智能路由器·负载均衡
Hello__77779 小时前
开源鸿蒙 Flutter 实战|自定义头像组件全流程实现
flutter·华为·harmonyos
模拟IC攻城狮10 小时前
华为2026 年校园招聘——硬件技术工程师-电源方向-机试题(12套)(每套四十题)
嵌入式硬件·华为·硬件架构·芯片
花先锋队长10 小时前
从“耐刮”到“通透”:华为抗反光耐刮昆仑玻璃,如何重新定义屏幕体验?
华为