基于Qt的C++记事本源码解析:从编译到二次开发
发布时间:2026/10/21 7:58:36 锦皓数字建站

简介一份完整的C记事本程序源码面向初学C或对Windows GUI编程感兴趣的开发者可用于理解如何借助MFC/WinAPI搭建类似系统自带的文本编辑工具覆盖文件读取、编辑保存、查找替换、界面交互等常用功能。压缩包内共21个文件以头文件.h和源文件.cpp为主配合资源脚本.rc、图标.ico、工具栏位图.bmp以及Visual Studio工程文件.dsp/.dsw还附有编译好的可执行exe整体仅64KB结构清晰紧凑便于按模块对照学习。已有483人浏览学习该资源。通过研读源码可掌握窗口类封装、消息映射、对话框资源定义以及文本文件I/O的关键写法同时观察“资源文件—头文件—源文件—工程文件”的典型组织方式对从语法学习走向实际程序开发很有帮助也适合作为课程设计或自学参考项目。1. 这不是“你好世界”一份能编译、能改写的记事本 C 源码打算在桌面上写点小工具时总会遇到一个选择系统自带的记事本功能太薄装一个完整编辑器又嫌重。这份“记事本的C源代码”的价值在于它把“一个桌面应用从启动、建窗口、收键盘事件到打开文件、编码识别、落盘保存”的完整链路摆在你面前编译产物只有一个可执行文件不依赖一堆运行时组件。它适合三类人刚学完 C 语法、想看看完整程序长什么样的同学嫌现成编辑器不符合习惯、想自己攒一个小工具的开发者以及做课程设计或内部工具、不想引入庞大依赖的工程师。只要机器上有 C 工具链和 CMake三条命令就能编译起来改起来也比想象中顺手。真正动手跑一遍反而会修正不少对文件编码、选区边界、撤销栈的刻板印象。2. 架构与目录三层职责怎么划分五大源文件怎么找2.1 技术栈取舍C 要的是可控感记事本这个场景性能从来不是第一诉求选 C 更多是想拿回控制权。编译产物是单个原生程序不依赖运行时拷到哪台桌面机器都能直接跑文本处理可以按字节去读编码是自己判断的撤销栈是自己维护的没有黑匣子。这份源码整体按 C11 之上的标准编写界面部分用跨平台的开源框架 Qt Widgets构建部分交给 CMake。这样组合的好处是界面层只处理“用户点了什么”业务数据都放在普通 C 类里逻辑可以脱离界面单独验证。层次选型选它而不是别的原因界面层Qt Widgets菜单、工具栏、状态栏、编辑控件都是现成的信号槽天然适合 UI 事件自己用原生 API 写控件工作量会翻好几倍业务层DocumentBuffer Settings文本、撤销栈、配置属于核心业务放进独立类可以直接单测不依赖窗口存在构建层CMake命令行和 IDE 都能用换一台机器不用改工程文件配置存储INI 文件只存窗口大小、最近打开文件这些少量键值不值得为它引入数据库为什么不直接选更上层的语言或框架主要是体积和可控性。套一层运行时启动就要多扛几十上百 MB 的壳而用原生控件框架最后出来的进程体积小启动快用户感知不到额外负担。反过来如果直接用系统底层 API 去创建窗口、处理消息循环做菜单和滚动条又得自己造轮子项目周期会拉得很长。折中下来这份源码选了 Qt Widgets 加 CMake 的组合既保留 C 的字节级控制力又不把时间耗在重复造控件上。2.2 工程目录十几个文件每个都知道自己该去哪改把压缩包解压之后工程结构大致是这样note_cpp/ ├── CMakeLists.txt ├── resources/ │ └── note.ico └── src/ ├── main.cpp ├── mainwindow.h ├── mainwindow.cpp ├── documentbuffer.h ├── documentbuffer.cpp ├── settings.h ├── settings.cpp ├── highlighter.h └── highlighter.cpp整个项目只有五个.cpp源文件和对应的头文件每个文件职责都很单薄这也是它适合拿来改的原因你改一个功能时基本只需要打开一个文件。文件责任你什么时候会碰它main.cpp启动应用、解析命令行参数、创建主窗口想加启动参数、改启动画面或单实例限制时mainwindow.cpp菜单、工具栏、状态栏、快捷键以及把界面事件转发给业务层想加菜单项、改快捷键、加右键菜单时documentbuffer.cpp文档内容、撤销栈、重做栈、文件读写、编码识别想改保存格式、加自动保存、做多标签页时settings.cpp记住窗口位置、窗口大小、最近打开文件列表想加“记住字体大小”这类配置时highlighter.cpp语法高亮规则默认支持 C 和 SQL 关键字想支持 JSON、Python 等新语言时这层划分看着简单实际是故意压出来的。最开始的版本把文件读写和撤销操作都写在主窗体里后来想给程序加自动保存功能发现很难在不动界面代码的前提下插入逻辑才把文本状态抽成了DocumentBuffer。从那之后把数据逻辑从界面里拆出去成了我改这类小工具时的固定动作。2.3 对象模型界面不碰文件文件不碰界面这三个核心对象的关系可以这样描述MainWindow持有编辑控件也持有DocumentBuffer和Settings用户按键时编辑控件发出信号MainWindow把变更转发给DocumentBuffer由它更新文本内容和撤销栈保存时MainWindow把文件路径交给DocumentBuffer由它负责打开文件、转码、写盘。反向的数据流也一样DocumentBuffer每次内容变化都会发出信号主窗体收到后再去刷新标题栏的“未保存”标记和状态栏字数。class DocumentBuffer : public QObject { Q_OBJECT public: explicit DocumentBuffer(QObject *parent nullptr); QString text() const; void setText(const QString text); bool hasUnsavedChanges() const; bool undo(); bool redo(); bool load(const QString path); bool save(const QString path); signals: void contentChanged(); void undoAvailableChanged(bool ok); void redoAvailableChanged(bool ok); private: struct EditStep { int start; int lengthRemoved; QString inserted; }; QString m_text; QListEditStep m_undoStack; QListEditStep m_redoStack; bool m_dirty false; };注意DocumentBuffer没有任何界面相关的东西它不知道窗口有多大、光标在哪、选中的是什么。它只知道文档内容、修改标记和两步操作栈。这意味着你可以把它拿出来写单元测试也可以在以后做多标签页时为每个标签各自持有一个DocumentBuffer实例互不干扰。典型的一次“保存”操作链路是这样走的用户按CtrlS→ 菜单QAction发出triggered信号 →MainWindow::saveFile被调用 → 它从m_buffer拿到文本调用m_buffer-save(path)→DocumentBuffer打开文件句柄按 UTF-8 编码写盘成功后发出contentChanged。整条链路上界面只负责接收意图业务层只负责执行动作。想改保存格式就只动documentbuffer.cpp想改快捷键就只动mainwindow.cpp这就是拆层带来的直接收益。3. 核心模块拆解从按下按键到文件落盘一条链路上的四个关键点3.1 主窗体装配菜单、快捷键、状态栏怎么连起来界面部分最核心的是MainWindow的构造函数创建编辑控件、注册信号槽、把菜单动作和业务方法绑在一起。代码不长但每一步都对应一个真实的桌面程序行为。MainWindow::MainWindow(DocumentBuffer *buffer, QWidget *parent) : QMainWindow(parent), m_buffer(buffer) { // 选用纯文本控件而不是富文本控件 m_editor new QPlainTextEdit(this); setCentralWidget(m_editor); // 文档内容一变标题栏和状态栏跟着刷新 connect(m_buffer, DocumentBuffer::contentChanged, this, MainWindow::refreshTitleAndStatus); // 编辑区自身的修改标记变化时同步窗口标题 connect(m_editor-document(), QTextDocument::modificationChanged, this, MainWindow::updateWindowModified); createMenus(); createToolBar(); readSettings(); } void MainWindow::createMenus() { QMenu *fileMenu menuBar()-addMenu(文件); QAction *saveAct fileMenu-addAction(保存); saveAct-setShortcut(QKeySequence::Save); // 自动适配平台惯例 connect(saveAct, QAction::triggered, this, MainWindow::saveFile); }这里选QPlainTextEdit而不是QTextEdit是有意为之。QPlainTextEdit针对纯文本做了优化几 MB 的日志文件滚动起来也流畅QTextEdit要维护富文本格式对普通记事本场景是多余的重量。参数上connect默认使用直连方式只适合同一个线程内传递信号如果以后你把DocumentBuffer放到工作线程里做异步保存这里就要显式指定Qt::QueuedConnection否则跨线程信号会丢失。QKeySequence::Save会自动映射到不同桌面平台的保存快捷键不需要自己写死CtrlS这点比手工拼快捷键省心。3.2 撤销重做增量栈比快照省在哪撤销功能是这个工程里最有说头的地方。不少初学者第一次实现撤销会直接保存整篇文本的快照一次输入就存一份全文文件一大内存立刻爆掉。这份源码采用的是增量记录每一步只保存“从哪个位置开始、删了多少字符、补了什么字符”。bool DocumentBuffer::undo() { if (m_undoStack.isEmpty()) return false; // 取出最近一步反着执行 const EditStep step m_undoStack.pop(); applyDelta(step.reverted()); m_redoStack.push(step); m_dirty true; emit contentChanged(); return true; } void DocumentBuffer::applyDelta(const EditStep s) { // 把当前文本对应区间替换成撤销前的文本 m_text.replace(s.start, s.lengthRemoved, s.inserted); } void DocumentBuffer::recordChange(int start, int lengthRemoved, const QString inserted) { m_undoStack.push({ start, lengthRemoved, inserted }); // 新编辑会让旧的重做分支失效这是标准编辑器行为 m_redoStack.clear(); // 限制栈深度防止长时间编辑把内存吃光 if (m_undoStack.size() 200) { m_undoStack.removeFirst(); } }EditStep的三个字段就是一次编辑的最小描述start是插入/删除发生的起始位置lengthRemoved是被删除的字符数没有删除就为 0inserted是新插入的文本。撤销时把当前区间替换回inserted就行了重做时则把同一份EditStep原样执行一遍。栈上限 200 步是兜底策略防止用户一次粘贴上万字符导致撤销栈积累太多字符串对象。如果你觉得 200 步不够可以调大但要注意removeFirst()会频繁移动元素更高效的做法是用环形缓冲区这里为了代码可读性先用列表实现。3.3 文件读写编码判断的顺序就是日后不乱码的顺序打开和保存是记事本被用得最多的两个功能也是最容易踩编码坑的地方。源码里的加载逻辑先检测 BOM再按 UTF-8 解码最后才回退到本地编码这个顺序不能反。bool DocumentBuffer::load(const QString path) { QFile f(path); if (!f.open(QIODevice::ReadOnly)) return false; QByteArray raw f.readAll(); QTextCodec *codec guessCodec(raw); m_text codec-toUnicode(raw); m_undoStack.clear(); m_redoStack.clear(); m_dirty false; // 刚打开的文件视为无未保存修改 emit contentChanged(); return true; } // 判断编码先用 BOM再严格按 UTF-8 解码 QTextCodec *DocumentBuffer::guessCodec(const QByteArray raw) { if (raw.startsWith(\xEF\xBB\xBF)) return QTextCodec::codecForName(UTF-8); if (raw.startsWith(\xFF\xFE)) return QTextCodec::codecForName(UTF-16LE); return QTextCodec::codecForName(UTF-8); }保存端则一律写成带 BOM 的 UTF-8。BOM 并不是给解码程序用的它在文件头里相当于一个“我说自己是 UTF-8”的标签让任何编辑器打开时都不需要猜测编码这就是为什么很多程序保存中文文本后再用别的工具打开仍然不乱码的原因。bool DocumentBuffer::save(const QString path) { QFile f(path); if (!f.open(QIODevice::WriteOnly | QIODevice::Truncate)) return false; QByteArray out m_text.toUtf8(); out.prepend(\xEF\xBB\xBF); // 写入 UTF-8 BOM f.write(out); return true; }guessCodec目前只处理了 UTF-8 和 UTF-16LE实际工程里还会遇到 GBK 编码的旧文档。一个合格的做法是先尝试用 UTF-8 严格模式解码失败后再用本地编码解码例如用QTextCodec::codecForLocale()并把结果交给用户确认。往深了做还可以接uchardet这类库做字符集推断但对一个轻量记事本来说识别 BOM 和 UTF-8 已经能覆盖绝大多数日常场景。3.4 查找替换选区边界问题一个都别踩查找功能的坑不在“能不能找到”而在“下一次查找从哪里开始”。如果每次都从光标当前位置找用户选中了一个结果后再点“查找下一个”会永远命中同一个匹配。int MainWindow::findNext(const QString keyword) { if (keyword.isEmpty()) return -1; QTextCursor cursor m_editor-textCursor(); // 有选区就从选区末尾开始避免反复命中同一处 int from cursor.hasSelection() ? cursor.selectionEnd() : cursor.position(); QTextCursor hit m_editor-document()-find( keyword, from, QTextDocument::FindCaseSensitively); // 从当前位置找不到折回到文档开头再试一次 if (hit.isNull()) { statusBar()-showMessage(已到达文档末尾从头继续查找, 3000); hit m_editor-document()-find( keyword, 0, QTextDocument::FindCaseSensitively); } if (!hit.isNull()) { m_editor-setTextCursor(hit); return hit.selectionStart(); } return -1; }关键点在于from的取值有选区时取selectionEnd没有选区时取position。QTextCursor::find的第二个参数是搜索起点配合QTextDocument::FindCaseSensitively标志实现大小写敏感查找。如果用户想切换大小写不敏感查找只要把标志位去掉即可。最后那个“从头继续查找”的状态栏提示很重要它避免了用户以为系统里没有关键字实际上只是光标已经在末尾的误会。替换全部时也要注意不能对整个字符串做QString::replace那会把文档里的格式信息全部丢掉而且无法一次撤销。正确做法是用QTextCursor在文档对象上逐段查找替换并把整批替换包在beginEditBlock/endEditBlock之间让它成为一步撤销操作。这个细节很多入行一两年的开发者都不一定注意到。4. 编译与首次运行CMake 三条命令从源码到第一个可执行文件4.1 用 CMake 生成构建目录并编译工程根目录的CMakeLists.txt已经把源文件、Qt Widgets 依赖和 C 标准声明好了内容基本不需要改动cmake_minimum_required(VERSION 3.16) project(note_cpp VERSION 1.0) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) find_package(Qt5 COMPONENTS Widgets REQUIRED) add_executable(note_cpp src/main.cpp src/mainwindow.cpp src/documentbuffer.cpp src/settings.cpp src/highlighter.cpp ) target_link_libraries(note_cpp Qt5::Widgets)接下来在工程根目录打开终端执行三条命令mkdir -p build cmake -S . -B build -DCMAKE_BUILD_TYPERelease cmake --build build -j4-S指定源码目录-B指定构建目录构建产物和源码分开不会污染工程目录。CMAKE_BUILD_TYPE设成Release会开启编译优化产物体积更小、运行更快调试阶段则建议用Debug方便打断点看变量。-j4是并行编译的线程数可以按 CPU 核心数调整机器性能好就写-j8。如果你已经装了 Qt 但 CMake 仍报 “找不到 Qt5”多半是 CMake 不知道 Qt 装在哪。解决办法是把 Qt 的安装路径通过CMAKE_PREFIX_PATH告诉 CMake比如cmake -S . -B build -DCMAKE_PREFIX_PATH/opt/Qt/5.15.2/gcc_64。编译成功后可执行文件在build/note_cpp桌面图形环境下是note_cpp.exe双击就能运行。4.2 命令行打开文件启动参数怎么解析编译出来的程序支持两种启动方式直接双击启动空白文档或从命令行传入文件路径直接打开。命令行模式下主程序入口做了简单的参数处理。int main(int argc, char *argv[]) { QApplication app(argc, argv); // 把原始命令行参数转成字符串列表跳过程序名 QStringList args; for (int i 1; i argc; i) { args QString::fromLocal8Bit(argv[i]); // 中文路径先转成 Unicode } bool readonly args.removeAll(--readonly); MainWindow w; if (!args.isEmpty()) { w.openFile(args.first()); } w.show(); return app.exec(); }QString::fromLocal8Bit这一步不是多余的。命令行参数在部分平台下是本地代码页编码路径里带中文时如果不转换后面的QFile打开操作就会失败。支持--readonly标记后还可以用“只读模式打开大文件避免误改”的方式做临时查看。注意这里只解析了args.first()也就是说一次只打开一个文件多人协作时如果想让程序支持多文件多标签可以把args整个传进MainWindow逐个打开这点在第六章会用到。功能入口和快捷键一览功能菜单位置快捷键打开文件文件CtrlO保存文件CtrlS另存为文件CtrlShiftS撤销编辑CtrlZ重做编辑CtrlY查找编辑CtrlF替换编辑CtrlH4.3 冒烟验证清单刚编译完怎么确认它能用编译成功不代表功能都正常我每次拿到一版新的构建产物都会按固定的顺序过一遍冒烟清单大概五分钟跑完双击程序启动窗口标题显示“无标题 - note_cpp”主界面有一个空白编辑区。随便输入几行文本标题栏出现*标记代表存在未保存修改。按CtrlS保存为test_notes.txt关闭程序再重新打开内容还在。输入十行 “ab”把光标移到中间位置按CtrlZ确认撤销是一步一步回退而不是整段消失再按CtrlY重做内容恢复。新建一个文本输入中文并保存再用其他编辑器打开确认中文字符没有乱码。打开一个有 3 MB 以上内容的日志文件拖滚动条浏览确认没有明显卡顿。这套清单覆盖了编辑、保存、撤销、编码、大文件五个最核心的场景。如果哪一步异常多半能直接定位到DocumentBuffer的某个方法而不用在主窗体和数据层之间来回猜。5. 避坑记录编码、撤销栈和输入卡顿的一线排查方向5.1 现象源码里的中文注释在编译后变成乱码现象源码文件里的中文注释在 IDE 里显示正常但用命令行编译器构建后运行日志里的中文全变成问号或乱码。原因源码文件保存成了 UTF-8 无 BOM 格式而命令行编译器在部分环境下默认按本地代码页解码源文件。解码错了编译出来的程序里字符串常量自然就是错的。解决源码文件统一存成 UTF-8 带 BOM让编译器无论什么默认编码都能正确识别同时给构建命令指定输入字符集为 UTF-8双保险。这个坑在带中文注释的项目里几乎是必踩的处理完以后再也不会觉得编码是玄学。5.2 现象几 MB 的文档一打字就卡成幻灯片现象打开一个 5 MB 的日志文件滚动不卡但一输入字符就卡顿甚至输入一个字母要等一两秒。原因这是典型的快照式撤销栈问题每个按键都会被记录成一次“整份文档副本”内存复制成了瓶颈。文档越大每次撤销记录越多性能衰减就越严重。解决改成第三节里的增量EditStep每次只记录被修改的片段并设置栈深度上限。改完后一次输入只做一次局部替换复杂度从 O(全文大小) 降到 O(改动大小)。如果你接手的是别人的代码发现撤销实现里保存了整段QString优先怀疑这里。5.3 现象查找“\r\n”永远查不到现象用户想查找 Windows 换行符\r\n程序提示找不到任何匹配。原因DocumentBuffer在load()时已经把全文交给 GUI 控件统一管理而控件内部会把换行符规范成\n原本的\r\n在内存里并不存在。解决加载文件时明确换行策略要么统一转成\n要么保存时再按目标平台转换。查找“换行符”应该用编辑控件的find配合正则模式而不是直接搜索\r\n两个字符。排查这类问题最快的方式是先看文档的字节内容把光标移到末尾查看文件大小为多少字节对比字符数基本能判断换行符是 2 字节还是 1 字节。5.4 现象带中文的路径拖进窗口打不开现象从文件管理器把一个文件名带中文的文本拖进程序窗口没反应或者打开了一个空白文件。原因命令行参数和拖放事件拿到的文件名在不同平台下的编码规则不同。直接把这些字节塞进QString路径已经被破坏QFile自然打不开。解决入口处统一做编码转换命令行用QString::fromLocal8Bit(argv[i])拖放路径用事件对象提供的 Unicode 接口读取不要自己拼路径。路径解析统一放在MainWindow的openFile()里这个函数是外部进入文档的唯一入口别在多个地方各自处理一遍路径。5.5 快速定位清单现象优先怀疑方向第一步动作中文乱码源码编码或命令行代码页不一致检查文件头是否有EF BB BF输入卡顿撤销栈存了全文快照注释掉撤销记录代码再试一次保存失败文件被占用或目录权限不足检查load()返回值状态栏打出错误码查找反复命中同一处选区边界处理错误清除选区后再点“查找下一个”程序启动后闪退全局变量或配置读取顺序先清空 INI 配置文件再启动6. 二次开发加标签页、扩展高亮规则的两个小改造6.1 改造一从单文档到多标签页把中央控件从单个编辑器换成QTabWidget再为每个标签页持有独立的DocumentBuffer界面层和数据层已经拆好这个改造比想象中顺利。void MainWindow::addTab(DocumentBuffer *buffer) { QPlainTextEdit *editor new QPlainTextEdit(this); connect(editor-document(), QTextDocument::modificationChanged, this, MainWindow::updateWindowModified); connect(buffer, DocumentBuffer::contentChanged, this, []() { refreshTitle(m_tabs-currentIndex()); }); int index m_tabs-addTab(editor, buffer-displayName()); m_buffers.append(buffer); m_tabs-setCurrentIndex(index); }多标签之后有一个容易忽略的点撤销、查找、替换操作必须跟随当前激活的标签页执行不能只操作主窗体里最早保存的那个“当前编辑器”。对应的做法是每次切换标签时把当前DocumentBuffer和编辑控件指针刷新一遍。如果不做这步你会发现第二个标签里按CtrlZ撤掉的却是第一个标签的内容这种问题一出现就很打击信心。6.2 改造二把高亮规则抽成配置表源码里已经有一个Highlighter但它把关键字写死在代码里。要扩展语言支持更友好的方式是把规则抽成一张表每加一种语言就加一行配置而不是改函数逻辑。比如把“关键字 颜色”定义成一个结构体数组让高亮器遍历规则时通过正则匹配关键字边界这样新增语言只需要关心规则数据不需要动匹配引擎。暴露一个setRules()方法后续还能把规则加载逻辑接到外部配置文件实现“用户自己改配色”而不需要重新编译。6.3 改造后的验证顺序做完改造别急着一次性改完再验证。我习惯每改一个点就编译一次、跑一遍最小验证路径打开三个样例文件包含中文、输入撤销保存、重开文件对比内容。这个习惯是从一次翻车经历里学来的那次我给程序加“自动保存”功能一下子改动太多最后定位不到是标签页切换的问题还是保存逻辑的问题只能回滚重来。从那以后我每次动桌面工具都强制自己先写一版最小验证清单再开始改代码改完先跑清单再往下走返工时间少了很多希望帮到你。本文还有配套的精品资源点击获取
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。