系列文章: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 和领域函数组织成完整业务动作

如果说 PrefsTodayRepoDailyEntry 都是零件,那 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 落地了。( ̄▽ ̄*)

Logo

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

更多推荐