【时光清单|06】HarmonyOS ArkTS 应用状态仓库实战:让页面统计与持久化结果保持同步

时光清单应用状态仓库封面

在一个包含首页、全部清单、分类列表、详情页和新增页的 HarmonyOS 应用里,最容易被低估的问题不是“数据能不能保存”,而是“保存之后,已经打开的页面什么时候知道数据变了”。如果每个页面都持有一份数组副本,新增页写入 Preferences 后返回首页,首页仍可能展示旧统计;详情页完成置顶后,列表顺序也可能停留在操作前。反过来,如果把完整业务数据都塞进 AppStorage,又会让持久化模型、响应式状态和页面生命周期纠缠在一起。

时光清单 的真实源码采用了一种轻量方案:AnniversaryRepository 负责内存集合与持久化,AppViewModel 作为页面调用仓库的窄入口,AppStore 在启动阶段初始化全局状态键,页面通过 @StorageLink(StateKeys.DATA_VERSION) 订阅一个递增的“数据版本号”。当前页面代码在 Repository 方法返回后递增版本号,正在显示的列表页面由 @Watch 重新从仓库读取数据。

这不是 Redux 式完整状态树,也不是数据库的变更通知,而是适合中小型本地应用的一种失效信号。本文基于 项目现存 ArkTS 源码,完整拆解它的边界、执行顺序、失败分叉与可演进方向。

本文将完成六项可复核分析:

  1. 区分 AppStoreAppViewModel、Repository 与页面状态的职责。
  2. 还原新增、编辑、删除、置顶之后的真实刷新链路。
  3. 解释为什么只把 dataVersion 放入 AppStorage
  4. 检查统计值、排序结果和持久化结果如何保持同源。
  5. 指出当前写操作与版本递增分散在页面中的一致性风险。
  6. 给出不推翻现有结构的渐进式改造与测试办法。

本文唯一标记:CSDN-SERIES:ALL-163203208

证据边界:当前事实、历史证据与建议实现

本文把三类内容明确分开。当前事实来自本次静态核验的 DataStore.etsAnniversaryRepository.etsAppViewModel.etsStateKeys.ets 以及相关页面源码;历史证据来自项目错误记录,其中记载了 2026 年 5 月 20 日“编辑后列表和统计停留在旧值”的问题、根因与当时的修复;建议实现是为了补齐失败可见性和提交语义而给出的演进代码,并不代表仓库当前已经具备这些能力。

本次没有执行新的工程构建、真机运行或发布回归,因此本文不会把历史 assembleHap 通过记录写成当前构建结论,也不会把源码静态路径推断成已验证的设备行为。文章讨论的是可从源码证明的调用关系,以及应如何设计下一轮可重复验收。

一、先确定四个状态边界

状态仓库这个词很容易让人误以为所有数据都应该集中到一个全局对象。真实工程里更重要的是按生命周期划分状态。

状态类型 当前归属 生命周期 示例
持久化业务数据 AnniversaryRepository + DataStore 跨启动 纪念日、置顶状态
业务访问入口 AppViewModel 页面实例期间 查询、保存、删除
跨页面失效信号 AppStorage 应用进程期间 dataVersion
页面展示快照 @State 页面期间 anniversaries、加载状态

这个划分的关键是:AppStorage 不保存纪念日数组本身,只保存“仓库内容已变化”的通知信号。页面数组仍然来自 Repository,因此列表、统计和详情最终读取的是同一数据源。

真实 StateKeys.ets 直接把这个意图写进了注释:

export class StateKeys {
  static readonly NAV_STACK: string = 'navStack';
  static readonly DARK_MODE: string = 'isDarkMode';

  // 数据版本号(触发 UI 刷新)
  static readonly DATA_VERSION: string = 'dataVersion';

  static readonly THEME_BG: string = 'themeBgColor';
  static readonly THEME_CARD: string = 'themeCardColor';
}

DATA_VERSION 不是业务版本、数据库 schema 版本或应用版本号。它只是一个单调递增的失效计数器。名字虽然短,但使用方必须理解它的语义,否则很容易把它当作可持久化业务字段。

