HarmonyOS Preferences 实战 - 基于 DailyEntry + TodayRepo 拆当前项目的 Repository 落地
本文详细拆解了HarmonyOS6.0「今天空白」应用的数据存储架构设计。文章展示了从底层Prefs存储封装到TodayRepo数据访问层、DailyEntry数据模型,再到TodayStore业务逻辑的四层清晰架构。重点阐述了如何通过Repository模式将系统API调用、key命名规则、JSON解析校验等细节与业务逻辑解耦,使上层只需关注核心业务状态。特别强调了各层的职责边界:Prefs仅封
系列文章:HarmonyOS 6.0 实战开发 - 「今天空白」应用 第9篇 / 共30篇 发布时间:2026-04-06 阅读时长:19分钟 难度:(进阶)
本文导读
上一篇我们把存储选型说清楚了:对「今天空白」当前项目来说,本地数据问题本质上就是“按日期读写一条记录”,所以 Preferences 足够。
但“选型正确”不等于“实现自然就会干净”。
真正决定代码质量的,是你怎么把这条链路拆开:
-
系统 Preferences API 直接让页面调用吗?
-
key 规则放哪一层?
-
JSON 解析失败算异常还是算空白?
-
创建记录和更新记录谁负责?
当前仓库给出的答案,是一条非常收敛的 Repository 链路:
-
Prefs:最小系统存储封装 -
TodayRepo:today 领域的数据访问层 -
DailyEntry:纯数据模型和纯函数 -
TodayStore:把这些能力组织成业务动作
本文会严格基于当前仓库真实代码,拆这 4 层是怎样接起来的。
主要文件如下:
-
frontend/entry/src/main/ets/common/storage/Prefs.ets -
frontend/entry/src/main/ets/features/today/TodayRepo.ets -
frontend/entry/src/main/ets/features/today/DailyEntry.ets -
frontend/entry/src/main/ets/features/today/TodayStore.ets -
frontend/entry/src/main/ets/common/time/DateKey.ets -
frontend/entry/src/main/ets/features/app/AppStore.ets
先看真实调用链:页面根本不碰 Preferences
在当前仓库里,页面不会直接调用 Preferences。
首页按钮最终走的是这条路径:
TodayPage -> AppStore.saveToday() -> TodayStore.save() -> TodayRepo.get() / set() -> Prefs.getString() / setString()
先看 AppStore.saveToday():
async saveToday(text: string): Promise<void> {
await this.todayStore.save(text);
if (this.todayStore.status !== 'recorded') {
return;
}
if (this.authStore.isLoggedIn && this.syncStore.isReady) {
const entry = await this.repo.get(this.todayStore.dateKey);
if (entry) {
await this.syncStore.pushEntry(entry, this.authStore.accessToken);
}
}
}
这里最值得看的不是同步,而是前半段:
-
页面不自己校验
-
页面不自己拼 key
-
页面不自己读写 JSON
-
页面只触发“保存今天”这个动作
这正是 Repository 模式在当前项目里的意义:把数据访问细节从 UI 和 Store 里剥出去。
Prefs:系统 API 的最小封装层
当前 Prefs.ets 非常克制,只有 4 个核心方法:
export class Prefs {
private context: common.UIAbilityContext;
constructor(context: common.UIAbilityContext) {
this.context = context;
}
private async open(): Promise<preferences.Preferences> {
return await preferences.getPreferences(this.context, { name: PREFS_NAME });
}
async getString(key: string): Promise<string | null> { ... }
async setString(key: string, value: string): Promise<void> { ... }
async delete(key: string): Promise<void> { ... }
}
这层的职责非常纯:
-
负责打开 Preferences
-
负责读写字符串
-
负责删除 key
它不负责:
-
解释业务字段
-
拼 today 记录的 key
-
判断 JSON 是否有效
-
决定空白态
也就是说,Prefs 当前只是一个系统能力适配器,而不是业务仓库。
为什么这里值得单独包一层
如果 TodayRepo 直接在内部调用系统 Preferences API,也能跑。
但包一层 Prefs 以后,至少有几个直接收益:
-
Preferences 的名字
today_blank被统一收口 -
flush()时机集中处理 -
以后如果别的模块也要存字符串,可以复用这层最小能力
-
TodayRepo可以只关心业务 key 和业务对象
这就是典型的“抽象不是为了炫技,而是为了隔离不同层次职责”。
getString() 的设计取向:出错就回 null
当前 Prefs.getString() 是这样写的:
async getString(key: string): Promise<string | null> {
try {
const pref = await this.open();
const value = await pref.get(key, '');
return typeof value === 'string' && value.length > 0 ? value : null;
} catch (_err) {
return null;
}
}
这段代码反映出当前仓库一个很明确的判断:
对“读取今天这条记录”来说,读不到值和读取失败,最终都可以先统一落到 null。
这和项目的业务语义是对齐的,因为上层本来就会把 null 解释成“今天空白”。
这样做的好处是:
-
TodayRepo不用处理一堆底层异常分支 -
TodayStore.refresh()可以维持很简单的分支 -
页面不会因为一条本地记录读失败就被迫进入复杂报错状态
当然,这种取向只适合当前项目的边界。
如果你在做的是账单、订单、草稿箱这种“读失败必须显式报错”的业务,处理方式就不能这么轻。但对「今天空白」现在这条本地链路来说,这样反而最稳。
写入和删除为什么都要 flush()
Prefs 在写入和删除后都会调用 flush():
async setString(key: string, value: string): Promise<void> {
const pref = await this.open();
await pref.put(key, value);
await pref.flush();
}
async delete(key: string): Promise<void> {
const pref = await this.open();
await pref.delete(key);
await pref.flush();
}
这意味着当前实现不是“写进缓存先算了”,而是每次变更后都立即提交。
对这个项目来说,这样的策略很合理,因为:
-
写入频率不高
-
每天就一条记录
-
用户更在意“我刚改的内容确实保存了”
也正因为数据量很小,当前没有必要为了所谓性能去引入更复杂的批量刷盘策略。
TodayRepo:把 today 领域的 key 规则和 JSON 规则收起来
真正进入 today 业务以后,当前仓库交给 TodayRepo 处理:
const KEY_PREFIX = 'daily_entry_';
function toKey(dateKey: string): string {
return `${KEY_PREFIX}${dateKey}`;
}
export class TodayRepo {
private prefs: Prefs;
constructor(prefs: Prefs) {
this.prefs = prefs;
}
}
这说明在当前项目里,Repository 的职责不是“做很多事”,而是做两件最关键的事:
1. 固定领域 key 规则
今天这一条记录到底存在哪个 key,下沉到 TodayRepo 内部,不暴露给页面和 Store。
2. 固定领域对象与字符串之间的转换规则
TodayRepo 负责:
-
从字符串解析
DailyEntry -
把
DailyEntry序列化成字符串 -
把无效值统一挡掉
这样上层就可以始终使用 DailyEntry | null 这个稳定接口,而不用直接面对字符串。
TodayRepo.get():把一切脏输入都收敛成 null
看 get() 的完整逻辑:
async get(dateKey: string): Promise<DailyEntry | null> {
const raw = await this.prefs.getString(toKey(dateKey));
if (!raw) {
return null;
}
try {
const parsed = JSON.parse(raw) as DailyEntry;
return isValidEntry(parsed) ? parsed : null;
} catch (_err) {
return null;
}
}
这里仓库的态度非常清晰:
-
没取到值,返回
null -
取到了但不是合法 JSON,返回
null -
JSON 能 parse 但字段不合法,返回
null -
文本是空白,也返回
null
也就是说,TodayRepo 在当前项目里承担了一个很重要的“数据净化”角色。
上层只需要知道一件事:
-
取到了合法的今日记录
-
或者没有合法记录
至于中间到底是缺 key、坏 JSON、字段残缺还是空文本,都被 Repo 层统一折叠掉了。
isValidEntry():Repository 不是简单转发,而是守门
当前 TodayRepo 里还有一个内部校验函数:
function isValidEntry(value: DailyEntry | null): boolean {
if (!value) {
return false;
}
return typeof value.date === 'string'
&& typeof value.text === 'string'
&& typeof value.createdAt === 'number'
&& typeof value.updatedAt === 'number'
&& !isBlankText(value.text);
}
这一步很关键,因为它说明当前仓库里的 Repository 不是单纯把 JSON.parse 结果原样交出去。
如果少了这层判断,上层就得自己处理:
-
字段是不是缺了
-
类型是不是对的
-
文本是不是空白
那 TodayStore 很快就会被迫关心数据质量问题,职责就开始混乱。
当前实现把这层挡在 Repo 内部,是非常正确的分工。
DailyEntry:领域规则被保持成纯函数
当前项目把 today 记录相关的基础规则放在 DailyEntry.ets:
export interface DailyEntry {
date: string;
text: string;
createdAt: number;
updatedAt: number;
}
export function normalizeText(text: string): string {
return text.trim();
}
export function isBlankText(text: string): boolean {
return normalizeText(text).length === 0;
}
然后创建和更新也都拆成纯函数:
export function createEntry(dateKey: string, text: string, nowMs: number): DailyEntry {
const normalized = normalizeText(text);
return {
date: dateKey,
text: normalized,
createdAt: nowMs,
updatedAt: nowMs
};
}
export function updateEntry(prev: DailyEntry, text: string, nowMs: number): DailyEntry {
const normalized = normalizeText(text);
return {
date: prev.date,
text: normalized,
createdAt: prev.createdAt,
updatedAt: nowMs
};
}
这有两个很直接的好处:
1. TodayStore 不用手写对象拼装细节
Store 只负责决定“现在该创建还是更新”,不需要自己拼字段。
2. 领域规则不会散落
trim、空白判定、更新时间刷新、创建时间保留,这些规则都集中在 DailyEntry 里,而不是散在 Repo 和 Store 各处。
DateKey:日期键规则也被单独收起来
除了记录模型本身,当前仓库连“今天的 key 长什么样”都单独放进了 DateKey.ets:
export function todayKey(now: Date = new Date()): string {
const year = now.getFullYear();
const month = `${now.getMonth() + 1}`.padStart(2, '0');
const day = `${now.getDate()}`.padStart(2, '0');
return `${year}-${month}-${day}`;
}
这样 TodayStore.refresh() 就可以直接拿到统一日期键:
const key = todayKey(now); this.dateKey = key; const entry = await this.repo.get(key);
这说明当前项目连最基础的日期格式规则,也没有写死在 Store 内部,而是继续保持单一职责。
TodayStore:把 Repo 和领域函数组织成完整业务动作
如果说 Prefs、TodayRepo、DailyEntry 都是零件,那 TodayStore 就是把这些零件拼成业务动作的那一层。
刷新今日状态
async refresh(now: Date = new Date()): Promise<void> {
this.errorMessage = '';
this.status = 'loading';
const key = todayKey(now);
this.dateKey = key;
const entry = await this.repo.get(key);
if (!entry) {
this.text = '';
this.updatedAt = 0;
this.status = 'blank';
this.lastStableStatus = 'blank';
return;
}
this.text = entry.text;
this.updatedAt = entry.updatedAt;
this.status = 'recorded';
this.lastStableStatus = 'recorded';
}
这里直接把 Repo 的返回结果映射成页面可用状态:
-
null->blank -
DailyEntry->recorded
保存今日记录
async save(nextText: string): Promise<void> {
const normalized = normalizeText(nextText);
if (isBlankText(normalized)) {
this.errorMessage = '内容不能为空';
return;
}
this.status = 'saving';
this.errorMessage = '';
const nowMs = Date.now();
try {
const existing = await this.repo.get(this.dateKey);
const entry = existing
? updateEntry(existing, normalized, nowMs)
: createEntry(this.dateKey, normalized, nowMs);
await this.repo.set(entry);
this.text = entry.text;
this.updatedAt = entry.updatedAt;
this.draftText = '';
this.status = 'recorded';
this.lastStableStatus = 'recorded';
} catch (_err) {
this.errorMessage = '保存失败';
this.status = 'editing';
}
}
这里可以很清楚地看到 4 层职责如何配合:
-
normalizeText/isBlankText负责输入规则 -
repo.get()判断当前是新增还是修改 -
createEntry/updateEntry负责生成领域对象 -
repo.set()负责真正落盘
Store 只负责组织流程和更新业务状态。
这套 Repository 落地,具体解决了什么问题
如果只按当前仓库的真实结构来总结,这套拆分至少解决了下面这些具体问题:
1. 页面不依赖系统存储 API
UI 层不需要知道 Preferences 怎么开、怎么 flush。
2. Store 不需要操作原始字符串
TodayStore 拿到的是 DailyEntry | null,不是 JSON 字符串。
3. today 领域规则有固定落点
key 命名、空白判定、创建更新时间规则,都没有散在各层里。
4. 以后要扩展同步,也不会污染本地 Repo
当前同步逻辑在 AppStore / SyncStore,本地 Repository 仍然只负责本地 today 记录。
当前实现故意没做什么
为了保持和仓库一致,这里也要把“没做”的部分说清楚。
当前 Preferences + Repository 方案里,没有这些能力:
1. 没有版本迁移
目前 DailyEntry 足够小,还没引入 schema version。
2. 没有批量列表接口
TodayRepo 不提供“查询全部记录”。
3. 没有把异常细分成很多错误类型
当前策略是:读取异常大多折叠成 null,写入失败由 Store 统一映射成 保存失败。
4. 没有为了测试方便额外造很多抽象层
仓库当前保持的是最小层次:
-
Prefs -
TodayRepo -
DailyEntry -
TodayStore
这正符合项目当前规模。
小结
这一篇最核心的结论可以直接落回当前仓库:
Repository 模式在「今天空白」里不是“搞个 Repo 类就完了”,而是把系统存储、领域对象、key 规则、空白语义和业务动作清楚地分在了不同层里。
当前实现里这条链路已经很完整:
-
Prefs隔离系统 Preferences API -
TodayRepo固定 today 领域的 key 和 JSON 规则 -
DailyEntry保持纯数据和纯函数 -
TodayStore负责把这些零件组织成refresh / save / clear
这样做的直接结果是:页面更干净,状态更稳定,存储边界也更容易继续维护。
下一篇我们就进入第 10 篇,把 DailyEntry / TodayRepo / TodayStore / AppStore 这套结构继续提升一个层次,从领域模型和依赖方向的角度看它为什么已经非常接近一个简洁的 DDD 落地了。( ̄▽ ̄*)
更多推荐
所有评论(0)