把心事存进鸿蒙:ArkTS 为日记本设计长文本表与时间戳字段
实例:电子日记本(Diary)|技术:长文本存储、时间分组查询、关键词搜索
一、业务需求分析:日记本的数据形态与记账本有何不同
前三个实例我们处理了「任务清单」(短文本+状态)、「通讯录」(结构化字段)和「记账本」(数值+时间)。到了实例 4「电子日记本」,数据形态发生了一次质变:主体内容从「字段」变成了「长文本」。
日记应用的核心业务需求可以归纳为五点:
- 写日记:标题 + 正文(可能上千字的长文本)+ 心情 + 天气 + 地点,一次保存;
- 按时间轴浏览:日记天生是「按时间组织的个人编年史」,页面要按日期/月份分组展示,时间线是主视觉;
- 关键词搜索:用户想「找那天写日记提到爬山的记录」,需要标题/正文双字段模糊搜索;
- 编辑与删除:日记写错了要能改、能删;
- 轻量统计:总篇数、本月篇数,支撑页面头部信息。
对比记账本,日记本的数据特点:
| 维度 | 记账本(实例 3) | 日记本(实例 4) |
|---|---|---|
| 主数据 | 金额 REAL + 分类 TEXT | 正文长文本 TEXT |
| 时间角色 | 统计维度(SUM/GROUP BY) | 组织维度(时间轴分组) |
| 检索方式 | 分类精确匹配 | 关键词 LIKE 模糊 |
| 更新频率 | 几乎不更新(删了重记) | 经常编辑(改日记) |
这些差异决定了表结构设计的不同侧重。下面进入字段设计。
二、字段设计表:每一列都为「时间轴」服务
| 字段名 | 类型 | 约束 | 说明 |
|---|---|---|---|
| id | INTEGER | PRIMARY KEY AUTOINCREMENT | 自增主键 |
| title | TEXT | NOT NULL | 标题 |
| content | TEXT | NOT NULL | 正文(长文本,可数千字) |
| mood | TEXT | DEFAULT ‘😀’ | 心情 emoji(😀😐😢…) |
| weather | TEXT | DEFAULT ‘’ | 天气(晴/多云/雨…) |
| location | TEXT | DEFAULT ‘’ | 地点 |
| created_time | INTEGER | NOT NULL | 创建时间戳(毫秒) |
| updated_time | INTEGER | NOT NULL | 最近修改时间戳(毫秒) |
设计要点逐条拆解:
1. content 用 TEXT 存长文本,不需要分表。这是初学者最容易纠结的问题:「正文那么长,要不要单独建一张 content 表?」答案是不需要。SQLite 单条 TEXT 字段可以存数百 MB,一篇几千字的日记(约 10KB)毫无压力。RDB 的设计原则是「一行一个业务实体」——一篇日记就是一行,正文是它的一个属性。强行拆表反而增加 JOIN 复杂度,得不偿失。
2. mood 直接存 emoji 字符。心情用 emoji(😀/😐/😢)而非数字编码。为什么这里不用记账本 type 的 0/1 数字编码?因为心情没有「参与计算」的需求——不做 SUM、不做 GROUP BY 统计,只有展示。当枚举值只用于展示时,直接存可读文本,UI 零转换,是最务实的方案。判断用数字还是文本的标准:这个字段会不会参与聚合运算?会(如记账本 type)就编码成数字,不会(如心情)就存文本。
3. location 取代「标签」体系。早期的文章版设计里有tags(逗号分隔标签)字段,落地版将其替换为location(地点)。原因:个人日记场景下「标签」概念偏重,而「地点」(新家/公司/书房)更贴近日记的自然属性,也与文章版 4-2 的时间轴节点设计一致。如果你需要标签体系,逗号分隔 + LIKE 查询的模式可以随时加回来——SQLite 对字符串的处理非常灵活。
4. created_time 与 updated_time 双时间戳。这是内容型应用的标准配置:
created_time:创建时刻,永不变更;updated_time:每次编辑刷新,时间轴排序用它——「最近修改的日记排前面」比「最早写的排前面」更符合用户心智(刚编辑完的日记大概率还想继续看)。
5. 为什么只给 created_time 建索引?落地版的建表 SQL 为created_time建了idx_diary_time索引,排序、按月份LIKE '2025-06%'过滤都走它。其实更严谨的做法是索引updated_time(排序字段),但个人日记几百条数据,两者性能差异不可感知;选择 created_time 是因为它的值域稳定(插入后不变),索引不会因频繁更新而失效。这个细节体现了「索引服务于查询」的朴素原则。
三、建表 SQL:把设计变成现实
CREATETABLEIFNOTEXISTSdiary(idINTEGERPRIMARYKEYAUTOINCREMENT,titleTEXTNOTNULL,contentTEXTNOTNULL,moodTEXTDEFAULT'😀',weatherTEXTDEFAULT'',locationTEXTDEFAULT'',created_timeINTEGERNOTNULL,updated_timeINTEGERNOTNULL);CREATEINDEXIFNOTEXISTSidx_diary_timeONdiary(created_time);注意三点:
IF NOT EXISTS幂等:重复执行不报错,这是 getStore 里建表语句的统一要求;DEFAULT '😀':mood 有默认值,插入时可以省略,代码更简洁;- NOT NULL + DEFAULT ‘’:weather、location 可空但默认空串,避免 NULL 泄漏到 UI 层(页面
|| ''兜底是双保险)。
四、DiaryDao 封装:把 SQL 关进类里
数据层核心是DiaryDao,职责边界与前面实例一致:只管数据库,不管 UI。先看实体接口:
exportinterfaceDiary{id:number;title:string;content:string;// 长文本正文mood:string;// 心情 emoji(😀😐😢…)weather:string;// 天气(晴/多云/雨…)location:string;// 地点createdTime:number;// 日记时间戳(毫秒)updatedTime:number;}字段命名依然是「DB 蛇形 ↔ 代码驼峰」的双风格映射,集中在rowToDiary方法:
privatestaticrowToDiary(result:relationalStore.ResultSet):Diary{return{id:result.getLong(result.getColumnIndex('id')),title:result.getString(result.getColumnIndex('title')),content:result.getString(result.getColumnIndex('content')),mood:result.getString(result.getColumnIndex('mood'))||'😀',weather:result.getString(result.getColumnIndex('weather'))||'',location:result.getString(result.getColumnIndex('location'))||'',createdTime:result.getLong(result.getColumnIndex('created_time')),updatedTime:result.getLong(result.getColumnIndex('updated_time')),};}与文章版代码的关键差异(这是本实例读者最容易踩的坑):
- 文章版用
row: relationalStore.ValuesBucket+row.id as number强转;落地版改用ResultSet+getColumnIndex+getLong/getString——前者拿到的getRow()是弱类型的键值容器,强转不安全;后者类型安全、NULL 可兜底。 - 文章版静态方法里写
this.store;落地版一律DiaryDao.store——ArkTS 禁止静态上下文使用this(arkts-no-this-in-static)。 - 文章版 context 用
common.UIAbilityContext;落地版统一common.Context基类,复用性更强。
getStore单例复用模式不变:
staticasyncgetStore(context:common.Context):Promise<relationalStore.RdbStore>{if(DiaryDao.store){returnDiaryDao.store;}constconfig:relationalStore.StoreConfig={name:'diary.db',securityLevel:relationalStore.SecurityLevel.S1,};DiaryDao.store=awaitrelationalStore.getRdbStore(context,config);// 建表 + 建索引(见上节 SQL)hilog.info(DOMAIN,TAG,'日记表初始化成功');returnDiaryDao.store;}五、核心 CRUD:写、查、改、删
插入(写日记):
staticasyncinsert(context:common.Context,d:Diary):Promise<number>{conststore=awaitDiaryDao.getStore(context);constvalues:relationalStore.ValuesBucket={title:d.title,content:d.content,mood:d.mood,weather:d.weather,location:d.location,created_time:d.createdTime,updated_time:d.updatedTime,};returnawaitstore.insert(DiaryDao.TABLE,values);}注意content作为字符串直接塞进 ValuesBucket——长文本无需特殊处理,SQLite 自动分配存储,这是 TEXT 类型的天然优势。
全部日记(按修改时间倒序):
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);}排序字段用created_time(与索引一致),页面时间轴按创建时间分组展示,语义上更符合「日记编年史」。
更新(编辑日记,刷新 updated_time):
staticasyncupdate(context:common.Context,d:Diary):Promise<number>{conststore=awaitDiaryDao.getStore(context);constvalues:relationalStore.ValuesBucket={title:d.title,content:d.content,mood:d.mood,weather:d.weather,location:d.location,updated_time:d.updatedTime,};constpredicates=newrelationalStore.RdbPredicates(DiaryDao.TABLE);predicates.equalTo('id',d.id);returnawaitstore.update(values,predicates);}注意 update 不更新created_time——创建时间不可变,只刷updated_time,这是双时间戳的协作方式。
删除与计数:
staticasyncdelete(context:common.Context,id:number):Promise<number>{conststore=awaitDiaryDao.getStore(context);constpredicates=newrelationalStore.RdbPredicates(DiaryDao.TABLE);predicates.equalTo('id',id);returnawaitstore.delete(predicates);}staticasynccount(context:common.Context):Promise<number>{conststore=awaitDiaryDao.getStore(context);constresult=awaitstore.querySql(`SELECT COUNT(*) AS c FROM${DiaryDao.TABLE}`);lettotal=0;if(result.goToNextRow()){total=result.getLong(result.getColumnIndex('c'));}result.close();returntotal;}六、时间分组查询:按月份捞日记
时间轴页面的核心需求:选中某个月份,只看那个月的日记。落地版提供queryByMonth:
staticasyncqueryByMonth(context:common.Context,month:string):Promise<Diary[]>{conststore=awaitDiaryDao.getStore(context);constpredicates=newrelationalStore.RdbPredicates(DiaryDao.TABLE);predicates.like('created_time',`${month}%`).orderByDesc('created_time');constresult=awaitstore.query(predicates);returnDiaryDao.collect(result);}这个方法的巧妙之处:like('created_time', '2025-06%')用 LIKE 前缀匹配模拟「时间范围查询」。因为 created_time 存的是毫秒时间戳的字符串形式——不对,等等,created_time 是 INTEGER 类型,LIKE 匹配的是数字的字符串表示。2025-06-01 00:00:00的时间戳是1748707200000,用LIKE '2025-06%'匹配不到!
这是一个需要澄清的坑:文章版的queryByMonth按updated_time LIKE的设计在 INTEGER 时间戳上是不成立的(那是按文本日期存储时的写法)。落地版页面(DiaryPage)采用更可靠的方式:直接queryAll拉全量,在内存里按fmtDate分组渲染时间轴节点。个人日记数据量小(几十上百篇),全量加载 + 前端分组是务实的选择;若数据量大,正确做法是between(startOfMonth, endOfMonth)毫秒区间查询——这与记账本实例的区间查询一脉相承。
关键词搜索(标题/正文双字段 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);}LIKE '%关键词%'是包含匹配——标题或正文任意位置出现关键词即命中。.or()把两个条件连接为 OR 语义。注意:LIKE 前导%会导致索引失效(无法走 B+ 树),全表扫描;但日记本几百条数据扫描无压力。如果将来数据量到十万级,应改用 FTS5 全文索引——那是另一个维度的优化,本书暂不展开。
七、技术要点对照表
| 技术点 | 实现方式 | 生产价值 |
|---|---|---|
| 长文本 | TEXT 字段直接存正文 | 无需分表,读写简单 |
| 双时间戳 | created_time + updated_time | 创建/编辑分离,时间轴按编辑排序 |
| 关键词搜索 | title/content OR LIKE | 双字段命中率高 |
| 时间分组 | 全量加载 + 前端按日期分组 | 小数据量下的务实方案 |
| emoji 存储 | mood 直接存字符 | UI 零转换 |
| 幂等建表 | CREATE IF NOT EXISTS | 重复启动不报错 |
八、文章小结
日记本数据层与记账本的数值聚合完全不同,重心在长文本存储策略 + 双时间戳的时间轴组织 + LIKE 关键词检索。TEXT 类型让长正文毫无负担,双时间戳让「最近编辑优先」成为可能,LIKE 让搜索零配置。下一篇(4-2)将展示这些数据如何被时间轴 UI 呈现——垂直时间轴 + 日期节点 + 心情天气卡片,把数据变成故事。
动手练习:尝试给search增加第三个条件(按天气过滤:equalTo('weather', '晴')),并观察 OR 与 AND 组合时 RdbPredicates 的链式写法。
