1. 从零搭建一个"Glearn"式学习工具:我的完整复盘
"Glearn"这个词第一次出现在我视野里,是在一个做在线教育产品的朋友群里。有人丢了一句"我想做个Glearn,把碎片化学习这件事真正跑通",然后群里就炸了。有人理解成"Group + Learn",有人理解成"Gamified Learning",还有人说是"Growth + Learn"。不管哪种拆法,核心诉求其实是一致的:把学习这件事,从"我收藏了等于我学了"的幻觉里拽出来,变成一个可追踪、可反馈、可坚持的闭环系统。
我自己做工具类产品有十来年了,从最早的笔记软件插件,到后来的打卡小程序,再到这两年折腾的各种AI辅助学习工具,踩过的坑不算少。Glearn这个概念之所以让我有兴趣认真拆一遍,是因为它踩中了一个非常真实的痛点:大部分人不是不想学,而是学完之后没有任何"我确实进步了"的感知。收藏夹里躺着几百篇文章,网盘里塞满了课程视频,但真正内化的东西少得可怜。Glearn要解决的,就是把这个"输入—处理—输出—反馈"的链条显性化。
这篇文章适合谁看?如果你是产品经理、独立开发者、或者单纯想给自己搭一套学习系统的重度学习者,接下来的内容应该能给你一些可以直接抄作业的思路。我会从整体设计逻辑讲起,然后拆核心模块的实现细节,再给一套可落地的实操流程,最后把我踩过的坑和排查经验整理出来。全文没有虚的,都是我自己动手验证过的东西。
2. Glearn的整体设计思路与方案选型
2.1 为什么不做"又一个笔记软件"
市面上笔记软件已经卷到天际了,Notion、Obsidian、Logseq、飞书文档,每一个都能满足"记录"这个需求。Glearn如果只是做一个更漂亮的编辑器,那纯属浪费时间。我在动手之前给自己定了一条死规矩:Glearn的核心不是"存",而是"流转"。
什么意思?你学一个知识点,它应该经历这么几个状态:接触到(Captured)→ 理解了(Processed)→ 输出了(Articulated)→ 被验证了(Validated)→ 进入长期记忆(Retained)。大部分工具只覆盖了第一步,顶多到第二步。Glearn要做的是把后面三步也管起来。
这个定位直接决定了技术选型的走向。如果只是记录,一个本地Markdown文件夹就够了;但要管流转,就必须有状态机、有提醒机制、有反馈回路。所以我最终确定的架构是:轻量前端 + 本地优先存储 + 可选的云端同步 + 一个负责调度和提醒的定时任务层。
2.2 核心模块拆解与选型理由
我把Glearn拆成了四个核心模块,每个模块的选型我都纠结过,下面说说为什么这么定。
第一个模块是捕获层(Capture)。这一层要解决的是"随时随地能把想法和素材丢进来"。我试过纯手动输入,坚持了不到一周就放弃了,因为输入摩擦太大。后来改成支持三种入口:浏览器剪藏、快捷指令(手机端)、以及一个全局热键唤起的快速输入框。技术实现上,浏览器端用Content Script抓取正文,手机端走系统分享菜单,桌面端用一个常驻的轻量进程监听热键。
第二个模块是处理层(Process)。这是Glearn区别于普通笔记软件的关键。每一条捕获进来的内容,都会被赋予一个初始状态"待处理",然后系统会按照你设定的节奏(比如每天推送3条)把它推到你面前,逼你做一个动作:要么用自己的话复述一遍,要么打标签归档,要么直接删掉。这个"逼你处理"的机制,是整个系统的灵魂。
第三个模块是输出层(Articulate)。处理完的内容会进入一个"输出队列",系统会定期提醒你把它写成一段完整的表达——可以是一段文字、一张手绘图、甚至一段语音。我一开始想做成强制的,后来发现太反人性,改成了"柔性提醒+进度可视化"。
第四个模块是反馈层(Feedback)。这一层我用了一个间隔重复的简化版算法,不是完整的SM-2,而是一个基于"输出质量"打分的简化调度。你每次回顾一条内容时给它打个分(1-5),系统根据分数决定下次什么时候再推给你。
2.3 技术栈的取舍:为什么选本地优先
关于存储,我认真考虑过纯云端方案。云端的好处是同步方便、多端一致。但我最终选了本地优先(Local-First),理由有三个。
第一是隐私和掌控感。学习记录这种东西,某种程度上比聊天记录还私密,它暴露了你的知识盲区和思维习惯。放在自己手里更踏实。
第二是离线可用。我经常在通勤路上、飞机上处理内容,云端方案一旦断网就抓瞎。
第三是性能。本地读写是毫秒级的,云端再怎么优化也有网络延迟。对于一个需要高频交互的工具来说,这个差异很致命。
具体实现上,我用SQLite做本地存储,数据结构就是几张表:items(捕获的内容)、states(状态流转记录)、reviews(回顾记录)、tags(标签)。同步层用的是一个基于CRDT的增量同步方案,冲突处理走"最后写入优先+手动合并"的混合策略。这部分后面会详细讲。
3. 核心细节解析与实操要点
3.1 状态机设计:Glearn的心脏
Glearn最核心的设计就是那个状态机。我把它画成了一张表,每个状态之间的流转条件都写得很清楚。
| 状态 | 含义 | 进入条件 | 离开条件 |
|---|---|---|---|
| Captured | 刚捕获,未处理 | 任何入口写入 | 被推送到处理队列 |
| Processing | 正在处理中 | 用户打开处理界面 | 完成复述/打标签/删除 |
| Articulated | 已输出表达 | 完成一次完整输出 | 进入回顾队列 |
| Reviewing | 回顾中 | 到达回顾时间 | 打分完成 |
| Retained | 进入长期记忆 | 连续3次高分回顾 | 无(除非手动降级) |
| Archived | 归档 | 手动归档或长期未回顾 | 可手动恢复 |
这张表看起来简单,但每一个流转条件背后都有讲究。比如"Captured → Processing"这一条,我没有做成自动流转,而是加了一个"推送"动作。为什么?因为如果自动流转,用户会面对一个巨大的待处理列表,直接劝退。推送机制把大任务拆成了每天几个小任务,心理压力小很多。
再比如"Reviewing → Retained"需要连续3次高分,这个数字是我调出来的。一开始设的是1次,结果发现太容易"毕业",很多内容其实还没记牢就被标记为已掌握。设成5次又太苛刻,用户会烦躁。3次是个比较舒服的平衡点。
注意:状态机的流转条件一定要可配置。我一开始写死了,后来发现不同人对"掌握"的定义差异很大,有人觉得能复述就算掌握,有人觉得要能教别人才算。所以后来把所有阈值都抽成了配置项。
3.2 捕获层的实现细节与避坑
捕获层看起来简单,其实坑最多。我按入口分别说一下。
浏览器剪藏这块,最大的坑是正文提取。很多网页的DOM结构极其混乱,直接用document.body.innerText会抓一堆导航栏和广告。我试过几个开源库,Readability.js是效果最好的,但也不是万能的。我的做法是:先用Readability提取,如果提取结果长度小于200字,就降级到手动选择区域。这个降级逻辑救了我很多次。
// 简化的剪藏逻辑 function captureFromPage() { const article = new Readability(document.cloneNode(true)).parse(); if (article && article.textContent.length > 200) { return { title: article.title, content: article.textContent }; } // 降级:让用户手动选择 return { needsManualSelection: true }; }手机端快捷指令这块,我用的是系统自带的分享菜单扩展。这里有个细节:分享过来的内容格式五花八门,有的是纯文本,有的是URL,有的是图片。我统一做了一个归一化处理,把URL的内容抓取正文,图片走OCR提取文字,纯文本直接用。OCR我用的是系统本地的识别能力,不上传云端,速度和隐私都更好。
桌面全局热键这块,最大的坑是热键冲突。我一开始设的是Ctrl+Shift+L,结果发现和好几个常用软件的快捷键撞了。后来改成了一个不太常用的组合,并且在设置里允许用户自定义。另外,快速输入框的唤起速度很关键,我实测下来,从按下热键到输入框可输入,超过300毫秒用户就会觉得"卡"。所以这个进程我做了常驻内存+预加载。
3.3 处理层的"逼你处理"机制怎么设计才不招人烦
处理层的核心矛盾是:不逼,用户不会处理;逼太狠,用户会卸载。我在这上面反复调了很多次。
最终的方案是"三档节奏":轻度模式每天推1条,标准模式每天推3条,重度模式每天推5条。用户自己选,而且随时可以改。推送的时间也可以设,我默认设的是早上9点和晚上8点各推一次,因为这两个时间点大部分人比较有空。
推送的形式也讲究。我一开始做的是应用内通知,结果发现用户根本不开应用。后来改成系统级通知,点击直接进入处理界面,处理完直接关闭,整个流程不超过30秒。这个"30秒原则"很重要——如果处理一条内容需要超过30秒,用户就会拖延。
处理界面我做了极简设计,就三个按钮:"我懂了"(复述一遍)、"先放着"(打标签归档)、"不要了"(删除)。没有第四个选项。选项越多,决策成本越高,拖延越严重。
实操心得:处理界面的加载速度比什么都重要。我一开始用了太多动画和过渡效果,结果每次打开要等1秒多,用户流失率很高。后来把所有动画砍掉,改成瞬时渲染,完成率直接上去了。
3.4 输出层与反馈层的联动设计
输出层和反馈层是绑在一起的。你输出一段内容,系统会根据输出的质量给你一个初始分,然后进入回顾队列。
输出质量的评分我用了几个维度:长度、原创性、结构清晰度。长度不是越长越好,我设了一个合理区间(100-500字),太短说明没展开,太长说明可能注水。原创性是通过和原文的相似度来算的,相似度太高说明你只是抄了一遍,没有内化。结构清晰度比较主观,我用的是简单的段落数和连接词密度来估算。
回顾队列的调度算法,我用的是简化版的间隔重复。基础间隔是1天、3天、7天、15天、30天,根据你每次回顾的评分动态调整。评分高就拉长间隔,评分低就缩短间隔。这个算法的细节后面实操部分会展开。
4. 实操过程与核心环节实现
4.1 环境准备与项目初始化
先说环境。我用的是一套比较主流的技术栈:前端用React + TypeScript,桌面端用Tauri(比Electron轻很多),移动端用React Native,后端逻辑全部跑在本地,同步层用一个轻量的Node服务。
为什么桌面端选Tauri不选Electron?主要是体积和内存占用。Electron打包出来动辄100多兆,内存占用也高。Tauri用系统自带的WebView,打包出来只有几兆,内存占用也低一个数量级。对于一个需要常驻后台的工具来说,这个差异很关键。
初始化项目的步骤大致如下:
# 创建Tauri项目 npm create tauri-app@latest glearn cd glearn # 安装核心依赖 npm install better-sqlite3 npm install @mozilla/readability npm install date-fns # 启动开发环境 npm run tauri dev数据库初始化我用的是better-sqlite3,同步API,性能很好。建表语句如下:
CREATE TABLE IF NOT EXISTS items ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT, content TEXT NOT NULL, source_url TEXT, source_type TEXT, created_at INTEGER NOT NULL, state TEXT DEFAULT 'captured' ); CREATE TABLE IF NOT EXISTS reviews ( id INTEGER PRIMARY KEY AUTOINCREMENT, item_id INTEGER NOT NULL, reviewed_at INTEGER NOT NULL, score INTEGER, next_review_at INTEGER, FOREIGN KEY (item_id) REFERENCES items(id) ); CREATE TABLE IF NOT EXISTS tags ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT UNIQUE NOT NULL ); CREATE TABLE IF NOT EXISTS item_tags ( item_id INTEGER NOT NULL, tag_id INTEGER NOT NULL, PRIMARY KEY (item_id, tag_id) );这个schema我改过好几版。最早的时候我把状态直接存在items表里,后来发现状态流转需要历史记录,就拆了一张states表。再后来发现回顾记录也需要独立存储,又加了reviews表。数据库设计这块,我的经验是:宁可一开始多拆几张表,也不要后面改schema,因为数据迁移真的很烦。
4.2 间隔重复算法的参数计算
间隔重复这块,我没有直接用Anki的SM-2,因为SM-2是为记忆卡片设计的,而Glearn处理的是更复杂的内容单元。我自己设计了一个简化版,核心公式如下:
下次间隔 = 基础间隔 × 难度系数 × 评分系数其中基础间隔是一个递增序列:[1, 3, 7, 15, 30, 60, 120](单位:天)。难度系数根据内容的标签来定,比如"数学公式"这种难度高的,系数是0.7(间隔缩短),"生活常识"这种难度低的,系数是1.3(间隔拉长)。评分系数根据你回顾时的打分来定:1分对应0.5,2分对应0.7,3分对应1.0,4分对应1.3,5分对应1.6。
举个例子:一条内容基础间隔走到了7天这一档,标签是"编程概念"(难度系数1.0),你回顾时打了4分(评分系数1.3),那么下次间隔就是7 × 1.0 × 1.3 = 9.1,取整为9天。
这个公式的好处是直观、可调。每个系数都可以在设置里改,用户可以根据自己的实际情况微调。我实测下来,这套参数对大部分人来说是比较舒服的,既不会太频繁地打扰,也不会让内容"失联"太久。
注意:间隔重复算法最忌讳的就是"一刀切"。不同人对同一内容的记忆曲线差异很大,所以一定要留出可调节的空间。我甚至考虑过做一个"自适应"版本,根据用户的历史表现自动调整系数,但后来觉得太复杂,暂时搁置了。
4.3 同步层的冲突处理实操
同步这块我踩的坑最多,重点说一下。
本地优先的架构下,多端同步是绕不开的问题。我用的是CRDT(无冲突复制数据类型)的思路,但没上完整的CRDT库,因为太重了。我的简化方案是:每条记录带一个updated_at时间戳和一个device_id,同步时对比时间戳,新的覆盖旧的。如果两端的时间戳非常接近(比如相差不到1秒),就判定为冲突,弹窗让用户手动选择保留哪一版。
这个方案在大部分情况下够用,但有一个坑:设备时钟不准。我有一次发现两台设备的同步一直出问题,查了半天才发现其中一台设备的系统时间慢了3分钟。后来我加了一个逻辑:同步时先对比两端的时间戳差值,如果差值超过5分钟,就提示用户检查设备时间。
function resolveConflict(localItem, remoteItem) { const timeDiff = Math.abs(localItem.updated_at - remoteItem.updated_at); if (timeDiff < 1000) { // 时间戳太接近,判定为冲突 return { conflict: true, local: localItem, remote: remoteItem }; } return localItem.updated_at > remoteItem.updated_at ? localItem : remoteItem; }另外一个坑是删除的同步。如果我在A设备删了一条内容,B设备不知道,同步时又把它传回来了,就会出现"删不掉"的情况。我的解决方案是软删除:删除时不真删,而是把状态标记为deleted,同步时这个状态也会传过去。定期清理的时候再真正物理删除。
4.4 通知系统的实现与调优
通知系统看起来简单,其实很考验细节。我用的是系统级通知,桌面端走Tauri的通知API,移动端走React Native的推送。
通知的触发逻辑是这样的:每天早上9点和晚上8点,系统检查一次待处理队列和回顾队列,如果有内容,就发一条通知。通知的内容我做了个性化,不是干巴巴的"你有3条待处理",而是带上具体内容的标题,比如"《如何设计状态机》该回顾了"。
这个细节看起来小,但效果很明显。带标题的通知点击率比不带的高了将近一倍,因为用户能立刻判断这条通知值不值得点。
通知的频率我也调过。一开始是每条内容都发一条通知,结果用户被轰炸得直接关了通知权限。后来改成聚合通知,一次最多提3条,超过的就说"还有N条待处理"。这个改动之后,通知权限的保留率明显上升。
实操心得:通知的文案一定要具体、有行动指向。"你有新消息"这种文案是最差的,"《XXX》该回顾了,预计2分钟"这种文案点击率最高。另外,通知的时间一定要可配置,默认时间不可能适合所有人。
5. 常见问题与排查技巧实录
5.1 捕获失败与内容丢失的排查
捕获失败是最让人抓狂的问题,因为用户会觉得"我的东西丢了"。我把常见的捕获失败场景和排查方法整理成了下面这张表。
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 剪藏后内容为空 | 页面是动态渲染的 | 查看页面是否用了大量JS | 等待页面加载完成再剪藏 |
| 剪藏内容包含大量广告 | 正文提取失败 | 检查Readability返回结果 | 降级到手动选择模式 |
| 手机分享无反应 | 分享扩展未注册 | 检查系统分享菜单 | 重新安装或重启应用 |
| 热键无响应 | 热键冲突 | 逐个关闭其他软件测试 | 更换热键组合 |
| 内容写入后消失 | 数据库写入失败 | 查看应用日志 | 检查磁盘空间和权限 |
这里面最常见的是"剪藏后内容为空",尤其是那些用React或Vue做的单页应用。这类页面的内容是在JS执行后才渲染出来的,如果剪藏脚本执行太早,抓到的就是空壳。我的解决方案是加一个延迟,等页面load事件触发后再等500毫秒再抓取。这个延迟时间可以配置,默认500毫秒,遇到特别慢的页面可以手动调大。
另一个常见问题是"内容写入后消失",这个通常是数据库写入失败导致的。better-sqlite3在磁盘空间不足或者文件权限有问题时会抛异常,如果没捕获,用户就会觉得内容凭空消失了。我的做法是所有写入操作都包在try-catch里,失败时弹窗提示,并且把内容暂存到内存里,等下次启动时重试。
5.2 同步冲突的典型场景与处理
同步冲突我遇到过好几次,最典型的有两个场景。
场景一:两端同时编辑同一条内容。这个场景其实很少见,因为Glearn的内容大部分是单向的(捕获→处理→输出),很少有需要同时编辑的情况。但偶尔还是会发生,比如我在电脑上给一条内容打标签,同时在手机上给它打分。这种情况下,我的处理是弹窗让用户选择保留哪一版,或者手动合并。
场景二:一端删除,另一端修改。这个场景更常见。比如我在电脑上删了一条觉得没用的内容,但手机上之前给它打了标签,同步时就会冲突。我的处理是:删除优先。因为删除通常是有意为之,而修改可能是无意的。但这个规则也不是绝对的,所以我在设置里留了一个开关,让用户自己选。
注意:同步冲突的处理策略一定要让用户可感知。最怕的就是"静默处理",用户不知道发生了什么,就会觉得数据不可靠。我现在的做法是每次冲突都记一条日志,用户可以在设置里查看冲突历史。
5.3 性能问题的排查与优化
Glearn用久了之后,性能问题会逐渐显现。我遇到过的性能问题主要有三个。
第一个是数据库查询变慢。当items表的数据量超过1万条时,一些没有索引的查询会明显变慢。我的解决方案是给常用查询字段加索引,比如state、created_at、next_review_at。加索引之后,查询时间从几百毫秒降到了几毫秒。
第二个是界面渲染卡顿。处理队列如果一次渲染太多条,界面会卡。我的解决方案是虚拟列表,只渲染可视区域内的内容。这个改动之后,即使队列里有几千条内容,滚动也很流畅。
第三个是同步耗时变长。数据量大了之后,全量同步会越来越慢。我的解决方案是增量同步,只同步上次同步之后有变化的内容。这个改动之后,同步时间从几十秒降到了几秒。
-- 加索引的语句 CREATE INDEX idx_items_state ON items(state); CREATE INDEX idx_items_created_at ON items(created_at); CREATE INDEX idx_reviews_next_review_at ON reviews(next_review_at);5.4 用户坚持不下去怎么办
这是最根本的问题,也是所有学习工具都面临的终极挑战。我的观察是,用户放弃通常发生在第3天到第7天之间,也就是新鲜感过去、习惯还没养成的阶段。
针对这个问题,我做了几件事。第一是降低启动门槛,新用户第一次打开时,不要求做任何设置,直接进入一个"体验模式",系统预置了几条示例内容,让用户先体验一遍完整流程。第二是可视化进度,用一个简单的进度条展示"本周处理了X条,回顾了Y条",让用户有成就感。第三是柔性提醒,如果用户连续3天没打开,不轰炸通知,而是发一条温和的提醒,比如"你的学习队列里还有5条内容,有空的时候来看看"。
这些措施的效果是有的,但也不是万能的。我的体会是:工具能做的只是降低摩擦,真正的坚持还是靠用户自己。Glearn的定位是"让坚持变得容易一点",而不是"替用户坚持"。
6. 我踩过的坑与独家经验
6.1 关于功能取舍的教训
做Glearn的过程中,我最大的教训就是功能加得太快。一开始我想做一个"全能学习工具",又是笔记、又是思维导图、又是闪卡、又是社区。结果做了三个月,核心流程还没跑通,功能倒是堆了一堆。
后来我痛定思痛,把所有非核心功能全砍了,只保留"捕获—处理—输出—回顾"这条主线。砍完之后,代码量少了一半,但核心体验反而好了很多。这个教训让我明白一个道理:工具的价值不在于功能多,而在于核心流程是否顺畅。
6.2 关于数据安全的经验
学习数据是用户的长期积累,一旦丢失,损失巨大。我在数据安全上做了几层防护。
第一层是本地备份。每天自动备份一次数据库文件,保留最近7天的备份。备份文件放在一个单独的目录,和主数据库分开。
第二层是导出功能。支持导出为Markdown、JSON、CSV等格式,用户可以随时把数据带走。这个功能看起来简单,但很重要,它给用户一种"数据是我的"的安全感。
第三层是同步冗余。如果用户开启了同步,数据会同时存在本地和云端,任何一端出问题都不会导致数据丢失。
实操心得:备份一定要自动化,不能依赖用户手动操作。我见过太多用户因为忘记备份而丢失数据的案例。另外,备份文件一定要定期验证可恢复性,不然备份了等于没备份。
6.3 关于用户体验的细节
有几个细节是我后来才意识到很重要的。
第一个是加载速度。前面提过,处理界面的加载速度直接影响完成率。我后来把所有能预加载的都预加载了,包括下一条内容、标签列表、常用操作。用户点开就能用,没有任何等待。
第二个是错误提示。早期的错误提示都是技术性的,比如"SQLITE_BUSY",用户根本看不懂。后来我全部改成了人话,比如"数据库正忙,请稍后重试"。这个改动之后,用户遇到问题时的焦虑感明显降低。
第三个是空状态设计。当用户没有待处理内容时,界面不应该是一片空白,而应该给一些引导,比如"今天没有待处理内容,要不要回顾一下之前学过的?"这个细节能有效提升用户的留存。
6.4 关于后续扩展的思考
Glearn目前还是一个比较基础的工具,但它的架构留了不少扩展空间。我最近在尝试的一个方向是基于内容的智能推荐,根据用户的历史处理记录,推荐相关的学习内容。这个功能还在实验阶段,效果还不稳定,但方向我觉得是对的。
另一个方向是协作学习。Glearn目前是单机工具,但学习这件事其实可以有社交属性。比如几个人组成一个学习小组,互相查看对方的输出,互相点评。这个功能我还在设计阶段,主要考虑的是隐私问题——不是所有人都愿意把自己的学习记录分享给别人。
还有一个方向是和现有工具的集成。比如和日历集成,把回顾任务直接同步到日历里;和待办工具集成,把处理任务变成待办事项。这些集成的技术难度不大,但能显著提升工具的实用性。
7. 写在最后的一些个人体会
做Glearn这段时间,我最大的收获不是技术上的,而是对"学习"这件事本身的理解。以前我总觉得学习就是"输入",看得越多越好。但做完这个工具之后,我越来越意识到,学习的本质是"输出"和"反馈"。你看了一百篇文章,不如认真写一篇自己的理解;你收藏了一千个视频,不如把其中一个讲给别人听。
Glearn这个工具,说到底就是把这个道理工具化了。它逼着你从"收藏家"变成"创作者",从"输入者"变成"输出者"。这个过程一开始会很不舒服,因为输出比输入累多了。但坚持一段时间之后,你会发现自己的进步是实实在在的。
如果你也在做类似的事情,或者想给自己搭一套学习系统,我的建议是:先从最小的闭环开始。不要一上来就追求完美,先把"捕获—处理—输出"这条线跑通,哪怕是用最土的办法。跑通之后,再慢慢优化。工具是次要的,习惯才是核心。
最后分享一个小技巧:我在Glearn里加了一个"随机回顾"功能,每天随机抽一条很久以前的内容推给我。这个功能经常能给我带来惊喜,因为很多当时觉得没用的内容,过了一段时间再看,突然就有了新的理解。学习这件事,有时候真的需要一点时间的发酵。