最近,我把一款 iOS 上的心情日记和习惯追踪 App,用 ArkTS + ArkUI 在 HarmonyOS 上重新实现了一遍。

它的功能并不复杂:每天选择一种心情,加上工作、睡眠、运动等情境标签,写一句话日记,再顺手完成几个习惯打卡。用户可以通过月历回看记录,也可以查看最近 7 天或 30 天的简单统计。

但这个项目有一条很明确的产品边界:

不要求账号,不依赖服务器,不接广告和第三方统计 SDK,心情、日记和习惯数据默认只保存在用户自己的设备中。

这条边界直接影响了技术选择。最终的 HarmonyOS 版本采用:

模块 实现方式
应用模型 Stage 模型
UI ArkTS + ArkUI
本地存储 Preferences + JSON
应用锁 UserAuthenticationKit
文件导出 CoreFileKit DocumentViewPicker
多语言 简体中文、英文、日文
数据网络传输

下面不是完整的代码搬运,而是这次原生重写中最值得复用的 6 个设计决策。

一、先迁移领域模型,不要先翻译页面

跨平台重写很容易从 UI 开始:先画首页,再做按钮,最后才考虑数据。这样短期看起来进度很快,后期却容易陷入页面状态和持久化结构反复调整。

我的做法是先把应用中真正稳定的对象定义出来:

export interface MoodEntry {
  id: string;
  timestampMillis: number;
  dayIdentifier: string;
  mood: MoodKind;
  note: string;
  tags: string[];
  createdAtMillis: number;
  updatedAtMillis: number;
}

export interface HabitCompletion {
  id: string;
  habitId: string;
  timestampMillis: number;
  dayIdentifier: string;
  isCompleted: boolean;
  createdAtMillis: number;
  updatedAtMillis: number;
}

这里同时保存了时间戳和 dayIdentifier

  • 时间戳适合排序和展示具体时间;
  • dayIdentifier 负责表达“这是用户本地日历中的哪一天”;
  • 心情记录按日期唯一;
  • 习惯打卡按 habitId + dayIdentifier 唯一。

UI 可以换,存储框架也可以换,但这些业务约束不会轻易变化。先把它们固定下来,后面的今日页、历史页和统计页就能共享同一套语义。

二、“一天”是本地日历概念,不是 UTC 的 24 小时

日记类应用最容易被低估的问题之一,就是日期。

如果直接把时间戳转换为 UTC 日期,用户在北京时间凌晨记录的内容,可能会被归到前一天。对日志系统来说这也许没问题,对日记 App 来说却是明显错误。

项目中使用本地日期生成稳定标识:

export function dayIdentifier(date: Date): string {
  const year: string = date.getFullYear().toString();
  const month: string = (date.getMonth() + 1).toString().padStart(2, '0');
  const day: string = date.getDate().toString().padStart(2, '0');
  return `${year}-${month}-${day}`;
}

保存今日心情时,先通过日期查找已有记录:

private todayEntry(): MoodEntry | undefined {
  const id: string = this.todayId();
  return this.moodEntries.find(
    (entry: MoodEntry) => entry.dayIdentifier === id
  );
}

如果存在就更新,不存在才新增。这样可以在数据层面贯彻“每天一条心情记录”,而不是只依靠按钮禁用或页面提示来约束用户。

同样的标识还被月历、历史编辑、习惯打卡和 7/30 天统计共同使用,避免各页面分别解释日期。

三、用 Preferences 保存 JSON,也要考虑升级和并发写入

这个 App 没有账号和云端同步,数据量也比较小,因此没有一开始就引入数据库,而是把完整状态序列化为 JSON,保存在 Preferences 中。

核心状态包含设置、心情记录、习惯和打卡:

export interface PersistedState {
  exportVersion: number;
  settings: AppSettings;
  moodEntries: MoodEntry[];
  habits: Habit[];
  habitCompletions: HabitCompletion[];
}

这个方案简单,但仍然有两个不能省略的细节。

1. 为旧数据补默认值

应用升级后,新版本可能增加设置字段。读取旧 JSON 时不能假设所有字段都存在:

parsed.settings = {
  privacyAcceptedVersion:
    persistedSettings.privacyAcceptedVersion ?? 0,
  onboardingCompleted:
    persistedSettings.onboardingCompleted ?? false,
  appLockEnabled:
    persistedSettings.appLockEnabled ?? false,
  languagePreference:
    persistedSettings.languagePreference ?? LanguagePreference.SYSTEM
  // 其他字段省略
};

同时保留 exportVersion,为未来的数据迁移和导入兼容留出空间。

2. 串行化保存操作

用户可能连续修改心情、标签和习惯。如果多次异步写入互相超车,旧快照可能覆盖新快照。

因此仓库层维护一个简单的保存队列:

private saveQueue: Promise<void> = Promise.resolve();

const operation: Promise<void> = this.saveQueue.then(async () => {
  await store.put(STATE_KEY, encoded);
  await store.flush();
});

