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

实例:个人记账本(Ledger)|技术:SUM/COUNT 聚合、GROUP BY 统计、日期范围查询
阅读收益:学完本篇文章,你将掌握鸿蒙应用中「流水型业务数据」从建表到 DAO 封装的完整方法论,并理解为什么报表类功能要把时间戳字段设计成核心索引。

一、业务需求分析:记账本到底要记录什么

在开始写任何一行代码之前,我们先冷静下来思考一个问题:一个「个人记账本」应用,它最核心的业务数据是什么?答案只有一个词——流水(Ledger)。每一笔收入、每一笔支出,都是流水的具体表现。

我们不妨把自己代入产品经理的角色,对着白板画出记账本的需求清单:

  1. 记一笔账:用户能快速录入一笔收入或支出,包含金额、分类、备注、时间四个核心要素。这是整个应用的数据入口,必须快、必须稳。
  2. 本月总支出 / 总收入 / 结余统计:打开应用第一眼看到的就是这个数字面板。用户想知道「我这个月花了多少、挣了多少、还剩多少」。
  3. 按分类统计占比:除了总览,用户还想知道「钱都花到哪里去了」——餐饮占了多少、交通占了多少、购物占了多少。这个需求天然对应数据库里的 GROUP BY 分组聚合。
  4. 按月查看历史流水:上个月花了多少?三个月前的大额支出是什么?用户需要按月份切分时间维度来回顾历史。
  5. 按日期范围筛选:更精细的场景——「6 月 1 日到 6 月 15 日我花了多少钱」,这对应 SQL 里的 BETWEEN 区间查询。

把这五个需求翻译成数据库术语,我们会发现它们全部围绕两个字段展开:trade_time(交易时间)和 amount(金额)。时间字段支撑区间查询、按月分组;金额字段支撑求和、均值等所有聚合运算。这就是流水型业务的核心:时间是纵轴,金额是横轴,分类是标签

很多初学者一上来就设计一张「大而全」的表,把用户信息、预算信息、账户信息全部塞进去,结果查询效率低下、逻辑混乱。正确的做法是:先明确这张表只为「一笔账」服务,其他概念(用户、预算、账户)在后续的实例中各自独立成表,通过外键关联。这就是我们第一个实例(待办清单)和第二个实例(通讯录)反复强调的「单表职责单一」原则。

二、字段设计:每一列都有它的宿命

明确了业务需求,我们就可以开始设计 ledger 表的字段了。设计表结构时,我习惯先列出候选字段,然后逐个质问它:这个字段被谁查询?被谁聚合?值域是什么? 下面是我们最终敲定的字段清单:

字段名 类型 约束 说明
id INTEGER PRIMARY KEY AUTOINCREMENT 自增主键,每笔流水唯一标识
type INTEGER NOT NULL 收支类型:0 支出 / 1 收入
amount REAL NOT NULL 金额(元),如 25.50
category TEXT NOT NULL 分类(餐饮/交通/购物/工资…)
note TEXT DEFAULT ‘’ 备注,可为空
trade_time INTEGER NOT NULL 交易时间戳(毫秒)
created_time INTEGER NOT NULL 创建时间戳(毫秒)

逐个字段拆解,说明设计决策背后的考量:

id 自增主键。这是所有表的默认配置,无需多言。AUTOINCREMENT 保证删除最后一条记录后新插入的记录不会复用旧的 id,避免缓存引用错乱。

type 用 0/1 数字而非字符串。为什么不直接存 ‘支出’ / ‘收入’?两个理由:第一,数字占用的存储空间远小于 UTF-8 编码的中文字符串;第二,数字可以直接参与 WHERE type=0 这种条件判断,还能在聚合 SQL 里用 CASE WHEN type=1 THEN amount ELSE 0 END 做条件求和。生产级应用里,凡是值域有限的枚举字段,一律用整数编码,展示层再映射成文案。这是第一实例 TodoDao 里 completed 字段用 0/1 的同一个道理。

