
VNote 是一个基于 Qt 开发的 Markdown 编辑器,项目地址:https://github.com/vnotex/vnote, 是一个非常好的学习 Qt 应用开发的示例。本文会拆解一下这个项目的架构、核心类和一些关键的业务流程。
1. 界面结构
梳理清楚 VNote 的界面结构和对象树是了解这个项目最好的"切入点":
1.1 界面结构

1.2 对象树
MainWindow
├── ViewArea (window 🠚 m_viewArea)
│ └── ViewSplit ( QTabWidget ) (m_viewArea 🠚 m_currentSplit)
│ └── MarkdownViewWindow (m_currentSplit 🠚 m_currentViewWindow)
│ ├── MarkdownViewer (m_currentViewWindow 🠚 m_viewer)
│ └── MarkdownEditor ( m_currentViewWindow 🠚 m_editor)
└── NotebookExplorer (window 🠚 m_notebookExplorer)
└── NotebookNodeExplorer (m_notebookExplorer 🠚 m_nodeExplorer)
└── QTreeWidget (m_nodeExpoer 🠚 m_masterExplorer)
2. 基础概念
2.1 视图层组件
2.1.1 ViewArea
主窗口的编辑区,是所有文档相关组件的最上层组件,从逻辑上讲,它的"直属下级"是所有的 ViewSplit,但它同时还将持有所有的 Workspace 和当前的 ViewSplit 以及当前的 ViewWindow,因为它要做一些全局的协调工作。

空的 ViewArea 区域
2.1.2 ViewSplit
继承自 QTabWidget,它叫 split,是因为在一个 ViewArea 中可以切分出多个 ViewSplit,但每一个 ViewSplit 其实都是 Tab 组件,它里面可以添加多个 Tab,每一个 Tab 里的放的就是一个 ViewWindow

单一 ViewSplit

多个 ViewSplit
2.1.3 VewWindow ( MarkdownViewWindow、TextViewWindow )
针对不同文档类型设计的窗口基类,有 MarkdownViewWindow、TextViewWindow 等子类,ViewSplit 里的一个 Tab 放一个 VewWindow,ViewWindow 内嵌一个 QSplit,可以填一到两个 Viewer 或 Editor,对于 Text 文档只有一个 TextEditor,但对于 Markdown 文档就可以有一个 MarkdownEditor 和一个 MarkdowViewer

一个 MarkdownViewWindow 的完整展示
2.1.4 MarkdownViewer
是一个 QWebEngineView 的实现类,负责以"阅读"模式展示 Markdown 文档
2.1.5 MarkdownEditor
编辑"模式下打开并编辑 Markdown 文档的编辑器,会重度依赖 Buffer 操作文档。
2.1.6 Workspace
是在界面上可见的一个概念,一个 Workspace 就是一个 ViewSplit 中打开的所有 ViewWindow,但是,一个 ViewSplit 并不是和一个 Workspace 绑死的,当用户在 ViewSplit 的右侧菜单中选择切换 Workspace 时,ViewSplit 将现有 Workspace 的所有 ViewWindow (也就是所有 Tab 页下的 ViewWindow) 都移除(不是销毁,这些 Window 还由原来的 Workspace 保管着),然后把切换后的 Workspace 里的所有 ViewWindow 一一添加到各个 Tab 中。

切换前的默认Workspace:Workspace 1

切换后到第二个Workspace:Workspace 2
虽然一个 ViewSplit 只能在同一时刻展示一个 Workspace,但多个 Workspace 未必不能同时可见,因为:ViewArea 可以 split 出多个 ViewSplit,每一个新的 ViewSplit 会自动创建自己的 Workspace:

一个 ViewSplit 对应一个 Workspace
Workspace 和 ViewSplit 的关系可以归纳为:一个 ViewSplit 上 show 的就是一个 Workspace 里的所有 ViewWindow,ViewSplit 和 Workspace 不是"绑死"的关系,ViewSplit 可以 detach 一个 Workspace 然后 attatch 另一个 Workspace,Workspace 可以有很多个,但一个 ViewSplit 同一时间只能展示其中一个,但是,当你有多个 ViewSplit 时就会多个 Workspace 都处于前台可见状态。
2.2 文档层组件
2.2.1 Node
继承自:QEnableSharedFromThis,代表树形目录结构中的一个"节点",可能是一个文档,也可能是一个文件夹,它主要存储文件或文件夹的元数据。Node 不包含文档的内容,文档内容是由 Buffer 持有和管理的。
2.2.2 Buffer
负责存储和管理文档内容的对象,它和 ViewWindow 一起构成"文档-视图分离"(Document-View Architecture)架构。重点:当一个文档在多个 ViewWindow 打开时,对应的是同一个 Buffer!这可以保证:当我们在一个 ViewWindow 中编辑文件时,其他也打开了该文件的 ViewWindow 会实时同步呈现最新的改动!