二、启动阶段:AppStore 只建立全局运行时状态

AppStore.bootstrap() 在应用启动阶段执行,并通过静态布尔值避免重复初始化:

export class AppStore {
  private static initialized: boolean = false;
  private static appContext: common.ApplicationContext | null = null;

  static bootstrap(context: common.UIAbilityContext | common.Context): void {
    if (AppStore.initialized) return;

    AppStorage.setOrCreate<boolean>(StateKeys.DARK_MODE, isDark);
    AppStorage.setOrCreate<string>(StateKeys.CURRENT_THEME, 'chinese_ink');

    if (AppStorage.get<NavPathStack>(StateKeys.NAV_STACK) === undefined) {
      AppStorage.setOrCreate<NavPathStack>(
        StateKeys.NAV_STACK,
        new NavPathStack()
      );
    }

    AppStorage.setOrCreate<number>(StateKeys.DATA_VERSION, 0);
    AppStore.applyTheme('chinese_ink');
    AppStore.initialized = true;
  }
}

这里有三个工程细节。

第一,使用 setOrCreate,而不是无条件覆盖。页面或服务已经设置过值时,重复启动逻辑不会把状态重置为默认值。

第二,dataVersion 从零开始,只存在于当前应用运行期。重新启动后页面会重新从持久化仓库加载完整数据,因此不需要延续上一次运行时的计数。

第三,AppStore 同时管理主题、颜色模式、导航栈和窗口系统栏,但它没有直接读写纪念日集合。全局 UI 状态与领域数据之间仍有明确边界。

当前写入通知链路与失败分叉

三、Repository 是运行期查询入口,Preferences 是预期持久化来源

AnniversaryRepository 是单例,内部维护 items 数组,并通过 DataStore 完成 JSON 持久化:

export class AnniversaryRepository {
  private static _instance: AnniversaryRepository | null = null;
  private items: Anniversary[] = [];
  private initialized: boolean = false;
  private store: DataStore = DataStore.getInstance();

  static getInstance(): AnniversaryRepository {
    if (!AnniversaryRepository._instance) {
      AnniversaryRepository._instance = new AnniversaryRepository();
    }
    return AnniversaryRepository._instance;
  }

  async init(): Promise<void> {
    if (this.initialized) return;
    this.items = await this.store.getJson<Anniversary[]>(
      DataKeys.ANNIVERSARIES,
      []
    );
    this.initialized = true;
  }
}

单例让不同页面创建的 AppViewModel 最终访问同一个 Repository 实例。initialized 保证正常情况下只从持久化介质水合一次,后续查询从内存集合返回。

这里必须避免把“同一个 Repository 实例”扩大解释成“持久化一定成功”。当前 DataStore.putJson()getJson()getJsonSync()remove() 都在内部捕获异常;当 Preferences 尚未初始化或写入失败时,写方法可能直接返回,调用者拿不到明确失败结果。因此,Repository 的 items 是页面在本次进程中的即时查询来源,Preferences 是预期的耐久来源;两者在正常写入时一致,但源码本身不能证明异常路径下仍然一致。

但 Repository 没有把内部数组直接交出去。getAll() 调用 sortedCopy()

private sortedCopy(): Anniversary[] {
  return [...this.items].sort((a: Anniversary, b: Anniversary) => {
    if (a.pinned !== b.pinned) return a.pinned ? -1 : 1;
    return a.targetDate - b.targetDate;
  });
}

async getAll(): Promise<Anniversary[]> {
  await this.init();
  return this.sortedCopy();
}

[...this.items] 创建浅拷贝后再排序,避免页面查询改变仓库内部数组顺序。排序规则也集中在仓库:置顶项优先,其余按目标日期升序。于是首页、全部列表和筛选列表不会各自实现一套稍有差异的排序。

四、写操作顺序:先改变内存,再完成持久化

保存方法先判断是编辑还是新增。编辑时更新现有对象和 updatedAt,新增时通过 createAnniversary() 建立完整模型,最后统一调用 persist()

