:用 Flutter + Rust 做可靠的本地 Markdown 筆記)
項目倉庫https://atomgit.com/nutpi/InkNote本地筆記應用看起來不復雜左邊列表中間編輯器右邊預覽再加一個“自動保存”。可真正開始寫以后最先暴露的問題往往不是 Markdown 渲染而是數(shù)據(jù)什么時候落盤。用戶連續(xù)輸入時上一輪保存可能還沒結束切換筆記時當前內(nèi)容可能仍在防抖計時器里進程恰好在寫元數(shù)據(jù)時退出還可能留下半截 JSON。InkNote 把這些問題拆成兩層Flutter 負責編輯狀態(tài)與保存時機Rust 負責文件組織、元數(shù)據(jù)恢復和原子寫入中間用 FRB 連接。當前界面圖中是 InkNote 在 HarmonyOS PC 真機上的運行狀態(tài)我新建FRB Storage Acceptance輸入正文等底部變成“已保存”關閉應用窗口再從設備端重新啟動 InkNote。重啟后標題、113 字正文和分欄預覽仍然存在。發(fā)布圖只裁切了應用窗口原始截圖沒有用于正文因為桌面背景不屬于項目結果。倉庫Docs/images里還保留了早期 Tauri 版截圖和小窗 GIF它們可以用于講遷移歷史但不能當成當前 Flutter 版成品圖。此前用 Fake Repository 生成的截圖也已經(jīng)撤下它只能測頁面不能拿來證明保存鏈路。從哪些文件開始讀InkNote/ |-- lib/main.dart RustLib 初始化和應用入口 |-- lib/src/app/app_controller.dart 編輯、revision、防抖和切換邏輯 |-- lib/src/notes/note_repository.dart | Dart 倉庫接口與 FRB 實現(xiàn) |-- 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確認“怎樣落盤”。如果直接從按鈕一路點進生成代碼很容易把大量序列化細節(jié)誤當成核心邏輯。先劃分數(shù)據(jù)所有權InkNote 沒有把每一次按鍵都直接送進 Rust而是讓 Dart 保留正在編輯的臨時狀態(tài)。Rust 只接收一次完整的保存事務內(nèi)容變化700 ms 防抖FRB updateNote臨時文件 renameNote / NoteSummary / ErrorFlutter 編輯器AppControllerNoteRepositoryRust notes APINoteStorenotes/*.mdmetadata.json / settings.json本地文件系統(tǒng)列表、預覽、保存狀態(tài)這條邊界有兩個好處。輸入框不會因為跨語言調用而卡頓文件一致性又集中在 Rust 一處處理。FRB 配置與 Rust API項目使用的配置如下rust_input:crate::apirust_root:rust/dart_output:lib/src/rust手寫 API 只表達應用真正需要的動作初始化、列出、讀取、創(chuàng)建、更新、刪除和分類管理。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 啟動時分別請求設置、筆記列表和分類產(chǎn)生三個時序不確定的加載狀態(tài)。文件保存為什么放在 Rust關鍵不在于 Rust 寫文件更快而在于這里可以把寫入規(guī)則固定下來。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中途退出正式文件仍然保留上一版。重新啟動時設置文件解析失敗會先備份再回到默認設置元數(shù)據(jù)損壞則掃描筆記目錄重建索引?;謴瓦壿嫴皇?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 時發(fā)起保存等待 Rust 返回期間用戶又敲了幾個字revision 變成 13。此時第 12 版寫盤成功但界面仍應保持dirty繼續(xù)保存第 13 版不能顯示“已保存”后就停住。切換筆記也不能只改selectedId??刂破鲿萬lushPendingSave()保存失敗時返回false阻止切換。否則用戶看到的是下一篇筆記上一篇未保存內(nèi)容卻已經(jīng)從編輯器里消失。Dart 倉庫層不要省頁面若直接調用生成的notes.updateNote()測試就必須初始化原生庫。項目中保留NoteRepository接口生產(chǎn)環(huán)境使用 FRB 實現(xiàn)Widget 測試使用內(nèi)存實現(xiàn)。這樣可以穩(wěn)定復現(xiàn)下面的操作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);這也是本文的實操案例創(chuàng)建筆記、連續(xù)編輯、等待 700ms 自動保存再確認倉庫收到內(nèi)容且 UI 進入已保存狀態(tài)。本地復現(xiàn)cdInkNote flutter pub get flutter_rust_bridge_codegen generatecargotest--manifest-path rust/Cargo.toml fluttertestflutter run-dmacos建議再手工做兩次破壞性測試先把metadata.json改成不完整 JSON確認啟動后能備份并重建再讓數(shù)據(jù)目錄只讀確認保存失敗時界面沒有悄悄切換到另一篇筆記。真機實操一定要做到“關掉再打開”我這次采用的是下面這條可重復路徑在空工作區(qū)點擊“新建筆記”確認進入編輯器而不是只在列表插入占位項。修改標題和正文等待 700ms 防抖結束。檢查底部從編輯中/保存中切換為“已保存”同時記下字數(shù)。關閉整個應用窗口不只是返回上一頁。再次啟動同一包名確認標題、正文、分類和 Markdown 預覽都能恢復。重新修改一處內(nèi)容再觀察 revision 是否繼續(xù)遞增并保存。設備側命令如下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 的內(nèi)存內(nèi)容代替磁盤結果。保存失敗時頁面應該怎樣表現(xiàn)可靠保存并不是遇到異常就彈一個 SnackBar。下面幾類失敗要分開處理場景控制器應保留的狀態(tài)用戶下一步數(shù)據(jù)目錄不可寫當前草稿和failed修復權限后重試不允許靜默丟稿保存中繼續(xù)輸入新 revision 保持dirty當前寫入完成后繼續(xù)保存最新版切換筆記前保存失敗仍停留在當前筆記重試或明確放棄metadata.json損壞備份損壞文件并掃描正文檢查恢復列表正文存在、索引缺失從notes/*.md重建摘要重新選擇并核對標題臨時文件殘留正式文件保持上一版啟動恢復時清理或忽略.tmp這里最重要的是“失敗不能偽裝成成功”。如果 Rust 拋錯Dart 不能因為定時器已經(jīng)結束就把狀態(tài)改為saved如果切換動作被阻止也要留在原編輯器里讓草稿仍可見。再補一組真正有用的測試Widget 測試驗證 700ms 后倉庫收到內(nèi)容還不夠。Rust 側應使用臨時目錄執(zhí)行一次真實文件回環(huán)創(chuàng)建筆記、更新、銷毀 Store、用同一目錄重新初始化、再次讀取并比較字段。接著人為寫壞元數(shù)據(jù)確認恢復不會刪除正文。#[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);}如果倉庫現(xiàn)有方法名與示例略有不同以rust/src/store.rs為準。測試目標不是照抄函數(shù)名而是強制跨越“Store 被銷毀并重新打開”這條邊界。驗收記錄應該寫事實不寫感受驗收項本次結果證據(jù)HarmonyOS 應用啟動PASS真機包名啟動成功新建并編輯PASS編輯區(qū)與預覽區(qū)出現(xiàn)相同正文自動保存PASS底部實際顯示“已保存”關閉后重開恢復PASS重啟后仍為 113 字且內(nèi)容一致?lián)p壞元數(shù)據(jù)恢復需按交付環(huán)境復測Rust 單測與人工破壞測試斷電級持久性本截圖不證明需要設備斷電重啟專項測試“本地優(yōu)先”在這個項目里到底指什么很多筆記軟件也把文件放在本機但只要保存按鈕的反饋不可靠用戶依然不敢把重要內(nèi)容交給它。InkNote 對本地優(yōu)先的理解不是“沒有服務器”這么簡單而是編輯過程中不依賴網(wǎng)絡、保存結果可以被確認、異常退出后能重新找到內(nèi)容并且文件損壞時有明確的恢復路徑。數(shù)據(jù)放在哪里只是第一步數(shù)據(jù)在什么時候從內(nèi)存變成可恢復狀態(tài)才是核心。Flutter 編輯器里顯示的文字首先屬于當前草稿。用戶每輸入一個字符界面應該立即響應而不是等待 Rust 寫完文件再刷新。到了防抖時間控制器截取當時的標題、正文、分類和 revision組成一次保存請求。請求發(fā)出以后新輸入仍然可以繼續(xù)進入草稿。這樣頁面不會因磁盤寫入停頓Rust 又能拿到一份邊界明確的完整內(nèi)容。這里有一個容易忽略的認知差異磁盤里已經(jīng)寫入 revision 12并不代表用戶看到的 revision 13 已經(jīng)安全。底部狀態(tài)必須描述當前草稿而不是描述最近一次成功請求。只有返回的 revision 等于當前 revision才能顯示“已保存”。這種判斷看似多了一兩個字段卻直接決定用戶是否會在錯誤時間關閉窗口。我為什么堅持做關閉后重開的驗證組件測試中的內(nèi)存?zhèn)}庫可以很方便地返回一條筆記也能讓底部出現(xiàn)“已保存”。問題是內(nèi)存?zhèn)}庫不會遇到路徑權限、序列化格式、臨時文件替換和應用支持目錄這些真實條件。此前的截圖雖然畫面完整但它沒有經(jīng)過 Rust Store不能回答內(nèi)容是否真的存在磁盤上。這正是這次必須重新取證的原因。真機操作時我先新建筆記輸入標題和一段能說明調用鏈的正文等待狀態(tài)變成“已保存”。隨后不是簡單返回列表而是關閉整個應用窗口。再次用同一包名啟動后應用完成 FRB 初始化、打開原來的數(shù)據(jù)目錄、讀取元數(shù)據(jù)并加載正文。標題、113 字正文、分欄預覽和保存狀態(tài)都恢復才算跨過了持久化邊界。這個驗證仍然有范圍。關閉窗口再打開能夠證明正常退出和應用重啟后的讀取不等于突然斷電也一定安全。要驗證斷電需要在設備寫入期間切斷電源或強制終止進程并重復足夠次數(shù)觀察正式文件、臨時文件和目錄元數(shù)據(jù)。文章把這項單獨列為未證明就是避免用一次正常重啟覆蓋更嚴格的可靠性問題。元數(shù)據(jù)損壞時為什么不能直接清空筆記系統(tǒng)通常有兩類數(shù)據(jù)正文文件是真正的用戶資產(chǎn)元數(shù)據(jù)負責標題摘要、分類、排序和索引。若元數(shù)據(jù) JSON 無法解析最省事的處理是恢復默認值但這樣會讓列表看起來一篇筆記都沒有。正文其實還在用戶卻會以為全部丟失。InkNote 的恢復思路是先保留損壞文件再掃描正文目錄重建索引把能確認的內(nèi)容盡量找回來?;謴蜁r也不能過度猜測。文件名可以提供 ID正文可以提供內(nèi)容但分類和更新時間未必能完整重建。無法確認的字段應使用可識別的默認值并在診斷信息中說明發(fā)生過恢復。靜默生成一份看似正常的新元數(shù)據(jù)雖然界面好看卻會掩蓋數(shù)據(jù)曾經(jīng)受損的事實也讓后續(xù)排查失去原始證據(jù)。臨時文件的處理同樣需要謹慎。原子替換前留下的.tmp可能是未完成寫入也可能比正式文件更新。應用啟動時不能只按修改時間選擇較新的一個因為較新的臨時文件也可能只有半段內(nèi)容。至少要先校驗格式和關聯(lián) ID必要時將其移入恢復區(qū)由用戶或支持人員決定是否采納。自動恢復的原則應該是“不破壞最后一份已知有效數(shù)據(jù)”。編輯體驗和數(shù)據(jù)可靠性并不沖突有人擔心把保存狀態(tài)做得太嚴格會讓頁面頻繁顯示“未保存”影響觀感。實際上清楚的狀態(tài)比長期顯示綠色更讓人安心。編輯中可以用輕量文字提示不必每次彈窗保存失敗時才使用明顯顏色并提供重試。切換筆記或關閉窗口時如果仍有未保存內(nèi)容則等待當前任務完成或給出明確選擇。用戶需要的是可預期而不是永遠看起來成功。搜索和分類也要尊重同一原則。當前筆記還沒保存時列表摘要可以即時反映草稿但搜索索引究竟基于草稿還是磁盤內(nèi)容必須統(tǒng)一。如果列表顯示新標題搜索卻仍只能搜到舊標題用戶會懷疑數(shù)據(jù)丟失。比較穩(wěn)妥的設計是界面明確區(qū)分當前草稿和已持久化索引并在保存成功后刷新列表與搜索數(shù)據(jù)。對于大筆記實時 Markdown 預覽和文件保存最好各自防抖。預覽屬于可丟棄的計算結果舊任務返回后可以直接忽略保存屬于數(shù)據(jù)事務不能簡單取消正在進行的寫入。兩者都由內(nèi)容變化觸發(fā)卻不能共用一個“最后任務獲勝”的粗糙邏輯。文章里 revision 的設計正是把這兩類異步結果分開處理的基礎。交接項目時應留下哪些說明除了源碼和測試我會把數(shù)據(jù)目錄結構、各文件用途、恢復優(yōu)先級和備份策略寫進維護文檔。開發(fā)者必須知道哪些文件可以重建哪些文件刪除后不可恢復也要知道遷移版本時先備份什么。沒有這份說明新功能最容易在“整理舊文件”時誤刪用戶正文。格式升級最好使用顯式 schema 版本。新版本第一次打開舊數(shù)據(jù)時先復制或記錄遷移點再逐步轉換遷移完成后重新讀取并校驗不能只因為寫文件沒有拋異常就宣布成功。若中途失敗應繼續(xù)允許舊版本數(shù)據(jù)被識別至少提供導出通道。對本地筆記來說向后兼容往往比新增一種編輯器樣式重要得多。最后還要考慮用戶主動備份。數(shù)據(jù)雖然存在應用私有目錄但最好能導出普通 Markdown 和必要的元數(shù)據(jù)讓內(nèi)容不被應用鎖死。Rust Store 內(nèi)部可以采用更適合一致性的組織方式對外導出則應保持開放、可讀。做到這一點本地優(yōu)先才不僅是技術架構也成為用戶真正擁有數(shù)據(jù)的一種承諾。當前完成狀態(tài)一次保存為什么要留下可追蹤的版本自動保存還有一個經(jīng)常被忽略的問題用戶看到的內(nèi)容、正在寫入的內(nèi)容和磁盤上已經(jīng)確認的內(nèi)容可能同時屬于三個不同版本。假設用戶連續(xù)輸入 A、B、C保存 A 的異步任務此時才返回如果頁面僅收到一個“成功”布爾值就可能把包含 C 的界面錯誤標成已保存。這里不能用完成時間推斷新舊而要讓每次編輯遞增 revision保存請求攜帶自己的 revision成功回調也返回同一個值。只有返回值等于當前內(nèi)容版本頁面才顯示“已保存”較舊任務成功只能說明舊快照已經(jīng)落盤。這個版本號不必永久寫進每一篇正文但在進程內(nèi)必須保持單調。切換筆記時版本還要和 note ID 一起比較避免上一條筆記的遲到回調改變當前頁面狀態(tài)。此類問題在快速輸入時不明顯通常在磁盤較慢、首次創(chuàng)建目錄或系統(tǒng)忙碌時才暴露所以測試中應人為延遲第一次保存讓第二次編輯先發(fā)生再核對最終文件和狀態(tài)提示。退出流程也應復用同一套判斷。窗口收到關閉請求后先看當前 revision 是否已有對應的持久化確認若沒有則等待正在執(zhí)行的寫入或者發(fā)起最后一次保存。超過合理時間仍失敗時應明確告訴用戶草稿尚未落盤而不是強行關閉并留下一個“正常退出”的假象。桌面應用里的關閉按鈕看似只是 UI 行為實際上是數(shù)據(jù)可靠性鏈路的最后一道關口。這些設計不會讓編輯器顯得復雜反而把問題收攏在倉庫層。Widget 只顯示正在編輯、保存中、已保存和保存失敗Rust Store 只承諾某個確定版本是否完成原子替換。兩邊通過明確的數(shù)據(jù)語義協(xié)作比頁面自行觀察文件時間更容易測試也更便于交接。當前 Flutter 版已經(jīng)具備本地 Markdown 編輯、實時預覽、分類、搜索、700ms 自動保存、保存狀態(tài)提示以及 Rust 側的原子元數(shù)據(jù)寫入和損壞恢復。圖中內(nèi)容來自 HarmonyOS PC 真機筆記完成保存后關閉整個應用再次啟動時由同一數(shù)據(jù)目錄恢復標題、正文和預覽。舊 Tauri 界面僅用于版本遷移對照不參與本次持久化結果判斷。做完這個項目后我對“本地優(yōu)先”的理解也變得具體了不是數(shù)據(jù)存在本機就算完成而是在斷網(wǎng)、寫入失敗、切換頁面和異常退出這些情況下用戶仍然知道內(nèi)容處于什么狀態(tài)并且有辦法恢復。