ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

把心事存进鸿蒙:ArkTS 为日记本设计长文本表与时间戳字段

2026/8/16 3:52:37 拓冰建站 浏览量
把心事存进鸿蒙:ArkTS 为日记本设计长文本表与时间戳字段


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

一、业务需求分析:日记本的数据形态与记账本有何不同

前三个实例我们处理了「任务清单」(短文本+状态)、「通讯录」(结构化字段)和「记账本」(数值+时间)。到了实例 4「电子日记本」,数据形态发生了一次质变:主体内容从「字段」变成了「长文本」

日记应用的核心业务需求可以归纳为五点:

  1. 写日记:标题 + 正文(可能上千字的长文本)+ 心情 + 天气 + 地点,一次保存;
  2. 按时间轴浏览:日记天生是「按时间组织的个人编年史」,页面要按日期/月份分组展示,时间线是主视觉;
  3. 关键词搜索:用户想「找那天写日记提到爬山的记录」,需要标题/正文双字段模糊搜索;
  4. 编辑与删除:日记写错了要能改、能删;
  5. 轻量统计:总篇数、本月篇数,支撑页面头部信息。

对比记账本,日记本的数据特点:

维度记账本(实例 3)日记本(实例 4)
主数据金额 REAL + 分类 TEXT正文长文本 TEXT
时间角色统计维度(SUM/GROUP BY)组织维度(时间轴分组)
检索方式分类精确匹配关键词 LIKE 模糊
更新频率几乎不更新(删了重记)经常编辑(改日记)

这些差异决定了表结构设计的不同侧重。下面进入字段设计。

二、字段设计表:每一列都为「时间轴」服务

字段名类型约束说明
idINTEGERPRIMARY KEY AUTOINCREMENT自增主键
titleTEXTNOT NULL标题
contentTEXTNOT NULL正文(长文本,可数千字)
moodTEXTDEFAULT ‘😀’心情 emoji(😀😐😢…)
weatherTEXTDEFAULT ‘’天气(晴/多云/雨…)
locationTEXTDEFAULT ‘’地点
created_timeINTEGERNOT NULL创建时间戳(毫秒)
updated_timeINTEGERNOT 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);

注意三点:

  1. IF NOT EXISTS幂等:重复执行不报错,这是 getStore 里建表语句的统一要求;
  2. DEFAULT '😀':mood 有默认值,插入时可以省略,代码更简洁;
  3. 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')),};}

与文章版代码的关键差异(这是本实例读者最容易踩的坑):

  1. 文章版用row: relationalStore.ValuesBucket+row.id as number强转;落地版改用ResultSet+getColumnIndex+getLong/getString——前者拿到的getRow()是弱类型的键值容器,强转不安全;后者类型安全、NULL 可兜底。
  2. 文章版静态方法里写this.store;落地版一律DiaryDao.store——ArkTS 禁止静态上下文使用thisarkts-no-this-in-static)。
  3. 文章版 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%'匹配不到!

这是一个需要澄清的坑:文章版的queryByMonthupdated_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 的链式写法。