做日记App这件事,很多开发者的路径都差不多:先做一个能打字、能存图片的本地记事本,然后越用越觉得不对劲——记是能记了,但想翻出三个月前某件小事的时候费半天劲,写的时候没有灵感,忘记记了也没人提醒。这次我把自己的HarmonyOS日记应用从“简单记录”往“智能日记”方向做了一次完整的功能深化,核心就是把三类能力塞进一个个人工具里:情绪分析自动打标、关键词抽取做关联检索、定时回顾推送和周统计报表。这篇实践笔记会从数据模型、轻量算法选型、ArkUI交互改造一路讲到实际踩过的坑,适合正在做HarmonyOS应用、或者想把本地工具型App做得更“通人性”的开发者参考。
1. 整体设计思路:先想清楚“智能”到底解决什么问题
做功能深化之前,我先把老版本的使用体验复盘了一遍。旧版就是典型的“哑存储”:打开App、写一段话、配张图、保存,然后这条记录就躺在列表里再也不被翻到。问题不是“能不能记”,而是记完之后这套系统有没有给用户提供任何额外价值。所以这次深化的目标很明确:让应用理解内容、主动出现、定期给用户一个回顾生活的窗口。
1.1 从“记录”到“智能”的差距在哪里
普通日记和智能日记之间隔了三层能力,这三点也是我这次改造的主线:
第一,内容理解。纯文本对应用来说只是一个字符串,不知道用户今天心情如何、写了什么主题、有哪些反复出现的词汇。要“智能”,第一步就是把日记内容解析成结构化信息:情绪分、关键词标签、字数、时间分布。
第二,主动服务。老版本的App自己不“开口”,用户不打开就什么都没发生。深化之后,应用应该在晚上某个时间提醒你“今天还没写”,或者在某个特殊日子把去年的今天翻出来给你看。
第三,周期整理。日记的价值有一部分在复盘。按时间轴翻是线性阅读,但人更想要的是“这周我总体开心吗”“最近总在聊什么话题”。这需要应用有能力在后台做聚合统计,而不是每次都全量扫描。
想清楚这三点之后,功能清单就顺理成章了:情绪打标、关键词标签、每日提醒、往年今日、周报统计。
1.2 功能深化前先想清楚的三件事
确认要做哪些功能之后,我给自己立了三条原则,现在看来每一条都帮了大忙。
首先是离线优先。日记是非常私密的数据,用户对它的安全预期远高于普通应用。如果把正文上传到云端做分析,不管是出于什么理由,都会劝退一批用户。所以我把情绪分析和关键词抽取全部放在本地执行,一个字不出设备。这个决定也让应用在网络差、没网的情况下依然完整可用,反而成了卖点。
其次是克制加功能。我见过不少工具型App死于功能堆砌。这次每个候选功能上线前都问一个问题:这功能到底降低了用户的哪一项操作成本?比如自动打标签,降低了“手动分类”的成本;周报,降低了“回看”的成本;但像“好友排行榜”这种功能,属于社交属性,和日记的私密调性冲突,直接砍掉。
第三是数据结构先行。智能功能最怕的就是回头补数据。老版本只有content和date两个字段,这次建表时我直接把mood、tags、word_count全部预留好。宁可先空着,也不能等到用户写了几百条之后再做迁移,那才是真正的灾难。
1.3 技术栈与整体架构
技术选型没有追求花哨,全部围绕HarmonyOS现有能力展开:开发语言用ArkTS,UI层用ArkUI,数据层用Preferences加关系型数据库RelationalStore。智能分析模块没有接任何云端大模型SDK,而是自研了一套轻量词库算法,原因后面专门讲。后台提醒走系统提醒代理,耗时分析走任务池并发。
整体分四层:视图层是ArkUI组件;状态层用@State、@Observed这类装饰器管理界面数据;业务层拆成日记服务、分析服务、统计服务三个模块;数据层统一封装RdbStore的增删改查。把分析逻辑单独抽成服务是有意的:未来如果真的想换成能力更强的模型,只需要替换分析服务内部实现,数据层和UI层完全不用动。
2. 数据层设计:存储选型、建表和升级
功能深化最怕地基歪。这章我把数据层从头讲一遍,包括为什么选关系型数据库、表结构每个字段的来龙去脉、以及升级时怎么平稳过渡,这些都是直接能抄的作业。
2.1 存储方案怎么选
HarmonyOS应用有几种常见本地存储手段,我当时画了个对比,结论很清晰:
| 存储方案 | 适合场景 | 为什么不作为日记主存储 |
|---|---|---|
| Preferences 键值存储 | 用户设置项、上次同步时间、小体量配置 | 结构弱,无法按日期、情绪、标签做筛选和聚合,数据一多就难维护 |
| RelationalStore 关系型数据库 | 条目型业务数据,需要SQL查询、排序、索引 | ——(最终选它) |
| 分布式键值数据库 | 多设备同步轻量数据 | 更适合同步配置和摘要,全文同步会遇到数据量和冲突处理的复杂度 |
日记的每条记录天然是“一行数据”,而列表页、时间轴、周报、情绪趋势这些功能,本质都是对数据做条件查询和聚合运算。关系型数据库的SQL能力和索引机制,做这件事最合适。Preferences虽然用起来简单,但它在逻辑上是扁平键值对,想表达“某月所有日记的平均情绪值”这种查询非常别扭,硬写能写,代价是代码可读性和性能一起崩。
另外我在项目里仍然保留了Preferences,用来存少量设置项,比如每日提醒时间、用户是否同意过通知权限、上次数据统计的时间戳。它和RDB各管一段,分工明确。
2.2 日记数据模型与建表细节
数据模型是这次深化最关键的一步。建表SQL如下,字段解释放在后面:
CREATE TABLE IF NOT EXISTS diary ( id INTEGER PRIMARY KEY AUTOINCREMENT, content TEXT NOT NULL, date_text TEXT NOT NULL, mood INTEGER DEFAULT 0, tags TEXT DEFAULT '[]', image_uris TEXT DEFAULT '[]', word_count INTEGER DEFAULT 0, create_time INTEGER NOT NULL, update_time INTEGER NOT NULL ); CREATE INDEX IF NOT EXISTS idx_diary_date ON diary(date_text); CREATE INDEX IF NOT EXISTS idx_diary_mood ON diary(mood);每个字段都有具体考虑。content存正文;date_text用“YYYY-MM-DD”这种格式化字符串,而不是直接用时间戳,原因是“按天/按月归类”变成纯字符串范围查询,简单直观,而且不受时区换算影响。create_time和update_time用长整型毫秒时间戳,负责精确排序和“最近修改”逻辑。
mood不是布尔或简单的三档枚举,而是存一个-10到10的整数情绪分,交给你后面章节的情绪分析模块来填。之所以保留细粒度分值而不是直接存“开心/难过”,是为了周报里能算“平均情绪值”。tags字段存JSON数组字符串,比如“["工作","加班","地铁"]”。有人会问为什么不用一张独立的标签关联表,我的实际判断是:个人日记一年也就几百条,JSON字符串在读写上更简单,查询时用接口里的“包含”匹配或反序列化后内存过滤都够快;真到几万条再拆表,为时未晚。
word_count是冗余字段,每次保存时算好字数存进去。周报插件聚合每天字数时,直接SUM这一列,避免在统计时对几百条全文做扫描。这一类“预计算字段”是本地统计场景里性价比最高的优化。image_uris同理,JSON数组存图片URI,列表页不需要每次读取原图元数据就知道有几张图。
索引方面,date_text建索引是为了时间轴分页查询,mood建索引是为了情绪趋势筛选。注意一点:tags这类JSON字符串列不适合建普通索引,因为查询走的是包含匹配,索引帮不上忙,这种字段保持普通存储就好。
2.3 分页查询与数据库迁移
列表页的数据读取不能一次全查,数据多了之后Ano崩溃。我用的分页方式很朴素:
// 示意:按时间倒序分页 let predicates = new relationalStore.RdbPredicates('diary'); predicates.orderByDesc('date_text'); predicates.limit(20, page * 20); store.query(predicates, ['id', 'content', 'date_text', 'mood', 'tags', 'image_uris'], (err, resultSet) => { // 处理结果集 });初始阶段每页20条足够,配合滚动到底部加载下一页。顶部再做一个月末筛选:查某个月的日记,predicates.between('date_text', '2025-03-01', '2025-03-31'),直接走索引。
数据库迁移是另一个必须提前准备的问题。RDB有版本号机制,每次调整表结构,版本号加1,系统会触发对应的升级回调。我当时的处理是:
if (oldVersion < 2) { store.executeSql('ALTER TABLE diary ADD COLUMN mood INTEGER DEFAULT 0'); store.executeSql('ALTER TABLE diary ADD COLUMN tags TEXT DEFAULT \'[]\''); store.executeSql('ALTER TABLE diary ADD COLUMN word_count INTEGER DEFAULT 0'); store.executeSql('ALTER TABLE diary ADD COLUMN image_uris TEXT DEFAULT \'[]\''); }这里有个重要提醒:ALTER TABLE能加列,但改不了已有列的类型,也删不了列。如果你在后续版本里想把mood从INTEGER改成TEXT,就必须走“建新表、拷贝数据、删旧表、重命名”这条完整迁移流程。迁移操作请务必包在事务里,并在开始前提示用户备份数据,别问我为什么知道。
3. 智能功能落地:情绪分析、自动打标与回顾提醒
这一章是全文的技术重点。我详细讲清每一个算法模块的思路、代码和设计取舍,包括为什么坚持本地轻量方案,以及如何保证这些功能在不同型号设备上都跑得动。
3.1 情绪分析:不依赖云端也能跑
情绪分析我没有接云端大模型,而是用了一个经过扩充的情绪词库加规则打分。原因有三:第一,日记内容必须是私密的,上传分析很多人不接受;第二,本地词库分析毫秒级完成,体验远好于网络请求的等待;第三,词库方法完全可解释,用户可以看着这个词表知道应用凭什么判断心情,大模型则是个黑盒。
核心思路很简单:准备一张积极词表、一张消极词表,每个词带一个权重分。扫描正文时,每命中一个词就把对应分值累加,同时处理否定词和程度副词。否定词会把情绪取反,程度副词会把分值放大或缩小。最后截断到-10到10之间:
const positiveWords: Map<string, number> = new Map([ ['开心', 2], ['高兴', 2], ['满足', 2], ['感谢', 3], ['顺利', 2], ['爱', 3] ]); const negativeWords: Map<string, number> = new Map([ ['难过', 2], ['失望', 3], ['焦虑', 3], ['生气', 2], ['累', 1], ['烦', 1] ]); const negators: string[] = ['不', '没', '未', '别']; const intensifiers: Map<string, number> = new Map([ ['非常', 1.5], ['特别', 1.5], ['有点', 0.6] ]); export function analyzeMood(text: string): number { let score = 0; // 去掉标点,便于按字符窗口扫描 const cleaned = text.replace(/[,。!?、,.!?\s]/g, ''); for (let i = 0; i < cleaned.length - 1; i++) { const word = cleaned.substring(i, i + 2); let w = positiveWords.get(word) ?? negativeWords.get(word) ?? 0; if (w === 0) continue; // 否定词:前一个字符是否定词则取反 if (i > 0 && negators.includes(cleaned.charAt(i - 1))) { w = -w; } // 程度副词:前一个字符是程度词则调整权重 if (i > 0 && intensifiers.has(cleaned.charAt(i - 1))) { w = Math.round(w * intensifiers.get(cleaned.charAt(i - 1))!); } score += w; } return Math.max(-10, Math.min(10, score)); }这段代码用的是双字符滑窗,对中文二字词很有效,但对“不开心”这类三字表达,滑窗会先命中“开心”再被否定词“不”取反,逻辑上也说得通。实际调参时我发现,把词表的权重差异拉大比堆词量更有效,高频基础情绪词(开心、难过、累)权重落在1到2之间,强烈情绪词(绝望、惊喜)给到3,避免一段话里三四个情绪词就冲爆分数。
保存日记时把mood写进数据库。列表页用颜色区分:正分绿色系、负分红色系、零分灰色系。周报里再算平均分,转成“本周心情指数”。
3.2 关键词提取与自动打标
自动打标本质是“抽取式关键词”,不需要生成新词,只需要从原文里挑出最值得作为标签的词汇。HarmonyOS端不方便直接引入重量级分词库,我采用的是“正向最大匹配 + 词频统计”的轻量方案。
正向最大匹配需要一个内置词表。我手工维护了一个几百词的常用词表,覆盖生活、工作、天气、运动、饮食等常见主题,用户还能在设置页把自己常用的词加进去。算法从文本开头尝试取出最长的词表命中词,跳过分词结果中的停用词,最后按出现频次排序取前几个:
const DICT: Set<string> = new Set([ '今天', '天气', '周末', '项目', '加班', '地铁', '朋友', '生日', '跑步', '旅行' ]); const STOP_WORDS = new Set(['的', '了', '是', '我', '你', '在', '和', '也', '都']); function forwardMatch(text: string): string[] { const words: string[] = []; let i = 0; const maxLen = 4; while (i < text.length) { let matched = ''; for (let len = maxLen; len > 0; len--) { if (i + len <= text.length && DICT.has(text.substring(i, i + len))) { matched = text.substring(i, i + len); break; } } if (matched) { words.push(matched); i += matched.length; } else { i++; } } return words; } export function extractTags(text: string, topN = 3): string[] { const freq: Map<string, number> = new Map(); for (const w of forwardMatch(text)) { if (STOP_WORDS.has(w)) continue; freq.set(w, (freq.get(w) ?? 0) + 1); } return [...freq.entries()] .sort((a, b) => b[1] - a[1]) .slice(0, topN) .map(e => e[0]); }这个方案的优点是快,几百字正文分析在本地跑基本无感。缺点也明显:词表外的词识别不到。比如用户写“去咖啡馆坐了一下午”,词表里没有“咖啡馆”,就提取不出这个标签。我的缓解办法是在设置页提供“自定义词库”入口,同时把提取结果展示在编辑页让用户确认,允许手动增删标签。自动生成的内容永远只是草稿,最终判断权在人手里,这一点做工具类产品很重要。
标签存到tags字段后,搜索逻辑就分两层:先按标签精确检索,再看内容LIKE匹配。这样既快又准,比全文扫描强得多。
3.3 每日提醒、往年今日与周报统计
智能日记不能只在用户打开应用时才有智能。这部分的实现我拆成三个小功能。
每日提醒走的是系统提醒能力,而不是单纯的后台定时任务。原因是系统级提醒可以在应用不处于前台、甚至进程被回收后依然按时间触发,稳定得多:
import { reminderAgentManager } from '@kit.BackgroundTasksKit'; // 示意代码,模块路径与参数以当前SDK版本为准 let reminder: reminderAgentManager.ReminderRequestAlarm = { reminderType: reminderAgentManager.ReminderType.REMINDER_TYPE_ALARM, title: '日记时间', content: '今天发生了什么值得记的事?花两分钟写下来。', hour: 21, minute: 0, daysOfWeek: [1, 2, 3, 4, 5, 6, 7] }; reminderAgentManager.publishReminder(reminder, (err, reminderId) => { // 保存reminderId,下次先取消旧的再发布 });这里有个非常容易踩的坑:每次启动应用都去重复发布提醒,会生成一堆相同的通知。正确做法是把reminderId存到Preferences里,发布前先检查,存在就取消旧的再重新发布。
“往年今日”是体验提升最明显的一个功能。实现上就是在应用启动或用户点击回忆入口时,用当天日期构造一个查询:取date_text中间部分等于当前月日的数据,展示成“去年的今天你写了这些”。比如今天是3月18日,就筛date_text以“-03-18”结尾的记录。这个查询用LIKE '%-03-18'就能实现,走不上索引,但数据量小,完全够用。
周报统计则是把一周的数据做一次聚合。查询本周所有记录后,在内存里计算:平均情绪分、总字数、每日写没写(连续打卡天数)、出现最多的标签。展示层不需要复杂图表,我用了一组统计卡片加进度条:情绪分对应一个笑脸,连续打卡天数对应一个数字,标签用普通的流式布局。用户看到“你这周记录了4天,平均心情指数6.2,最多出现的话题是'工作'”的时候,是真的会感受到这个应用不一样了。
4. UI交互深化:时间轴、卡片与分析联动
智能能力有了,但用户首先接触的是界面。这章讲界面怎么从“列表盒子”变成“有温度的时间轴”,以及编辑器里智能分析的实时联动。
4.1 从列表到时间轴卡片
老版本是标准的列表行:标题、摘要、日期。看久了会发现一个问题——日记是很强调“时间感”的内容,一行行的平铺完全抹掉了这种感受。改成时间轴之后,每条记录变成一张卡片,卡片左侧有一条竖线和节点圆点,视觉上就是在一条时间线上流动。
实现上不复杂,ArkUI的List加ListItem就能做:
@Entry @Component struct DiaryTimelinePage { @State diaries: DiaryItem[] = []; build() { List({ space: 12 }) { ForEach(this.diaries, (item: DiaryItem) => { ListItem() { Row() { // 时间轴节点和竖线 Column() { Circle({ width: 10, height: 10 }) .fill(item.mood > 0 ? '#4CAF50' : (item.mood < 0 ? '#F44336' : '#BDBDBD')) Divider() .strokeWidth(2) .color(item.mood > 0 ? '#4CAF50' : (item.mood < 0 ? '#F44336' : '#BDBDBD')) .height(60) } .width(20) // 卡片主体 Column() { Text(item.dateText).fontSize(12).fontColor('#888888') Text(item.preview).fontSize(16).maxLines(3) .textOverflow({ overflow: TextOverflow.Ellipsis }) Row() { ForEach(item.tags, (tag: string) => { Text('#' + tag) .fontSize(12) .fontColor('#3F6DFB') .margin({ right: 8 }) }) } } .layoutWeight(1) .padding(12) .backgroundColor(Color.White) .borderRadius(12) } .alignItems(VerticalAlign.Top) } }) } } }注意节点颜色直接跟随mood正负值变化,绿色代表这天过得不错,红色代表情绪低落。用户翻历史的时候,一眼就能扫出哪段时间是低谷,哪段时间是高峰——这个视觉反馈比任何文字都直接。
卡片里还放了最多三张图片缩略图。这里有个性能细节:不能直接加载原图,我保存图片时会同时生成一份宽边不超过480像素的缩略图,列表只加载缩略图,点击卡片才看原图,否则翻页卡顿会让人崩溃。
4.2 编辑器与智能分析联动
编辑页这次也做了深化。用户输入正文时,底部会有一个轻量的“分析预览”区域:实时显示字数、预测的心情标签和提取出的关键词标签,标签允许直接点击删除或手动添加。
实时分析不能每次按键都跑全套算法,我加了一个300毫秒的防抖:用户停止输入300毫秒后才触发一次分析。分析逻辑丢到任务池(TaskPool)里的子线程执行,避免在UI线程做字符串扫描。任务池的好处是不用手动管理线程,系统根据设备负载调度,适合这种短暂的计算密集型任务。
保存按钮点击后,应用把最终确认的mood和tags写进数据库。注意:分析结果在编辑页是可以被用户改掉的,保存时以用户当前看到的结果为准,而不是重新跑一遍算法。这一点非常关键,否则用户手动调整过的标签会被自动结果覆盖。
4.3 多设备场景的设计取舍
最后聊一点多端。HarmonyOS强调多设备协同,但在日记这个场景,我不建议一开始就把全文做成分布式同步。全文同步会遇到冲突处理、同步时序、数据量控制一系列问题,做成型的成本不低。
我实际采用的折中方案是:通过分布式键值库同步“最近七天的日记摘要”,包括日期、情绪分、标签列表,让用户在手机和折叠屏之间切换时能看到近况概览;正文和图片仍然留在本机,只有在用户主动点击某一条时,才从本机数据库查询完整内容。这样实现了“轻量协同”的感觉,又避免了把整套同步机制拖进项目。等用户规模和数据量真涨上来了,再考虑服务端同步也不迟。
5. 常见问题与排查技巧实录
功能做完不是终点,联调阶段的问题才最磨人。这章把我实际遇到的高频问题按主题整理成速查,每个问题都给了排查路径和最终方案。
5.1 数据库升级与历史数据兼容
第一代版本发布后有用户更新,一升级就出现列表空白。排查后发现问题出在数据库迁移的时序上:我在初始化数据库对象之后、执行迁移脚本之前,就先发起了查询,结果查询跑在新表结构上,而旧数据还没完成转换,自然什么都查不到。
解决方法是把“打开数据库 -> 执行迁移 -> 再允许业务查询”改成严格串行,用一个初始化Promise包住,业务层所有查询都await这个Promise结束后再执行。另外,给迁移脚本追加了幂等判断:如果目标列已经存在,就跳过ALTER操作,避免重复升级时炸掉。老版本遇到类似问题,先看日志里迁移回调有没有执行到,再确认版本号是否真的递增了,基本能定位。
还有一次是给已有表增加tags列后,老数据全部是“[]”,导致周报统计标签时出现了空数组的解析报错。处理方式是迁移时顺手把存量行更新为默认值,逻辑很简单,但很多人会忘掉“存量数据也需要初始化”这一步。
5.2 提醒不触发与通知权限
每日提醒上线后,测试发现部分设备上收不到。第一层原因是通知权限:HarmonyOS的通知需要用户授权,用户第一次拒绝后应用就再也发不出通知。我在首次打开时主动弹权限请求,并说明用途,被拒绝后在应用内设置页提供二次开启的入口,同时加了一个“最近未写日记”的角标作为兜底提示。
第二层原因是系统对后台任务有省电策略限制。提醒代理机制整体比普通后台任务可靠,但个别设备还是会在长时间未打开应用后暂停提醒。我的处理是在用户打开应用时做一次补偿检查:如果发现最近三天没有写日记,直接在本机补一条本地通知。这不算完美的方案,但在“不打扰用户”和“保证提醒可达”之间,已经是我能给出的最稳的组合。
5.3 性能与内存:缩略图、任务池与防抖
日记应用的数据量虽然不大,但图片和全文扫描还是会带来肉眼可见的卡顿。图片这块,除了前面说的缩略图策略,我还在保存图片时限制了最长边不超过2048像素,并转成JPEG压缩。一张手机原图动辄三五兆,压缩后一两百KB,对存储空间和多端轻量同步都是实打实的改善。
情绪分析和关键词提取虽然算法不重,但频率一高,UI线程也会掉帧。处理方式是全套分析模块设计成纯函数,丢进任务池执行,主线程只拿到结果再更新状态。这里踩过一个小坑:任务池的回调里不能直接操作非线程安全的共享对象,我给分析结果定义了一个独立的结构体,通过返回值传出来,而不是让子线程去改页面状态。
还有一个容易被忽略的瓶颈是字数统计。编辑器里用onChange实时数字数,如果正文很长,每次输入都扫描全量字符串,老旧设备会明显卡。我用了和情绪分析一样的防抖方案,300毫秒统计一次,实测性能从明显卡顿变成毫无感知。
6. 一些个人体会
整个项目做下来,我最深的感受是:个人工具里的“智能”,重点不在模型有多大,而在于能不能在合适的时间,把用户真正需要的内容送到眼前。一个词库算法撑起的情绪标签,配合一个按时出现的提醒和一份直观的周报,带给用户的“懂我”的感觉,可能比一个云端大模型生成的华丽总结更实在。
如果这个项目还要继续往下走,我建议下一代做两件事:一是把情绪分析和关键词抽取的接口整理得更干净,之后想接更重模型时直接替换实现;二是对历史数据做一次批量回填,让上线前的老日记也能拥有情绪标签和关键词,这样周报和回忆功能的覆盖范围才完整。开发永远是这样,先把地基打牢,再慢慢往上面添砖。