Buffer 有四种具体实现:

2.2.3 BufferProvider
BufferProvider 不是用来提供 Buffer 的,而是为 Buffer 提供 content,这个类名起的有歧义。一个 Buffer 持有一个 BufferProvider 的共享指针( QSharedPointer<BufferProvider> m_provider ),Buffer 对文档的读写都是通过 BufferProvider 完成的,原因在于:通过 BufferProvider 可以让 Buffer 在操作文档内容时和具体的存储方式解耦,因为 BufferProvider 是一个接口类,它有 FileBufferProvider、NodeBufferProvider、UrlBasedBufferProvider 这些具体实现类,从名字上你就能明白:如果一个文档是使用文件方式直接打开编辑,那用的就是 FileBufferProvider,如果是从左侧目录树中双击打开的文档,那用的就是 NodeBufferProvider。
2.2.4 BufferMgr
BufferMgr 负责创建(通过工厂)、缓存、提供 Buffer。前面提到过:一个文档可以同时被多个 ViewWindow 打开,但它们使用的是同一个 Buffer,只有这样才能保证在其中一个窗口中改动文件会在所有其他窗口中实时同步更新。保证它们获得同一个 Buffer 的就是 BufferMgr,因为它们都是通过 BufferMgr::open() 方法来获取 Buffer 实例的,这个方法会先从缓存(它的 m_buffers 字段)中查找有没有目标 Buffer,如果没有再使用 BufferFactory 创建并添加到缓存中,是 BufferMgr 保证了一个文档只有一个 Buffer 实例。
2.2.5 IBufferFactory
典型的工厂方法模式,专门用于"多态性"地生产 Buffer,有四种具体实现: 
VNote 使用工厂方法模式生产 Buffer 是因为 Buffer 有四种具体实现,但 Buffer 的使用方可以仅通过抽象接口 Buffer 来使用它,不会牵涉底层实现,为了所"创建 Buffer 具体实例的代码也封装屏蔽"起来,使用工厂方法模式是最佳选择。 注意:BufferFactory 只负责创建 Buffer 实例,并不负责管理 Buffer 的声明周期,这是 BufferMgr 的职责。
2.2.6 Notebook
文档组织的最上层结构,一个 Notebook 映射一个 Root Folder,里面所有的文件和子文件夹构成了一个目录树,Notebook 就是这棵目录树的 Owner,持有它的 Root Node。创建了一个 Notebook 后,会在其根目录上自动创建一个 vx_notebook 目录,用于存放与这个 notebook 有关的配置信息,通常包括:
vx_notebook\vx_notebook.jsonvx_notebook\notebook.db
2.2.7 INotebookFactory

