【时光清单|01】HarmonyOS ArkTS 纪念日仓库实战:统一新增、更新、排序和删除边界

发布时间:2026/9/1 20:35:12
【时光清单|01】HarmonyOS ArkTS 纪念日仓库实战:统一新增、更新、排序和删除边界 【时光清单01】HarmonyOS ArkTS 纪念日仓库实战统一新增、更新、排序和删除边界纪念日应用看起来只是“保存一个日期”真正进入工程阶段后却很容易出现几类互相牵连的问题新增页面写一套默认值编辑页面又写一套字段覆盖首页按日期排序组件卡片却仍按原始数组顺序展示删除成功后页面刷新了但桌面卡片读到的还是旧数据某次把标题清空保存逻辑因为把空字符串当成“没有传值”旧标题竟然留了下来。这些现象并不是 ArkUI 组件本身造成的而是数据边界没有收拢。时光清单的真实源码把纪念日实体、Preferences 存储、仓库与 ViewModel 分开页面只表达意图AnniversaryRepository统一负责初始化、查询、保存、置顶、删除和落盘。本文以这条真实链路为基础拆解 HarmonyOS 5.0 及以上版本中一个轻量仓库应该承担什么、不应该承担什么以及怎样把新增和更新的语义做得可复核。本文会解决四个具体问题用同一个仓库收口异步初始化与同步卡片读取避免页面直接操作 Preferences。区分“创建完整实体”和“更新已有实体”让默认值、时间戳和 ID 只在一个位置生成。明确置顶优先、日期升序的稳定读取规则并让调用方拿到副本。用可执行的边界用例检查空字符串、零时间戳、重复初始化、删除不存在记录等情况。本文唯一标记CSDN-SERIES:ALL-163201804一、先看真实数据链路页面不应该认识 Preferences项目中的调用路径并不复杂却刻意保留了职责层次HomeView / AllView / WidgetView ↓ AppViewModel ↓ AnniversaryRepository ↓ DataStore ArkData PreferencesHomeView和AllView都通过AppViewModel.loadAnniversaries()获取数据保存、删除和置顶也由 ViewModel 转交仓库。页面没有存储键名没有 JSON 序列化也不需要知道数据来自 Preferences。export class AppViewModel { private anniversaryRepo: AnniversaryRepository AnniversaryRepository.getInstance();async loadAnniversaries(): PromiseAnniversary[] { return this.anniversaryRepo.getAll(); }async saveAnniversary(data: AnniversarySaveData): PromiseAnniversary { return this.anniversaryRepo.save(data); }async deleteAnniversary(id: string): Promiseboolean { return this.anniversaryRepo.delete(id); }async togglePin(id: string): Promiseboolean { return this.anniversaryRepo.togglePin(id); } }这层转发看似薄却给后续演进留下了清晰位置页面负责加载态、空态和交互反馈ViewModel 负责页面可用的业务表达仓库负责实体集合的一致性DataStore负责平台 API 和序列化。以后如果将 Preferences 换成关系型数据库页面不必跟着改存储细节。对于 HarmonyOS 多设备应用这种隔离还有额外价值。手机页面通常走异步生命周期桌面卡片扩展可能需要在较短的回调窗口里同步取得已初始化数据。只要两条入口最终落到同一仓库与同一存储键排序和字段默认值就不会因设备形态而分叉。二、实体模型先定义完整态保存参数表达输入态真实源码没有直接让页面构造Anniversary而是另外定义AnniversarySaveData。两种类型的差别就是仓库边界的第一道约束。export type AnniversaryType countdown | memorial | love | salary | custom; export type RepeatMode none | yearly | monthly;export interface Anniversary { id: string; type: AnniversaryType; title: string; targetDate: number; startDate?: number; icon: string; cover?: string; remark?: string; repeat: RepeatMode; pinned: boolean; createdAt: number; updatedAt: number; }export interface AnniversarySaveData { id?: string; type: AnniversaryType; title: string; targetDate: number; startDate?: number; icon?: string; cover?: string; remark?: string; repeat?: RepeatMode; pinned?: boolean; createdAt?: number; updatedAt?: number; }完整实体要求id、图标、重复规则、置顶状态以及时间戳始终存在保存参数允许页面省略可以推导的字段。这个区别避免了两个常见问题一是新增页面复制一大段默认值二是编辑页面为了满足完整类型而把旧实体整个回传导致本不该修改的字段也被覆盖。可以把二者理解为两份不同契约契约面向谁允许缺省主要责任AnniversarySaveData页面、表单、导入流程是表达用户本次提交了什么Anniversary仓库、列表、卡片、备份否表达系统中可持久化的完整记录ArkTS 的严格类型在这里不是形式要求。它迫使我们在编译期回答字段缺失时由谁补齐字段为空时代表清空还是未提交时间戳由调用者还是仓库掌握。只要这些问题没有答案新增和更新就不可能真正共享一条可靠路径。三、新增记录ID、默认值和时间戳只生成一次创建实体由createAnniversary()统一完成。仓库的save()在找不到已有记录时只负责调用工厂、加入集合并持久化。export function createAnniversary( partial: AnniversarySaveData ): Anniversary { const now Date.now(); return { id: partial.id ?? ${now}_${Math.random().toString(36).slice(2, 8)}, type: partial.type, title: partial.title, targetDate: partial.targetDate, startDate: partial.startDate, icon: partial.icon ?? getDefaultIcon(partial.type), cover: partial.cover, remark: partial.remark ?? , repeat: partial.repeat ?? none, pinned: partial.pinned ?? false, createdAt: partial.createdAt ?? now, updatedAt: now }; }这里有三个值得保留的工程决定。第一默认图标由类型决定而不是由某个新增页面决定。以后“纪念日”还可能从备份导入、快捷入口或跨设备迁移创建所有入口都能得到一致图标。第二使用??而不是||。pinned: false、空备注和合法的数值边界都不应该因为是假值而被默认值覆盖。空值合并只在null或undefined时启动更符合保存参数的语义。第三updatedAt在创建时由系统当前时间生成。即使导入数据携带旧的updatedAt现有实现也会把“本次写入”视为最新更新时间而createdAt允许保留来源时间。这一行为需要在备份导入协议中明确否则调用方可能误以为两个时间戳都会原样恢复。当前 ID 由毫秒时间和六位随机串组成足以覆盖本地轻量列表但它不是跨端全局唯一协议。如果后续引入分布式同步或多端并发创建应切换到系统 UUID 或服务端生成 ID并在迁移时保持旧 ID 可引用。四、初始化边界异步页面与同步卡片共用一份内存仓库是单例并通过initialized防止重复读取。异步入口面向常规页面同步入口则在已有Context的扩展场景中快速完成初始化。export class AnniversaryRepository { private static _instance: AnniversaryRepository | null null; private items: Anniversary[] []; private initialized: boolean false; private store: DataStore DataStore.getInstance();async init(): Promisevoid { if (this.initialized) return; this.items await this.store.getJsonAnniversary[]( DataKeys.ANNIVERSARIES, [] ); this.initialized true; }initSync(context: Context): void { if (this.initialized) return; this.store.initSync(context); this.items this.store.getJsonSyncAnniversary[]( DataKeys.ANNIVERSARIES, [] ); this.initialized true; } }真实项目的CountdownFormAbility会取得仓库单例先调用initSync(context)再读取getAllSync()。这说明同步接口不是为了让页面绕过异步流程而是服务于卡片扩展的生命周期约束。需要特别注意并发首次初始化。当前AnniversaryRepository.init()只检查布尔值如果两个页面在第一次读取时同时进入理论上会发起两次getJson()。下层DataStore用initPromise复用了 Preferences 初始化但仓库读取本身仍可能重复。轻量 JSON 读取通常不会破坏数据不过更稳的仓库可以复用初始化 Promiseprivate initPromise: Promisevoid | null null;async init(): Promisevoid { if (this.initialized) return; if (!this.initPromise) { this.initPromise this.loadItems(); } await this.initPromise; }private async loadItems(): Promisevoid { this.items await this.store.getJsonAnniversary[]( DataKeys.ANNIVERSARIES, [] ); this.initialized true; }这个改进的重点不是“少读一次磁盘”而是让所有首次调用等待同一个完成信号。若以后加载过程加入迁移、校验或数据修复就不会出现一个调用拿到旧结构、另一个调用拿到新结构的竞态。五、排序边界置顶优先日期升序而且不改原数组列表排序被放在仓库的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; } ); }getAllSync(): Anniversary[] { return this.sortedCopy(); }async getAll(): PromiseAnniversary[] { await this.init(); return this.sortedCopy(); }[...this.items]很关键。JavaScript/ArkTS 的sort()会原地修改数组如果直接排序this.items一次读取就会改变仓库内部状态并且下一次持久化可能把“展示顺序”意外写成“存储顺序”。返回副本则把排序限定为查询行为。当前排序规则可以准确复述为所有pinnedtrue的记录排在未置顶记录之前。同一置顶组内按targetDate从小到大排列。日期相同没有额外稳定键结果依赖运行时稳定排序行为。当产品希望相同日期按最近更新排序时可以明确加入第三条件if (a.targetDate ! b.targetDate) { return a.targetDate - b.targetDate; } return b.updatedAt - a.updatedAt;不要让首页、全部列表和桌面卡片各自实现这套规则。真实源码中的HomeView、AllView、WidgetView都从 ViewModel 或仓库取得同一排序结果正是仓库收口的实际收益。六、更新记录真值判断会吞掉合法的“清空”现有save()先按id查找记录找到后原地更新字段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.type) { existing.type data.type; } if (data.targetDate) { existing.targetDate data.targetDate; } if (data.startDate ! undefined) { existing.startDate data.startDate; } if (data.cover ! undefined) { existing.cover data.cover; } if (data.remark ! undefined) { existing.remark data.remark; } if (data.pinned ! undefined) { existing.pinned data.pinned; } existing.updatedAt Date.now(); await this.persist(); return existing; }这段代码已经对可选的cover、remark、pinned使用了! undefined因此可以清空封面、清空备注和取消置顶。但title、type、targetDate、icon、repeat仍使用真值判断边界并不完全一致。最明显的例子是标题。若产品允许暂存未命名事项title: 会被忽略旧标题保留targetDate: 0虽然通常不是业务日期却仍是合法数字也会被忽略。更一致的更新写法应判断“调用者有没有传这个字段”而不是判断“字段转成布尔值后是否为真”if (data.title ! undefined) { existing.title data.title; } if (data.targetDate ! undefined) { existing.targetDate data.targetDate; } if (data.icon ! undefined) { existing.icon data.icon; } if (data.repeat ! undefined) { existing.repeat data.repeat; }不过当前AnniversarySaveData把title、type、targetDate声明为必填这又暴露出另一个设计选择它究竟是“完整保存命令”还是“局部补丁命令”如果是完整保存更新分支可以直接赋值必填字段如果是局部补丁应另建AnniversaryPatch把所有可更新字段设为可选并统一检查undefined。不要让类型说“必填”实现却按“可能没传”处理。七、删除和置顶返回布尔值让页面能表达结果删除逻辑先查索引只有命中时才修改集合和持久化async delete(id: string): Promiseboolean { 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; }不存在的 ID 返回false不会产生无意义的写盘。页面据此可以区分“删除完成”和“记录已经不存在”例如在详情页返回前显示轻提示或重新加载列表以处理多入口修改。置顶操作也采用相同模式async togglePin(id: string): Promiseboolean { await this.init(); const item this.items.find( (value: Anniversary) value.id id ); if (!item) return false;item.pinned !item.pinned; item.updatedAt Date.now(); await this.persist(); return true; }这里更新updatedAt是必要的因为AllView的ForEach键使用了${item.id}_${item.updatedAt}。置顶后更新时间变化ArkUI 能把该行识别为需要重建的内容仓库重新读取时排序规则又会将它移动到置顶组。但布尔返回值只能表达成功或未命中不能表达 Preferences 写入失败。真实DataStore.putJson()捕获异常后只记录日志不向上抛出因此persist()返回时仓库无法判断数据是否真正落盘。如果保存可靠性要求提高应让存储层返回结果或抛出受控错误再由 ViewModel 转换成页面可展示的失败态。八、持久化实现每次变更写入同一个 JSON 快照DataStore基于kit.ArkData的 Preferences将完整数组序列化到ds_anniversariesasync putJsonT(key: string, value: T): Promisevoid { await this.ensureReady(); if (!this.pref) return; try { const json JSON.stringify(value); await this.pref.put(key, json); await this.pref.flush(); } catch (e) { hilog.error( DOMAIN, TAG, putJson [%{public}s] failed: %{public}s, key, JSON.stringify(e) ); } }private async persist(): Promisevoid { await this.store.putJson( DataKeys.ANNIVERSARIES, this.items ); }对少量纪念日记录这种“整表 JSON 快照”有明显优点实现短、迁移成本低、读取一次即可完成列表展示也便于备份导出。它的适用边界同样清楚数据特征Preferences JSON更适合 RDB数十条轻量记录合适可以但偏重按多个字段组合查询需要内存过滤合适频繁并发写入风险上升合适局部更新单条记录仍写完整数组可定向更新模式迁移与索引需手写版本逻辑支持更系统这套实现的核心假设是纪念日集合规模小写入频率低同一进程内由单例仓库串行操作。如果未来加入云同步、批量导入或后台提醒服务同时修改数据就需要事务、版本号或写入队列不能继续把整表覆盖当作天然安全。九、页面刷新不靠“碰巧”数据版本连接 ArkUI 状态仓库只管理数据不直接持有 ArkUI 页面状态。AllView使用StorageLink(StateKeys.DATA_VERSION)与Watch监听共享版本版本变化时重新读取StorageLink(StateKeys.DATA_VERSION) Watch(onDataChange) dataVersion: number 0;State anniversaries: Anniversary[] [];onDataChange(): void { this.loadData(); }private async loadData(): Promisevoid { this.anniversaries await this.viewModel.loadAnniversaries(); }这个设计把两类状态分开items是可持久化业务数据dataVersion是通知页面刷新的短期信号。仓库完成保存后调用入口需要递增版本监听页面再通过统一查询取得排序后的副本。这样不会把页面组件、回调或State引用塞进仓库。多设备场景尤其要检查“写入完成”和“刷新通知”的顺序。正确顺序应是等待save()、delete()或togglePin()完成。确认操作结果。再递增DATA_VERSION或发送应用内事件。页面重新查询桌面卡片按平台能力请求更新。如果先刷新后写盘页面可能读取旧快照如果只修改页面数组而不经过仓库桌面卡片和下一次冷启动仍会看到旧数据。十、把边界写成可执行用例仓库逻辑很适合做纯边界测试。即使 Preferences 需要 HarmonyOS 运行环境也可以给DataStore增加窄接口并注入内存实现验证排序、更新和返回值。interface AnniversaryStore { getJsonT(key: string, fallback: T): PromiseT; putJsonT(key: string, value: T): Promisevoid; }class MemoryAnniversaryStore implements AnniversaryStore { private values: Mapstring, string new Map();async getJsonT(key: string, fallback: T): PromiseT { const value this.values.get(key); return value ? JSON.parse(value) as T : fallback; }async putJsonT(key: string, value: T): Promisevoid { this.values.set(key, JSON.stringify(value)); } }最小用例集不应只测“新增成功”还要覆盖这些边界新增时缺省图标、重复规则、备注和置顶状态是否正确。两条记录一条置顶、一条未置顶置顶项是否始终在前。同一置顶组内较早目标日期是否排在前面。getAll()返回数组被调用方排序后仓库内部顺序是否不受影响。更新备注为空字符串持久化结果是否真的清空。更新pinnedfalse是否不会被真值判断吞掉。删除存在 ID 是否返回true并写盘一次。删除不存在 ID 是否返回false且不写盘。置顶不存在 ID 是否返回false。两个首次异步读取是否共享同一次初始化。若暂时不改生产代码也可以先在 DevEco Studio 中通过页面操作验证前八项并用hilog观察DataStore的初始化与写入日志。关键是让“边界”成为明确预期而不是等用户反馈后再猜测。十一、真机与多设备验证清单本地仓库没有网络依赖但发布前仍应在 HarmonyOS 5.0 及以上设备验证完整链路。建议按以下顺序执行冷启动应用确认空数据时首页、全部列表和筛选页可正常进入。新增五种类型各一条检查默认图标、日期文案和重复规则。将较晚日期置顶确认它越过较早日期进入置顶组。修改标题、日期、备注、封面和重复规则退出详情后重新进入核对。清空可清空字段杀进程再启动确认清空结果仍在。删除一条记录确认首页、全部列表、筛选页与卡片入口同步更新。快速连续进入两个依赖纪念日的页面观察首次加载是否重复或闪回空列表。重启设备或清理应用进程后再次读取确认 Preferences 已 flush。在手机、平板或 2in1窗口宽度下检查长标题不让数据正确但界面截断到不可理解。切换深浅色模式检查列表文字、日期数字、置顶状态与空态的对比度。仓库验证不能只看“当前页面变了”。真正的持久化成功至少要经过一次进程重启真正的多入口一致至少要同时检查首页、列表、详情和桌面卡片读取路径。十二、常见故障与排查顺序现象优先检查常见原因修复方向新增后当前页有重启后丢失DataStore.pref与flush()Context 未初始化或写入异常被吞掉让写入失败向上返回保存后再刷新清空备注有效清空标题无效save()字段判断if (data.title)把空串当未提交用! undefined或拆分补丁类型置顶后顺序没变化读取是否经过getAll()页面继续使用本地旧数组操作完成后递增数据版本并重读卡片与应用顺序不同同步入口与排序函数卡片绕过仓库或使用原数组统一走getAllSync()首次进入偶尔空列表初始化时序Preferences 尚未初始化或并发首次读取复用初始化 Promise显式处理加载态删除提示成功但重启又出现存储错误传播putJson()捕获异常后仍被视为成功返回写入结果失败时恢复内存或重试相同日期顺序漂移比较器缺少第三键只比较置顶和目标日期加入updatedAt或createdAt排查时先看边界再看页面。确认仓库内存是否变化、持久化是否完成、查询是否重新执行、最后才检查 ArkUI 是否刷新。这个顺序能快速区分“数据没写进去”和“数据写了但界面没重读”。总结时光清单的纪念日仓库展示了一条适合轻量 HarmonyOS 应用的真实路径用完整实体承载持久化状态用保存参数承载页面输入用仓库收口增删改查与排序再把 Preferences 的平台细节限制在DataStore。真正决定可靠性的不是代码行数而是语义是否一致初始化只能完成一次创建默认值只能生成一次查询排序不能污染原数组删除和置顶要返回可判断结果更新字段必须区分“未提交”和“提交了空值”。把这些边界写清楚后首页、列表、详情和桌面卡片才能围绕同一份数据稳定协作也为将来迁移到 RDB 或多设备同步保留了清晰接口。AI 辅助声明本文由 AI 辅助整理核心结论、代码路径与行为描述均基于D:\huawei\one8中的真实 ArkTS 源码复核示例中的改进建议需结合项目测试后采用。

相关新闻