我那个叫“随手记”的小应用,最初的功能简单到可以用一句话概括:打开,写一段文字,存下来。页面就一个TextArea、一个保存键、一个时间倒序列表。第一版上线以后我自己倒是坚持写了三个月,直到有一天翻记录,发现自己写了三十多篇却没有一篇想再点开。这个信号让我意识到,能记录和值得记录是两回事。这篇博文不是讲怎么搭一个最简单的笔记应用,而是完整复盘我怎样把它从“简单记录”一路深化成带智能分析能力的“智能日记”,包括数据模型怎么改、端侧智能怎么接、交互怎么调整,以及整个过程中反复推翻的决策。如果你手里也有一个功能单一的工具类应用,想一步步让它变得更懂用户,这篇实践内容应该能帮上忙。
1. 初版应用的真实窘境:能记但不等于好用
1.1 第一版是典型的“功能正确但体验残缺”
我之前实现第一版时,HarmonyOS侧的技术选型非常简单,核心页面用ArkUI声明式写法,一个TextArea绑定内容,保存按钮直接把文本插进本地数据库表。页面代码大概长这样:
@Entry @Component struct DiaryPage { @State content: string = '' build() { Column() { TextArea({ placeholder: '今天发生了什么?', text: this.content }) .onChange((value: string) => { this.content = value }) Button('保存').onClick(() => this.saveDiary(this.content)) } } }保存之后就是往本地单表插一条记录,列表页按create_time倒序拉出来。从“能不能用”的角度,它是完整的;可从“好不好用”的角度,它缺了几乎所有东西。真正让我不舒服的是,这个版本把“记日记”当成了单纯的存储行为。写的人和读的人其实是同一个人,但应用完全没有帮这个“读的人”做任何事。打开后能看到什么?只能看到一行行标题和日期。内容里那些反复出现的人和事、那些明显低落的夜晚、那些值得记住的小进展,全部被埋在文本里。这说明体验残缺不是UI问题,而是数据模型和处理逻辑的问题。
1.2 三个让我决定重做的真实信号
第一个信号是我自己翻历史记录时毫无收获,写过的日记跟消息记录一样扁平。第二个信号是,我把应用给朋友试用,一周后问他有什么反馈,他说“挺好的,但我忘了打开它”——每周提醒只靠通知,提醒之后你还是不知道昨天写过什么。第三个信号是,我尝试在列表里搜索一段往事的关键词,只能遍历内存里的JSON再过滤,虽然数据量小,但代码逻辑已经预示着再往后根本撑不住。
这三个信号指向同一个结论:用户要的不是一个更快的“记”,而是一个能在恰当时候把记忆重新拿出来的“读”。这个“读”的前提,是应用得有办法理解内容。前两步等于把产品重新定位,第三步则逼着我做数据层改造。从那天起,我不再纠结字体、主题、云同步这些边角功能,专心把“理解”这条路走通。
1.3 日记不等于便签:先想清楚产品定位
在我开始改代码前,先做了一次定位梳理。便签的核心价值是“暂时放下”,用户需要快速记,然后走;日记的核心价值是“以后回溯”,用户记录当下,是为了未来的自己能够重温、复盘、对比。既然是回溯,就必须在保存时做好结构化处理——相当于给未来的读者编索引。
我把目标从“增强编辑体验”扭转为“让写入时顺便完成一次理解”。后面的每一步都围绕这个定位展开。这听起来像一句正确的废话,但它在实际开发中帮我顶住了很多诱惑:比如有人建议加AI自动续写,理论上很酷,可这跟“帮助用户理解自己”不是一回事,我就没做。于是接下来的重点就清楚了:解析正文、抽关键词、判情绪、生成摘要。
2. 把“智能”拆成能落地的子任务
2.1 从用户场景倒推能力
“智能日记”这个词听着很唬人,落到实处却是一个一个具体动作。我先模拟了几个高频场景:
- 周五晚上想回顾这周过得怎么样,希望应用能告诉他“本周我有3天偏积极、1天低能量,关键事件是项目完成和一次跑步”。
- 几个月后想找“和某朋友一起吃的火锅”,希望输入一个名字就能定位到那一天。
- 心情低落时打开应用,希望出现过去类似处境下自己是怎么走出来的记录。
- 连续几天没记录,希望应用能温和提醒,而不是冷冰冰的“该写日记了”。
对标一圈下来,真正的共性不是“聊天机器人给我写日记”,而是“理解内容、提取特征、帮助我们回顾”。这些特征就是:关键词、情绪标签、摘要、时间段统计。这个结论很重要,因为它意味着我不需要追求对话式AI的复杂度,只需要一套能稳定产出结构化标签的文本分析链路。
2.2 能力清单分级与首版范围
我把功能按“依赖程度”和“实现成本”排了个队:
| 能力 | 用户价值 | 端侧实现难度 | 优先级 |
|---|---|---|---|
| 自动提取关键词 | 高 | 低 | P0 |
| 自动情绪标签 | 高 | 低 | P0 |
| 结构化检索 | 高 | 低 | P0 |
| 周期性回顾摘要 | 中高 | 中 | P1 |
| 语义相似日子推荐 | 中 | 高 | P2 |
| 自动续写或生成日记 | 低 | 高 | 砍掉 |
这个表格直接决定了我的第一版范围:只做P0和P1,P2先留着。原因很简单,把“提取关键词”“判断情绪”做顺,就已经能让用户明确感知到“它变聪明了”。而“自动续写”这类功能,就算做出来也很难保证质量,与其让用户觉得AI在瞎写,不如先把能写实的功能做扎实。开发过程中这个表格也在不断修正,但优先级基本没变过。
2.3 边界:不做什么比做什么更重要
在智能化的项目里,最难的不是技术而是克制。我的明确边界有三条。第一,不把用户的日记上传到云端做分析。日记是极度私密的数据,虽然端侧模型在效果上限可能不如云端大模型,但不出设备这条原则能让用户安心,也让权限设计简单很多。第二,不做“看起来聪明”的续写功能,因为续写一旦偏离用户真实记忆,会非常令人反感。第三,不做复杂的多标签体系,先保持每篇最多5个关键词、1个情绪标签,精简系统复杂度,等积累到足够样本再做聚类。
这些边界在后续开发中帮我省掉大量麻烦,也让我能在有限的精力里把核心链路打磨得更稳定。说句实在话,如果没有这三条边界,我很可能又会陷入“什么都想做、什么都做不深”的状态。做工具应用的人应该都有体会,功能边界划得越清楚,交付质量反而越高。
3. 数据层重构:把一行文本变成有结构的记忆
3.1 数据结构从Text到实体
第一版的核心表只有一张,字段也没几个:id、content、create_time。要在上面做关键词搜索和情绪统计,等于每次都要扫描全文。重构后的实体长这样:
export class DiaryEntity { id: number = 0 title: string = '' content: string = '' summary: string = '' mood: string = 'neutral' // positive | neutral | negative tags: string[] = [] keywords: string[] = [] createTime: number = 0 updateTime: number = 0 isAnalyzed: boolean = false }我特意加了一个isAnalyzed字段。因为智能分析不是瞬间完成的,尤其是历史记录批量处理时,必须让系统知道哪些记录已经分析过、哪些还在队列里,这样重启应用也不会重复跑。这里的核心思路是:数据模型不再为“存储”设计,而是为“理解”设计。之后不管是搜索、统计还是回顾卡片,底层都由这些结构化字段驱动。
3.2 数据库迁移和不丢数据的降级方案
设计新表时我做了两件事:版本号提升,以及将旧表原样保留。迁移函数里先判断当前版本,如果版本低就创建新表,然后把旧表数据一行行复制过来,并对每一条旧内容跑一次分析,把结果回填到新字段。这个过程中如果某条记录分析失败,不能影响整批迁移,我把失败记录放进单独的任务队列,等负载空闲时再重试。数据库表结构如下:
CREATE TABLE IF NOT EXISTS diary ( id INTEGER PRIMARY KEY AUTOINCREMENT, content TEXT NOT NULL, title TEXT DEFAULT '', summary TEXT DEFAULT '', mood TEXT DEFAULT 'neutral', tags TEXT DEFAULT '', keywords TEXT DEFAULT '', create_time INTEGER, update_time INTEGER, is_analyzed INTEGER DEFAULT 0 ); CREATE INDEX idx_diary_create_time ON diary(create_time); CREATE INDEX idx_diary_mood ON diary(mood);因为字段tags和keywords本身是数组,我用逗号拼接成字符串存储,查询时再拆分。虽然不算优雅,但在这个规模下足够直接,而且索引友好。
3.3 一个关键决定:端上优先,数据不出设备
我在调研方案时,脑子里闪过的第一个念头是“接一个云端语言模型,让模型理解日记,然后吐结构化结果”。这种方案效果当然好,但需要用户登录、需要上传内容、需要处理网络异常,还有很大的隐私心理门槛。最后我确定了端上优先原则:使用系统提供的端侧分析能力,加上本地词典规则做兜底。
随之而来的限制就是模型能力和输入长度约束。所以我对正文做了2000字截断,太长的日记先分段处理,再把结果合并。这些设计在后来的用户体验里没有造成明显损失,反而因为速度快,用户对“保存后立刻能看到分析结果”这个反馈印象深刻。
4. 端侧智能接入:关键词、情绪与回顾生成的落地
4.1 封装调用层:把“能力”和“业务”隔开
我没有直接在页面里调用智能接口,而是先封装了一个模块,方便以后替换底层能力。这一步在工程上收益很大,因为系统接口的版本、可用性和返回格式都可能变化,业务层不能跟着被绑死。封装后的调用入口非常简单:
// analysisApi.ts export interface AnalysisResult { keywords: string[] mood: 'positive' | 'neutral' | 'negative' summary?: string } export async function analyzeDiary(content: string): Promise<AnalysisResult> { const normalized = normalize(content) // 优先走端侧分析能力,快速失败时走本地规则 try { return await analysisApi.extract(normalized) } catch (e) { return localFallback(normalized) } }有了这层封装,页面只管调用analyzeDiary,至于内部用的是系统接口还是规则词典,都是可以被替换的细节。对需要长期迭代的工具型应用来说,这种“可降级”的架构比技术本身更重要。我后来更换过一次底层能力,业务代码一行没改,只换了analysisApi内部的实现。
4.2 关键词提取与自动标签
关键词提取的逻辑分为两层:优先让端侧分析接口返回词语列表;如果接口不可用,就退到本地词典抽取。本地抽取做起来并不玄乎:用标点切分句子,按空格和分词把词干挑出来,去掉停用词,统计词频,按词频排序后取前5个作为关键词。虽然比不了深度学习模型准确,但日记这种短文本里的高频词往往就是日常生活的核心实体,所以效果可以接受。
保存后的流程是:先写入正文,再触发分析,拿到关键词后更新tags字段,并把页面上的“分析中”状态切到“已完成”。整个过程让用户觉得日记一保存就被“读”过了。遇到一些特殊文本比如“今天好累”,本地词典可能提取不出有价值的关键词,这时候我会把正文里出现频次最高的实意词当作兜底标签,再不行就干脆留空,也不会影响保存主流程。
4.3 情绪识别的最小可用方案
情绪识别我没有一开始就追求多分类,而是先做了“积极/中性/消极”三分类。实现思路是这样的:先调用端侧接口,让它抽取文本的情感倾向;接口失败时,用一份几百词的本地情绪词表打底,积极词加分、消极词减分,最后看总分落在哪个区间。对于像“今天虽然下雨,但我完成了答辩”这种一句话里正负情绪混杂的情况,我做了窗口化处理:把文本拆成句子,每句单独打分,再按整体权重合成。
这套方案肯定不如大模型细腻,但胜在可解释。我把最终得到的mood标签存进数据库,列表页就能用颜色区分,统计页也能轻松画出一周情绪曲线。对用户而言,“这周有多少天是积极的”比“今天综合情绪指数0.62”更有温度。在算分的时候,我还特别处理了感叹号和程度副词,比如“太棒了”“非常失望”,它们对情绪强度的贡献是普通词的两倍。
附加一份简化的情绪判断逻辑示意:
function detectMoodByLocalDict(content: string): 'positive' | 'neutral' | 'negative' { const sentences = content.split(/[。!?!?]/) let score = 0 for (const sentence of sentences) { const s = scoreSentence(sentence) score += s } if (score > 2) return 'positive' if (score < -2) return 'negative' return 'neutral' }4.4 回顾摘要:把几天的日记浓缩成一段话
回顾摘要是P1能力,我把它放在功能开发的中后期才实现,原因是它的成败直接取决于前面的关键词和情绪数据积累。实现流程是:取近7天日记正文,按时间拼接成一段文本,调用端侧文本摘要接口给出最多150字的浓缩,再把摘要保存到week_review表,供首页回顾卡片读取。为了消化接口的输出波动,我做了稳定性封装:如果摘要结果为空,就把每天的关键词按情绪分组拼成一句话;如果接口超时,就直接用最近一条日记的标题和mood兜底。
这样用户在任何网络和机器状态下都能看到界面上的回顾内容,而不是一个转圈的空页面。代码示意如下:
async function generateWeekReview(days: DiaryEntity[]): Promise<string> { const input = days .sort((a, b) => a.createTime - b.createTime) .map(d => d.content) .join('\n') .slice(0, 3000) try { const summary = await analysisApi.summarize(input, 150) if (summary) return summary } catch (e) { // 忽略,走兜底逻辑 } return days.map(d => `${d.mood}:${d.keywords.join('/')}`).join(';') }在实践中我发现,摘要接口对“流水账式日记”特别不友好,经常吐出“用户今天记录了若干事项”这种正确的废话。为了避免这种情况,我会在拼接输入时给每天日记加上一个前置标题,例如“4月12日”,让模型有更强的日期锚点感,摘要质量会提升不少。这个小技巧算是意外的收获。
5. 交互体验深化:让智能功能真正被用户感知
5.1 时间线聚合:摆脱“一天一条”的枯燥列表
老版本列表就是每条日记一张卡片,按日期倒序,连续三个月之后浏览成本非常高。我把列表改成了“周聚合”的时间线:左侧显示自然周标签,右侧是该周的日记卡片,每张卡片在最上方用很小的色块标出情绪。这样用户每天打开,首先看到的是“这一周整体色调”,而不是在无限列表中往下滚。时间线聚合还能顺带解决“记录频率不高”的心理问题:一周只有三条,渲染出来依然感觉充实,用户回看的意愿明显上升。
5.2 回顾卡片:把结构化数据变成阅读入口
首页顶部我加了一张“本周回顾卡”,展示本周的日记篇数、积极天数、最高频关键词,以及那条自动生成的摘要。点进去是这周的完整日记列表。这张卡片的点击率是我所有页面里最高的,因为它在回答用户潜意识里的那个问题:“这周我到底过得怎么样?”实现上我没有任何复杂动画,只是保证卡片在数据变化后能自动刷新:每当周回顾表更新,就用状态管理刷新首页数据源,卡片中的数字和文案跟着变。
5.3 结构化搜索与结果高亮
搜索是智能日记最容易忽略但用户会高频使用的功能。我做了两类搜索:一类按关键词匹配正文,另一类按结构和情绪筛选,例如“只看消极情绪的记录”。查询时用mood = 'negative'走索引,关键词检索也用LIKE匹配tags和keywords字段,配合简单的高亮展示。在搜索结果页我把匹配到的片段截取出来,前后文各保留20个字符,关键词用加粗展示。这个改动虽然很小,但用户找旧日记的路径从“翻一个月的列表”缩成了“一次搜索一次点击”,对“回溯”价值的贡献非常大。
5.4 动效与性能:别让“智能”毁在卡顿上
接入智能分析后,最容易出现的劣化就是保存按钮点击后页面卡住。我把分析流程全部放到异步任务队列,保存记录本身只负责插入数据库并立即返回,分析结果通过回调刷新UI。列表页为了流畅,使用了懒加载组件,每屏只渲染可见卡片。整套下来,在普通设备上打开几百条记录也不会有明显掉帧。动效方面我只保留了列表卡片进入时的轻微透明度变化,其余全部从简。日记应用的使用场景通常是深夜或碎片时间,动画太多反而累赘。
6. 这轮实践踩过的坑和最后的取舍
6.1 端侧分析结果不稳定,需要多套兜底链
我用“端侧优先,本地规则兜底”听着很稳,但真正的问题是端侧接口偶尔会返回空结果、异常结果,甚至有极低的概率返回乱码。第一版我在调用层只写了一个try/catch,空数组照样会把页面渲染成“没有关键词”。后来改成了三步链路:先检查返回值是否合法,再对内容做空值兜底,最后把整个分析包装成带超时的任务。用户面对的是“有结果”的稳定体验,哪怕结果是规则兜底出来的,也好过看到一处空白。
次要但也很现实的问题是,系统能力在不同系统版本上的表现不同。有的版本对中文分词效果好,有的版本返回速度慢。我在测试设备矩阵上专门跑了一遍,发现同一篇长日记在不同设备上的分析耗时能差出三四倍。因此我在低端机上主动降级:只保留本地规则分析,不去调用更重的系统接口,换取稳定的保存速度。
6.2 旧数据清洗引发“重写日记”事故
迁移旧数据时,我的清洗脚本里有个判断逻辑把“空标题”的记录识别成了无效数据,结果把所有没有标题的历史日记标记成了待删除。因为数据库操作没有做事务保护,测试机上一度出现了几百条记录消失的假象。还好我备份过原始数据库,立刻恢复了。这次事故让我记住了两条规则:迁移和清洗必须开事务,清洗前必须导出备份;所有批量更新操作都要先在模拟数据上跑通再上真机。
引发这个问题的本质是我在清洗脚本里复用了“入参校验”的逻辑,忽略了旧数据本身的分布特征。之后我把清洗脚本单独拆成一个小工具,只负责清理明显异常的记录,不再把“缺字段”当成“脏数据”处理。同一段逻辑修复之后,我还在单元测试里加了一个用例,专门造出一批没有标题的历史记录,确保不会再被误伤。
6.3 后台分析任务和UI更新并发打架
当历史数据批量分析时,如果用户正在首页刷新列表,数据源会被两处同时修改,表现为列表偶发跳动、卡片数据错乱。我加了一个单写入队列:所有分析结果统一从一个队列里按顺序写入数据库,UI只订阅数据库变更通知,而不是让每个异步任务去改内存数组。这样并发问题就消失了,代码也更好理解。如果你也在做类似功能,建议从一开始就把“写数据库”和“改UI数据源”分开,别让两套状态模型互相纠缠。
6.4 如果再来一次,我会少做什么
回头看,这轮功能深化里最值得砍掉的是我为“智能续写”写的一个实验性接口。它花了我将近两个晚上,最后上线时根本没有入口,纯粹是被“AI写字很酷”的心态带偏了。我后来把它彻底删掉,焦点回到“理解用户、辅助回忆”这条主线上。技术选型也一样,很多能力看着花哨,但你的用户真正需要的就是那些每天能被用到的朴素功能。
6.5 这轮改造完成之后我的真实感受
改造上线后,我自己的使用习惯发生了明显变化:以前写完就关,现在每周会点开回顾卡看看这周的关键词;偶尔心情不好的时候,会主动去翻以前的积极记录。做技术的人容易沉浸在“我接入了什么能力”里,但用户感知到的永远是“这个应用有没有帮我记住生活”。如果你也打算把一个简单的工具往智能化方向做,我建议先画一张能力清单,用表格标好优先级,坚决砍掉那些短期内只能叫好不能叫座的特性。从数据建模开始重构,把结构化和兜底做好,再一步一步把智能能力填进去。整个过程不急,一步一个脚印,最后出来的应用一定会比当初那个只有一个输入框的版本靠谱得多。如果后面还有余力,我想做的下一步是把“相似日子”推荐做出来:当你某天状态不好时,推荐过去类似处境里你的记录。这个功能需要更多样本和语义计算,但方向依然是帮助用户理解自己,而不是让AI替用户“编”。这次就先写到这里,希望我的这条路能给你一些参考。