async save(data: AnniversarySaveData): Promise<Anniversary> {
  await this.init();
  const existing = data.id
    ? this.items.find((item: Anniversary) => item.id === data.id)
    : undefined;

  if (existing) {
    if (data.title) { existing.title = data.title; }
    if (data.targetDate) { existing.targetDate = data.targetDate; }
    if (data.pinned !== undefined) { existing.pinned = data.pinned; }
    existing.updatedAt = Date.now();
    await this.persist();
    return existing;
  }

  const newItem = createAnniversary(data);
  this.items.push(newItem);
  await this.persist();
  return newItem;
}

删除和置顶也遵循同一顺序:

async delete(id: string): Promise<boolean> {
  await this.init();
  const index = this.items.findIndex(
    (item: Anniversary) => item.id === id
  );
  if (index >= 0) {
    this.items.splice(index, 1);
    await this.persist();
    return true;
  }
  return false;
}

async togglePin(id: string): Promise<boolean> {
  await this.init();
  const item = this.items.find(
    (i: Anniversary) => i.id === id
  );
  if (item) {
    item.pinned = !item.pinned;
    item.updatedAt = Date.now();
    await this.persist();
    return true;
  }
  return false;
}

只有 persist() 成功返回,方法才向页面报告成功。因此页面应当在 await 完成并确认结果之后再发出版本变更信号。若保存异常就提前递增版本号,订阅页面虽然会重读,但得到的仍是旧数据,还会制造一次没有意义的重绘。

五、AppViewModel:窄接口而不是第二份状态

AppViewModel 没有维护 anniversaries 数组,也没有复制 Repository 的初始化状态。它只把页面需要的操作包装成类型明确的方法:

export class AppViewModel {
  private anniversaryRepo: AnniversaryRepository =
    AnniversaryRepository.getInstance();

  async loadAnniversaries(): Promise<Anniversary[]> {
    return this.anniversaryRepo.getAll();
  }

  async saveAnniversary(
    data: AnniversarySaveData
  ): Promise<Anniversary> {
    return this.anniversaryRepo.save(data);
  }

  async deleteAnniversary(id: string): Promise<boolean> {
    return this.anniversaryRepo.delete(id);
  }

  async togglePin(id: string): Promise<boolean> {
    return this.anniversaryRepo.togglePin(id);
  }
}

这层看起来很薄,却提供了三个价值。

  1. 页面不需要知道 Repository 是单例,也不直接依赖 DataStore
  2. 日期文案计算被集中到 getDaysText()getDaysSubtitle() 等方法。
  3. 未来若加入事务、错误映射、埋点或多仓库组合,页面接口可以保持稳定。

需要注意,当前 ViewModel 并不是一个拥有响应式字段的长期对象。每个页面都可以创建自己的实例,但这些实例通过单例 Repository 汇合到同一数据源。因此它更接近页面服务门面,而不是完整 MVVM 中负责持有 UI State 的状态容器。

六、读页面如何订阅 dataVersion

HomeView 的核心做法是用 @StorageLink 连接全局键,再通过 @Watch 观察变化:

@Component
export struct HomeView {
  @StorageLink(StateKeys.DATA_VERSION)
  @Watch('onDataChange')
  dataVersion: number = 0;

  @State anniversaries: Anniversary[] = [];
  private viewModel: AppViewModel = new AppViewModel();

  async aboutToAppear(): Promise<void> {
    await this.loadData();
  }

  private async onDataChange(): Promise<void> {
    await this.loadData();
  }

  private async loadData(): Promise<void> {
    this.anniversaries =
      await this.viewModel.loadAnniversaries();
  }
}

AllViewFilteredListView 使用相同模式。页面首次出现时主动加载,之后版本号变化时重新查询。这样能覆盖两类场景:

  • 首次进入页面,版本号尚未变化,也必须有初始数据。
  • 页面已经存在于导航结构中,其他页面写入后无需销毁重建即可刷新。

页面统计应从本次加载得到的 anniversaries 派生,而不是另外持久化一个计数器。例如“全部数量”“纪念日数量”“爱情天数”都应该从同一数组计算。否则删除一条记录时,需要同时更新集合、总数、分类数和首页摘要,任何一个遗漏都会形成漂移。

