从 iOS 到 HarmonyOS:用 ArkTS 重写一款本地优先心情日记的 6 个关键决策
最近,我把一款 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 组件不会出现一部分已更新、一部分仍使用旧语言的状态。
六、本地优先不是一句文案,而是一组产品约束
“数据只保存在本机”如果只是写在介绍页里,意义并不大。它必须体现在真实的数据流和权限流程中。
这个项目目前遵循几条约束:
- 首次隐私同意前,不创建默认习惯,不读写用户日记;
- 不提供账号系统,不上传心情、日记和习惯;
- 只有用户主动开启应用锁后,才调用系统设备认证;
- 用户可以随时导出完整 JSON 或心情 CSV;
- 用户可以在设置中撤回同意或删除全部本地数据;
- 统计只描述记录分布,不做医疗判断、诊断或治疗建议。
应用锁通过 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,希望这篇实战复盘能帮你少踩几个坑。
更多推荐


所有评论(0)