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&注入依赖——项目中没有单例。各层职责可概括为:
| 图层 | 位置 | 职责 | 示例 |
|---|---|---|---|
| Model | src/models | 数据表示,Qt Model/View 集成 | NotebookNodeModel通过QAbstractItemModel暴露节点层级 |
| View | src/views | 展示数据、捕获用户输入、发出信号 | NotebookNodeView渲染树并发出nodeActivated信号 |
| Controller | src/controllers | 处理动作、编排 Model/View、业务逻辑 | NotebookNodeController处理新建/删除/重命名 |
| Service | src/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/目录下的模型构成了完整的"数据表示"清单。下表是模块文档中的完整模型库存,并补充了各模型对应的源码位置:
| 模型 | 基类 | 用途 | 源码 |
|---|---|---|---|
NotebookNodeModel | QAbstractItemModel | 节点层级(文件夹与文件) | notebooknodemodel.h |
NotebookNodeProxyModel | QSortFilterProxyModel | 节点排序/过滤 | notebooknodeproxymodel.h |
OutlineModel | QAbstractItemModel | 文档标题大纲 | outlinemodel.h |
SearchResultModel | QAbstractItemModel | 搜索结果数据 | searchresultmodel.h |
SnippetListModel | QAbstractItemModel | 代码片段列表数据 | snippetlistmodel.h |
TagModel | QAbstractItemModel | 标签层级 | tagmodel.h |
TagFileModel | QAbstractItemModel | 与标签关联的文件 | tagfilemodel.h |
AttachmentListModel | QAbstractItemModel | 附件列表数据 | attachmentlistmodel.h |
TreeFilterProxyModel | QSortFilterProxyModel | 通用树形过滤代理 | treefilterproxymodel.h |
INodeListModel | — | 节点列表模型的接口 | inodelistmodel.h |
从 src/models/CMakeLists.txt 可以看到,目录中还包含HistoryListModel(历史记录列表)与TaskTreeModel(任务树)两个模型,全部编译进vnote目标。这些模型统一放在src/models/,通过target_include_directories暴露给其余模块,是整库唯一的数据表示层。
INodeListModel:节点列表模型的统一契约
NotebookNodeModel同时继承自QAbstractItemModel与INodeListModel(inodelistmodel.h)。后者是所有节点列表模型的抽象接口,定义了**共享的角色(Roles)与能力查询(Capability Query)**两套契约。
共享角色(注释中明确要求"取值必须与NotebookNodeModel此前使用的保持一致"):
| 角色 | 取值 | 类型 |
|---|---|---|
NodeInfoRole | Qt::UserRole + 1(257) | NodeInfo结构体(经 QVariant) |
IsFolderRole | 258 | bool |
NodeIdentifierRole | 259 | NodeIdentifier结构体(经 QVariant) |
IsExternalRole | 260 | bool |
ChildCountRole | 261 | int |
PathRole | 262 | QString |
ModifiedTimeRole | 263 | QDateTime |
CreatedTimeRole | 264 | QDateTime |
PreviewRole | 265 | QString |
IsMissingRole | 266 | bool(瞬时标志:磁盘上内容缺失) |
纯虚访问器:nodeIdFromIndex()、nodeInfoFromIndex()、indexFromNodeId()构成索引↔节点标识的双向转换;nodeInfoFromNodeId()提供按标识直接查节点信息(绕过索引重建,默认返回无效值,带节点缓存的模型应覆写)。
能力查询(默认全部返回false):supportsDragDrop()、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,尚未索引)返回false;canFetchMore():节点尚未拉取(不在m_fetchedNodes中)时返回true;fetchMore():真正触发拉取,先经NotebookCoreService::listFolderExternal()拉取外部节点(按名称不区分大小写排序,置于列表前部),再经listFolderChildren()拉取索引节点;prefetchChildrenOfChildren():拉取完成后预取孙级,使子文件夹能立即显示正确的子节点计数。
拉取结果通过parseChildrenFromJson()/parseNodeInfoFromJson()从 JSON 解析(folders、files数组、createdUtc/modifiedUtc时间戳、tags、metadata中的加密标志与背景/边框/文字颜色),并以beginInsertRows/endInsertRows包裹行插入。
五份缓存与双向索引映射
类内部维护了五份可变缓存(见 notebooknodemodel.h):
| 缓存 | 类型 | 作用 |
|---|---|---|
m_nodeCache | QMap<NodeIdentifier, NodeInfo> | 节点标识 → 节点信息 |
m_childrenCache | QMap<NodeIdentifier, QVector<NodeIdentifier>> | 父节点 → 子节点列表 |
m_fetchedNodes | QSet<NodeIdentifier> | 已拉取节点集合(懒加载状态) |
m_indexIdCache | QHash<NodeIdentifier, quintptr> | 节点标识 → 内部索引 ID |
m_indexIdLookup | QHash<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,此外还有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前缀替换为新前缀。 - 遍历已加载子树 rekey(
subtree队列,BFS):m_nodeCache.take(oldId)取出后改id再插回;m_childrenCache中每项子 ID 同步改前缀;m_fetchedNodes中旧 ID 迁到新 ID;m_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路径不被破坏)。
代理模型:排序与过滤
NotebookNodeProxyModel
NotebookNodeProxyModel(notebooknodeproxymodel.h)是NotebookNodeModel之上的QSortFilterProxyModel,负责节点的排序与过滤:
- 类型过滤:
FilterFlag枚举——ShowFolders = 0x01、ShowNotes = 0x02、ShowAll = 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()接收数据并重建内部树——这是"模型是纯数据层"的典型示范。
模型层实践要点速查
- 写新模型时:继承
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),仅供参考