七、新增链路:方法返回后递增版本,但返回不等于落盘确认

AddView 的真实保存链路是:

const saved = await this.viewModel.saveAnniversary(data);
if (!saved) {
  return;
}

const ver =
  AppStorage.get<number>(StateKeys.DATA_VERSION) ?? 0;
AppStorage.set<number>(
  StateKeys.DATA_VERSION,
  ver + 1
);

从交互到页面同步,可以还原为以下时序:

用户点击保存
  -> AddView 校验输入并组装 AnniversarySaveData
  -> AppViewModel.saveAnniversary()
  -> AnniversaryRepository.save()
  -> 内存 items 更新
  -> DataStore.putJson() 尝试写入 Preferences
  -> AddView 递增 DATA_VERSION
  -> HomeView / AllView / FilteredListView 的 @Watch 触发
  -> 页面重新调用 loadAnniversaries()
  -> 新数组驱动列表和统计刷新

从页面表面顺序看,await DataStore.putJson() 位于通知之前;但当前 putJson() 会在内部捕获异常并返回 void,Repository 无法区分“写入成功”和“异常被吞掉”。所以当前事实只能表述为“写入尝试返回后通知页面”,不能表述为“已确认持久化成功后通知”。版本号可以让其他页面重读内存快照,却不能证明重启后仍能得到相同数据。真正需要建立的不变量应当是:只有拿到可判定的持久化成功结果,才发布刷新信号并展示成功反馈。

八、详情页的置顶、删除与编辑

详情页包含三种会改变列表结果的操作。

1. 置顶

置顶改变 pinned,而 sortedCopy() 把置顶作为第一排序条件。操作成功后如果没有通知,详情页中的图标或许已更新,但返回列表时顺序仍可能是旧快照。

2. 删除

删除会影响列表数量、分类统计、首页摘要以及可能存在的桌面卡片选择。页面应该只在 deleteAnniversary() 返回 true 后递增版本并返回上一页。

3. 编辑

编辑可能改变标题、类型、目标日期和置顶状态。即使列表数量不变,排序位置、分类归属和倒计时数字也可能同时变化,因此同样需要触发失效。

当前状态分层与建议结果化仓库

九、为什么不把完整数组放入 AppStorage

直接把 Anniversary[] 放入 AppStorage 看似能省掉重读,但会带来一组难以察觉的问题。

第一,页面可能直接修改数组元素,而没有经过 Repository 的 persist()。界面显示已经变化,重启后却恢复原值。

第二,排序、过滤和更新对象引用会触发不同的响应式行为。开发者需要额外保证每次都替换数组引用,而不是只修改内部字段。

第三,业务模型中若加入复杂对象、迁移字段或不可序列化值,全局状态与持久化格式会互相限制。

第四,多个写入口很难确保 AppStorage 数组和 Repository 内存数组始终同步,系统中出现两个真源。

版本号方案的优势正是让全局状态保持极小:

AppStorage:
  dataVersion = 17

Repository:
  完整 Anniversary[] + 持久化职责

Page:
  当前展示快照 + 派生统计

它牺牲了一次内存查询,却换来清晰的数据所有权。由于 Repository 已水合并缓存 items,正常刷新并不会每次都重新读取 Preferences;getAll() 主要执行的是数组复制与排序。

十、当前实现的第一个风险:通知逻辑分散

真实源码中,AddViewCoupleViewHabitViewHabitWallViewWishListView 等页面都存在相似代码:

const ver =
  AppStorage.get<number>(StateKeys.DATA_VERSION) ?? 0;
AppStorage.set<number>(
  StateKeys.DATA_VERSION,
  ver + 1
);

这说明通知机制已经接入多处页面,但发布责任分散到了各个写入口。新增一种写入口时,开发者可能完成内存修改和持久化尝试,却忘记递增版本。结果通常不是立即崩溃,而是某些页面继续展示旧快照,这类问题最难定位。

最小改进是先提供统一函数:

export class DataVersion {
  static notifyChanged(): void {
    const current =
      AppStorage.get<number>(StateKeys.DATA_VERSION) ?? 0;
    AppStorage.set<number>(
      StateKeys.DATA_VERSION,
      current + 1
    );
  }
}

页面仍可在拿到明确结果后调用,但重复代码被收拢,键名和递增规则不会散落。进一步可以由 ViewModel 在确认提交成功后通知。下面代码属于建议实现,不是当前源码:

async saveAnniversary(
  data: AnniversarySaveData
): Promise<Anniversary> {
  const result = await this.anniversaryRepo.save(data);
  DataVersion.notifyChanged();
  return result;
}

这样页面只负责展示保存结果,不再承担跨页面一致性协议。不过这会改变现有 ViewModel 的职责,需要统一迁移所有写入口,不能一半由页面通知、一半由 ViewModel 通知,否则一次写入可能递增两次。

十一、当前实现的第二个风险:读改写并非原子操作

版本递增由“读取当前值”和“写入加一”两步组成:

const current = AppStorage.get<number>(key) ?? 0;
AppStorage.set<number>(key, current + 1);

ArkUI 页面交互通常运行在 UI 线程,连续点击又有按钮状态保护时,丢失递增的概率很低。但如果未来引入 Worker、并行异步回调或批量导入,两次写操作可能先后读到同一个旧值,然后都写入相同的新值。

这里不必盲目引入复杂锁。因为版本号只承担失效作用,即使两次写合并为一次变化,订阅页面只要在最终持久化后重读,通常仍能得到最新集合。真正需要保证的是:

  1. 最后一次写成功后至少发生一次通知。
  2. 页面加载不能把较早请求的结果覆盖到较晚请求之上。
  3. 批处理期间不要每写一条就触发一次昂贵刷新。

批量导入更适合在事务或批处理完成后统一通知一次,而不是追求版本号精确等于写入次数。

十二、异步重载的竞态保护

@Watch 回调是异步的。若连续发生两次变更,页面可能同时启动两次 loadData()。当前 Repository 查询很快,风险有限;一旦数据源扩展为 RDB、文件解析或云同步,就应防止旧请求后返回并覆盖新结果。

可以在页面维护请求序号:

@State anniversaries: Anniversary[] = [];
private loadSequence: number = 0;

private async loadData(): Promise<void> {
  const sequence = ++this.loadSequence;
  const result =
    await this.viewModel.loadAnniversaries();

  if (sequence !== this.loadSequence) {
    return;
  }
  this.anniversaries = result;
}

也可以在短时间连续通知时做合并刷新。选择哪种方案取决于查询成本,而不是为了形式上的“架构完整”。当前本地数组查询无需增加节流,保持简单更重要。

十三、失败状态不能只写日志

Repository 的 persist() 可能因存储初始化、空间、序列化或系统异常失败。若异常一路抛到页面,页面至少要恢复保存按钮、显示错误并保留用户输入。

推荐把页面状态显式化:

type SaveState =
  'idle' | 'saving' | 'success' | 'error';

@State saveState: SaveState = 'idle';
@State errorMessage: string = '';

private async submit(): Promise<void> {
  if (this.saveState === 'saving') return;
  this.saveState = 'saving';

  try {
    await this.viewModel.saveAnniversary(this.formData);
    DataVersion.notifyChanged();
    this.saveState = 'success';
  } catch (error) {
    this.errorMessage = '保存失败,请稍后重试';
    this.saveState = 'error';
  }
}

关键不是捕获后沉默,而是确保失败时不递增版本、不退出编辑页、不丢失草稿。重复点击也应在 saving 状态被阻止,避免产生重复记录。

十四、统计与列表必须从同一快照派生

页面刷新后,统计逻辑应一次性基于同一数组计算:

interface AnniversaryStats {
  total: number;
  pinned: number;
  upcoming: number;
  expired: number;
}

