鸿蒙个人记账本实战:端云一体架构下如何做数据同步与聚合统计
为什么记账应用值得研究
记账本看起来是最简单的一类应用:记一笔、查一下、算个总和。但真正做起来,问题全在数据层:
- 用户在电梯里没信号,还能不能记账?
- 同一笔支出在手机和平板上各记了一次,怎么去重?
- 删掉一笔记录,云端要不要跟着删?
- 月度统计的口径是按账单日期还是按记账时间?
这些问题在单机应用里都不存在,一旦上了端云一体架构就全部冒出来。鸿蒙实践中心的记账本案例(account_book)把这些问题做了一个示范性的解答。
一、数据模型设计
1.1 账单记录
核心实体是账单,字段大致是:
interface BillRecord {
id: string; // 全局唯一 ID
amount: number; // 金额,单位分
category: string; // 分类:餐饮、交通、购物...
type: number; // 0 支出 1 收入
billDate: number; // 账单发生时间
createdAt: number; // 记录创建时间
updatedAt: number; // 最后修改时间
deleted: boolean; // 软删除标记
synced: boolean; // 是否已同步到云端
}
几个设计要点值得说:
- 金额用整数分而不是浮点数。0.1 + 0.2 在浮点下不等于 0.3,账目上出现一分钱的误差会引起用户质疑。全部按分存储,显示时除以 100。
- billDate 和 createdAt 分开。用户可能今天补记上周的账,账单日期是上周,但记录是今天创建的。统计时按 billDate 算,同步冲突判断时用 updatedAt。
- 软删除。直接物理删除会导致同步时无法区分「云端有、本地没有是因为删了」还是「因为还没拉下来」。加 deleted 标记,删除变成一次普通更新。
1.2 本地存储选型
鸿蒙提供了几种本地存储方案:
| 方案 | 适用场景 |
|---|---|
| Preferences | 键值对配置,不适合大量结构化数据 |
| 关系型数据库 (RDB) | 结构化账单数据,支持 SQL 查询和聚合 |
| 分布式数据对象 | 多设备实时同步的轻量场景 |
记账本用 RDB 最合适,因为统计需要 GROUP BY、SUM 这类聚合操作,用 SQL 一行就能算出来。如果自己用数组遍历,数据量一大就卡。
二、端云同步策略
2.1 本地优先写入
记账这个场景,用户的期望是「点下保存就该立刻看到」。所以写入顺序一定是本地优先:
- 写入本地数据库,标记
synced = false - 界面立即刷新,用户看到记录出现
- 后台异步推送到云端
- 推送成功后把
synced置 true
如果反过来先等云端响应,弱网环境下用户要盯着加载圈好几秒,体验很差。而且一旦超时,用户会重复点击,产生重复记录。
2.2 冲突解决:最后写入胜出
多设备场景下,同一条记录可能在两端被修改。常见的解决方案有三种:
| 策略 | 说明 | 适用性 |
|---|---|---|
| Last Write Wins | 比较 updatedAt,后改的覆盖先改的 | 记账场景够用 |
| 版本号乐观锁 | 每次更新 version+1,不一致则拒绝 | 更严格,但需要用户处理冲突 |
| CRDT | 数学上保证最终一致 | 过重,记账不需要 |
记账本用 LWW 就够了。因为账单的修改频率很低,同一笔账在两端同时修改的概率微乎其微。真发生了,用户发现金额不对重新改一次即可,成本很低。
2.3 增量同步
每次同步全量拉取数据是不现实的。做法是记录一个 lastSyncTime,每次只拉取 updatedAt > lastSyncTime 的记录:
GET /bills?since=1700000000000
服务端返回这段时间内新增和修改的所有记录,客户端按 id 合并。注意要包含已删除的记录(deleted=true),否则删除操作同步不过去。
三、聚合统计的实现
3.1 月度汇总
最常用的统计是「这个月花了多少」。用 SQL 一条搞定:
SELECT type, category, SUM(amount) as total, COUNT(*) as cnt FROM bills WHERE deleted = 0 AND billDate >= ? AND billDate < ? GROUP BY type, category
把起止时间按自然月算好传进去,就能拿到按分类拆分的收支汇总。同比环比只需要换两次时间参数再查一遍。
3.2 趋势图数据
折线图需要按天聚合:
SELECT strftime('%Y-%m-%d', billDate / 1000, 'unixepoch', 'localtime') as day,
SUM(CASE WHEN type = 0 THEN amount ELSE 0 END) as expense,
SUM(CASE WHEN type = 1 THEN amount ELSE 0 END) as income
FROM bills
WHERE deleted = 0 AND billDate >= ?
GROUP BY day
ORDER BY day
注意时区处理。billDate 存的是毫秒时间戳,直接按 UTC 格式化会让凌晨的账单算到前一天。加 localtime 修正。
3.3 统计口径要一致
一个容易被忽略的坑:列表页显示的条数和统计页算出的笔数经常对不上。原因通常是两处用的过滤条件不同——列表可能默认只显示本月的,但统计按全部算;或者列表把软删除的记录过滤了,统计没有。
解决办法是把过滤条件抽成一个公共方法,两处都调用它。这类一致性问题在数据量小的时候看不出来,用户数据积累半年后就会暴露,那时候排查很痛苦。
四、几个实战细节
4.1 分类的层级设计
餐饮下面有正餐、外卖、咖啡,交通下面有地铁、打车、加油。用两级分类比单级更实用,但要注意统计时既要能看小类,也要能汇总到大类。数据库里存子类 id,同时冗余一个父类 id,查询时就不用做连表。
4.2 金额输入的防错
输入框要做几件事:限制只能输数字和一个小数点、小数点后最多两位、不允许以小数点开头。提交前再校验一次金额大于 0。这些校验放在一个统一的函数里,收入支出两个入口都调用。
4.3 同步状态的可见性
列表项上可以加一个小标记,表示「未同步」。用户看到就知道这条还没上云,心里有底。全部同步完成后标记消失。这个小细节能大幅降低用户对数据安全的焦虑。
五、小结
记账应用的技术含量集中在数据层,界面反而是最简单的部分。这套端云一体的思路可以迁移到很多场景:
- 本地优先写入,云端异步同步
- 软删除代替物理删除,保证同步可追溯
- 用 updatedAt 做增量拉取和冲突判断
- 聚合统计全部放在数据库层,不在内存里算
- 过滤条件统一封装,避免口径不一致
把这几点做好,一个记账本的数据层就相当可靠了。剩下的工作主要是界面打磨和报表可视化,那些更多是审美问题,不是工程问题。
- 点赞
- 收藏
- 关注作者
评论(0)