VNote 模型层深度解析:Qt Model/View 纯数据层设计与文件夹重命名不变量
2026/9/23 2:41:33 网站建设 项目流程

VNote 模型层深度解析:Qt Model/View 纯数据层设计与文件夹重命名不变量

【免费下载链接】vnoteA pleasant note-taking platform in native C++.项目地址: https://gitcode.com/gh_mirrors/vn/vnote

VNote(原生 C++ 笔记平台)采用 Model-View-Controller(MVC)架构,其中模型层位于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.h
NotebookNodeProxyModelQSortFilterProxyModel节点排序/过滤notebooknodeproxymodel.h
OutlineModelQAbstractItemModel文档标题大纲outlinemodel.h
SearchResultModelQAbstractItemModel搜索结果数据searchresultmodel.h
SnippetListModelQAbstractItemModel代码片段列表数据snippetlistmodel.h
TagModelQAbstractItemModel标签层级tagmodel.h
TagFileModelQAbstractItemModel与标签关联的文件tagfilemodel.h
AttachmentListModelQAbstractItemModel附件列表数据attachmentlistmodel.h
TreeFilterProxyModelQSortFilterProxyModel通用树形过滤代理treefilterproxymodel.h
INodeListModel节点列表模型的接口inodelistmodel.h

从 src/models/CMakeLists.txt 可以看到,目录中还包含HistoryListModel(历史记录列表)与TaskTreeModel(任务树)两个模型,全部编译进vnote目标。这些模型统一放在src/models/,通过target_include_directories暴露给其余模块,是整库唯一的数据表示层。

INodeListModel:节点列表模型的统一契约

NotebookNodeModel同时继承自QAbstractItemModelINodeListModel(inodelistmodel.h)。后者是所有节点列表模型的抽象接口,定义了**共享的角色(Roles)能力查询(Capability Query)**两套契约。

共享角色(注释中明确要求"取值必须与NotebookNodeModel此前使用的保持一致"):

角色取值类型
NodeInfoRoleQt::UserRole + 1(257)NodeInfo结构体(经 QVariant)
IsFolderRole258bool
NodeIdentifierRole259NodeIdentifier结构体(经 QVariant)
IsExternalRole260bool
ChildCountRole261int
PathRole262QString
ModifiedTimeRole263QDateTime
CreatedTimeRole264QDateTime
PreviewRole265QString
IsMissingRole266bool(瞬时标志:磁盘上内容缺失)

纯虚访问器nodeIdFromIndex()nodeInfoFromIndex()indexFromNodeId()构成索引↔节点标识的双向转换;nodeInfoFromNodeId()提供按标识直接查节点信息(绕过索引重建,默认返回无效值,带节点缓存的模型应覆写)。

能力查询(默认全部返回false):supportsDragDrop()supportsPreview()supportsHierarchy()supportsExternalNodes()supportsDisplayRoot()NotebookNodeModel对上述能力全部返回true(见 notebooknodemodel.h),表明它支持拖放、预览、层级、外部节点与扁平显示根。这套接口让视图(如文件列表、大纲树)可以面向INodeListModel编程,而不耦合具体模型实现。

NotebookNodeModel:笔记本节点树的实现剖析

NotebookNodeModel是模型层的核心,它把 vxcore 的笔记本节点层级(文件夹/文件)暴露给 Qt 的 model/view 框架。关键设计决策写在头文件注释中:使用NodeIdentifier/NodeInfo而不是Node*,数据经ServiceLocatorNotebookCoreService获取——模型不持有任何裸指针,天然避免了悬垂指针问题。

懒加载机制

笔记本节点树可能包含成百上千个节点,全量加载会拖慢启动。NotebookNodeModel实现了 Qt 标准的懒加载协议:

  • hasChildren():文件夹未拉取时返回true(有展开箭头),文件与外部文件夹(opaque,尚未索引)返回false
  • canFetchMore():节点尚未拉取(不在m_fetchedNodes中)时返回true
  • fetchMore():真正触发拉取,先经NotebookCoreService::listFolderExternal()拉取外部节点(按名称不区分大小写排序,置于列表前部),再经listFolderChildren()拉取索引节点;
  • prefetchChildrenOfChildren():拉取完成后预取孙级,使子文件夹能立即显示正确的子节点计数。

拉取结果通过parseChildrenFromJson()/parseNodeInfoFromJson()从 JSON 解析(foldersfiles数组、createdUtc/modifiedUtc时间戳、tagsmetadata中的加密标志与背景/边框/文字颜色),并以beginInsertRows/endInsertRows包裹行插入。

五份缓存与双向索引映射

类内部维护了五份可变缓存(见 notebooknodemodel.h):