function buildStats(
  items: Anniversary[],
  now: number
): AnniversaryStats {
  return {
    total: items.length,
    pinned: items.filter(
      (item: Anniversary) => item.pinned
    ).length,
    upcoming: items.filter(
      (item: Anniversary) => item.targetDate >= now
    ).length,
    expired: items.filter(
      (item: Anniversary) => item.targetDate < now
    ).length
  };
}

如果列表调用一次 loadAnniversaries(),统计又单独调用四次查询,虽然本地数据通常一致,但会增加重复排序,也给未来异步数据源留下不同快照的可能。一次加载、一次派生更容易测试。

日期统计还应固定 now。同一次计算中多次调用 Date.now(),在午夜边界可能让两个分类使用不同时间基准。把 now 作为参数传入,测试也能覆盖临界日期。

十五、深浅色状态与业务版本号为何能共存

AppStore 同时管理主题值和 DATA_VERSION,但两者更新路径不同。

主题变化通过 AppStore.applyTheme() 写入多个语义色键,并更新系统栏内容颜色;业务数据变化只递增 DATA_VERSION,由页面重读 Repository。两类状态共用 AppStorage 并不等于它们共用同一种处理方式。

主题变化
  -> 写 themeBg/themeCard/themePrimary
  -> ArkUI 直接响应颜色值

业务数据变化
  -> 写 dataVersion
  -> @Watch 触发查询
  -> 页面替换业务数组快照

颜色值本身适合直接成为响应式状态;持久化业务集合则更适合保留在 Repository。根据数据性质选择同步方式,比强行统一状态工具更可靠。

十六、测试这条同步链路

可以把验证分成三层。

Repository 层

  1. 空存储初始化得到空数组。
  2. 新增后 getAll() 返回新对象。
  3. 编辑后 updatedAt 更新且字段落盘。
  4. 删除不存在 ID 返回 false
  5. 置顶后排序前移。
  6. 重建 Repository 或重新水合后结果仍一致。

ViewModel 层

  1. 保存、删除、置顶正确委托给 Repository。
  2. getDaysText() 对未来、过去与爱情起始日返回正确文案。
  3. Repository 失败时异常不会被错误吞掉。

页面联动层

启动 -> 首页显示 2 条
新增 -> 保存成功 -> 首页显示 3 条
置顶 -> 返回列表 -> 目标项移动到首位
编辑日期 -> 倒计时和排序同时变化
删除 -> 首页、全部页和分类统计都减少 1
保存失败 -> 页面不退出、统计不变化
快速连续保存 -> 无重复记录、最终列表正确

HarmonyOS 多设备场景还应检查手机、平板和 2in1 的导航缓存行为。不同布局可能让多个子页面同时存活,正是 dataVersion 失效机制比“返回时刷新”更有价值的地方。

十七、性能边界:什么时候该升级方案

当前实现每次变化都会让订阅页面重新执行数组复制和排序。几十或几百条本地纪念日数据时,这个成本很低。以下信号出现时才值得升级:

信号 可选演进
数据达到数万条 RDB 分页、索引与增量查询
多模块频繁写入 统一 Mutation Service
页面只关心局部变化 按领域拆分版本键
云端与本地共同更新 明确同步状态与冲突策略
多进程或多设备协同 使用平台支持的数据同步机制
批量导入频繁刷新 批处理完成后合并通知

不要因为未来可能增长,就提前把简单本地应用改造成复杂事件总线。当前机制的价值在于用一个整数连接“写方法返回”和“读页面失效”;它解决了运行期页面刷新问题,但还没有单独解决异常路径下的持久化确认问题。

十八、建议的渐进式重构顺序

如果要在现有项目上提高一致性,建议按以下顺序推进:

  1. 先让 DataStore 返回可判定的写入结果,不再吞掉失败语义。
  2. 新增 DataVersion.notifyChanged(),收拢重复递增代码。
  3. 为保存、删除、置顶补充 saving/error/disabled 状态。
  4. 统一页面 loadData() 的错误与空状态。
  5. 把列表统计改为从同一查询快照派生。
  6. 只有在异步查询明显变慢后,再加入请求序号保护。
  7. 只有在数据规模增长后,再评估 RDB 与分页。