2.2.8 INotebookConfigMgr
在一个目录配置为一个 VNote 的 Notebook 后,会在该目录及其所有子目录中生成相应的 vx.json 配置文件,这个文件记录了其所在目录包含的子目录和文件信息:以下是一个根目录下的该配置文件的内容:
json
{
"created_time": "2026-01-27T13:40:49Z",
"files": [
],
"folders": [
{
"name": "AIML"
},
{
"name": "AWS"
},
...
...
],
"id": "1",
"modified_time": "2026-01-27T13:40:49Z",
"signature": "1525035978253189217",
"version": 3
}
而以下是 AIML 子目录下的该配置文件的内容:
json
{
"background_color": "#55aaff",
"created_time": "2026-01-27T13:40:49Z",
"files": [
{
"attachment_folder": "478078161160403",
"created_time": "2026-01-27T13:40:49Z",
"id": "3",
"modified_time": "2026-03-11T05:09:52Z",
"name": "cut与分层抽样.md",
"signature": "16304240755591397473",
"tags": [
]
},
{
"attachment_folder": "527851500308951",
"created_time": "2026-01-27T13:40:49Z",
"id": "4",
"modified_time": "2026-03-11T05:01:50Z",
"name": "Handson ML一书教给我们的一些数据分析的必要操作.md",
"signature": "15255706176256000097",
"tags": [
]
},
...
...
,
"folders": [
],
"id": "2",
"modified_time": "2026-01-27T13:40:49Z",
"signature": "482446943620481121",
"version": 3
}
这些文件中的信息就是 Notebook 的配置信息,它不单单是文件相关的信息,确实有用户配置的信息,比如:VNote 允许为单个目录或文件设置它们在左侧树形目录中的背景色,边框等属性,这些是写在 vx.json 文件中的。而 INotebookConfigMgr 就是负责读取、保存、更新这些配置信息的类。
2.2.9 ConfigMgr
ConfigMgr 负责管理和维护所有配置信息的类,它负责加载配置文件和保存配置。vnote 把配置分成了四大类:CoreConfig、EditorConfig、WidgetConfig、SessionConfig,其中前三类配置合起来称为 MainConfig,对应的存储文件是 %LOCALAPPDATA%\VNote\VNote\vnotex.json,最后一个单独存放在另一个文件中,对应的存储文件是 %LOCALAPPDATA%\VNote\VNote\session.json。这些 Config 类包含了所有"配置项"的读写方法,它是通过解析对应的配置文件而创建的,同时通过它们设置新值时又会写回到配置文件中。

ConfigMgr
│
├── MainConfig 🠚 `%LOCALAPPDATA%\VNote\VNote\vnotex.json`
│ │
│ ├── CoreConfig
│ ├── EditorConfig
│ └── WidgetConfig
│
└── SessionConfig 🠚 `%LOCALAPPDATA%\VNote\VNote\session.json`
2.2.10 VXNotebookConfigMgr
VXNotebookConfigMgr 读取的是 vx.json 配置文件,该文件分布于每一个目录中,

3. 主流程
3.1 加载目录树
NotebookMgr 先加载所有的 notebooks,notebooks 是 ConfigMgr 从配置文件 session.json 中读取出来的,然后在 NotebookMgr 中转换为 Notebook 实例。加载完成后,NotebookMgr 会持有所有的 m_notebooks 和 m_currentNotebookId 两个字段。在设置好 m_currentNotebookId 后,NotebookMgr 会发送一个 emit currentNotebookChanged(notebook) 事件,该事件会被 NotebookExplorer::setCurrentNotebook) 响应:
cpp
connect(¬ebookMgr, &NotebookMgr::currentNotebookChanged, m_notebookExplorer,
&NotebookExplorer::setCurrentNotebook);
setCurrentNotebook() 方法会从配置中读取 Notebook 的目录树,完成整个目录树的加载工作。核心工作是通过 NotebookNodeExplorer::generateMasterNodeTree() 方法完成的,它有两步关键操作:
先通过 m_notebook->getRootNode() 加载出 root 节点(包含其一级子目录和子文件),实际操作是由 nodeConfigToNode 完成的,它根据根目录下的 vx.json 加载出 root 节点,root 节点的 m_children 会包含它的直属文件 Node 和子目录 Node,但子目录 Node 是"未加载"状态。
generateMasterNodeTree
-> m_notebook->getRootNode()
-> VXNotebookConfigMgr::loadRootNode
-> VXNotebookConfigMgr::nodeConfigToNode
再通过 loadRootNode(rootNode.data()) 加载出 root 下的各级节点,这个过程是有"递归"的。这个过程比较复杂,我们用示例的方式驱动讲解会比较好理解。以下是一个 Notebook 的目录结构:
MY-NOTEBOOK(根目录)
├── 2026
│ ├── 01
│ │ ├── ML中的数据预处理.md
│ │ ├── Sklearn中的算法效果评估手段.md
│ │ └── vx.json
│ ├── 02
│ │ ├── Flink 流上的不确定性.md
│ │ └── vx.json
│ └── vx.json
├── @测试文档.md
├── vx.json
└── vx_notebook
├── notebook.db
└── vx_notebook.json
"递归"加载这个目录树的过程如下:

