项目仓库https://atomgit.com/nutpi/InkNote本地笔记应用看起来不复杂左边列表中间编辑器右边预览再加一个“自动保存”。可真正开始写以后最先暴露的问题往往不是 Markdown 渲染而是数据什么时候落盘。用户连续输入时上一轮保存可能还没结束切换笔记时当前内容可能仍在防抖计时器里进程恰好在写元数据时退出还可能留下半截 JSON。InkNote 把这些问题拆成两层Flutter 负责编辑状态与保存时机Rust 负责文件组织、元数据恢复和原子写入中间用 FRB 连接。当前界面图中是 InkNote 在 HarmonyOS PC 真机上的运行状态我新建FRB Storage Acceptance输入正文等底部变成“已保存”关闭应用窗口再从设备端重新启动 InkNote。重启后标题、113 字正文和分栏预览仍然存在。发布图只裁切了应用窗口原始截图没有用于正文因为桌面背景不属于项目结果。仓库Docs/images里还保留了早期 Tauri 版截图和小窗 GIF它们可以用于讲迁移历史但不能当成当前 Flutter 版成品图。此前用 Fake Repository 生成的截图也已经撤下它只能测页面不能拿来证明保存链路。从哪些文件开始读InkNote/ |-- lib/main.dart RustLib 初始化和应用入口 |-- lib/src/app/app_controller.dart 编辑、revision、防抖和切换逻辑 |-- lib/src/notes/note_repository.dart | Dart 仓库接口与 FRB 实现 |-- lib/src/notes/note_editor.dart 编辑/预览界面 |-- rust/src/api/notes.rs 对 Flutter 暴露的笔记 API |-- rust/src/api/models.rs 跨语言结构 |-- rust/src/store.rs 文件布局、原子写入和恢复 -- lib/src/rust/ 自动生成绑定这个阅读顺序有个好处先看控制器就能知道“什么时候保存”再看仓库知道“调用了什么”最后到store.rs确认“怎样落盘”。如果直接从按钮一路点进生成代码很容易把大量序列化细节误当成核心逻辑。先划分数据所有权InkNote 没有把每一次按键都直接送进 Rust而是让 Dart 保留正在编辑的临时状态。Rust 只接收一次完整的保存事务内容变化700 ms 防抖FRB updateNote临时文件 renameNote / NoteSummary / ErrorFlutter 编辑器AppControllerNoteRepositoryRust notes APINoteStorenotes/*.mdmetadata.json / settings.json本地文件系统列表、预览、保存状态这条边界有两个好处。输入框不会因为跨语言调用而卡顿文件一致性又集中在 Rust 一处处理。FRB 配置与 Rust API项目使用的配置如下rust_input:crate::apirust_root:rust/dart_output:lib/src/rust手写 API 只表达应用真正需要的动作初始化、列出、读取、创建、更新、删除和分类管理。pubfninitialize(data_dir:String)-ResultAppSnapshot,String{letstoreNoteStore::open(PathBuf::from(data_dir)).map_err(|error|error.to_string())?;crate::store::replace_store(store).map_err(|error|error.to_string())?;with_store(|store|{Ok(AppSnapshot{settings:store.load_settings()?,notes:store.list_notes()?,categories:store.list_categories()?,})}).map_err(|error|error.to_string())}pubfnupdate_note(id:String,title:String,content:String,category:String,)-ResultNote,String{with_store(|store|store.update_note(id,title,content,category)).map_err(|error|error.to_string())}初始化一次返回AppSnapshot避免 Flutter 启动时分别请求设置、笔记列表和分类产生三个时序不确定的加载状态。文件保存为什么放在 Rust关键不在于 Rust 写文件更快而在于这里可以把写入规则固定下来。NoteStore::write_json最终进入write_bytes先写.tmp刷新后再替换目标文件。fnwrite_jsonT:Serialize(self,path:Path,value:T,)-Result(),StoreError{letmutbytesserde_json::to_vec_pretty(value)?;bytes.push(b\n);self.write_bytes(path,bytes)}fnwrite_bytes(self,path:Path,bytes:[u8])-Result(),StoreError{ifletSome(parent)path.parent(){fs::create_dir_all(parent)?;}lettemp_pathpath.with_extension(tmp);letmutfilefs::File::create(temp_path)?;file.write_all(bytes)?;file.sync_all()?;drop(file);fs::rename(temp_path,path)?;Ok(())}如果程序在write_all中途退出正式文件仍然保留上一版。重新启动时设置文件解析失败会先备份再回到默认设置元数据损坏则扫描笔记目录重建索引。恢复逻辑不是 UI 的职责也不应该散落在多个页面中。700ms 自动保存还不够Flutter 侧的防抖只是减少保存频率。真正防止“旧保存覆盖新编辑”的是 revisionvoidupdateDraft({String?title,String?content,String?category}){if(selectedNotenull)return;finalnextTitletitle??draftTitle;finalnextContentcontent??draftContent;finalnextCategorycategory??draftCategory;if(nextTitledraftTitlenextContentdraftContentnextCategorydraftCategory)return;draftTitlenextTitle;draftContentnextContent;draftCategorynextCategory;_revision1;saveStateSaveState.dirty;_autoSaveTimer?.cancel();if(settings.autoSave){_autoSaveTimerTimer(constDuration(milliseconds:700),saveNow,);}notifyListeners();}FuturevoidsaveNow()async{_autoSaveTimer?.cancel();if(selectedNotenull||saveStateSaveState.saved)return;finalnoteIdselectedNote!.id;finalrevision_revision;finaltitledraftTitle;finalcontentdraftContent;finalcategorydraftCategory;saveStateSaveState.saving;notifyListeners();try{finalupdatedawait_repository.updateNote(id:noteId,title:title,content:content,category:category,);if(selectedNote?.idnoteId)selectedNoteupdated;await_refreshNotes();saveStaterevision_revision?SaveState.saved:SaveState.dirty;}catch(_){saveStateSaveState.failed;}notifyListeners();}场景很实际revision 为 12 时发起保存等待 Rust 返回期间用户又敲了几个字revision 变成 13。此时第 12 版写盘成功但界面仍应保持dirty继续保存第 13 版不能显示“已保存”后就停住。切换笔记也不能只改selectedId。控制器会先flushPendingSave()保存失败时返回false阻止切换。否则用户看到的是下一篇笔记上一篇未保存内容却已经从编辑器里消失。Dart 仓库层不要省页面若直接调用生成的notes.updateNote()测试就必须初始化原生库。项目中保留NoteRepository接口生产环境使用 FRB 实现Widget 测试使用内存实现。这样可以稳定复现下面的操作finalrepositoryFakeNoteRepository();finalcontrollerAppController(repository);awaittester.pumpWidget(InkNoteApp(controller:controller));awaittester.tap(find.byKey(constKey(new-note-button)));awaittester.enterText(find.byKey(constKey(note-content-field)),# 今天\n\n- 完成 FRB 迁移,);awaittester.pump(constDuration(milliseconds:800));expect(repository.updates.last.content,contains(FRB 迁移));expect(find.text(已保存),findsOneWidget);这也是本文的实操案例创建笔记、连续编辑、等待 700ms 自动保存再确认仓库收到内容且 UI 进入已保存状态。本地复现cdInkNote flutter pub get flutter_rust_bridge_codegen generatecargotest--manifest-path rust/Cargo.toml fluttertestflutter run-dmacos建议再手工做两次破坏性测试先把metadata.json改成不完整 JSON确认启动后能备份并重建再让数据目录只读确认保存失败时界面没有悄悄切换到另一篇笔记。真机实操一定要做到“关掉再打开”我这次采用的是下面这条可重复路径在空工作区点击“新建笔记”确认进入编辑器而不是只在列表插入占位项。修改标题和正文等待 700ms 防抖结束。检查底部从编辑中/保存中切换为“已保存”同时记下字数。关闭整个应用窗口不只是返回上一页。再次启动同一包名确认标题、正文、分类和 Markdown 预览都能恢复。重新修改一处内容再观察 revision 是否继续递增并保存。设备侧命令如下HDC/Applications/DevEco-Studio.app/Contents/sdk/default/openharmony/toolchains/hdcBUNDLE_NAME$(awk-F/bundleName/ {print $4; exit}ohos/AppScope/app.json5)$HDCshell aa start-aEntryAbility-b$BUNDLE_NAME$HDCshell uitest dumpLayout-b$BUNDLE_NAME\-p/data/local/tmp/inknote-reopened-layout.json$HDCshell snapshot_display\-f/data/local/tmp/inknote-reopened.jpeg重新启动后的界面树仍能读到FRB Storage Acceptance、完整正文和“已保存 / 113 字 / Markdown · UTF-8”。这次验证覆盖了 UI、FRB、Rust Store 和重新加载不再用 Fake Repository 的内存内容代替磁盘结果。保存失败时页面应该怎样表现可靠保存并不是遇到异常就弹一个 SnackBar。下面几类失败要分开处理场景控制器应保留的状态用户下一步数据目录不可写当前草稿和failed修复权限后重试不允许静默丢稿保存中继续输入新 revision 保持dirty当前写入完成后继续保存最新版切换笔记前保存失败仍停留在当前笔记重试或明确放弃metadata.json损坏备份损坏文件并扫描正文检查恢复列表正文存在、索引缺失从notes/*.md重建摘要重新选择并核对标题临时文件残留正式文件保持上一版启动恢复时清理或忽略.tmp这里最重要的是“失败不能伪装成成功”。如果 Rust 抛错Dart 不能因为定时器已经结束就把状态改为saved如果切换动作被阻止也要留在原编辑器里让草稿仍可见。再补一组真正有用的测试Widget 测试验证 700ms 后仓库收到内容还不够。Rust 侧应使用临时目录执行一次真实文件回环创建笔记、更新、销毁 Store、用同一目录重新初始化、再次读取并比较字段。接着人为写坏元数据确认恢复不会删除正文。#[test]fnnote_survives_store_reopen(){letdirtempfile::tempdir().unwrap();letmutfirstNoteStore::open(dir.path().to_path_buf()).unwrap();letnotefirst.create_note(验收记录.into()).unwrap();first.update_note(note.id,验收记录.into(),Flutter - FRB - Rust.into(),未分类.into(),).unwrap();drop(first);letsecondNoteStore::open(dir.path().to_path_buf()).unwrap();letrestoredsecond.load_note(note.id).unwrap();assert_eq!(restored.content,Flutter - FRB - Rust);}如果仓库现有方法名与示例略有不同以rust/src/store.rs为准。测试目标不是照抄函数名而是强制跨越“Store 被销毁并重新打开”这条边界。验收记录应该写事实不写感受验收项本次结果证据HarmonyOS 应用启动PASS真机包名启动成功新建并编辑PASS编辑区与预览区出现相同正文自动保存PASS底部实际显示“已保存”关闭后重开恢复PASS重启后仍为 113 字且内容一致损坏元数据恢复需按交付环境复测Rust 单测与人工破坏测试断电级持久性本截图不证明需要设备断电重启专项测试“本地优先”在这个项目里到底指什么很多笔记软件也把文件放在本机但只要保存按钮的反馈不可靠用户依然不敢把重要内容交给它。InkNote 对本地优先的理解不是“没有服务器”这么简单而是编辑过程中不依赖网络、保存结果可以被确认、异常退出后能重新找到内容并且文件损坏时有明确的恢复路径。数据放在哪里只是第一步数据在什么时候从内存变成可恢复状态才是核心。Flutter 编辑器里显示的文字首先属于当前草稿。用户每输入一个字符界面应该立即响应而不是等待 Rust 写完文件再刷新。到了防抖时间控制器截取当时的标题、正文、分类和 revision组成一次保存请求。请求发出以后新输入仍然可以继续进入草稿。这样页面不会因磁盘写入停顿Rust 又能拿到一份边界明确的完整内容。这里有一个容易忽略的认知差异磁盘里已经写入 revision 12并不代表用户看到的 revision 13 已经安全。底部状态必须描述当前草稿而不是描述最近一次成功请求。只有返回的 revision 等于当前 revision才能显示“已保存”。这种判断看似多了一两个字段却直接决定用户是否会在错误时间关闭窗口。我为什么坚持做关闭后重开的验证组件测试中的内存仓库可以很方便地返回一条笔记也能让底部出现“已保存”。问题是内存仓库不会遇到路径权限、序列化格式、临时文件替换和应用支持目录这些真实条件。此前的截图虽然画面完整但它没有经过 Rust Store不能回答内容是否真的存在磁盘上。这正是这次必须重新取证的原因。真机操作时我先新建笔记输入标题和一段能说明调用链的正文等待状态变成“已保存”。随后不是简单返回列表而是关闭整个应用窗口。再次用同一包名启动后应用完成 FRB 初始化、打开原来的数据目录、读取元数据并加载正文。标题、113 字正文、分栏预览和保存状态都恢复才算跨过了持久化边界。这个验证仍然有范围。关闭窗口再打开能够证明正常退出和应用重启后的读取不等于突然断电也一定安全。要验证断电需要在设备写入期间切断电源或强制终止进程并重复足够次数观察正式文件、临时文件和目录元数据。文章把这项单独列为未证明就是避免用一次正常重启覆盖更严格的可靠性问题。元数据损坏时为什么不能直接清空笔记系统通常有两类数据正文文件是真正的用户资产元数据负责标题摘要、分类、排序和索引。若元数据 JSON 无法解析最省事的处理是恢复默认值但这样会让列表看起来一篇笔记都没有。正文其实还在用户却会以为全部丢失。InkNote 的恢复思路是先保留损坏文件再扫描正文目录重建索引把能确认的内容尽量找回来。恢复时也不能过度猜测。文件名可以提供 ID正文可以提供内容但分类和更新时间未必能完整重建。无法确认的字段应使用可识别的默认值并在诊断信息中说明发生过恢复。静默生成一份看似正常的新元数据虽然界面好看却会掩盖数据曾经受损的事实也让后续排查失去原始证据。临时文件的处理同样需要谨慎。原子替换前留下的.tmp可能是未完成写入也可能比正式文件更新。应用启动时不能只按修改时间选择较新的一个因为较新的临时文件也可能只有半段内容。至少要先校验格式和关联 ID必要时将其移入恢复区由用户或支持人员决定是否采纳。自动恢复的原则应该是“不破坏最后一份已知有效数据”。编辑体验和数据可靠性并不冲突有人担心把保存状态做得太严格会让页面频繁显示“未保存”影响观感。实际上清楚的状态比长期显示绿色更让人安心。编辑中可以用轻量文字提示不必每次弹窗保存失败时才使用明显颜色并提供重试。切换笔记或关闭窗口时如果仍有未保存内容则等待当前任务完成或给出明确选择。用户需要的是可预期而不是永远看起来成功。搜索和分类也要尊重同一原则。当前笔记还没保存时列表摘要可以即时反映草稿但搜索索引究竟基于草稿还是磁盘内容必须统一。如果列表显示新标题搜索却仍只能搜到旧标题用户会怀疑数据丢失。比较稳妥的设计是界面明确区分当前草稿和已持久化索引并在保存成功后刷新列表与搜索数据。对于大笔记实时 Markdown 预览和文件保存最好各自防抖。预览属于可丢弃的计算结果旧任务返回后可以直接忽略保存属于数据事务不能简单取消正在进行的写入。两者都由内容变化触发却不能共用一个“最后任务获胜”的粗糙逻辑。文章里 revision 的设计正是把这两类异步结果分开处理的基础。交接项目时应留下哪些说明除了源码和测试我会把数据目录结构、各文件用途、恢复优先级和备份策略写进维护文档。开发者必须知道哪些文件可以重建哪些文件删除后不可恢复也要知道迁移版本时先备份什么。没有这份说明新功能最容易在“整理旧文件”时误删用户正文。格式升级最好使用显式 schema 版本。新版本第一次打开旧数据时先复制或记录迁移点再逐步转换迁移完成后重新读取并校验不能只因为写文件没有抛异常就宣布成功。若中途失败应继续允许旧版本数据被识别至少提供导出通道。对本地笔记来说向后兼容往往比新增一种编辑器样式重要得多。最后还要考虑用户主动备份。数据虽然存在应用私有目录但最好能导出普通 Markdown 和必要的元数据让内容不被应用锁死。Rust Store 内部可以采用更适合一致性的组织方式对外导出则应保持开放、可读。做到这一点本地优先才不仅是技术架构也成为用户真正拥有数据的一种承诺。当前完成状态一次保存为什么要留下可追踪的版本自动保存还有一个经常被忽略的问题用户看到的内容、正在写入的内容和磁盘上已经确认的内容可能同时属于三个不同版本。假设用户连续输入 A、B、C保存 A 的异步任务此时才返回如果页面仅收到一个“成功”布尔值就可能把包含 C 的界面错误标成已保存。这里不能用完成时间推断新旧而要让每次编辑递增 revision保存请求携带自己的 revision成功回调也返回同一个值。只有返回值等于当前内容版本页面才显示“已保存”较旧任务成功只能说明旧快照已经落盘。这个版本号不必永久写进每一篇正文但在进程内必须保持单调。切换笔记时版本还要和 note ID 一起比较避免上一条笔记的迟到回调改变当前页面状态。此类问题在快速输入时不明显通常在磁盘较慢、首次创建目录或系统忙碌时才暴露所以测试中应人为延迟第一次保存让第二次编辑先发生再核对最终文件和状态提示。退出流程也应复用同一套判断。窗口收到关闭请求后先看当前 revision 是否已有对应的持久化确认若没有则等待正在执行的写入或者发起最后一次保存。超过合理时间仍失败时应明确告诉用户草稿尚未落盘而不是强行关闭并留下一个“正常退出”的假象。桌面应用里的关闭按钮看似只是 UI 行为实际上是数据可靠性链路的最后一道关口。这些设计不会让编辑器显得复杂反而把问题收拢在仓库层。Widget 只显示正在编辑、保存中、已保存和保存失败Rust Store 只承诺某个确定版本是否完成原子替换。两边通过明确的数据语义协作比页面自行观察文件时间更容易测试也更便于交接。当前 Flutter 版已经具备本地 Markdown 编辑、实时预览、分类、搜索、700ms 自动保存、保存状态提示以及 Rust 侧的原子元数据写入和损坏恢复。图中内容来自 HarmonyOS PC 真机笔记完成保存后关闭整个应用再次启动时由同一数据目录恢复标题、正文和预览。旧 Tauri 界面仅用于版本迁移对照不参与本次持久化结果判断。做完这个项目后我对“本地优先”的理解也变得具体了不是数据存在本机就算完成而是在断网、写入失败、切换页面和异常退出这些情况下用户仍然知道内容处于什么状态并且有办法恢复。