在这里插入图片描述
在这里插入图片描述

实例:个人记账本(Ledger)|技术:种子数据注入、跨月数据构造

一、为什么需要种子数据

一个全新的记账应用,用户打开后第一眼看到的是空荡荡的页面——零流水、零统计、仪表盘全是 0。这种「空壳」体验对 Demo 和开发调试都非常不友好:开发者没法快速验证聚合 SQL 是否正确,读者没法直观看到仪表盘的视觉效果。

种子数据(Seed Data)解决的就是这个问题:在应用首次启动时自动注入一批模拟数据,让页面一开屏就「热闹非凡」。这是所有带演示性质的应用的标准做法,生产环境的「新用户引导数据」「内置示例」也源于此。

本实例的种子数据设计目标非常明确:

  1. 数量适中:30 条流水,既能让列表滚动起来,又不至于淹没重点;
  2. 跨月分布:跨越最近 30 天(约 1 个月),保证「本月」统计有数据;
  3. 类型齐全:支出(餐饮/交通/购物/娱乐/住房/通讯)与收入(工资/兼职/红包)混合,仪表盘双态统计才有看头;
  4. 金额梯度:从 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 条流水种子数据');

代码细节:

  1. const day = 86400000:一天的毫秒数(24 × 60 × 60 × 1000),用乘法表达意图清晰;
  2. now - 30 * day:30 天前的时刻。第 30 条用 12 * 3600000(12 小时前),让列表顶部出现一条「今天」的流水,时间格式化后显示「今天 14:30」这种友好文案;
  3. created_time: s.tradeTime:创建时间直接复用交易时间,语义一致;
  4. hilog.info 日志:注入完成后打一条日志,调试时在 Log 面板能看到「已填充 30 条流水种子数据」,确认注入成功。

关于 hilog 的说明import { hilog } from '@kit.PerformanceAnalysisKit'DOMAIN0x0001 业务域,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 天前)」。

七、验证种子数据的方法

种子数据写好后,怎么验证它真的进了数据库?三种手段:

  1. hilog 日志:Log 面板搜 LedgerDao,看到「已填充 30 条流水种子数据」即成功;
  2. 页面显示:运行 App 进入记账本页面,仪表盘出现大数字、列表出现 30 条记录;
  3. 数据库文件:用 DevEco Studio 的 Device File Explorer 找到应用沙箱下的 ledger.db,用 SQLite 工具打开:
    SELECT COUNT(*) FROM ledger;        -- 应返回 30
    SELECT category, SUM(amount) FROM ledger WHERE type=0 GROUP BY category;
    
    看到与第五节表格一致的结果,说明 GROUP BY 聚合正确。

八、幂等性验证

连续两次进入页面(或杀掉 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 元)、日常餐饮交通、偶尔购物娱乐、月末收红包和过生日。这种「有故事」的种子数据有三个好处:

  1. 演示有说服力:向客户或领导演示时,数据背后的生活场景让人一秒代入;
  2. 统计有层次:收入支出类型齐全、金额跨度大(9 元到 12000 元),聚合结果不会出现「全是 0」或「全是小数字」的尴尬;
  3. 测试有覆盖:分类覆盖了餐饮/交通/购物/娱乐/住房/通讯/工资/兼职/红包 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 全量代码与运行效果,并总结整个记账本实例的技术脉络。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