每一步都能独立验证,也不会迫使项目一次性更换状态框架。

1. 历史问题证明了“页面失效信号”的必要性

项目错误记录在 2026 年 5 月 20 日留下过一条可追溯证据:编辑倒计时、纪念日或恋爱数据后,相关列表和统计不会立即刷新,往往要切换页面才能看到新结果。当时确认的根因有两个:一是页面之间缺少稳定的数据变化通知;二是列表 ForEach 只用 id 作为键,编辑同一对象后键值不变,局部渲染可能继续复用旧节点。

对应修复是让 HomeViewAllViewFilteredListView 订阅 DATA_VERSION,详情页完成编辑、删除、置顶后递增该值,并把列表键改为 ${id}_${updatedAt}。这段历史记录能证明当前通知设计为何存在,也能证明“只刷新详情页局部字段”不足以覆盖列表排序、分类统计和倒计时变化。

历史记录还提到当时的 assembleHap 通过,但那是历史时点证据。本次文章精修只做源码静态核验,不能据此宣称当前工作区已经重新构建通过。可靠的写法是把“过去发生过什么”和“本轮实际验证了什么”分别记录。

2. 建议一:让持久化层返回结果,而不是只返回 void

当前 DataStore.putJson() 的核心缺口不是使用 Preferences,而是失败信息没有越过存储边界。建议先定义一个小而明确的联合类型,让调用方必须处理成功与失败。以下代码是建议接口:

export type WriteFailure =
  | 'storage_unavailable'
  | 'serialize_failed'
  | 'flush_failed';

export type WriteResult =
  | { ok: true }
  | { ok: false; reason: WriteFailure };

这个结果不需要携带异常堆栈,更不应把底层隐私数据带到界面。它只需要回答一个影响业务决策的问题:这次修改能否被当作已提交。页面可以据此决定是否退出、是否显示成功提示,Repository 也可以决定是否保留内存修改。

建议的 putJson 应把序列化和 Preferences 写入分别映射到稳定错误类型:

async putJson<T>(key: string, value: T): Promise<WriteResult> {
  const pref = await this.requirePreferences();
  if (!pref) return { ok: false, reason: 'storage_unavailable' };

  try {
    const payload = JSON.stringify(value);
    await pref.put(key, payload);
    await pref.flush();
    return { ok: true };
  } catch (error) {
    return { ok: false, reason: 'flush_failed' };
  }
}

实际工程可以进一步区分 JSON.stringifyput/flush 的异常,但不要为了错误分类而泄漏底层对象。重要的是不再把失败压缩成和成功相同的 void

3. 建议二:Repository 提交失败时恢复内存快照

既然当前 Repository 先修改 items,就应在写入失败时恢复旧快照。对中小型本地数组,提交前复制一次集合通常足够清晰:

export interface MutationResult<T> {
  ok: boolean;
  value?: T;
  reason?: WriteFailure;
}

async save(data: AnniversarySaveData): Promise<MutationResult<Anniversary>> {
  await this.init();
  const before = this.items.map((item: Anniversary) => ({ ...item }));
  const saved = this.applySaveToMemory(data);
  const write = await this.store.putJson(DataKeys.ANNIVERSARIES, this.items);

  if (!write.ok) {
    this.items = before;
    return { ok: false, reason: write.reason };
  }
  return { ok: true, value: saved };
}

这里的回滚只是一种建议。数据量很大或模型存在深层对象时,应改用不可变更新、领域命令或数据库事务,而不是无条件深拷贝。本文示例的目标是把提交边界讲清楚:页面看到的新数据、Repository 内存和 Preferences 至少要在方法返回成功时达成一致。

4. 建议三:只有提交成功才发布刷新事件

通知应靠近确认成功的边界,而不是散落在每个页面。ViewModel 可以接收结果、发布领域修订号,并把稳定结果交给页面:

async saveAnniversary(
  data: AnniversarySaveData
): Promise<MutationResult<Anniversary>> {
  const result = await this.anniversaryRepo.save(data);
  if (result.ok) {
    DataVersion.notifyChanged();
  }
  return result;
}