loadRootNode() 的逻辑是:取出 root 的 children,对它进行排序,在排序的时候会"顺带"加载每一个 child,具体方法是 NotebookNodeExplorer::sortNodes,这是项目写的不好的一个地方,把排序和加载子节点的工作耦合在了一起。
sortNodes()的逻辑是:迭代所有节点(root 的 children),是 Folder Node 就调用 Node::load() 加载它,迭代完所有的 Folder Node 后也就找到了第一个 File Node 的索引(貌似节点加载子节点时都是 Folder Node 在前,File Node 在后的),然后针对 Folder Node 和 File Node 分别进行排序。
Node::load() 的逻辑是:调用 VXNotebookConfigMgr::loadNode 去加载节点,这个方法命名有歧义,其实应该是 loadFolderNode,因为通过 ConfigMgr 加载的 Node 都是 FolderNode,因为只有 FolderNode 才有配置配置文件,而它实际上也确实是调用的 VXNotebookConfigMgr::loadFolderNode
VXNotebookConfigMgr::loadFolderNode是很重要的一个方法,它一边读取明节点目录下的 vx.json文件,一边根据文件中的数据创建 Node。
NotebookNodeExplorer::loadMasterNode 方法递归遍历 Node 树,并为每个 Node 创建对应的 QTreeWidgetItem,从而在 UI 上构建笔记目录树。该方法的第一步就是检查当前 node 是否已经加载,如果没有先加再它,然后在去调用子方法 loadMasterNodeChildren 去加载当前节点的所有子节点,而在这个 loadMasterNodeChildren 方法中又会通过 loadMasterNode 来加载每一个子节点,从而形成了递归调用:

递归是发生在 loadMasterNode 方法中! loadMasterNode -> loadMasterNodeChildren -> loadMasterNode
generateMasterNodeTree
-> loadRootNode()
-> loadMasterNode <─────────────┐
-> loadMasterNodeChildren ───┘
下面是加载目录树过程中一个典型的调用栈:

备注:每一个 Node 都有一个 m_loaded 标志位,如果是一个 File Node,则当它根据配置文件中的信息初始化为实例时就是 true,但如果是一个 Folder Node,虽然它的实例也是根据配置文件中的信息初始化的,但它还是已加载状态,只有读取了改节点下的配置文件,把它的所有 children 都实例化之后,它的 m_loaded 才是 true!
3.2 打开文档
打开一个文档的流程会为两个阶段:先准备文档,创建或打卡文档对应的 Buffer,Buffer 准备妥当后,会 emit 一个 bufferRequested 事件,然后转到 UI 层开始第二个阶段:文件展示的工作。
3.2.1 Buffer 层操作
打开文件后,触发 BufferMgr::open():


该方法从 Node 参数中获取将被打开文件的类型(例如:.txt 和 .md),根据文件类型拿到创建对应 Buffer (例如 TextBuffer 和 MarkdownBuffer)的工厂类(例如 TextBufferFactory 和 MarkdownBufferFactory,然后使用工厂创建了 buffer,这里是典型的"工厂方法"模式,方法中只出现工厂和产品的"抽象"接口 IBufferFactory 和 Buffer,不牵涉任何具体的实现类。在方法最后会主动 emit 一个 bufferRequested() 信号:

然后,程序跳转到 UI 层组件开始第二阶段的数据加载与初始化相关的工作。
3.2.2 UI 层操作
响应 bufferRequested() 信号的方法是 ViewArea::openBuffer(),之后运行栈就跳转到了 ViewArea::openBuffer() 继续执行,创建出的 Buffer 也会一同传递过去:

这个方法会用传入的 Buffer 的 Buffer::createViewWindow 去创建 ViewWindow,这又是一个"工厂方法",每一种具体的 Buffer 会创建与自己配套的 ViewWindow,这里创建的是一个 MarkdownViewWindow。其实这个方法起名 open 不是很贴切,它主要目的是调用 Buffer 的 createViewWindow 方法获得这个 Buffer 对应的 ViewWindow。MarkdownViewWindow 创建好后会模式设置为 Read 模式,这会触发 MarkdownViewWindow 进一步加载阅读模式的子空间 MarkdownViewer,也就是栈顶的 MarkdownViewWindow::setupViewer() 方法,它会创建 MarkdownViewer。
3.3 编辑文档
在完成 3.2 节后,如果用户点击文档工具栏上的"编辑"按钮,会触发 MarkdownViewWindow 进一步加载 MarkdownEditor 的操作,触发与加载方式和 MarkdownViewer 一样,也是通过修改模式为 Edit 而触发的:
