便签墙的数据库骨架:ArkTS 为鸿蒙便签设计置顶与颜色字段


实例:备忘录便签(Memo)|技术:便签字段、置顶/颜色字段、MemoDao
一、业务需求分析:便签的数据形态
便签(Memo)是轻量级记事工具——比日记轻(不用长篇大论)、比任务清单自由(不用状态管理)。它的数据行为有几个独特之处:
- 置顶:重要便签钉在列表最前(「待办」钉在顶部提醒自己)——排序字段
pinned; - 颜色标签:便签用不同颜色区分用途(黄色=工作、粉色=购物、绿色=健身)——颜色即分类;
- 部分更新:改标题不动内容、换颜色不动标题——单字段轻量更新是便签的高频操作;
- 多彩墙展示:彩色便签像便利贴一样铺满墙面,视觉即内容。
核心需求清单:
| 需求 | 数据层实现 | 页面呈现 |
|---|---|---|
| 新建便签 | insert(标题+内容+颜色) | 便签墙新增彩签 |
| 置顶/取消 | 部分更新 pinned 字段 | 置顶区/普通区 |
| 换颜色 | 部分更新 color 字段 | 便签变色 |
| 编辑 | 全量更新 | 弹窗编辑 |
| 颜色筛选 | 按 color 过滤 | 颜色筛选条 |
| 删除 | delete | 长按删除 |
二、字段设计:一张灵活的便签表
便签表 memo
| 字段名 | 类型 | 约束 | 说明 |
|---|---|---|---|
| id | INTEGER | PRIMARY KEY AUTOINCREMENT | 自增主键 |
| title | TEXT | NOT NULL | 标题 |
| content | TEXT | DEFAULT ‘’ | 内容 |
| color | TEXT | DEFAULT ‘#FFE8A3’ | 便签底色 HEX |
| pinned | INTEGER | NOT NULL DEFAULT 0 | 0 普通 / 1 置顶 |
| created_time | INTEGER | NOT NULL | 创建时间戳 |
| updated_time | INTEGER | NOT NULL | 最近修改时间戳 |
设计要点拆解:
1. color 存 HEX 色值字符串。便签颜色是「展示属性」——直接存 #FFE8A3 这样的色值,页面 backgroundColor(c.color) 零转换渲染。为什么不用枚举数字?因为颜色是「开放式取值」(用户可以加新颜色),枚举反而限制;而且色值本身就是可读的。展示型属性存原始值。
2. pinned 用 0/1 数字。置顶与否是「状态」,可参与排序(ORDER BY pinned DESC)和过滤(WHERE pinned=1)——数字编码正确选择。排序时置顶在前:orderByDesc('pinned') 让 1(置顶)排 0(普通)前面。
3. updated_time 驱动排序。便签墙的默认顺序:置顶在前,同状态按更新时间倒序(最近编辑的在前)——ORDER BY pinned DESC, updated_time DESC。两个排序键的组合让置顶区和普通区内部都「最新的在前」。
4. content 可空。便签可以只写标题(如「超市采购清单」),content 默认空串。标题必填(NOT NULL),内容可选。
5. 六色便签色板。DAO 导出色板常量供页面使用:
export const MEMO_COLORS: string[] = [
'#FFE8A3', '#FFD1DC', '#C9F2C7', '#BFE3FF', '#E2D5FF', '#FFE0B3',
];
米黄/粉/绿/蓝/紫/橙六色——新建便签时循环取色,颜色筛选条也用这个数组。色板常量单一来源:页面取色、筛选、渲染都引用它。
三、建表 SQL 与索引
CREATE TABLE IF NOT EXISTS memo (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
content TEXT DEFAULT '',
color TEXT DEFAULT '#FFE8A3',
pinned INTEGER NOT NULL DEFAULT 0,
created_time INTEGER NOT NULL,
updated_time INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_memo_pinned ON memo (pinned);
idx_memo_pinned 索引:服务「置顶在前」的排序与「置顶区」过滤。pinned 只有 0/1 两个值,索引收益有限,但表达「这是查询热点」的意图。数据量小(12 张便签)时索引与否无差别——本实例建索引更多是「声明式意图」。
四、MemoDao 封装:三种更新形态
数据层核心 MemoDao。本实例的技术亮点是更新方法的三种形态——全量更新、部分更新(置顶)、部分更新(颜色):
形态一:全量更新(编辑内容)
static async update(context: common.Context, m: Memo): Promise<number> {
const store = await MemoDao.getStore(context);
const values: relationalStore.ValuesBucket = {
title: m.title, content: m.content, color: m.color,
updated_time: m.updatedTime,
};
const predicates = new relationalStore.RdbPredicates(MemoDao.TABLE);
predicates.equalTo('id', m.id);
return await store.update(values, predicates);
}
编辑弹窗保存时调用——更新标题、内容、颜色三个业务字段 + 刷新 updated_time(不更新 pinned 和 created_time)。
形态二:部分更新(仅置顶状态)
static async togglePin(context: common.Context, id: number, pinned: number): Promise<number> {
const store = await MemoDao.getStore(context);
const values: relationalStore.ValuesBucket = {
pinned: pinned,
updated_time: Date.now(),
};
const predicates = new relationalStore.RdbPredicates(MemoDao.TABLE);
predicates.equalTo('id', id);
return await store.update(values, predicates);
}
部分更新的精髓:ValuesBucket 只放 pinned(+ 顺带刷新 updated_time)——其他字段(标题、内容、颜色)完全不动。这就是「只改置顶」的轻量操作,避免了「先查全量再更新全量」的冗余读。
形态三:部分更新(仅换颜色)
static async changeColor(context: common.Context, id: number, color: string): Promise<number> {
const store = await MemoDao.getStore(context);
const values: relationalStore.ValuesBucket = {
color: color,
updated_time: Date.now(),
};
const predicates = new relationalStore.RdbPredicates(MemoDao.TABLE);
predicates.equalTo('id', id);
return await store.update(values, predicates);
}
与 togglePin 同构——只改 color 字段。两个部分更新方法展示了 RDB 的天然能力:UPDATE 语句本来就可以只更新指定列,ValuesBucket 里有什么就更新什么。这是 SQLite 相对「ORM 全量更新」的优势——按需更新,零冗余写。
五、查询方法:全量、按颜色、计数
全部便签(置顶在前 + 更新倒序):
static async queryAll(context: common.Context): Promise<Memo[]> {
const store = await MemoDao.getStore(context);
const predicates = new relationalStore.RdbPredicates(MemoDao.TABLE);
predicates.orderByDesc('pinned').orderByDesc('updated_time');
const result = await store.query(predicates);
return MemoDao.collect(result);
}
双键排序:先按 pinned 降序(置顶组在前),组内按 updated_time 降序(最近编辑在前)。排序的层级结构:主键定分组、次键定组内顺序。
按颜色筛选:
static async queryByColor(context: common.Context, color: string): Promise<Memo[]> {
const store = await MemoDao.getStore(context);
const predicates = new relationalStore.RdbPredicates(MemoDao.TABLE);
predicates.equalTo('color', color).orderByDesc('pinned').orderByDesc('updated_time');
const result = await store.query(predicates);
return MemoDao.collect(result);
}
计数(标题栏统计):
static async count(context: common.Context): Promise<number> {
const store = await MemoDao.getStore(context);
const result = await store.querySql(`SELECT COUNT(*) AS c FROM ${MemoDao.TABLE}`);
let total = 0;
if (result.goToNextRow()) {
total = result.getLong(result.getColumnIndex('c'));
}
result.close();
return total;
}
六、技术要点对照表
| 技术点 | 实现方式 | 生产价值 |
|---|---|---|
| 置顶排序 | orderByDesc(‘pinned’) | 重要便签在前 |
| 双键排序 | pinned + updated_time | 分组 + 组内序 |
| 部分更新 | ValuesBucket 只放目标列 | 单字段轻量写 |
| 颜色存储 | HEX 字符串 | UI 零转换 |
| 色板常量 | MEMO_COLORS 导出 | 单一来源 |
| 更新排序 | 部分更新时刷 updated_time | 编辑上浮 |
七、文章小结
备忘录便签的数据层核心是**「置顶 + 颜色」两个特色字段 + 三种更新形态**:pinned 驱动置顶排序(双键排序的分组结构)、color 驱动彩色墙与颜色筛选、部分更新(togglePin/changeColor)展示 RDB「按需更新」的轻量能力。这是「轻量内容应用」的标准建模——字段少、更新灵、排序活。
下一篇(11-2)展示多彩便签墙 UI——置顶区 + 颜色筛选 + 新建浮钮,像便利贴墙一样赏心悦目。
八、便签表字段设计详解:五个字段背后的决策
建表容易,建好表难。便签表六列虽少,每一列都对应一个产品决策。本节把五个核心字段逐个拆解,讲清「为什么这样设计、换一种设计会怎样」。
8.1 title:必填的「门面字段」
| 决策点 | 本实例选择 | 备选方案 | 取舍理由 |
|---|---|---|---|
| 是否必填 | NOT NULL | 允许 NULL | 便签墙卡片靠标题识别,空标题 = 空墙 |
| 长度限制 | TEXT 不限长 | VARCHAR(50) | SQLite 的 TEXT 无长度上限,UI 层负责限制输入 |
| 空串处理 | 允许 | 禁止 | 极少数场景可只写内容,UI 兜底显示「无标题」 |
标题是便签墙的「门面」——卡片第一行永远是标题。约束层(NOT NULL)管「必须有值」,UI 层管「值不能是空白」,双保险。
8.2 content:纯文本的可空内容
content 允许为空(DEFAULT ‘’),因为便签可以只写标题。内容按纯文本存储,详见第十节「富文本与纯文本的选择」。换行用 \n 字符直接存进 TEXT——查询、展示、编辑全程零转换。
8.3 color:HEX 字符串——展示属性存原始值
颜色是本实例最有代表性的「展示型属性」:不参与业务计算、只参与渲染。存 HEX 字符串 #FFE8A3 而非枚举数字,三个理由:
- 零转换渲染:
backgroundColor(c.color)直接用,无需查映射表; - 开放式取值:后续加颜色不改表结构、不改 DAO;
- 可读性:
#FFD1DC一看就是粉色,数字 2 代表什么要翻文档。
HEX 存储的纪律:色板常量统一大写、六位、不带 alpha(#RRGGBB)。若将来要半透明,扩为八位 #AARRGGBB 即可——存储格式兼容,只是约定升级。色值纪律比约束更重要:数据库管不了大小写,MEMO_COLORS 色板常量就是「事实上的校验」。
8.4 pinned:0/1 状态位——排序与过滤的双用字段
pinned 用 INTEGER 0/1 而非 TEXT ‘yes’/‘no’,因为它是参与排序的状态:
| 用法 | SQL 表达 | 数字方案 | 文本方案 |
|---|---|---|---|
| 排序 | ORDER BY pinned DESC | 1 在前、0 在后 | 需 CASE WHEN 转数字 |
| 过滤 | WHERE pinned = 1 | 直接等值 | 引号 + 大小写敏感 |
| 更新 | SET pinned = 1 | 直接赋值 | 需防 ‘YES’/‘No’ 混乱 |
数字 0/1 在排序、过滤、更新三个场景全部「零转换」,文本方案处处要转换或兜底。参与计算的字段用数字编码,参与展示的字段存原始值——这是字段类型选择的黄金法则。
8.5 两个时间戳:created_time 与 updated_time 的分工
| 字段 | 写入时机 | 服务场景 |
|---|---|---|
| created_time | insert 时一次写入 | 新建时间展示 |
| updated_time | insert + 每次更新都刷新 | 排序 + 最近修改展示 |
created_time 是「身份证」,一次写入不再变;updated_time 是「心跳」,任何变更(改内容、换颜色、置顶)都要刷新——因为它是排序键之一。两个时间戳不能合并:合并后「创建时间」会随编辑漂移,「最近编辑」会丢失。
九、置顶排序思路:置顶优先 + 时间倒序
便签墙的排序是一条规则:先置顶、后普通;同组内最近编辑在前。实现就是双键 ORDER BY:
SELECT * FROM memo ORDER BY pinned DESC, updated_time DESC;
9.1 为什么不是「独立置顶表」
有人会问:置顶是不是该单独建一张 memo_pin 关联表(memo_id + pin_time)?本实例选择直接在 memo 表加 pinned 字段:
| 维度 | 单表 pinned 字段 | 独立置顶表 |
|---|---|---|
| 查询 | 一条 SQL 双键排序 | JOIN 或子查询 |
| 更新 | UPDATE memo SET pinned=1 | 插入/删除关联记录 |
| 事务 | 单表原子 | 两表需事务 |
| 适用量级 | 百张以内 | 海量数据场景 |
独立置顶表适合「置顶是长列表独立操作」的场景(如消息列表);便签数量少、置顶操作频繁(toggle),字段方案更简单直接——简单需求用简单建模。
9.2 置顶时刷新 updated_time 的连锁反应
togglePin 部分更新时顺带刷新 updated_time:
const values: relationalStore.ValuesBucket = {
pinned: pinned,
updated_time: Date.now(),
};
这一步是有意为之:置顶本身是一次「用户关注的行为」,刷新时间让刚置顶的便签在置顶组内也上浮到最新。排序键与操作绑定——任何改变用户关注度的操作都刷新 updated_time,排序永远反映「最近被关注」。
十、富文本与纯文本:内容字段的技术选型
便签内容存什么格式,是内容字段最重要的选型。本实例选纯文本:
| 维度 | 纯文本 | 富文本(HTML/Markdown) |
|---|---|---|
| 存储 | 原始字符串 | 带标签字符串(体积 3~10 倍) |
| 编辑 | TextArea 直接读写 | 需解析器 + 编辑器 |
| 渲染 | Text 组件直接显示 | 需 RichText/Web 解析 |
| 搜索筛选 | 无影响 | 标签干扰 LIKE 匹配 |
| 复杂度 | 零依赖 | 需引入渲染库 |
纯文本的优势在于整条数据链路零转换:写入(TextArea.value)→ 存储(TEXT)→ 展示(Text)→ 编辑回填,全程同一字符串。\n 换行符原样存储,卡片预览取第一行即可:
// 卡片摘要:取内容第一行,超 20 字省略
private preview(content: string): string {
const line = content.split('\n')[0] ?? '';
return line.length > 20 ? `${line.slice(0, 20)}…` : line;
}
何时才需要富文本:内容需格式排版(加粗、列表、链接)、内容超长需折叠展示。便签是短内容 + 彩色卡片视觉,纯文本已够用,多一分存储就多一分复杂度。
十一、FAQ:建表与数据层常见问题
Q1:为什么 id 用 AUTOINCREMENT 而不是 UUID?
便签是单机数据,无跨设备合并需求;自增整数索引紧凑、查询快。若未来做多端同步,再迁移为字符串主键。
Q2:置顶便签多了会不会影响查询性能?
pinned 只有 0/1,idx_memo_pinned 索引区分度低(每个值约一半数据),实际走表扫描。但便签表数据量小(百张以内),扫描开销可忽略。索引收益与数据量成正比——本实例建索引更多是表达查询意图。
Q3:颜色存 HEX,用户自定义颜色怎么校验?
数据层只保证非空,格式校验放 UI 层(色板选择 + 正则 ^#[0-9A-Fa-f]{6}$ 兜底)。数据层不做无业务意义的强校验,校验放在产生数据的边界(UI)。
Q4:部分更新不读旧值,会不会覆盖其他字段的并发修改?
单用户单设备无并发写;且 UPDATE 只写 ValuesBucket 中的列,其他列保持原值——这正是部分更新的安全性来源。
Q5:同样置顶的便签顺序会不会乱?
不会。双键排序是确定性的:pinned 相同按 updated_time 倒序,updated_time 也相同则按 id 倒序兜底——排序键越加越稳,生产环境建议显式追加 id 作第三键。
Q6:内容存富文本标签会不会更好?
见第十节。便签场景纯文本是「够用且简单」的最优解;需求进化到排版时,再评估迁移成本。
十二、本章小结
本文补齐了建表与数据层的完整拼图:五列字段各有决策依据(必填/可空/编码/展示)、颜色 HEX 的存储纪律、置顶排序的字段 vs 关联表之争、纯文本的内容选型。建表不是写 SQL,而是把产品决策翻译成列约束——每一列都应该回答「为什么这样存」。
更多推荐




所有评论(0)