三亩地 三亩地SAN MU DI · CODE DIARY
ARTICLE DETAIL

日记详情

真实记录编程学习的某一天,欢迎挑你感兴趣的翻一翻。

找回昨天的自己:ArkTS 时间分组与关键词搜索的查询实现

找回昨天的自己:ArkTS 时间分组与关键词搜索的查询实现


实例:电子日记本(Diary)|技术:时间分组查询、关键词 LIKE 搜索

一、日记查询的两大需求

日记应用的查询需求与记账本完全不同:记账本要「算」(SUM/GROUP BY),日记本要「找」——在个人编年史里快速定位某段时间、某篇日记。具体拆解为两个核心查询:

  1. 按时间找:时间轴浏览。用户想知道「6 月写了什么」——按月份/日期分组展示;
  2. 按内容找:关键词搜索。用户记得「那篇提到爬山的日记」——标题/正文模糊匹配。

本篇文章逐一讲解这两种查询在 ArkTS + RDB 中的实现,以及一个必须避开的经典坑。

二、时间分组:全量加载 + 前端分组

先看需求本质:时间轴页面需要把日记按「日期」分组展示。最直观的 SQL 方案是按日期 GROUP BY,但日记的时间粒度是「天」——每天可能 0 篇或 1 篇,GROUP BY 出来的分组跟原始列表差异不大,还得额外处理空日期。

落地版采用了更务实的方案:数据层全量按时间倒序返回,页面在渲染时用方法动态分组

数据层查询(已在 4-1 展示):

staticasyncqueryAll(context:common.Context):Promise<Diary[]>{conststore=awaitDiaryDao.getStore(context);constpredicates=newrelationalStore.RdbPredicates(DiaryDao.TABLE);predicates.orderByDesc('created_time');constresult=awaitstore.query(predicates);returnDiaryDao.collect(result);}

页面拿到按时间倒序的完整数组后,时间轴节点直接由fmtDate(d.createdTime)生成——日期本身就是分组的自然呈现,每一篇日记的左侧节点显示它的日期,视觉上自动形成「同一日期相邻」的分组效果:

privatefmtDate(ts:number):string{constd=newDate(ts);return`${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;}privatefmtMonth(ts:number):string{constd=newDate(ts);return`${d.getFullYear()}${d.getMonth()+1}`;}

为什么全量加载是合理选择?三个前提条件都满足:

  1. 数据量小:个人日记一年撑死一两百篇,全量加载内存占用不到 1MB;
  2. 查询简单:没有分页需求,时间轴一屏展示全部(滚动浏览);
  3. 分组逻辑简单:按日期展示,SQL 分组反而多此一举。

如果数据量到上万篇,就应改为「按月查询」:queryByMonth(context, '2025-06')between(月初毫秒, 月末毫秒)拉取单月数据,再前端按日分组——这是数据量增长后的自然演进路径。DAO 里已经备好了queryByMonth方法(4-1 文章讲过它的 LIKE 写法),读者可以自行改造成 between 版本。

三、关键词搜索:双字段 OR LIKE

「找回昨天」的另一个入口是搜索。用户记不清日期,只记得内容片段,这时标题/正文的模糊匹配就派上用场:

staticasyncsearch(context:common.Context,keyword:string):Promise<Diary[]>{conststore=awaitDiaryDao.getStore(context);constpredicates=newrelationalStore.RdbPredicates(DiaryDao.TABLE);predicates.like('title',`%${keyword}%`).or().like('content',`%${keyword}%`).orderByDesc('created_time');constresult=awaitstore.query(predicates);returnDiaryDao.collect(result);}

SQL 语义拆解。RdbPredicates 链式调用最终生成:

SELECT*FROMdiaryWHEREtitleLIKE'%关键词%'ORcontentLIKE'%关键词%'ORDERBYcreated_timeDESC
  • LIKE '%关键词%':两侧都有%通配符,表示「任意位置包含」——这是模糊搜索的标配;
  • .or():把前后两个条件连接为 OR。RdbPredicates 的.or().and()是条件连接器,默认 AND 语义,需要 OR 时必须显式调用;
  • orderByDesc('created_time'):搜索结果仍按时间倒序,与时间轴视图一致,用户检索后看到的还是时间线。

执行流程:页面onSearch拿到输入 →DiaryDao.search查询 → 返回结果直接替换this.diaries→ List 重新渲染。输入清空时refresh()恢复全量。整个过程数据层只做一件事:LIKE 匹配 + 时间排序

四、LIKE 的性能边界:为什么这个坑值得讲

LIKE 模糊搜索有一个众所周知的性能特性:前导%会让索引失效

LIKE '关键词%'(前缀匹配)可以走 B+ 树索引——SQLite 能利用索引快速定位到以「关键词」开头的行;而LIKE '%关键词%'(包含匹配)无法走索引——因为目标字符串可能在字段的任意位置,索引的排序优势完全失效,只能全表扫描

这对日记本意味着什么?看数据规模:

数据量全表扫描耗时结论
几百条(个人日记)微秒级无感,随便用
几万条(团队笔记)毫秒级可接受
百万条(全文检索)秒级必须换方案

个人日记场景几百条数据,LIKE '%关键词%'全表扫描微秒级完成,性能完全不是问题。在数据量小的场景,优先考虑实现简洁性,不必过度优化——这是本实例与「生产大数据量」场景的关键区别。真到了十万级,解决方案是 SQLite 的FTS5 全文索引CREATE VIRTUAL TABLE diary_fts USING fts5(title, content)+MATCH查询),那是另一个量级的技术,本书后续不展开。

同样受 LIKE 坑影响的还有 4-1 文章的queryByMonthlike('created_time', '2025-06%')在 INTEGER 时间戳上既不匹配也不走索引——这是文章版代码的遗留设计,落地版页面已绕开(全量加载 + 前端分组),读者在使用 queryByMonth 前务必理解这一点。

五、组合条件:AND 与 OR 的链式写法

如果搜索需求升级为「标题含关键词天气是晴」,条件组合怎么写?RdbPredicates 支持混合链式:

// 标题含关键词 且 天气晴predicates.like('title','%关键词%').and().equalTo('weather','晴');// (标题含关键词 或 正文含关键词) 且 天气晴predicates.like('title','%关键词%').or().like('content','%关键词%').and().equalTo('weather','晴');

注意 OR 与 AND 的优先级:RdbPredicates 的链式条件按书写顺序从左到右组合,没有 SQL 里的括号优先级概念(AND优先于OR)。如果你的逻辑需要「(A OR B) AND C」这样的括号语义,当前写法恰好符合(先 OR 后 AND);但如果是「A AND (B OR C)」,链式写法会生成错误语义——这种情况应改用querySql手写 SQL 并显式加括号:

constresult=awaitstore.querySql(`SELECT * FROM${DiaryDao.TABLE}WHERE title LIKE '%${kw}%' AND (weather = '晴' OR location = '家') ORDER BY created_time DESC`);

安全警示:手写 SQL 时kw来自用户输入,直接拼接有 SQL 注入风险。生产环境应对用户输入做转义(把'替换为'')或改用参数化查询。RdbPredicates 的 like 方法自带参数化,无此问题——所以能用 RdbPredicates 就用 RdbPredicates,只有复杂括号逻辑才退回到手写 SQL,且必须消毒输入

六、双时间戳在查询中的协作

4-1 文章介绍了 created_time 与 updated_time 双字段。在查询层面,它们各司其职:

字段查询用途语义
created_time排序、时间轴分组「什么时候写的」
updated_time编辑后刷新「什么时候改的」

当前页面排序用created_time(时间轴按写作时间组织)。如果产品想支持「最近编辑优先」视图,只需把 queryAll 的排序字段换成updated_time

predicates.orderByDesc('updated_time');// 切换为编辑时间排序

一行改动即可切换两种时间语义——这就是双时间戳设计带来的灵活性。

6.1 从「按月查询」到「按天分组」的完整演进

如果我们进一步思考,会发现时间查询还有更细的粒度需求:某一天写了什么(日历视图点击某天)、某个星期(周报视图)、某个季度(季度回顾)。这些都可以用同一套 between 模式扩展:

// 某天区间:00:00:00.000 ~ 23:59:59.999staticasyncqueryByDay(context:common.Context,day:number):Promise<Diary[]>{conststart=newDate(day).setHours(0,0,0,0);constend=start+86399999;conststore=awaitDiaryDao.getStore(context);constpredicates=newrelationalStore.RdbPredicates(DiaryDao.TABLE);predicates.between('created_time',start,end).orderByDesc('created_time');constresult=awaitstore.query(predicates);returnDiaryDao.collect(result);}

毫秒区间的边界技巧86399999是一天毫秒数(86400000)减 1——因为时间戳是连续整数,[start, start+86400000)是半开区间,用+86399999得到闭区间[start, end]。这个「毫秒区间端点计算」在报表类查询里反复出现,记住dayMs - 1这个细节可以避免「少查一条/多查一条」的边界 bug。

6.2 时间分组的前端渲染细节

前面提到页面用fmtDate动态分组。真正的渲染代码里还有一个细节:月份标签只在「月份变化的第一条」显示,避免每张卡片都重复显示「2025年6月」造成视觉噪音:

// 伪代码示意:遍历时记录上一个月份,变化时才渲染月份标题letlastMonth='';for(constdofdiaries){constmonth=fmtMonth(d.createdTime);if(month!==lastMonth){renderMonthHeader(month);// 渲染月份分组标题lastMonth=month;}renderDiaryCard(d);}

时间轴页面(4-2 文章)用更简单的方式——每张卡片底部都显示月份,靠视觉重复形成分组感;如果要做「月份吸顶标题」的精美效果,上述「变化时渲染」模式就是标准实现。两种方案按产品要求取舍。

七、技术要点对照表

技术点实现方式适用场景
时间分组全量加载 + 前端按日期渲染个人日记(小数据量)
月查询between(月初, 月末)改造 queryByMonth大数据量演进方案
天查询between(dayStart, dayStart+86399999)日历视图点击某天
关键词搜索title/content OR LIKE标题正文双字段命中
条件组合RdbPredicates 链式 and/or简单组合
复杂括号手写 querySql + 输入消毒AND 与 OR 嵌套
LIKE 前导%全表扫描小数据量可接受,大数据量换 FTS5

八、常见问题 FAQ

Q1:为什么 search 的结果顺序和时间轴一致?
A:search 方法里显式orderByDesc('created_time'),保证搜索结果仍是时间线视图——用户搜索后看到的还是按时间排的故事线,而不是随机的命中列表。这是产品层面的有意设计。

Q2:中文分词搜索怎么办?
A:LIKE ‘%关键词%’ 不做分词——用户输入「爬山」只能命中包含这两个连续字的日记。中文场景如果需要「输入 山 也命中 爬山」,需要分词库(如 jieba)或 FTS5 的 tokenizer 配置。个人日记场景「整词命中」已够用,生产级搜索另开文章讲解。

Q3:搜索太慢怎么办?
A:先确认数据量。几百条慢是心理作用,几万条才是真慢。真慢了三个方案:给 title 建索引(只对前缀 LIKE 有效)、限制搜索范围(只搜标题)、上 FTS5 全文索引。

Q4:String(d.getMonth() + 1).padStart(2, '0')为什么必须补零?
A:不补零会得到2025-6-1这种变长字符串,字典序排序错乱(2025-10-1会排在2025-6-1前面,因为 ‘1’ < ‘6’)。所有日期格式化工具必须统一YYYY-MM-DD定长格式,这是时间字段字符串化方案的铁律。

Q5:queryByMonth 到底能不能用?
A:能用但要注意——落地版里它的 LIKE 写法对 INTEGER 时间戳不生效,需要按本文章节 6.1 改成 between 区间。如果你已经按文章版代码实现,务必修正。

Q6:搜索框输入很快,会不会频繁查询数据库?
A:当前实现每次 onChange 都查询。个人日记数据量小无感;若担心性能,可加 300ms 防抖——setTimeout延迟执行查询,用户停顿后才真正查询。实例 2 通讯录已经演示过防抖写法,可以对照参考。

Q7:OR LIKE 的查询能命中「标题有、正文也有」的记录吗?
A:会命中一次而不是两次——OR 是逻辑合并,一行记录只要标题或正文任一匹配就返回该行一次。COUNT不会重复计数,这是 SQL 集合语义的天然保证。

Q8:FTS5 和 LIKE 可以共存吗?
A:可以。FTS5 是虚拟表(CREATE VIRTUAL TABLE),需要额外的同步逻辑(触发器或手动同步)。小数据量用 LIKE 足够,大数据量再迁移 FTS5,两者可以在不同阶段并存过渡。

九、文章小结

本篇文章讲解了日记本的两大查询:时间分组(全量 + 前端分组)与关键词搜索(双字段 OR LIKE),并顺带澄清了两个关键坑:INTEGER 时间戳不能 LIKE 匹配月份(queryByMonth 的正确用法是 between 区间)、LIKE 前导 % 导致索引失效(小数据量无感,大数据量换 FTS5)。查询逻辑的核心心法:根据数据量选方案,简单场景用简单做法,复杂场景再升级。从天查询到季度查询,between 毫秒区间模式可以覆盖所有时间粒度。

下一篇(4-4)将展示 12 篇跨月日记种子数据如何让时间轴「饱满」起来,验证本篇文章的查询在真实数据下的效果。

补充:时间范围查询细节

补充 1:按日期字符串比较的范围查询写法

前面章节的范围查询全部基于 INTEGER 时间戳的 between。如果建表时把时间存成YYYY-MM-DD HH:mm:ss定长字符串,范围查询可以直接用字符串比较——定长格式的字典序就是时间序,这是字符串方案能成立的前提:

// 日期字符串方案:查 2025-06-01 到 2025-06-30 的日记predicates.greaterThanOrEqualTo('created_time','2025-06-01 00:00:00').and().lessThanOrEqualTo('created_time','2025-06-30 23:59:59');

注意字符串比较要求格式严格定长(年-月-日-时分秒位数字数固定),'2025-06-30' > '2025-06-01'的字典序判断才等价于时间先后;一旦出现2025-6-1这种变长写法(Q4 的坑),整个字符串方案立即失效。

补充 2:跨月查询的边界处理

跨月查询最容易漏掉首尾两天。以「6 月 28 日到 7 月 3 日」为例,两种方案的端点计算不同:

方案起始端点结束端点易错点
between 毫秒首日 00:00:00.000末日 23:59:59.999忘记-1会多查一天
日期字符串'YYYY-MM-DD 00:00:00''YYYY-MM-DD 23:59:59'漏写时间部分会截断首尾两天

字符串方案还有个更稳的写法:用半开区间[start, nextStart),即结束条件写成lessThan('created_time', '2025-07-04 00:00:00')——这样完全不用关心 6 月有 30 天还是 28 天,跨月/跨年一律用「下一个月初」作右端点,彻底规避月末天数计算:

// 跨月半开区间:6 月 28 日到 7 月 3 日predicates.greaterThanOrEqualTo('created_time','2025-06-28 00:00:00').and().lessThan('created_time','2025-07-04 00:00:00');

补充 3:时间戳与日期字符串的选择对比

维度INTEGER 时间戳日期字符串
排序/比较数字比较,天然正确依赖定长格式保证字典序
LIKE 匹配月份完全无效(见 6.1)'2025-06%'直接命中
存储/索引4 字节,索引紧凑19 字符,索引略大
可读性需 fmtDate 格式化肉眼可读,调试方便

个人日记「排序为主、按月浏览为辅」选时间戳更优;若业务大量按月份前缀查询(报表按月汇总),日期字符串反而省心——但必须守住定长格式铁律,且范围查询优先用半开区间。

FAQ

Q1:字符串范围查询和 between 毫秒,哪个更快?
A:都是范围扫描,量级相同。INTEGER 时间戳索引更紧凑、扫描 I/O 略少,但格式统一的字符串比较同样走索引。个人日记数据量下性能完全无感,按查询习惯选即可。

Q2:跨月查询有没有现成 API?
A:没有按月专用 API,但可封装queryByMonthRange(year, month):用「本月月初」和「下月月初」构造半开区间,天然规避月末 31/30/29 天的计算,这是最稳的跨月写法。

Q3:日期字符串能 LIKE 查月份,时间戳不行,是不是字符串更好?
A:看 LIKE 是否核心需求。日记时间轴是「按范围浏览」而非「按前缀搜索」,LIKE 月份只是文章版妥协设计。排序、范围、索引三项时间戳均占优,不建议为了一个可绕开的需求换存储格式。

← 返回列表