三十条流水灌满仪表盘:鸿蒙记账本种子数据与统计面板效果


实例:个人记账本(Ledger)|技术:种子数据注入、跨月数据构造
一、为什么需要种子数据
一个全新的记账应用,用户打开后第一眼看到的是空荡荡的页面——零流水、零统计、仪表盘全是 0。这种「空壳」体验对 Demo 和开发调试都非常不友好:开发者没法快速验证聚合 SQL 是否正确,读者没法直观看到仪表盘的视觉效果。
种子数据(Seed Data)解决的就是这个问题:在应用首次启动时自动注入一批模拟数据,让页面一开屏就「热闹非凡」。这是所有带演示性质的应用的标准做法,生产环境的「新用户引导数据」「内置示例」也源于此。
本实例的种子数据设计目标非常明确:
- 数量适中:30 条流水,既能让列表滚动起来,又不至于淹没重点;
- 跨月分布:跨越最近 30 天(约 1 个月),保证「本月」统计有数据;
- 类型齐全:支出(餐饮/交通/购物/娱乐/住房/通讯)与收入(工资/兼职/红包)混合,仪表盘双态统计才有看头;
- 金额梯度:从 9 元的公交到 12000 元的工资,进度条和数字面板有层次感。
二、种子数据的结构设计
先定义种子数据的接口。注意它与正式实体 LedgerRecord 的区别:种子不需要 id(自增)、createdTime(由 tradeTime 派生),因此单独定义一个精简的 LedgerSeed:
/** 种子数据条目 */
interface LedgerSeed {
type: number;
amount: number;
category: string;
note: string;
tradeTime: number;
}
接口设计原则:种子数据结构只包含「业务上必要的字段」,其他由代码自动补齐。id 交给 AUTOINCREMENT,created_time 直接复用 tradeTime——这样既能少写一堆冗余字段,又让数据语义一致(录入时间 = 交易时间)。
三、initSeedData:幂等注入的核心方法
种子数据的注入方法叫 initSeedData,它必须满足一个关键性质——幂等性:多次调用不会重复插入。实现手法是先查表内数据量,非空则直接返回:
static async initSeedData(context: common.Context): Promise<void> {
const store = await LedgerDao.getStore(context);
const countResult = await store.querySql(`SELECT COUNT(*) AS c FROM ${LedgerDao.TABLE}`);
let total = 0;
if (countResult.goToNextRow()) {
total = countResult.getLong(countResult.getColumnIndex('c'));
}
countResult.close();
if (total > 0) {
return; // 已有数据,跳过
}
// ...注入种子
}
为什么必须幂等? 因为 initSeedData 会在 aboutToAppear()(页面每次显示)时调用。如果用户已经删光了所有流水,重新进入页面又注入一遍,那删除就白做了。幂等保护让「首次启动注入」与「用户日常使用」互不干扰——只有第一次(表为空时)才注入。
四、30 条种子数据全表
下面是我们精心设计的 30 条流水数据(时间用相对当前时间的偏移量表示,保证任何时刻运行 Demo 都能看到「最近一个月」的完整数据):
| # | type | 金额 | 分类 | 备注 | 时间偏移 |
|---|---|---|---|---|---|
| 1 | 0 支出 | 28.50 | 餐饮 | 楼下快餐 | 30 天前 |
| 2 | 0 支出 | 12.00 | 交通 | 地铁通勤 | 29 天前 |
| 3 | 0 支出 | 198.00 | 购物 | 买件外套 | 28 天前 |
| 4 | 0 支出 | 45.00 | 餐饮 | 朋友聚餐 | 27 天前 |
| 5 | 1 收入 | 12000.00 | 工资 | 上月工资 | 26 天前 |
| 6 | 0 支出 | 320.00 | 住房 | 电费 | 25 天前 |
| 7 | 0 支出 | 68.00 | 娱乐 | 电影票 | 24 天前 |
| 8 | 0 支出 | 15.50 | 餐饮 | 早餐 | 23 天前 |
| 9 | 0 支出 | 260.00 | 购物 | 日用品 | 22 天前 |
| 10 | 0 支出 | 9.00 | 交通 | 公交 | 21 天前 |
| 11 | 0 支出 | 88.00 | 娱乐 | 游戏充值 | 20 天前 |
| 12 | 0 支出 | 156.00 | 餐饮 | 火锅 | 19 天前 |
| 13 | 0 支出 | 50.00 | 通讯 | 话费充值 | 18 天前 |
| 14 | 0 支出 | 420.00 | 购物 | 球鞋 | 16 天前 |
| 15 | 0 支出 | 30.00 | 餐饮 | 奶茶 | 15 天前 |
| 16 | 0 支出 | 220.00 | 住房 | 水费燃气 | 14 天前 |
| 17 | 0 支出 | 78.00 | 娱乐 | KTV | 13 天前 |
| 18 | 1 收入 | 800.00 | 兼职 | 周末兼职 | 12 天前 |
| 19 | 0 支出 | 45.50 | 餐饮 | 工作餐 | 11 天前 |
| 20 | 0 支出 | 135.00 | 购物 | 书两本 | 10 天前 |
| 21 | 0 支出 | 12.00 | 交通 | 打车 | 9 天前 |
| 22 | 0 支出 | 300.00 | 住房 | 宽带费 | 8 天前 |
| 23 | 0 支出 | 60.00 | 餐饮 | 外卖 | 7 天前 |
| 24 | 0 支出 | 25.00 | 娱乐 | 咖啡 | 6 天前 |
| 25 | 0 支出 | 168.00 | 购物 | 护肤品 | 5 天前 |
| 26 | 0 支出 | 32.00 | 餐饮 | 超市便当 | 4 天前 |
| 27 | 1 收入 | 500.00 | 红包 | 抢到红包 | 3 天前 |
| 28 | 0 支出 | 96.00 | 交通 | 高铁票 | 2 天前 |
| 29 | 0 支出 | 210.00 | 餐饮 | 生日聚餐 | 1 天前 |
| 30 | 0 支出 | 42.00 | 购物 | 零食 | 12 小时前 |
数据的叙事性设计:这是一份「普通上班族的一个月」账单——月初发工资(12000),日常餐饮交通,偶尔娱乐购物,住房水电固定支出,月末收红包、过生日。读者看着数据表就能脑补出一个真实的生活场景,这让 Demo 不只是「数据堆」,而是一个有故事的产品原型。
五、代码实现:相对时间偏移的构造
种子数据的 tradeTime 用「当前时间 - 偏移量」动态计算,而不是写死时间戳。这样做的好处是:任何时刻打开 Demo,数据看起来都是「最近一个月」发生的,仪表盘的「本月」统计永远有数据。
const now = Date.now();
const day = 86400000;
const seed: LedgerSeed[] = [
{ type: 0, amount: 28.5, category: '餐饮', note: '楼下快餐', tradeTime: now - 30 * day },
{ type: 0, amount: 12.0, category: '交通', note: '地铁通勤', tradeTime: now - 29 * day },
// ...(其余 28 条略,全表见上)
{ type: 0, amount: 42.0, category: '购物', note: '零食', tradeTime: now - 12 * 3600000 },
];
for (const s of seed) {
const values: relationalStore.ValuesBucket = {
type: s.type, amount: s.amount, category: s.category,
note: s.note, trade_time: s.tradeTime, created_time: s.tradeTime,
};
await store.insert(LedgerDao.TABLE, values);
}
hilog.info(DOMAIN, TAG, '已填充 30 条流水种子数据');
代码细节:
const day = 86400000:一天的毫秒数(24 × 60 × 60 × 1000),用乘法表达意图清晰;now - 30 * day:30 天前的时刻。第 30 条用12 * 3600000(12 小时前),让列表顶部出现一条「今天」的流水,时间格式化后显示「今天 14:30」这种友好文案;created_time: s.tradeTime:创建时间直接复用交易时间,语义一致;hilog.info日志:注入完成后打一条日志,调试时在 Log 面板能看到「已填充 30 条流水种子数据」,确认注入成功。
关于 hilog 的说明:import { hilog } from '@kit.PerformanceAnalysisKit',DOMAIN 用 0x0001 业务域,TAG 用 'LedgerDao' 便于过滤。生产环境用 hilog 比 console.log 更规范——它带域、级别、标签,可被系统日志统一收集。
六、种子数据与仪表盘的联动效果
30 条数据注入后,仪表盘页面会呈现以下效果(以「本月」为统计区间,因数据全部在最近 30 天内):
仪表盘数字面板:
| 数据项 | 计算方式 | 预期值 |
|---|---|---|
| 本月支出 | SUM(type=0) | 约 3,586 元 |
| 本月收入 | SUM(type=1) | 13,300 元 |
| 本月笔数 | COUNT(*) | 30 笔 |
| 结余 | 收入 - 支出 | 约 9,714 元 |
分类占比进度条(按金额降序):
| 分类 | 金额 | 占比 |
|---|---|---|
| 餐饮 | 约 625 元 | 最大头 |
| 购物 | 约 1,223 元 | 紧随其后 |
| 住房 | 约 840 元 | 第三 |
| 娱乐 | 约 259 元 | 第四 |
| 交通 | 约 129 元 | 第五 |
注:具体数值会因「本月」是否跨月而有细微差异——如果今天恰好是月初,30 天前那条会落在上月。这是相对时间方案的已知特性,不影响 Demo 效果。
流水列表:30 条按时间倒序排列,最新一条是「零食 - 42.00 元(12 小时前)」,最旧一条是「楼下快餐 - 28.50 元(30 天前)」。
七、验证种子数据的方法
种子数据写好后,怎么验证它真的进了数据库?三种手段:
- hilog 日志:Log 面板搜
LedgerDao,看到「已填充 30 条流水种子数据」即成功; - 页面显示:运行 App 进入记账本页面,仪表盘出现大数字、列表出现 30 条记录;
- 数据库文件:用 DevEco Studio 的 Device File Explorer 找到应用沙箱下的
ledger.db,用 SQLite 工具打开:
看到与第五节表格一致的结果,说明 GROUP BY 聚合正确。SELECT COUNT(*) FROM ledger; -- 应返回 30 SELECT category, SUM(amount) FROM ledger WHERE type=0 GROUP BY category;
八、幂等性验证
连续两次进入页面(或杀掉 App 重开),initSeedData 被调用两次,但数据不会被插入两遍:
第一次进入:SELECT COUNT(*) → 0 → 注入 30 条
第二次进入:SELECT COUNT(*) → 30 → 直接 return
这个行为可以通过日志验证:第一次打印「已填充 30 条流水种子数据」,第二次无输出。幂等是种子数据方法的第一质量要求,写任何 initSeedData 都要记住这个模式。
九、种子数据的进阶设计思考
30 条数据的规模看似简单,背后其实藏着几个可以迁移到生产环境的种子数据设计思路,值得单独展开讲讲。
9.1 相对时间 vs 绝对时间
我们选择用 now - 30 * day 这种相对时间偏移,而不是写死 2025-06-01 08:00:00 这样的绝对时间戳。两者的本质区别在于:
- 绝对时间:适合「演示某个历史时刻」的场景(如重现某天的报表),但缺点是 Demo 存放久了数据会「过期」——半年后打开,这些流水全落在上上上月,本月的仪表盘空空如也;
- 相对时间:任何时刻打开都呈现「最近一个月」的活数据,与页面「本月」统计天然契合,演示效果永远新鲜。
生产环境里,相对时间通常用于「新用户引导数据」「每日示例」,绝对时间用于「固定报表归档」。理解这个差异,你就能根据场景选择正确的策略。
9.2 数据叙事:让种子有故事感
细心的读者会发现,这 30 条数据不是随机生成的,而是一个「普通上班族的一个月」的生活剪影:月初发工资(12000 元)、日常餐饮交通、偶尔购物娱乐、月末收红包和过生日。这种「有故事」的种子数据有三个好处:
- 演示有说服力:向客户或领导演示时,数据背后的生活场景让人一秒代入;
- 统计有层次:收入支出类型齐全、金额跨度大(9 元到 12000 元),聚合结果不会出现「全是 0」或「全是小数字」的尴尬;
- 测试有覆盖:分类覆盖了餐饮/交通/购物/娱乐/住房/通讯/工资/兼职/红包 9 个分类,GROUP BY 结果丰富,前端进度条各种颜色都展示出来了。
9.3 数量的选择:不多不少刚刚好
为什么是 30 条而不是 100 条?平衡点在于:
- 太少(如 5 条):列表一眼看完,滚动无意义,仪表盘数字单薄;
- 太多(如 500 条):首次注入耗时,列表滚动卡顿,重点信息被淹没;
- 30 条:正好铺满一屏多一点,滚动体验真实,聚合统计有代表性,注入只需几十毫秒。
生产环境的「引导数据」量级参考值是「能撑起所有页面形态的最小集合」——每个分类有代表、每个状态有覆盖、列表能滚动两屏,就够了。
9.4 扩展:如何把种子数据做成「可恢复的默认值」
如果你的应用需要「重置数据」功能,可以基于 initSeedData 做一个增强版:先 DELETE FROM ledger 清空,再注入种子。一个 resetToSeed() 方法就能实现「一键恢复出厂数据」,这对 Demo 应用和测试环境都是很实用的能力:
static async resetToSeed(context: common.Context): Promise<void> {
const store = await LedgerDao.getStore(context);
const predicates = new relationalStore.RdbPredicates(LedgerDao.TABLE);
await store.delete(predicates); // 清空全部流水
await LedgerDao.initSeedData(context); // 重新注入(此时表为空,必然注入)
hilog.info(DOMAIN, TAG, '已重置为种子数据');
}
这个模式比「删库重装」优雅得多——数据库文件还在,只是数据被重置,用户不需要重新授权、不需要重建索引。
十、常见问题 FAQ
Q1:为什么我的 App 里没有出现种子数据?
A:检查 initSeedData 是否在 aboutToAppear() 里被调用;检查日志是否打印「已填充 30 条流水种子数据」;检查是不是之前已经注入过(幂等保护导致第二次不注入)——可以在 DevEco Studio 里卸载应用再重装,触发全新首次启动。
Q2:注入的金额为什么显示成 3586.000000001 这种精度问题?
A:REAL 类型在 SQLite 里是 8 字节浮点,累加时可能出现极小误差。展示层用 toFixed(2) 格式化即可解决,如 this.expense.toFixed(2)。生产环境的严谨方案是把金额字段改为 amount_cents INTEGER(存分),彻底避免浮点误差——我们在 3-1 文章里已经预告过这个取舍。
Q3:修改种子数据后,旧设备上的数据不更新怎么办?
A:幂等保护导致已注入的设备不会重新注入。两个方案:一是改数据库版本号触发迁移逻辑,二是像 9.4 节那样提供重置入口。Demo 阶段直接卸载重装最快。
Q4:种子数据里可以加中文备注吗?
A:可以。SQLite 存储 UTF-8 中文毫无压力,ValuesBucket 直接传字符串即可。我们 30 条数据里大量使用中文备注(楼下快餐、生日聚餐),页面渲染完全正常。
Q5:可以注入 1000 条数据做性能测试吗?
A:当然可以。把 seed 数组用循环生成(随机金额、随机分类)即可。但注意一次事务插入 1000 条会比较慢,建议包在事务里(beginTransaction/commit),后续实例(库存进销存)会演示事务的用法。
十一、文章小结
本篇文章讲解了实例 3 的种子数据设计:精简的 LedgerSeed 接口 + 幂等的 initSeedData + 相对时间偏移的 30 条跨月流水。种子数据让仪表盘一开屏就有真实感:双态统计有数字、分类占比有条目、流水列表可滚动——3-2 的 UI 与 3-3 的聚合 SQL 在真实数据下完成了闭环验证。进一步地,我们还探讨了相对时间策略、数据叙事、数量平衡、重置扩展等进阶设计,这些思路可以迁移到后续 17 个实例的种子数据中。
下一篇(3-5)是实例 3 的收官文章,展示 LedgerPage 全量代码与运行效果,并总结整个记账本实例的技术脉络。
更多推荐




所有评论(0)