this.saveQueue = operation.catch(() => {});
return operation;

它不是复杂的事务系统,但对这种单用户、单设备、小数据量应用已经足够实用。

四、ArkUI 列表不刷新时,问题可能出在 Key

开发过程中遇到过一个很有迷惑性的现象:数据已经更新,持久化也成功,但习惯图标、标签选中态或统计区域没有立即变化,切换页面后才显示正确。

原因之一是 ArkUI V1 的 ForEach 会根据 Key 复用子节点。如果 Key 只包含固定 ID,内部对象变化后,旧行仍可能被复用。

项目中增加了一个显式的 UI 修订号:

@State private uiRevision: number = 0;

private refreshUi(): void {
  this.uiRevision += 1;
}

随后把与显示结果有关的状态放进 Key:

ForEach(this.activeHabits(), (habit: Habit) => {
  // 习惯行
}, (habit: Habit) =>
  `${habit.id}-${habit.updatedAtMillis}-${this.uiRevision}`
)

标签选择也采用相同思路:

(tag: TagDefinition) =>
  `${tag.key}-${this.selectedTags.includes(tag.key)}-${this.uiRevision}`

这里的经验是:Key 不只是“保证不重复的 ID”,它还决定了框架何时应该把一个子节点视为新的显示实体。遇到“状态变了但局部 UI 不动”,除了检查 @State,也要检查 Key 是否表达了真正的刷新边界。

五、多语言不仅是翻译,还涉及持久化数据的稳定性

心晴手记支持跟随系统、简体中文、English 和日本語。

静态界面文案可以放在不同语言的资源文件中,但默认习惯名称不能简单把当前显示文字直接写入本地。否则用户从中文切到日文后,已经保存的“早睡”仍然会显示中文。

我的处理方式是:默认习惯保存稳定的内部名称,展示时再本地化。

{
  resourceName: 'starter_sleep',
  storedName: 'sleep-early',
  legacyNames: ['早睡', 'Sleep early', '早寝'],
  icon: 'moon_z',
  color: '#5E7CE2'
}
  • storedName 是稳定数据;
  • resourceName 用于获取当前语言文案;
  • legacyNames 兼容早期测试版本保存过的中、英、日名称;
  • 用户自己创建的习惯名称保持原样,不强行翻译。

切换语言后,应用保存选择并调用 restartApp(),确保资源字符串和 ArkUI 组件不会出现一部分已更新、一部分仍使用旧语言的状态。

六、本地优先不是一句文案,而是一组产品约束

“数据只保存在本机”如果只是写在介绍页里,意义并不大。它必须体现在真实的数据流和权限流程中。

这个项目目前遵循几条约束:

  1. 首次隐私同意前,不创建默认习惯,不读写用户日记;
  2. 不提供账号系统,不上传心情、日记和习惯;
  3. 只有用户主动开启应用锁后,才调用系统设备认证;
  4. 用户可以随时导出完整 JSON 或心情 CSV;
  5. 用户可以在设置中撤回同意或删除全部本地数据;
  6. 统计只描述记录分布,不做医疗判断、诊断或治疗建议。

应用锁通过 UserAuthenticationKit 调用设备已有的 PIN、人脸或指纹能力:

const authParam: userAuth.AuthParam = {
  challenge: new Uint8Array([1, 2, 3, 4, 5, 6]),
  authType: [
    userAuth.UserAuthType.PIN,
    userAuth.UserAuthType.FACE,
    userAuth.UserAuthType.FINGERPRINT
  ],
  authTrustLevel: userAuth.AuthTrustLevel.ATL3
};

导出则使用系统文件保存器,让用户自己决定文件位置:

const options = new picker.DocumentSaveOptions();
options.newFileNames = [fileName];

const documentPicker = new picker.DocumentViewPicker(context);
const uris: string[] = await documentPicker.save(options);

这两个功能的共同点是:应用不替用户发明另一套身份或文件体系,而是尽量使用系统已经提供的可信能力。

最后的体会

从 iOS 到 HarmonyOS,真正值得迁移的不是某个页面的像素,而是产品已经验证过的业务规则;真正需要重新设计的,是这些规则如何落到新的系统能力上。

这次重写让我印象最深的不是某个 API,而是下面几件事:

  • 日期标识要符合用户的本地日历直觉;
  • 简单存储也必须考虑版本升级和写入顺序;
  • ArkUI 的 Key 是状态更新模型的一部分;
  • 多语言需要区分“稳定数据”和“显示文案”;
  • 隐私承诺最终必须变成可检查的数据流和交互约束。

目前 HarmonyOS 版本已经完成今日记录、习惯管理、历史月历、7/30 天统计、应用锁、数据导出和中英日三语,正在进行真机验收与 AppGallery 发布准备。

如果你也在做 HarmonyOS 原生应用,或者正在把已有产品迁移到 ArkTS,希望这篇实战复盘能帮你少踩几个坑。

体验心晴手记

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