缓存类型作用
m_nodeCacheQMap<NodeIdentifier, NodeInfo>节点标识 → 节点信息
m_childrenCacheQMap<NodeIdentifier, QVector<NodeIdentifier>>父节点 → 子节点列表
m_fetchedNodesQSet<NodeIdentifier>已拉取节点集合(懒加载状态)
m_indexIdCacheQHash<NodeIdentifier, quintptr>节点标识 → 内部索引 ID
m_indexIdLookupQHash<quintptr, NodeIdentifier>内部索引 ID → 节点标识(反向)

Qt 要求createIndex()internalId在整个生命周期内保持稳定且唯一,VNote 的做法是:为每个节点分配自增的quintptr索引 ID(m_nextIndexId),index()时通过indexIdForNode()分配,nodeIdFromIndex()时通过nodeIdForIndexId()反查。切换笔记本(setNotebookId)、切换扁平显示根(setDisplayRoot)、整体刷新(reload)都会清空全部缓存并触发beginResetModel/endResetModel,此时旧索引作废,视图会重建——这是合法的"模型重置"路径。

data() 的角色分发

data()按角色分发(notebooknodemodel.cpp):DisplayRole/EditRole返回节点名,ToolTipRole返回相对路径(外部节点带[External]前缀),NodeInfoRole返回完整NodeInfo,此外还有IsFolderRoleNodeIdentifierRoleIsExternalRoleIsMissingRoleChildCountRolePathRole、时间戳等角色。特别地,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):

  1. 前置校验:只接受Qt::EditRole;若笔记本只读则直接拒绝(防御性检查——flags()虽已撤销Qt::ItemIsEditable,但程序化调用setData也必须被拦截,每次调用实时查询而非缓存);新名为空或与旧名相同则拒绝。
  2. 调用后端:文件夹走NotebookCoreService::renameFolder(),文件走renameFile(),成功才继续。
  3. 重算标识:构造newNodeId(新路径),renamedId()闭包把子树中任意节点旧路径的nodeId.relativePath前缀替换为新前缀。
  4. 遍历已加载子树 rekeysubtree队列,BFS):m_nodeCache.take(oldId)取出后改id再插回;m_childrenCache中每项子 ID 同步改前缀;m_fetchedNodes中旧 ID 迁到新 ID;m_indexIdCache/m_indexIdLookup保留同一个quintptr索引 ID 迁移到新标识——这正是已展开视图与持久化选区保持有效的关键。
  5. 更新父节点的子列表:在父节点m_childrenCache中找到旧节点并替换为新标识。
  6. 全部缓存一致后,才为子树中每个节点发出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路径不被破坏)。

代理模型:排序与过滤

NotebookNodeProxyModel

NotebookNodeProxyModel(notebooknodeproxymodel.h)是NotebookNodeModel之上的QSortFilterProxyModel,负责节点的排序与过滤:

  • 类型过滤FilterFlag枚举——ShowFolders = 0x01ShowNotes = 0x02ShowAll = ShowFolders | ShowNotes,经setFilterFlags()控制显示哪些节点类型;
  • 名称过滤setNameFilter()支持通配符模式;
  • 视图排序setViewOrder(ViewOrder)设置排序方式,默认OrderedByConfiguration
  • 便捷访问nodeIdFromIndex()/nodeInfoFromIndex()直接从代理索引取NodeIdentifier/NodeInfo,视图无需先mapToSource

其排序/过滤逻辑通过覆写filterAcceptsRow()lessThan()实现,是QSortFilterProxyModel的标准扩展点。

TreeFilterProxyModel

TreeFilterProxyModel(treefilterproxymodel.h)是可复用的递归树过滤代理:接受DisplayRole中包含过滤文本(不区分大小写)的行;过滤文本为空时接受所有行。它额外提供filterActiveChanged(bool)信号,让视图可以感知过滤状态(例如过滤激活时显示不同的占位提示),并通过setFilterText()槽驱动过滤。

OutlineModel:文档大纲树

OutlineModel(outlinemodel.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中的索引)、HeadingLevelRole(1 起始的标题级别)、ReorderableRole三个自定义角色,以及indexForHeadingIndex()供视图高亮当前光标所在标题。该模型不依赖ServiceLocator或任何单例,通过setOutline()接收数据并重建内部树——这是"模型是纯数据层"的典型示范。

模型层实践要点速查

  1. 写新模型时:继承QAbstractItemModel(或QSortFilterProxyModel),数据经ServiceLocator&获取,绝不直接依赖视图/控件;
  2. 需要节点列表能力时:实现INodeListModel,先声明能力位(supportsXxx),再按契约覆写访问器;
  3. 改节点标识(如重命名)时:走"逐缓存迁移"而非 reset,任何时刻保证五份缓存与索引映射一致后再发dataChanged
  4. 大量层级数据:务必实现canFetchMore/fetchMore懒加载,并配合prefetchChildrenOfChildren保证子计数即时可见;
  5. 代理层:需要类型/名称过滤与排序时复用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),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询