从零到一:在 HarmonyOS 上写一个本地优先的 Markdown 笔记应用(元忆 metanote 设计与踩坑实录)

源码可拷出即用任意编辑器打开,数据不经过任何服务器。这篇记录的是我在开发「元忆 metanote」过程中的架构选择与几个真正让我掉头发的坑。

为什么又造一个笔记「轮子」

市面上的笔记产品基本分两类:一类是云笔记,数据在别人服务器上;一类是 Obsidian 这类本地优先的应用,但移动端体验和多设备同步并不总是顺手。

我想做的东西很朴素:

  1. 本地优先:一篇笔记就是一个 .md 文件,存在应用沙箱里,随时能拷出来用别的编辑器打开;
  2. 多设备同步:同一华为账号下、可信关系已建立的设备之间,笔记自动双向同步;
  3. 毫秒级搜索:标题、文件名、正文一起检索,输入即出结果;
  4. 不联网、无 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 秒,且标题/文件名命中要排在正文命中之前,每条结果高亮关键词前后的片段。

实现上没什么黑魔法,重点是两点:

  1. 索引随变更增量更新:VaultStore 的变更广播直接驱动搜索索引的局部更新,而不是每次搜索都重新扫全盘;应用启动时再做一次后台全量构建。
  2. 排序权重:命中类型(标题 / 文件名 / 正文)作为第一优先级,命中片段作为展示。

实测在主流真机上,1000 篇规模键入到出结果约 0.15 秒,比验收标准松一个数量级。够用,且全程不联网。


三、设备间双向同步:最难的不是「同步」,是「不出事故」

这部分是元忆工程量最大、也最值得写的一篇。技术底座是 HarmonyOS 的分布式文件系统 hmdfs:把本地 filesDir/vault 和分布式目录 distributedFilesDir/vault 对齐,同一华为账号、已建立可信关系(超级终端)的设备之间,目录内容会被系统级地镜像。

但「系统帮你镜像目录」≠「应用同步没问题」。我踩了三个真坑。

坑 1:hmdfs 同名冲突副本 *_conflict_devN

当两端几乎同时写一个同名文件,hmdfs 不会帮你合并,而是各自留一份物理副本,命名形如 笔记_conflict_dev1.md。如果不处理,它会被当成「一篇独立的新笔记」拉到对端,凭空多出一堆幽灵笔记。

解法是在比对阶段做归一化:把冲突副本映射回逻辑原名,同名的多个版本取 mtime 最新者作为「远端当前状态」,再把原名实体正常参与合并。

// <原名>_conflict_dev.<扩展名> → 原名;同名多版本取 mtime 最新者
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 原生,本地优先。