羽毛球教程 HarmonyOS 学习应用(03):练习日志的本地记录设计
2026/7/26 14:25:12 网站建设 项目流程

一、练习记录需要同时满足录入、回看与统计

一次羽毛球练习不仅有“做过”这个布尔值,还包含日期、持续时间、训练类型、文字记录和自评。页面要支持新增、编辑、批量删除,也要即时计算总次数、总时长、本周次数和分类平均评分。

练习记录属于结构化列表,但当前规模较小、查询规则简单,因此采用内存响应状态加 Preferences 持久化。若未来需要上万条记录、多条件检索或跨表统计,再迁移到关系型存储。

二、PracticeLog 用稳定 id 区分每次训练

模型将业务字段和审计时间分开。id不依赖数组位置,编辑和删除都可以准确定位同一条记录。

export interface PracticeLog { id: string date: string durationMin: number type: string content: string rating: number createdAt: number updatedAt?: number } export const PRACTICE_TYPES: string[] = [ '单打对练', '双打对练', '步伐训练', '发球练习', '体能训练', '其他' ]
字段约束页面用途
dateYYYY-MM-DD周统计与列表展示
durationMin正整数总时长
type预设类型之一分类聚合
content可为空复盘备注
rating1–5星级和平均分
createdAt/updatedAt毫秒时间戳稳定排序与刷新键

三、写入前先复制数组

AppStorage中的列表是页面响应状态。直接在原数组上unshift或替换成员,可能不能稳定触发依赖该数组的界面更新。服务层先复制,再写回完整新数组。

private static save(logs: PracticeLog[]): void { AppStorage.setOrCreate<PracticeLog[]>(StorageKeys.PRACTICE_LOGS, logs) PreferencesHelper.get().persist(StorageKeys.PRACTICE_LOGS, logs) } static add(log: PracticeLog): void { const next: PracticeLog[] = PracticeLogService.list().slice() next.unshift(log) PracticeLogService.save(next) }

同一次save同时更新响应状态和持久化层:当前页面立即刷新,应用重启后又能恢复。页面不直接调用 Preferences,避免各处使用不同键名或序列化格式。

Preferences 的键值存储、内存加载与flush行为可参考华为官方 用户首选项持久化指南。练习列表规模增长后,应重新评估整份 JSON 读写的成本。

四、编辑和删除都通过 id 收敛

编辑先复制当前数组,再找到相同 id;删除则使用过滤生成新数组。找不到待编辑记录时保持原状态,不凭空新增重复项。

static update(log: PracticeLog): void { const next: PracticeLog[] = PracticeLogService.list().slice() const index: number = next.findIndex(item => item.id === log.id) if (index >= 0) { next[index] = log PracticeLogService.save(next) } } static remove(id: string): void { const next = PracticeLogService.list().filter(item => item.id !== id) PracticeLogService.save(next) }

新增记录的 id 由时间与随机片段组合,编辑记录保留原createdAt,只更新updatedAt。列表的ForEach键包含 id 与更新时间,因此内容变化后对应卡片会重新渲染。

五、编辑页在提交前归一化输入

页面字段只保存用户的临时输入,提交时再组装PracticeLog。日期为空、时长不合法或评分越界都应在写入前拦截。

const log: PracticeLog = { id: existing?.id ?? PracticeLogService.genId(), date: this.date.trim(), durationMin: Number(this.durationText), type: this.type, content: this.content.trim(), rating: this.rating, createdAt: existing?.createdAt ?? Date.now(), updatedAt: existing ? Date.now() : undefined } if (existing) { PracticeLogService.update(log) } else { PracticeLogService.add(log) }

页面跳转只携带可选logId。编辑已有记录时用 id 回读;新建时没有 id,使用默认日期、类型和评分。这样路由参数不会携带一整份可能过期的对象。

六、统计值从当前记录重新计算

总次数直接取数组长度,总时长对durationMin求和,平均评分按当前记录计算,本周次数则比较每条日期与本周一。删除或编辑后不手工递增、递减多个计数器,而是从新数组重新投影。

private avgRating(): string { if (this.logs.length === 0) return '-' let sum: number = 0 for (const log of this.logs) { sum += log.rating } return (sum / this.logs.length).toFixed(1) } private totalMinutes(): number { return this.logs.reduce((sum, log) => sum + log.durationMin, 0) }

这种计算牺牲了极少量遍历成本,却消除了“记录删除了,总时长没减”“类型改了,旧分类仍计数”等同步错误。

七、空状态、批量删除与恢复边界

首次进入且没有记录时,页面显示明确空状态和“开始记录”入口。进入编辑模式后,选中项保存在页面局部状态;退出编辑模式立即清空,避免下一次误删。

异常或边界处理方式不允许的结果
持久化数据为空返回空数组页面崩溃
编辑 id 不存在不写入静默新增重复记录
时长不是正数提交前提示写入NaN或负数
批量删除未选中隐藏删除操作删除全部
Preferences 写入失败保留当前状态并提示重试假装已永久保存

当前实现是本地单端模型,不应把它描述成云同步。跨设备共享需要额外的账号范围、冲突策略、重试队列和服务端标识。

八、运行界面与验收路径

现有运行界面验证了练习记录页面的空状态、主标题和新增入口。完整验收按以下顺序进行:

1. 从空状态新增一条练习,返回后列表立即出现。 2. 重启应用,记录仍存在且字段完整。 3. 编辑时长与评分,统计卡片和分类平均分同步变化。 4. 新增不同类型记录,分类统计分别归组。 5. 批量选择两条记录后删除,未选中的记录保留。 6. 输入空日期、零时长或越界评分,写入被拦截并可继续修正。

持久化验收不能只看当前列表。保存后要彻底退出并重新进入应用,核对日期、类型、备注、评分与统计是否一致;随后编辑同一条记录并再次重启,确认只存在一个 id,createdAt保留而updatedAt变化。批量删除后也要重启验证,防止 AppStorage 已删除但 Preferences 仍保留旧数组。若 JSON 解析失败,页面应进入可恢复空状态并记录诊断信息,不能在后台自动覆盖损坏数据而让排查线索消失。

九、小列表也需要明确的数据合同

练习日志规模不大,但新增、编辑、删除、统计和持久化已经形成完整闭环。把数组复制、统一写入、id 定位和派生统计放进服务边界后,页面只负责交互和展示;未来迁移关系型存储时,也能保持页面模型基本不变。

迁移时可先为服务层增加 Repository 接口,再把 Preferences 实现替换为关系型实现。页面继续调用新增、更新、删除和查询,不感知表结构;历史数据通过一次性迁移读取旧 JSON、校验每条字段、批量写入新库,全部成功后再写迁移标记。任何一条失败都保留旧数据并允许重试,避免半迁移状态。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询