页面拿到失败结果时保持编辑内容,不退出当前路由,并展示可恢复的错误状态;成功时再清空表单或返回。这样“成功提示”“刷新通知”和“持久化确认”使用同一个结果,不会出现页面提示已保存、列表数字已增加,但重启后记录消失的三方分叉。

5. 建议四:页面只消费同一修订号下的快照

当查询逐渐复杂时,可以把数组、统计和状态放进同一个不可变快照。以下仍是建议模型:

export interface AnniversarySnapshot {
  revision: number;
  items: Anniversary[];
  total: number;
  pinned: number;
  status: 'ready' | 'saving' | 'error';
}

首页数量、全部列表和分类统计都从同一 items 派生,并携带对应 revision。如果页面收到较旧修订号的异步结果,可以直接丢弃,避免后发请求先返回后又被旧请求覆盖。这里的 revision 仍是运行期同步概念,不要与 Preferences schema 版本或发布版本混为一谈。

6. 验收必须覆盖重启,而不能只看当前页面

只验证保存后当前列表增加一条,最多证明内存链路和刷新链路能工作。持久化是否可靠,必须销毁单例缓存或重启应用后重新加载。可以为 Repository 测试提供受控的重建入口,执行如下检查思路:

const saveResult = await repository.save(sample);
expect(saveResult.ok).assertTrue();

AnniversaryRepository.resetForTest();
const reloaded = await AnniversaryRepository.getInstance().getAll();
expect(reloaded.some((item: Anniversary) => item.id === sample.id)).assertTrue();

还要注入 Preferences 未初始化、序列化失败和 flush 失败,确认失败时内存已回滚、DATA_VERSION 没有变化、页面不显示成功、表单内容仍可重试。这样才能分别验证提交语义、通知语义和页面语义,而不是把一次界面变化误认为整条链路已经可靠。

十九、审核与发布前的复核点

状态同步问题通常不会在编译阶段暴露,却会直接影响 HarmonyOS 应用审核中的功能完整性和运行稳定性。发布前至少检查:

  • 新增、编辑、删除、置顶后的页面结果与重启后结果一致。
  • 写入失败不会显示虚假的成功状态。
  • 快速重复点击不会创建重复数据。
  • 空列表、加载中、加载失败均有明确界面。
  • 页面返回、系统返回与多窗口切换后数据仍正确。
  • 深浅色切换不会触发业务数据重置。
  • 平板或 2in1 双栏布局中,同时存在的页面能一起刷新。
  • 不把个人数据、调试日志或持久化内容上传到网络。
  • 应用实际存储行为与隐私说明保持一致。

这组检查对应的不是“状态管理是否高级”,而是用户能否相信页面上看到的数字。

二十、总结

时光清单 的应用状态同步链路可以概括为四句话:

  1. AnniversaryRepository 维护运行期业务集合,并负责持久化尝试与统一排序。
  2. AppViewModel 给页面提供类型明确的窄接口,不复制第二份状态。
  3. AppStore 初始化 DATA_VERSION,但不接管纪念日集合。
  4. 当前页面在写方法返回后递增版本号,订阅页面重读仓库并重新派生列表与统计。

这种设计适合数据规模不大的本地应用:结构足够清楚,响应式刷新成本可控,也保留了向 RDB、批处理和更完整 ViewModel 演进的空间。当前源码已经有跨页面失效信号,但 DataStore 吞掉写入异常,使“方法返回”还不能等同于“耐久提交成功”。下一步真正需要守住的不变量有两个:只有持久化确认成功才发出失效通知;页面统计始终从同一修订快照派生。


本文基于 时光清单 项目中的 AppStore.etsStateKeys.etsAppViewModel.etsAnniversaryRepository.ets 以及相关页面源码复核整理。文中改进代码用于解释工程演进方向,已明确区别于当前实现。

AI 辅助声明:本文在真实源码核验、结构梳理和文字编辑过程中使用了 AI 辅助;关键接口、调用关系和实现结论均以项目源码为依据进行人工复核。

Logo

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

更多推荐