
VNote 模型层深度解析Qt Model/View 纯数据层设计与文件夹重命名不变量【免费下载链接】vnoteA pleasant note-taking platform in native C.项目地址: https://gitcode.com/gh_mirrors/vn/vnoteVNote原生 C 笔记平台采用 Model-View-ControllerMVC架构其中模型层位于src/models/是连接 vxcore 后端数据与 Qt 视图的纯数据表示层。本文以模型层模块文档为核心结合源码与测试用例完整讲解模型清单、INodeListModel契约、NotebookNodeModel的懒加载与缓存设计以及最关键的文件夹重命名不变量Folder Rename Invariant——读完你将掌握 VNote 中所有模型类的职责边界、实现原理与正确的扩展姿势。模型层在 VNote MVC 架构中的定位VNote 使用干净架构clean architecture 依赖注入Model 持有数据View 负责展示Controller 处理业务逻辑Service 承载领域操作每个图层都通过ServiceLocator注入依赖——项目中没有单例。各层职责可概括为图层位置职责示例Modelsrc/models数据表示Qt Model/View 集成NotebookNodeModel通过QAbstractItemModel暴露节点层级Viewsrc/views展示数据、捕获用户输入、发出信号NotebookNodeView渲染树并发出nodeActivated信号Controllersrc/controllers处理动作、编排 Model/View、业务逻辑NotebookNodeController处理新建/删除/重命名Servicesrc/core/services领域操作通过 vxcore 访问数据NotebookCoreService封装 vxcore C API 完成笔记本 CRUD模型层遵守整库统一的 MVC 规则详见仓库根目录 AGENTS.md 的完整规则表规则理由Model 中绝不能包含 UI 逻辑模型可在不同视图间复用View 不得直接修改数据视图只负责展示和发信号Controller 不得继承 QWidget保证 Controller 在无 GUI 环境下可测试所有图层都接收ServiceLocator实现依赖注入与可测试性层间用信号/槽通信保持 M、V、C 之间松耦合模型层是纯数据层——它是QAbstractItemModel的子类与代理模型持有数据并通过 Qt 的 model/view 框架暴露给视图不依赖任何具体的视图或控件。这是 src/models/AGENTS.md 开篇就强调的核心约束。模型清单从数据到视图的桥梁src/models/目录下的模型构成了完整的数据表示清单。下表是模块文档中的完整模型库存并补充了各模型对应的源码位置模型基类用途源码NotebookNodeModelQAbstractItemModel节点层级文件夹与文件notebooknodemodel.hNotebookNodeProxyModelQSortFilterProxyModel节点排序/过滤notebooknodeproxymodel.hOutlineModelQAbstractItemModel文档标题大纲outlinemodel.hSearchResultModelQAbstractItemModel搜索结果数据searchresultmodel.hSnippetListModelQAbstractItemModel代码片段列表数据snippetlistmodel.hTagModelQAbstractItemModel标签层级tagmodel.hTagFileModelQAbstractItemModel与标签关联的文件tagfilemodel.hAttachmentListModelQAbstractItemModel附件列表数据attachmentlistmodel.hTreeFilterProxyModelQSortFilterProxyModel通用树形过滤代理treefilterproxymodel.hINodeListModel—节点列表模型的接口inodelistmodel.h从 src/models/CMakeLists.txt 可以看到目录中还包含HistoryListModel历史记录列表与TaskTreeModel任务树两个模型全部编译进vnote目标。这些模型统一放在src/models/通过target_include_directories暴露给其余模块是整库唯一的数据表示层。INodeListModel节点列表模型的统一契约NotebookNodeModel同时继承自QAbstractItemModel与INodeListModelinodelistmodel.h。后者是所有节点列表模型的抽象接口定义了**共享的角色Roles与能力查询Capability Query**两套契约。共享角色注释中明确要求取值必须与NotebookNodeModel此前使用的保持一致角色取值类型NodeInfoRoleQt::UserRole 1257NodeInfo结构体经 QVariantIsFolderRole258boolNodeIdentifierRole259NodeIdentifier结构体经 QVariantIsExternalRole260boolChildCountRole261intPathRole262QStringModifiedTimeRole263QDateTimeCreatedTimeRole264QDateTimePreviewRole265QStringIsMissingRole266bool瞬时标志磁盘上内容缺失纯虚访问器nodeIdFromIndex()、nodeInfoFromIndex()、indexFromNodeId()构成索引↔节点标识的双向转换nodeInfoFromNodeId()提供按标识直接查节点信息绕过索引重建默认返回无效值带节点缓存的模型应覆写。能力查询默认全部返回falsesupportsDragDrop()、supportsPreview()、supportsHierarchy()、supportsExternalNodes()、supportsDisplayRoot()。NotebookNodeModel对上述能力全部返回true见 notebooknodemodel.h表明它支持拖放、预览、层级、外部节点与扁平显示根。这套接口让视图如文件列表、大纲树可以面向INodeListModel编程而不耦合具体模型实现。NotebookNodeModel笔记本节点树的实现剖析NotebookNodeModel是模型层的核心它把 vxcore 的笔记本节点层级文件夹/文件暴露给 Qt 的 model/view 框架。关键设计决策写在头文件注释中使用NodeIdentifier/NodeInfo而不是Node*数据经ServiceLocator从NotebookCoreService获取——模型不持有任何裸指针天然避免了悬垂指针问题。懒加载机制笔记本节点树可能包含成百上千个节点全量加载会拖慢启动。NotebookNodeModel实现了 Qt 标准的懒加载协议hasChildren()文件夹未拉取时返回true有展开箭头文件与外部文件夹opaque尚未索引返回falsecanFetchMore()节点尚未拉取不在m_fetchedNodes中时返回truefetchMore()真正触发拉取先经NotebookCoreService::listFolderExternal()拉取外部节点按名称不区分大小写排序置于列表前部再经listFolderChildren()拉取索引节点prefetchChildrenOfChildren()拉取完成后预取孙级使子文件夹能立即显示正确的子节点计数。拉取结果通过parseChildrenFromJson()/parseNodeInfoFromJson()从 JSON 解析folders、files数组、createdUtc/modifiedUtc时间戳、tags、metadata中的加密标志与背景/边框/文字颜色并以beginInsertRows/endInsertRows包裹行插入。五份缓存与双向索引映射类内部维护了五份可变缓存见 notebooknodemodel.h缓存类型作用m_nodeCacheQMapNodeIdentifier, NodeInfo节点标识 → 节点信息m_childrenCacheQMapNodeIdentifier, QVectorNodeIdentifier父节点 → 子节点列表m_fetchedNodesQSetNodeIdentifier已拉取节点集合懒加载状态m_indexIdCacheQHashNodeIdentifier, quintptr节点标识 → 内部索引 IDm_indexIdLookupQHashquintptr, NodeIdentifier内部索引 ID → 节点标识反向Qt 要求createIndex()的internalId在整个生命周期内保持稳定且唯一VNote 的做法是为每个节点分配自增的quintptr索引 IDm_nextIndexIdindex()时通过indexIdForNode()分配nodeIdFromIndex()时通过nodeIdForIndexId()反查。切换笔记本setNotebookId、切换扁平显示根setDisplayRoot、整体刷新reload都会清空全部缓存并触发beginResetModel/endResetModel此时旧索引作废视图会重建——这是合法的模型重置路径。data() 的角色分发data()按角色分发notebooknodemodel.cppDisplayRole/EditRole返回节点名ToolTipRole返回相对路径外部节点带[External]前缀NodeInfoRole返回完整NodeInfo此外还有IsFolderRole、NodeIdentifierRole、IsExternalRole、IsMissingRole、ChildCountRole、PathRole、时间戳等角色。特别地PreviewRole对文件进行懒加载首次请求时经NotebookCoreService::peekFile()读取文件预览并通过const_cast mutable 缓存模式写回m_nodeCache避免重复磁盘读取。文件夹重命名不变量Folder Rename Invariant这是模型层文档中唯一被冠以不变量Invariant之名的硬性约束也是NotebookNodeModel::setData()最容易写错的地方。NotebookNodeModel::setData()必须对整个已加载子树重新设置键rekey节点数据、子列表、已拉取标记fetched markers以及两个索引-标识映射都必须同步更新。内部索引 ID 与行关系必须保留重命名不是模型重置reset也不是行的插入/删除。只有所有缓存都一致后才能通知角色变更dataChanged。为什么重命名不是 reset如果重命名文件夹时简单调用beginResetModel()endResetModel()虽然实现最省事代价是所有已展开的子树全部塌缩、持久化选区persistent selection失效、之后的所有节点都要重新拉取。用户在编辑器里编辑文件名按回车的那一瞬间整个树被打回原形——体验不可接受。VNote 的正确做法是重命名改变的是节点的路径标识不是它在树中的行位置。因此setData()采用逐缓存迁移rekey策略notebooknodemodel.cpp前置校验只接受Qt::EditRole若笔记本只读则直接拒绝防御性检查——flags()虽已撤销Qt::ItemIsEditable但程序化调用setData也必须被拦截每次调用实时查询而非缓存新名为空或与旧名相同则拒绝。调用后端文件夹走NotebookCoreService::renameFolder()文件走renameFile()成功才继续。重算标识构造newNodeId新路径renamedId()闭包把子树中任意节点旧路径的nodeId.relativePath前缀替换为新前缀。遍历已加载子树 rekeysubtree队列BFSm_nodeCache.take(oldId)取出后改id再插回m_childrenCache中每项子 ID 同步改前缀m_fetchedNodes中旧 ID 迁到新 IDm_indexIdCache/m_indexIdLookup保留同一个quintptr索引 ID 迁移到新标识——这正是已展开视图与持久化选区保持有效的关键。更新父节点的子列表在父节点m_childrenCache中找到旧节点并替换为新标识。全部缓存一致后才为子树中每个节点发出dataChanged让视图刷新名称显示失败则发errorOccurred信号供 UI 弹错。值得注意的实现细节NodeAfterRename钩子由NotebookCoreService::renameFile/renameFolder触发模型本身不重复触发notebooknodemodel.cpp。测试回归防线真正的代理/树回归测试位于 tests/models/test_notebooknodemodel.cpp 的testRenameExpandedSubtree()第 114 行起覆盖了模块文档点名的全部场景已展开的后代重命名后通过indexFromNodeId()能按新路径取到原来那个节点的QModelIndex后续拉取later fetching重命名一个此前未展开的lazy文件夹再展开它时新拉取的子节点路径必须与新前缀一致renamed/lazy/later.md持久化选区proxy.setData()重命名后被选中后代节点的显示文本正确更新重复折叠/展开而无需 Reload重命名后reload()之外的正常展开路径必须保持正确冲突回滚对已重命名节点做二次冲突重命名必须失败且已加载子树保持原状不变renamed路径不被破坏。代理模型排序与过滤NotebookNodeProxyModelNotebookNodeProxyModelnotebooknodeproxymodel.h是NotebookNodeModel之上的QSortFilterProxyModel负责节点的排序与过滤类型过滤FilterFlag枚举——ShowFolders 0x01、ShowNotes 0x02、ShowAll ShowFolders | ShowNotes经setFilterFlags()控制显示哪些节点类型名称过滤setNameFilter()支持通配符模式视图排序setViewOrder(ViewOrder)设置排序方式默认OrderedByConfiguration便捷访问nodeIdFromIndex()/nodeInfoFromIndex()直接从代理索引取NodeIdentifier/NodeInfo视图无需先mapToSource。其排序/过滤逻辑通过覆写filterAcceptsRow()与lessThan()实现是QSortFilterProxyModel的标准扩展点。TreeFilterProxyModelTreeFilterProxyModeltreefilterproxymodel.h是可复用的递归树过滤代理接受DisplayRole中包含过滤文本不区分大小写的行过滤文本为空时接受所有行。它额外提供filterActiveChanged(bool)信号让视图可以感知过滤状态例如过滤激活时显示不同的占位提示并通过setFilterText()槽驱动过滤。OutlineModel文档大纲树OutlineModeloutlinemodel.h把 Markdown 文档的标题层级渲染成树标题按级别嵌套H1 为顶层H2 在 H1 下H3 在 H2 下……内部用OutlineNode构建树形结构。两个值得注意的设计补洞节点当标题跳级如从 H1 直接到 H3时通过OutlineProvider::makePerfectHeadings()插入补洞gap-filling虚拟节点保证大纲层级完整m_headingIndex -1标识虚拟节点m_reorderable标记哪些是真实可编辑的标题章节编号支持自动章节编号默认模式字符串1.1.m_detectHeading1ForSectionNumber默认开启可经setSectionNumberOptions()配置。它对外提供HeadingIndexRole扁平Outline::m_headings中的索引、HeadingLevelRole1 起始的标题级别、ReorderableRole三个自定义角色以及indexForHeadingIndex()供视图高亮当前光标所在标题。该模型不依赖ServiceLocator或任何单例通过setOutline()接收数据并重建内部树——这是模型是纯数据层的典型示范。模型层实践要点速查写新模型时继承QAbstractItemModel或QSortFilterProxyModel数据经ServiceLocator获取绝不直接依赖视图/控件需要节点列表能力时实现INodeListModel先声明能力位supportsXxx再按契约覆写访问器改节点标识如重命名时走逐缓存迁移而非 reset任何时刻保证五份缓存与索引映射一致后再发dataChanged大量层级数据务必实现canFetchMore/fetchMore懒加载并配合prefetchChildrenOfChildren保证子计数即时可见代理层需要类型/名称过滤与排序时复用NotebookNodeProxyModel需要通用递归过滤时复用TreeFilterProxyModel。相关模块与延伸阅读模型层文档明确指引了它在 MVC 中的上下游src/controllers/AGENTS.md — 操作模型重命名、拖放、多选等的控制器约定src/views/AGENTS.md — 展示模型数据的视图约定与委托delegate模式根目录 AGENTS.md — 完整的 MVC 规则表与架构总览、构建与测试命令。若要进一步理解模型数据的来源可深入 src/core/services 中NotebookCoreService与 vxcore C API 的封装关系要验证重命名不变量的行为直接阅读并运行 tests/models/test_notebooknodemodel.cpp 中的回归用例即可。【免费下载链接】vnoteA pleasant note-taking platform in native C.项目地址: https://gitcode.com/gh_mirrors/vn/vnote创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考