amount 用 REAL 存「元」。这是一个需要权衡的决策。账务系统在严谨场景(银行、支付)下必须用 INTEGER 存「分」,防止浮点误差;但个人记账场景下,用户手动输入的金额最多两位小数,REAL 在查询聚合时的误差在展示层四舍五入后完全不可见,而且「元」单位对用户心智更友好,代码里不需要做分/元的换算。我们在注释里特意标注了这一点,提醒读者:如果将来接入支付系统,务必把字段改为 amount_cents INTEGER。这是「够用就好」与「生产严谨」之间的务实取舍。

category 存 TEXT 而非外键。有的架构师会主张建一张 category 字典表,用外键关联。但在个人记账场景下,分类是用户自由填写的标签(今天写「餐饮」,明天可能写「吃饭」),强行做成字典表反而增加维护成本。我们让 category 直接存文本,查询时 GROUP BY category 即可天然得到分类字典。第二个实例通讯录里我们建了索引列 pinyin 来优化排序,这里分类字段同样适合加索引吗?不一定——因为分类基数(不同值的数量)很小,SQLite 优化器大概率选择全表扫描,索引反而多余。这个细节我们在第四节再细说。

note 默认空字符串。备注是可选项,但字段必须有默认值,避免插入时显式传空导致的 SQL 拼接问题。

trade_time 与 created_time 为什么是两个字段?这是很多初学者最容易混淆的地方。trade_time 是「这笔钱实际发生的时间」,比如用户补录昨天的一笔消费,trade_time 是昨天;而 created_time 是「这条记录写入数据库的时间」,永远是现在。报表统计必须基于 trade_time——如果用户补录了昨天的账,只有按 trade_time 统计才能反映真实的花钱节奏。这两个字段分离,是流水表设计的黄金法则。

三、建表 SQL:把设计变成现实

字段设计完毕,接下来用 SQL 把它落地。注意我们的建表语句全部以 IF NOT EXISTS 开头,并配套建立索引:

CREATE TABLE IF NOT EXISTS ledger (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  type INTEGER NOT NULL,
  amount REAL NOT NULL,
  category TEXT NOT NULL,
  note TEXT DEFAULT '',
  trade_time INTEGER NOT NULL,
  created_time INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_ledger_time ON ledger (trade_time);
CREATE INDEX IF NOT EXISTS idx_ledger_type ON ledger (type);

这里有两个值得展开讲的点:

为什么给 trade_time 建索引? 因为我们的所有统计 SQL——区间查询、按月分组、SUM 聚合——都带 WHERE trade_time BETWEEN ? AND ?GROUP BY month 这样的时间条件。没有索引的话,SQLite 每次都要全表扫描;有了 idx_ledger_time,优化器可以直接走 B+ 树索引快速定位到时间范围内的记录。个人记账的数据量(几百上千条)也许感知不到差别,但同样的设计放在百万级流水的生产系统里,就是毫秒与秒级的差距。索引是给未来设计的

为什么给 type 建索引? type 只有 0/1 两个值,基数极低,严格来说索引收益不大。但我们在统计 SQL 里频繁使用 WHERE type=0 过滤,而且 type 与 trade_time 经常组合出现。建这个索引更多的是一种「声明式意图」:告诉后来的维护者,这个字段是查询热点。实际生产中你可以用 EXPLAIN QUERY PLAN 验证索引是否被使用,这也是我们第 8 批文章(分页实例)会详细演示的调优方法。

关于执行时机,getStore 方法里先建表再建索引的顺序很重要:索引必须依附于已存在的表。我们的 DAO 把建表和索引放在同一个方法里串行执行,保证幂等——多次调用不会报错,因为都有 IF NOT EXISTS 保护。

四、LedgerDao 封装:把 SQL 关进类里

数据层的核心是 LedgerDao 类。它的职责边界非常清晰:只负责与数据库打交道,不包含任何 UI 逻辑。页面拿到 DAO 返回的数据模型,直接渲染即可。这种分层让 UI 与数据彻底解耦——将来换数据库、改表结构,页面代码一行都不用动。

先看数据模型的定义:

export interface LedgerRecord {
  id: number;
  type: number;        // 0 支出 / 1 收入
  amount: number;
  category: string;
  note: string;
  tradeTime: number;
  createdTime: number;
}

注意字段命名风格:数据库里是蛇形(snake_case)trade_time,ArkTS 模型里是驼峰(camelCase)tradeTime。这是行业惯例——SQL 风格用下划线,代码风格用驼峰,DAO 在两者之间做映射转换。映射逻辑集中在 rowToRecord 方法里:

private static rowToRecord(result: relationalStore.ResultSet): LedgerRecord {
  return {
    id: result.getLong(result.getColumnIndex('id')),
    type: result.getLong(result.getColumnIndex('type')),
    amount: result.getDouble(result.getColumnIndex('amount')),
    category: result.getString(result.getColumnIndex('category')),
    note: result.getString(result.getColumnIndex('note')) || '',
    tradeTime: result.getLong(result.getColumnIndex('trade_time')),
    createdTime: result.getLong(result.getColumnIndex('created_time')),
  };
}

这个映射方法有讲究:我们用的是 ResultSet.getColumnIndex 按列名取值,而不是 getRow() 拿整行再转。为什么?两个原因:第一,getColumnIndex 让代码自文档化——一眼就能看出哪一列对应哪个字段;第二,对于可能为 NULL 的列(如 note),我们可以用 || '' 提供默认值,避免 null 泄漏到 UI 层。这是鸿蒙 RDB 开发中非常实用的小技巧。

然后是单例复用的 getStore

static async getStore(context: common.Context): Promise<relationalStore.RdbStore> {
  if (LedgerDao.store) {
    return LedgerDao.store;
  }
  const config: relationalStore.StoreConfig = {
    name: 'ledger.db',
    securityLevel: relationalStore.SecurityLevel.S1,
  };
  LedgerDao.store = await relationalStore.getRdbStore(context, config);
  // 建表 + 建索引(代码见上节)
  hilog.info(DOMAIN, TAG, '流水表初始化成功');
  return LedgerDao.store;
}

store 是静态私有字段,首次调用时创建并缓存,后续所有方法直接复用。securityLevel: S1 表示数据安全级别最低——个人记账数据不涉密,S1 足够;如果记录的是健康数据或支付信息,应该考虑 S2/S3。这是鸿蒙 RDB 特有的安全设计,从实例 1 开始我们就一直沿用这个模式。

注意这里有个 ArkTS 的语法细节:在静态方法里引用静态字段必须用类名 LedgerDao.store,不能写 this.store。这是因为 ArkTS 对 this 的语义做了严格限制,this 只在实例方法里指向当前实例,静态上下文里 this 是未定义的。这个坑我们前两批文章都踩过,编译器的报错信息 arkts-no-this-in-static 就是提醒你这一点。所有 DAO 方法签名里的 context 统一用 common.Context,而不是更具体的 UIAbilityContext——用基类更利于复用,页面传 getContext(this) 得到的实例天然是 UIAbilityContext,向下兼容。

五、核心 CRUD:写、查、删

流水业务的写操作非常简单——只有「记一笔」和「删一笔」,没有更新(账记错了就删掉重记,这是记账 App 的常见交互,也避免了 UPDATE 带来的审计风险)。看插入方法:

static async insert(context: common.Context, r: LedgerRecord): Promise<number> {
  const store = await LedgerDao.getStore(context);
  const values: relationalStore.ValuesBucket = {
    type: r.type, amount: r.amount, category: r.category,
    note: r.note, trade_time: r.tradeTime, created_time: r.createdTime,
  };
  return await store.insert(LedgerDao.TABLE, values);
}

ValuesBucket 是鸿蒙 RDB 的「键值容器」,键是数据库列名,值是列数据。它等价于 SQL 的 INSERT INTO ledger (type, amount, ...) VALUES (?, ?, ...) 的预处理参数形式——这也是为什么我们的 DAO 从不拼接字符串 SQL 插值(除了后面讲聚合时,数字参数直接内联的场景,那是安全的,因为值来自我们自己计算)。store.insert 返回新插入行的 id,方便后续引用。

查询方法有三个层级,对应第一节的三种需求:

全部流水(时间倒序)——列表页需要:

static async queryAll(context: common.Context): Promise<LedgerRecord[]> {
  const store = await LedgerDao.getStore(context);
  const predicates = new relationalStore.RdbPredicates(LedgerDao.TABLE);
  predicates.orderByDesc('trade_time');
  const result = await store.query(predicates);
  return LedgerDao.collect(result);
}

RdbPredicates 是鸿蒙的「查询条件构造器」,链式调用 orderByDesc 等价于 SQL 的 ORDER BY trade_time DESCcollect 是遍历 ResultSet 并逐行映射为模型数组的公共方法,避免每个查询重复写 while 循环。

按日期范围查询——统计页的底座:

static async queryByRange(context: common.Context, start: number, end: number): Promise<LedgerRecord[]> {
  const store = await LedgerDao.getStore(context);
  const predicates = new relationalStore.RdbPredicates(LedgerDao.TABLE);
  predicates.between('trade_time', start, end).orderByDesc('trade_time');
  const result = await store.query(predicates);
  return LedgerDao.collect(result);
}

between('trade_time', start, end) 等价于 WHERE trade_time BETWEEN start AND end。注意 start/end 都是毫秒时间戳——本月区间就是「本月 1 号 00:00 的时间戳」到「现在的时间戳」,这个计算放在页面层(monthRange()),DAO 只负责接收参数,保持纯粹。

删除流水

static async delete(context: common.Context, id: number): Promise<number> {
  const store = await LedgerDao.getStore(context);
  const predicates = new relationalStore.RdbPredicates(LedgerDao.TABLE);
  predicates.equalTo('id', id);
  return await store.delete(predicates);
}

equalTo('id', id) 等价于 WHERE id = ?。删除返回受影响行数,页面层可以据此判断是否删成功。

六、聚合查询:一条 SQL 的魔法

现在进入本实例的核心技术点——聚合 SQL。我们的仪表盘页面需要四个数字:总收入、总支出、总笔数、分类占比。如果逐条遍历所有记录在内存里累加,也能得到结果,但数据量一大就卡;正确做法是把计算下沉到 SQLite,让它用索引快速完成求和。

区间汇总——一条 SQL 出三个数

static async summary(context: common.Context, start: number, end: number): Promise<LedgerSummary> {
  const store = await LedgerDao.getStore(context);
  const result = await store.querySql(
    `SELECT
       SUM(CASE WHEN type=1 THEN amount ELSE 0 END) AS income,
       SUM(CASE WHEN type=0 THEN amount ELSE 0 END) AS expense,
       COUNT(*) AS cnt
     FROM ${LedgerDao.TABLE}
     WHERE trade_time BETWEEN ${start} AND ${end}`
  );
  // ...读取 result 中的 income/expense/cnt
}

这条 SQL 的精髓在 SUM(CASE WHEN type=1 THEN amount ELSE 0 END):它把「收入求和」和「支出求和」合并进了同一条语句,用一个条件聚合同时算出两个数。这正是我们在字段设计时坚持 type 用 0/1 数字的原因——如果 type 是字符串,这个 CASE WHEN 就得写 ‘收入’ 字面量,既啰嗦又容易拼写错误。COUNT(*) 统计笔数,与两个 SUM 平级,一次查询三个结果,数据库只扫描一遍时间范围内的数据,性能最优。

分类占比——GROUP BY 的艺术

static async categoryStats(context: common.Context, start: number, end: number): Promise<CategoryStat[]> {
  const result = await store.querySql(
    `SELECT category, SUM(amount) AS amount, COUNT(*) AS cnt
     FROM ${LedgerDao.TABLE}
     WHERE type=0 AND trade_time BETWEEN ${start} AND ${end}
     GROUP BY category
     ORDER BY amount DESC`
  );
  // ...遍历 result,每行一个分类
}

GROUP BY category 把支出按分类分组,SUM(amount) 求每组金额、COUNT(*) 求每组笔数,ORDER BY amount DESC 让花钱最多的分类排在最前——页面上的「分类占比进度条」直接按这个顺序渲染,越靠上的条越长,视觉上天然形成「大头支出」的提示。这里用 WHERE type=0 只统计支出,因为「分类占比」这个概念对收入没有意义。

月趋势——strftime 时间格式化

static async monthlyTrend(context: common.Context, months: number): Promise<MonthTrend[]> {
  const result = await store.querySql(
    `SELECT strftime('%Y-%m', trade_time/1000, 'unixepoch', 'localtime') AS month,
            SUM(CASE WHEN type=0 THEN amount ELSE 0 END) AS expense
     FROM ${LedgerDao.TABLE}
     GROUP BY month
     ORDER BY month DESC
     LIMIT ${months}`
  );
  // ...
}

strftime('%Y-%m', trade_time/1000, 'unixepoch', 'localtime') 是 SQLite 内置的日期格式化函数:我们的时间戳是毫秒,先除以 1000 转成秒;'unixepoch' 告诉 SQLite 这是 Unix 时间戳;'localtime' 转成本地时区;最后 %Y-%m 提取「年-月」,得到 ‘2025-06’ 这样的月份标签。GROUP BY month 按月份分组求和,配合 LIMIT 6 就能得到近 6 个月的支出趋势——这是趋势折线图的数据源。这个函数是 SQLite 自带的能力,不需要任何第三方库,是本实例最亮眼的技巧之一。

七、技术要点对照表

技术点 实现方式 生产价值
双态汇总 SUM(CASE WHEN type=1...) 一条 SQL 同时出收入+支出,扫描一次
分类占比 GROUP BY category ORDER BY amount DESC 饼图/进度条数据源,天然排序
月趋势 strftime('%Y-%m', ...) SQLite 自带时间格式化,零依赖
日期范围 between('trade_time', a, b) 按时间段筛选,走索引
金额精度 REAL 存元(严谨场景用分) 平衡易用与精度,注释留痕
枚举编码 type 用 0/1 数字 存储小、可参与 CASE WHEN
时间双字段 trade_time + created_time 补录场景报表不失真

八、与「文章版」代码的差异说明

这篇文章对应的文章目录里早期有一份「文章版」LedgerDao 代码,与本文最终落地版本有几处关键差异,这里如实说明,避免读者照着旧文章敲代码踩坑:

  1. 静态字段引用:旧版写 this.storethis.TABLE,ArkTS 编译直接报 arkts-no-this-in-static;落地版全部改为 LedgerDao.storeLedgerDao.TABLE
  2. ResultSet 读取:旧版用 result.getRow() 拿到 ValuesBucketrow.id as number 强转;落地版改用 result.getColumnIndex('id') + getLong/getDouble/getString,类型更安全,NULL 值可兜底。
  3. 返回类型:旧版 summary 返回内联对象字面量类型 { income, expense, count },ArkTS 禁止 arkts-no-obj-literals-as-types;落地版定义 LedgerSummaryCategoryStatMonthTrend 三个显式接口。
  4. context 类型:旧版用 UIAbilityContext,落地版统一为基类 common.Context,可复用性更强。
  5. 种子数据:旧版没有 initSeedData;落地版内置 30 条跨月流水种子数据,开屏即有完整仪表盘效果(详见 3-4 文章)。

九、文章小结

记账本数据层围绕 trade_time + amount 两个核心字段展开:区间查询用 BETWEEN,汇总用 SUM + CASE WHEN,占比用 GROUP BY,趋势用 strftime 格式化。这四类聚合 SQL 是报表类功能的地基。从第一个实例的基础 CRUD,到第二个实例的 LIKE 搜索与排序,再到本实例的聚合统计,我们正在逐步建立一套完整的 SQLite 生产级能力矩阵——下一个实例(电子日记本)将展示长文本存储与时间分组查询的又一变体。

本篇文章动手练习建议:在 DevEco Studio 里打开 LedgerDao.ets,尝试把 summary 里的 SUM(CASE WHEN...) 拆成两条独立查询,对比一下多一次数据库访问的代价;再试着给 category 加一个普通索引,用 EXPLAIN QUERY PLAN SELECT * FROM ledger WHERE category='餐饮' 观察优化器的选择。理解「什么时候该建索引、什么时候不该建」,是数据层工程师的分水岭。

Logo

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

更多推荐