搜索历史的记忆宫殿:ArkTS 为鸿蒙搜索框设计去重与上限的表


实例:搜索历史记录(SearchHistory)|技术:历史词表、唯一约束、SearchHistoryDao
一、业务需求分析:搜索历史的三个「怪癖」
搜索历史是每个 App 都有的小功能,看起来简单,但它的数据行为有三个「怪癖」,恰恰是数据库设计的考题:
- 去重:同一个词搜三次,历史里只显示一条,但可以记录搜索次数——用户搜「HarmonyOS」反复搜,说明这个词对他重要;
- 有上限:历史不能无限累积,一般保留最近 10~20 条,超出要淘汰最久未搜的——这是 LIMIT 的运用场景;
- 时间排序:最近搜过的排前面,超时自动上浮——用户扫一眼历史就能点回刚才搜的词。
这三个需求组合起来,就是一个精妙的数据库题:如何用一张表 + 唯一约束 + LIMIT 删除,实现「去重插入 + 超限淘汰 + 时间排序」。本实例就是这道题的完整解答。
核心业务需求清单:
| 需求 | 数据层实现 | 页面呈现 |
|---|---|---|
| 记录一次搜索 | 插入/更新时间戳 + 次数 | 标签流 + 时间线 |
| 去重 | keyword 唯一约束 | 同词只显示一条 |
| 超限淘汰 | LIMIT 删除最旧 | 保留 10 条 |
| 一键清空 | DELETE 全表 | 清空按钮 |
| 单条删除 | DELETE by id | 长按删除 |
二、字段设计:一张极简的历史表
搜索历史表 search_history
| 字段名 | 类型 | 约束 | 说明 |
|---|---|---|---|
| id | INTEGER | PRIMARY KEY AUTOINCREMENT | 自增主键 |
| keyword | TEXT | NOT NULL UNIQUE | 搜索词,唯一约束(去重核心) |
| search_time | INTEGER | NOT NULL | 最近搜索时间戳(毫秒) |
| count | INTEGER | NOT NULL DEFAULT 1 | 累计搜索次数 |
设计要点拆解:
1. keyword UNIQUE 是去重的数据库级保证。表结构层面直接声明 keyword TEXT NOT NULL UNIQUE——同一个词只能存在一行,第二次搜索不会新增记录,而是 UPDATE 这行的 search_time 和 count。去重逻辑交给数据库约束而非前端判断,这是本实例的核心设计思想。
2. search_time 每次搜索刷新。它记录「最近一次搜索的时间」,排序用 ORDER BY search_time DESC——最近搜的排最前。注意它不记录「第一次搜索时间」——历史列表只需要「最近」语义。
3. count 累计搜索次数。同词反复搜索,count 递增(count + 1)。页面可以用它显示「HarmonyOS ×8」这种热度徽标,暗示用户的搜索偏好。这个字段是可选的锦上添花,但实现成本极低(一次 UPDATE)。
4. 三字段极简设计。为什么没有「搜索类型」「来源页面」等扩展字段?因为搜索历史的本质就是「词 + 时间 + 次数」,加字段违反「单表职责单一」——复杂需求(如按来源分组)应拆表。能用 3 个字段表达的模型,绝不用 6 个。
三、建表 SQL:唯一约束的两种写法
CREATE TABLE IF NOT EXISTS search_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
keyword TEXT NOT NULL UNIQUE,
search_time INTEGER NOT NULL,
count INTEGER NOT NULL DEFAULT 1
);
UNIQUE 约束的两种声明方式:
-- 方式一:列级约束(本实例采用)
keyword TEXT NOT NULL UNIQUE
-- 方式二:表级约束(适合复合唯一)
UNIQUE (keyword)
单列唯一用列级即可;复合唯一(如打卡实例的 (habit_id, date))必须用表级。SQLite 的 UNIQUE 约束自动创建唯一索引——插入重复值会抛 UNIQUE constraint failed 异常,这正是我们「先查后改」逻辑(见下节)的兜底防线。
四、SearchHistoryDao 封装:去重插入的核心方法
数据层核心 SearchHistoryDao。最关键的方法是 addKeyword——它实现了「去重插入 + 时间刷新 + 次数累计 + 超限淘汰」四合一:
static async addKeyword(context: common.Context, keyword: string): Promise<void> {
const kw = keyword.trim();
if (!kw) {
return;
}
const store = await SearchHistoryDao.getStore(context);
// 1. 查重:按词查是否已存在
const pred = new relationalStore.RdbPredicates(SearchHistoryDao.TABLE);
pred.equalTo('keyword', kw);
const result = await store.query(pred);
if (result.goToNextRow()) {
// 已存在 → 更新时间 + 次数 +1
const id = result.getLong(result.getColumnIndex('id'));
const count = result.getLong(result.getColumnIndex('count'));
result.close();
const values: relationalStore.ValuesBucket = {
search_time: Date.now(),
count: count + 1,
};
const updatePred = new relationalStore.RdbPredicates(SearchHistoryDao.TABLE);
updatePred.equalTo('id', id);
await store.update(values, updatePred);
} else {
result.close();
// 不存在 → 插入新记录
const values: relationalStore.ValuesBucket = {
keyword: kw, search_time: Date.now(), count: 1,
};
await store.insert(SearchHistoryDao.TABLE, values);
}
// 2. 超限淘汰:保留最近 MAX_KEEP 条
await SearchHistoryDao.trim(context);
}
逻辑拆解:
第 1 步:查重分支。equalTo('keyword', kw) 查询——查到(重复词)走 UPDATE 分支(刷新 search_time、count+1),查不到走 INSERT 分支(新建 count=1)。这就是「去重插入」的完整语义:不是简单地 INSERT,而是「存在则更新、不存在则插入」(UPSERT 语义)。RDB 没有直接的 UPSERT 语法,用「先查后改」实现,逻辑清晰。
第 2 步:trim 超限淘汰。MAX_KEEP = 10 条上限,超出的最旧记录被删除(下节详解)。
关于 result.close() 的位置:查重分支里,UPDATE 前必须 close 结果集——虽然 RDB 通常允许查询后再写,但规范做法是先释放游标再做写操作,避免潜在的锁竞争。
五、超限淘汰:LIMIT 删除的 SQL 艺术
trim 方法实现「保留最近 MAX_KEEP 条,删除更旧的」:
static async trim(context: common.Context): Promise<void> {
const store = await SearchHistoryDao.getStore(context);
// 1. 统计总数
const countResult = await store.querySql(`SELECT COUNT(*) AS c FROM ${SearchHistoryDao.TABLE}`);
let total = 0;
if (countResult.goToNextRow()) {
total = countResult.getLong(countResult.getColumnIndex('c'));
}
countResult.close();
if (total <= MAX_KEEP) {
return; // 未超限,无需淘汰
}
// 2. 计算超出的条数
const overflow = total - MAX_KEEP;
// 3. 删除最旧的 overflow 条
await store.executeSql(
`DELETE FROM ${SearchHistoryDao.TABLE}
WHERE id IN (
SELECT id FROM ${SearchHistoryDao.TABLE}
ORDER BY search_time ASC
LIMIT ${overflow}
)`
);
}
这条 SQL 的精妙之处:
子查询选最旧:ORDER BY search_time ASC LIMIT ${overflow}——按搜索时间升序(最旧的在前)取前 overflow 条,就是「最久未搜的 overflow 个词」。
外层按 id 删除:DELETE ... WHERE id IN (子查询)——不能用 DELETE ... ORDER BY ... LIMIT(SQLite 的 DELETE 不支持 LIMIT),所以用子查询把「要删的 id 集合」算出来,再按 id 删。这是 SQLite 删除 TOP-N 记录的标准写法。
为什么不在 SQL 里一次算:可以先查「总数 - MAX_KEEP」再决定是否删除——总数 <= 上限时直接 return,避免无谓的子查询。先判断再操作减少不必要的 SQL 执行。
MAX_KEEP 常量:const MAX_KEEP = 10 定义在 DAO 顶部,是「保留条数」的单一事实来源。调整上限只改一处,页面无需改动——这是常量提取的意义。
六、基础查询与删除
全部历史(最近搜索在前):
static async queryAll(context: common.Context): Promise<SearchHistory[]> {
const store = await SearchHistoryDao.getStore(context);
const predicates = new relationalStore.RdbPredicates(SearchHistoryDao.TABLE);
predicates.orderByDesc('search_time');
const result = await store.query(predicates);
return SearchHistoryDao.collect(result);
}
orderByDesc('search_time')——最近搜的排最前,这是历史列表的基本顺序。
删除单条与一键清空:
static async deleteOne(context: common.Context, id: number): Promise<number> {
const store = await SearchHistoryDao.getStore(context);
const predicates = new relationalStore.RdbPredicates(SearchHistoryDao.TABLE);
predicates.equalTo('id', id);
return await store.delete(predicates);
}
static async clearAll(context: common.Context): Promise<number> {
const store = await SearchHistoryDao.getStore(context);
const predicates = new relationalStore.RdbPredicates(SearchHistoryDao.TABLE);
return await store.delete(predicates);
}
deleteOne 删单条(页面长按标签触发),clearAll 删全表(页面「清空」按钮触发)——注意 store.delete 的 predicates 不带条件时删除全部行,返回受影响行数。
七、技术要点对照表
| 技术点 | 实现方式 | 生产价值 |
|---|---|---|
| 去重 | keyword UNIQUE 约束 | 数据库级防重复 |
| 去重插入 | 先查后改(存在 UPDATE / 不存在 INSERT) | UPSERT 语义 |
| 次数累计 | count + 1 | 热度徽标数据源 |
| 超限淘汰 | DELETE … WHERE id IN (SELECT … LIMIT) | TOP-N 删除标准写法 |
| 时间排序 | orderByDesc(‘search_time’) | 最近优先 |
| 一键清空 | predicates 无条件 delete | 全表清除 |
八、文章小结
搜索历史的数据层核心是**「唯一约束 + 先查后改 + LIMIT 淘汰」**三件套:UNIQUE 保证去重、先查后改实现 UPSERT、DELETE ... IN (SELECT ... LIMIT) 实现超限淘汰。这是「有上限的键值记录」这类场景的标准建模——搜索历史、最近浏览、推荐记录都是同一模式。极简的 3 字段设计让模型一目了然。
下一篇(7-2)展示极简标签流 UI——搜索框 + 历史标签 + 一键清空,像热搜榜一样清爽。
九、字段设计详解:UNIQUE 去重、搜索次数与热度排序
前文的三字段极简设计,在实战中有三个细节值得深挖:
1. keyword UNIQUE 去重策略的边界。UNIQUE 约束区分大小写、也忽略不了空格——「HarmonyOS」与「harmonyos」是两条记录,「 HarmonyOS」(带空格)与「HarmonyOS」也是两条。所以 addKeyword 第一步 keyword.trim() 不是可有可无:输入层先归一化,数据库约束才能发挥「同词只留一行」的威力。若还想大小写不敏感去重,可在插入前统一 toLowerCase()(本实例保留原样,尊重用户输入)。
2. count 是「搜索次数」的唯一事实来源。它由「存在则 count + 1」维护,永不手工赋值——避免并发写导致次数失真。页面徽标(HarmonyOS 开发 ×8)读的就是这个字段。
3. 热度排序:count 的另一半价值。时间排序(search_time DESC)回答「最近搜了什么」,热度排序(count DESC)回答「我搜得最多的是什么」,两种排序对应两种产品语义:
| 排序维度 | SQL 排序 | 产品场景 |
|---|---|---|
| 时间排序 | ORDER BY search_time DESC | 历史时间线(默认) |
| 热度排序 | ORDER BY count DESC | 我的热搜榜、Top 10 |
// 按热度取 Top 10:搜得最多的词排最前
static async queryHot(context: common.Context, limit: number = 10): Promise<SearchHistory[]> {
const store = await SearchHistoryDao.getStore(context);
const predicates = new relationalStore.RdbPredicates(SearchHistoryDao.TABLE);
predicates.orderByDesc('count');
predicates.limitAs(limit);
const result = await store.query(predicates);
return SearchHistoryDao.collect(result);
}
limitAs(limit) 与 trim 里的 LIMIT ${overflow} 殊途同归——一个是查询侧取前 N,一个是删除侧删前 N,LIMIT 是 SQLite 控制「数量」的统一武器。
十、热门搜索统计的 GROUP BY 思路
细心的读者会发现:search_history 表因为有 keyword UNIQUE 约束,天然就是「每个词一行」——统计热门搜索根本不需要 GROUP BY,直接 count 降序即可。那 GROUP BY 什么时候用得上?
答案是:当「每次搜索」单独记日志时。假设升级需求「记录每个词每天被搜几次」,就要拆一张日志表 search_log:
-- 每次搜索记一行,不合并
CREATE TABLE IF NOT EXISTS search_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
keyword TEXT NOT NULL,
search_time INTEGER NOT NULL
);
热门统计就变成经典的聚合查询:
SELECT keyword, COUNT(*) AS cnt, MAX(search_time) AS last_time
FROM search_log
WHERE search_time >= ${startTime}
GROUP BY keyword
ORDER BY cnt DESC
LIMIT 10;
GROUP BY keyword 把同一词的多次搜索聚合成一行,COUNT(*) 算次数、MAX(search_time) 取最近时间。这就是单表 UNIQUE 换掉的复杂度——有唯一约束时 GROUP BY 是冗余的,没有约束时才需要它。两套方案的选择标准:历史只需要「最新状态」→ 单表 UNIQUE(本实例);历史需要「完整轨迹」→ 日志表 + GROUP BY。
十一、清空历史的 DELETE 操作细节
clearAll 一行 store.delete(predicates) 就把全表删光,但生产环境有三个细节值得推敲:
1. DELETE 不重置自增主键。删光后再次插入,id 会从 11 继续而不是从 1 开始。多数场景无感(id 只做内部标识),若产品要求「清空后重新编号」,需手动重置:
DELETE FROM sqlite_sequence WHERE name = 'search_history';
2. 清空建议走事务。clearAll 内部可包一层 store.beginTransaction(),配合「清空 + 灌种子数据」的连招(7-4 的 initSeedData 就是先清后灌),保证要么全成功要么全回滚,避免半清空状态。
3. 页面侧双重确认。清空是不可逆操作,页面应弹 AlertDialog 确认,防止误触——数据层的 DELETE 只管执行,要不要删、删前是否确认,是产品与 UI 层的责任。这条分层原则与「去重逻辑交给数据库」一脉相承:数据层只提供能力,不替业务做决策。
十二、FAQ:搜索历史表的高频疑问
| 问题 | 解答 |
|---|---|
| 为什么用 UNIQUE 不用前端去重? | 数据库约束是唯一可靠防线,前端去重在多入口(搜索框、热词点击)时会漏 |
| INSERT OR REPLACE 能替代先查后改吗? | 能去重,但会删除旧行重建新行,count 无法累计、id 会变化,本场景不合适 |
| trim 为什么不用 DELETE … LIMIT? | SQLite 的 DELETE 不支持 LIMIT,必须用 WHERE id IN (子查询) 迂回 |
| 时间戳为什么用毫秒? | Date.now() 直接可得,与 RDB 的 INTEGER 兼容,排序比较无精度损失 |
| 搜索词需要单独建索引吗? | UNIQUE 约束自动创建唯一索引,equalTo(‘keyword’) 查重已走索引,无需重复建 |
| MAX_KEEP 改 20 会影响性能吗? | 不会,trim 只删超出的几条,表数据量本身有上限,性能恒定 |
更多推荐




所有评论(0)