从零到一:在 HarmonyOS 上写一个本地优先的 Markdown 笔记应用(元忆 metanote 设计与踩坑实录)
源码可拷出即用任意编辑器打开,数据不经过任何服务器。这篇记录的是我在开发「元忆 metanote」过程中的架构选择与几个真正让我掉头发的坑。
为什么又造一个笔记「轮子」
市面上的笔记产品基本分两类:一类是云笔记,数据在别人服务器上;一类是 Obsidian 这类本地优先的应用,但移动端体验和多设备同步并不总是顺手。
我想做的东西很朴素:
- 本地优先:一篇笔记就是一个
.md文件,存在应用沙箱里,随时能拷出来用别的编辑器打开; - 多设备同步:同一华为账号下、可信关系已建立的设备之间,笔记自动双向同步;
- 毫秒级搜索:标题、文件名、正文一起检索,输入即出结果;
- 不联网、无 AI(至少 v1 不做):飞行模式下能完整走通「创建 → 编辑 → 预览 → 搜索 → 识图」全流程。
于是有了 元忆(metanote)——一个跑在 HarmonyOS 上的原生 Markdown 笔记应用。它不是某个大功能集的子集,反而是「砍掉云、砍掉 AI」之后剩下的刚需集合。下面把设计和踩坑拆开讲。
一、数据模型:一库 Markdown 文件 + 单一读写入口
元忆的笔记库(vault)就是应用沙箱 filesDir/vault 目录下的 .md 文件树,Obsidian 风格。每篇笔记头部带一段 YAML frontmatter:
---
title: 在鸿蒙上打造本地优先笔记应用
tags: [HarmonyOS, 第二大脑]
created: 2026-09-08T10:00:00.000Z
updated: 2026-10-11T15:30:00.000Z
---
正文从这里开始……
created 首次写入即固定,updated 每次保存自动刷新;重命名文件时 frontmatter 的 title 也会同步更新。
关键设计:所有文件读写都收敛到 VaultStore 这一个类,所有路径都是 vault 内相对路径(根为空串)。为什么?因为同步、搜索、视图三者都要监听文件变化,如果散落各处直接 fs.write ,事件就收不齐。于是:
-
任何写操作完成后,统一广播一条
VaultChangeEvent:UPSERT / RENAME / MOVE / DELETE; -
索引服务、同步服务、视图模型都订阅这个广播,而不是各自轮询文件系统。
// 写笔记后广播,索引/同步/视图三方订阅
this.writeRaw(absPath, serializeFrontmatter(fm) + content);
this.broadcast(new VaultChangeEvent(ChangeKind.UPSERT, relPath, ''));
另一个不起眼但救命的细节是 原子写:所有落盘都走 临时文件 + rename,而不是原地覆盖。否则同步比对或索引扫描很可能读到「写了一半」的内容——后面会看到这正是分布式同步里「撕裂读」问题的根源之一。
二、毫秒级全文搜索:增量索引而不是每次全扫
搜索是元忆的核心亮点。需求很硬:1000 篇笔记规模下,从输入关键词到出结果 ≤ 1 秒,且标题/文件名命中要排在正文命中之前,每条结果高亮关键词前后的片段。
实现上没什么黑魔法,重点是两点:
- 索引随变更增量更新:
VaultStore的变更广播直接驱动搜索索引的局部更新,而不是每次搜索都重新扫全盘;应用启动时再做一次后台全量构建。 - 排序权重:命中类型(标题 / 文件名 / 正文)作为第一优先级,命中片段作为展示。
实测在主流真机上,1000 篇规模键入到出结果约 0.15 秒,比验收标准松一个数量级。够用,且全程不联网。
三、设备间双向同步:最难的不是「同步」,是「不出事故」
这部分是元忆工程量最大、也最值得写的一篇。技术底座是 HarmonyOS 的分布式文件系统 hmdfs:把本地 filesDir/vault 和分布式目录 distributedFilesDir/vault 对齐,同一华为账号、已建立可信关系(超级终端)的设备之间,目录内容会被系统级地镜像。
但「系统帮你镜像目录」≠「应用同步没问题」。我踩了三个真坑。
坑 1:hmdfs 同名冲突副本 *_conflict_devN
当两端几乎同时写一个同名文件,hmdfs 不会帮你合并,而是各自留一份物理副本,命名形如 笔记_conflict_dev1.md。如果不处理,它会被当成「一篇独立的新笔记」拉到对端,凭空多出一堆幽灵笔记。
解法是在比对阶段做归一化:把冲突副本映射回逻辑原名,同名的多个版本取 mtime 最新者作为「远端当前状态」,再把原名实体正常参与合并。
// <原名>_conflict_dev
const base = this.stripConflictSuffix(rf.relPath);
const existing = remoteMap.get(base);
if (existing === undefined || rf.mtime > existing.mtime) {
remoteMap.set(base, this.normalizedRemote(rf, base));
}
坑 2:删除 / 改名被「拉取复活」
最反直觉的 bug:设备 A 删了一篇笔记,下一轮同步时,系统发现「远端还有这份文件」就会把它拉回本地——删除被悄悄复活了。改名同理(旧路径在远端还是一份独立文件)。
元忆的解法是一套 删除墓碑(tombstone)机制:
-
本地删除 / 改名旧路径时,记录一条墓碑
{ 相对路径: 时间戳 }; -
同步时把墓碑写进分布式 vault 根(
sync_tombstones.json),对端合并后删除本地对应副本,从源头消除「仅远端存在 → 拉取恢复」; -
同时要有重建检测:如果远端文件的 mtime 晚于墓碑时间戳,说明那是「删除之后又重新创建的内容」,此时撤销墓碑、按新文件正常拉取,避免「删除 + 同名重建」来回乒乓。
// 是否命中墓碑(自身或任一祖先目录命中都算,文件夹删除覆盖其下全部文件)
private isTombstoned(rel: string): boolean {
if (this.tombstones.has(rel)) return true;
let idx = rel.indexOf('/');
while (idx > 0) {
if (this.tombstones.has(rel.substring(0, idx))) return true;
idx = rel.indexOf('/', idx + 1);
}
return false;
}
坑 3:并发建链撕裂会话,导致「视图被清空」
这是最严重的一次事故。自动建链、设备上线、前台恢复可能并发触发多个 connectDfs 流程,它们对同一台设备竞相 connect / disconnect,把一个本来健康的会话撕成碎片,最终表现成「对端数据从分布式视图整体消失」。
修法是两板斧:
-
建链单飞(single-flight):多个并发建链请求复用同一个在途 Promise,而不是各起各的;
-
代际作废(generation / epoch):每次发起连接、手动断开、停止都递增一个计数器,过期流程的回调回来时先比对代际,发现已被取代就直接回滚自己、绝不落地,避免僵尸连接。
// 并发建链复用在途流程,避免竞相 connect/disconnect 撕裂会话
private autoConnect(): Promise{
if (this.connectedNetworkId.length > 0) return Promise.resolve(true);
if (this.autoConnectFlow !== null) return this.autoConnectFlow; // 复用
const flow = this.connectToAnyPeer(peers, this.connectEpoch);
this.occupyConnectSlot(flow);
return flow;
}
冲突怎么判:LAST_WIN + 锚点
两侧都存在同一文件时,先比内容,相等就跳过;不等则 mtime 较新者胜(LAST_WIN),同 mtime 推本地。
但「内容不等」就通知用户「有冲突」,会在重启后退化成噪音。所以元忆额外维护了一个 冲突锚点:记录上次同步时两侧各自的 mtime。只有当「两侧 mtime 都晚于锚点」(即双端在同步间隙都改过)才判定为真正冲突并弹横幅提示;否则只是正常的单边更新。锚点顺带还防了一种「撕裂读」:锚点匹配但内容不等,说明上一轮同步后留下了半截写入残留,直接重拉自愈,而不是让半截内容凭较新 mtime 反向覆盖好版本。
感知时效:watcher + 降级轮询 + 防抖
同步不是靠轮询硬刷的。两端目录各挂一个 fs.Watcher,文件一变就触发 3 秒防抖 后的全量比对;如果 30 秒没任何事件,watcher 自动降级为 30 秒一次的轮询(watcher 不关,一旦有事件立刻停轮询)。实测单端变更约 3 秒开始同步、30 秒内出现在对端,满足验收。
四、录入方式:离线优先的四种入口
底部悬浮胶囊菜单是全局操作入口,提供四种变成笔记的方式:
| 入口 | 能力 | 联网情况 |
|---|---|---|
| 文字 | 直接新建空白笔记编辑 | 不联网 |
| 录音 | 系统语音识别实时转写为文字 | 系统语音能力(部分机型依赖云端) |
| 识图 | 端侧 OCR 提取图中文字 | 不联网,>4096px 大图先降采样 |
| 链接 | 抓取网页正文转 Markdown(微信 js_content 特判) |
唯一主动联网的内容场景 |
有一个有意的克制:华为账号登录只用来展示昵称和头像,不设任何功能门禁。未登录状态下创建、编辑、搜索、录音、识图、链接收藏全部可用。
五、经验清单(给也想碰 hmdfs 的人)
- 原子写 + 临时文件是底线。分布式场景下「读到半截内容」比本地更常见,别偷懒原地覆盖。
- 分布式文件系统免费给你的是「镜像目录」,不是「同步语义」。冲突副本、删除复活、视图一致性、会话竞态,全得自己处理。
- 墓碑机制几乎是分布式删除的必选项。不这么做,删除一定会被拉取复活。
- 建链要做单飞和代际作废。并发 connect/disconnect 是分布式同步里最隐蔽的「视图清空」事故源。
- 冲突检测用锚点,而不是用「内容是否相等」。否则重启一次就退化成满屏冲突通知。
- 手动断开的设备不要自动重连,这是有意的设计——避免自动重连覆盖用户刚刚做的选择。
- 权限别忘:多设备同步需要
ohos.permission.DISTRIBUTED_DATASYNC,且首启onCreate早于权限弹窗,DeviceManager 可能因无权限初始化失败,授权成功后要补建并重试建链。
结语
元忆不是一个炫技项目,它解决的是我自己的痛点:数据在我手里、搜索够快、多设备不丢。它全部由标准 .md 文件组成,拷出来用 VS Code、Obsidian 都能直接打开——这一点,比任何功能都让我安心。
如果你的鸿蒙设备也想有个纯本地、能同步、搜得快的笔记应用,或者你对 hmdfs 同步的那些坑有不同解法,欢迎在评论区交流。
—— 元忆 metanote,HarmonyOS 原生,本地优先。
浙公网安备 33010602011771号