最近抽空把清单上躺了很久的一个小项目给落地了——用Qt从零写一个简易文本编辑器。项目标题叫"简易版",但真正做完整一轮,Qt桌面开发最常用的那批组件和机制基本都碰到了:QMainWindow的窗口结构、菜单和动作系统、QPlainTextEdit的编辑控件与文档模型、QFileDialog的文件对话框、QMessageBox的交互弹窗,还有贯穿始终的信号槽通信。整个项目用C++编写,基于Qt 5.15,编译环境是MinGW 64位,没有引入任何第三方库,所有功能都靠Qt自带的模块实现。
这篇复盘写给三类人看。第一类是刚学完C++、想找个完整图形界面项目练手的人,这个项目体量正合适,代码量控制在一到两个晚上能敲完的范围;第二类是已经在用Qt做界面开发、但想看看文本编辑器从零搭建时怎么处理文件读写、编码、撤销栈这些麻烦事的朋友,可以直接跳到第四章的排查记录;第三类是日常需要处理纯文本文档、想自己攒一个轻量小工具的人,做完之后当成顺手工具用完全没问题。
老规矩,先把话说明白:这不是一篇教你"五分钟做出记事本"的速成教程,我会把每个关键选择背后的原因讲清楚,包括哪些地方我一开始写错了、后来怎么改的。每一段代码都可以直接抄进工程里跑,但更重要的是理解为什么这么写。
1. 项目思路与整体设计
1.1 为什么选Qt而不是其他框架
做文本编辑器,可选的技术栈其实不少。我一开始也在几个方向之间纠结过,简单对比一下当时的选择过程。
用MFC做,好处是Windows原生体验好,但写起来啰嗦,消息映射那一套现在看着实在费劲;用C#的WinForms或者WPF,开发速度快,但脱离Windows平台就没法玩;用Electron套Web技术,界面能做得漂亮,可一个记事本级别的工具要背整个Chromium运行时,体积和内存占用都离谱。最后选了Qt,理由很实在。
Qt的核心设计是"信号槽"机制,简单说就是某个对象状态变化时发出信号,另一个对象的某个槽函数被自动触发。这种松耦合的通信方式特别适合做界面程序,因为界面交互天然就是事件驱动的。比如用户点了"打开"按钮,点击信号被发出去,主窗口负责处理文件打开的槽函数就被调用,不需要像传统回调那样写一堆函数指针和上下文参数。
Qt还内置了一套完整的控件库和布局系统,QMainWindow直接给了菜单栏、工具栏、状态栏、中央控件的标准窗口骨架,不用自己拼凑。文件对话框、消息提示框、字体选择对话框这些高频组件也都是现成的,省掉大量重复劳动。对一个简易编辑器来说,Qt属于"开箱即用"那一类,而且代码写出来跨平台,回头想移植到别的操作系统,基本不用改。
1.2 简易版编辑器该覆盖哪些功能
动手写代码之前先列功能清单,这个习惯帮我省了很多返工。我给这个版本划定的范围是:
- 文件操作:新建、打开、保存、另存为
- 编辑操作:撤销、重做、剪切、复制、粘贴、全选
- 查找操作:查找下一个、替换
- 视图操作:字体设置、字号调整
- 状态反馈:当前文件路径、光标行列位置、文档修改标记
为什么不把功能做得更多?两个原因。第一,骨架要稳。文本编辑器最核心的就是文件读写和文本编辑这两条主链路,先把这两条链路做到可靠,比堆砌功能重要得多。第二,很多高级功能(语法高亮、代码折叠、多标签页)表面上只是一个功能点,实际牵扯到独立的数据结构和渲染机制,放进"简易版"只会让代码失控。比如语法高亮需要自己实现一套词法分析配合QSyntaxHighlighter,这已经是一个独立项目的工作量了。
功能范围定下来之后,整个程序的交互流程也清晰了:用户打开文件→编辑内容→程序跟踪修改状态→关闭或切换文件时提示保存→保存时选择路径并把内容写回磁盘。这条主流程贯穿了所有功能模块,UI只是外衣,数据流和状态管理才是内核。
1.3 QTextEdit还是QPlainTextEdit,选型对比
Qt里用来编辑文本的控件主要有两个:QTextEdit和QPlainTextEdit。很多新手会直接选QTextEdit,因为名字里没带"Plain",看起来更强大,这其实是个常见误区。
QTextEdit定位是富文本编辑器,内部基于QTextDocument,支持HTML样式、图片嵌入、文本片段格式设置,功能上限高但开销也大。QPlainTextEdit从名字就能看出,它是纯文本场景的专用控件,内部做了大量针对性优化,比如对超长行的处理策略、光标定位算法、文本块缓存机制,都朝着"快速加载、流畅编辑大量纯文本"这个方向设计。
我的编辑器场景是纯文本,所以选了QPlainTextEdit。具体差异体现在几个地方:
| 对比维度 | QTextEdit | QPlainTextEdit |
|---|---|---|
| 底层文档模型 | QTextDocument(富文本) | QPlainTextDocumentLayout(纯文本) |
| 超大文件性能 | 一般,渲染开销大 | 明显更好 |
| 默认内容类型 | 可带格式 | 纯文本 |
| API定位 | 富文本操作 | 文本块、光标、查找 |
实际测下来,用QPlainTextEdit打开几MB的日志文件,滚动和编辑几乎没有卡顿,换成QTextEdit做同样操作,延迟能感觉到明显差异。如果只是写带格式的展示页面选QTextEdit没错,但做文本编辑器,QPlainTextEdit才是对的选择。
2. 界面搭建与核心功能实现
2.1 主窗口骨架与菜单结构
选好控件类型,接下来搭界面骨架。程序继承自QMainWindow,在构造函数里完成四件事:创建中央编辑器控件、构建动作(QAction)、组装菜单栏和工具栏、初始化状态栏。
QAction是Qt里非常巧妙的抽象。它把一个操作的所有信息(标题、快捷键、图标、触发信号)打包成一个对象,同一个QAction既可以被菜单引用,也可以被工具栏按钮引用,甚至可以被快捷键触发,不用重复写三套逻辑。比如"打开文件"这个动作,菜单里点它、工具栏按钮点它、按Ctrl+O,最终走的是同一个信号槽链。
菜单结构我按惯例分成三组。文件菜单放新建、打开、保存、另存为和退出;编辑菜单放撤销、重做、剪切、复制、粘贴、全选;格式菜单放字体设置和字号调整。这已经是文本编辑器最经典的分组方式,符合大多数用户对桌面软件的心理预期,不需要创新,稳妥就好。
主窗口初始化核心代码大概长这样:
MainWindow::MainWindow(QWidget *parent) : QMainWindow(parent) { textEdit = new QPlainTextEdit(this); setCentralWidget(textEdit); createActions(); createMenus(); createToolBar(); createStatusBar(); setWindowTitle(QStringLiteral("简易文本编辑器 - 未命名")); connect(textEdit->document(), &QTextDocument::modificationChanged, this, &MainWindow::updateWindowTitle); connect(textEdit, &QPlainTextEdit::cursorPositionChanged, this, &MainWindow::updateCursorPosition); resize(960, 640); }有个细节值得注意:把textEdit的父对象设成this(主窗口),Qt的内存管理机制会自动在窗口销毁时释放它,不用手动delete。Qt里绝大多数new出来的控件和对话框,只要指定了正确的父对象,内存泄漏的风险就小了一大半,这是写Qt代码的基本修养。
2.2 文件操作:新建、打开、保存、另存为
文件操作是编辑器的生命线,这里面的状态管理远比看起来复杂。我踩过的第一个坑就是:用户编辑了一个文件,没保存就点了新建,内容直接丢了。没有提示,没有后悔药。这个问题靠一个函数解决,我叫它maybeSave(),在新建和打开之前调用。
bool MainWindow::maybeSave() { if (!textEdit->document()->isModified()) return true; QMessageBox::StandardButton ret = QMessageBox::warning( this, QStringLiteral("未保存的更改"), QStringLiteral("文档已被修改,是否保存?"), QMessageBox::Save | QMessageBox::Discard | QMessageBox::Cancel); if (ret == QMessageBox::Save) return saveFile(); else if (ret == QMessageBox::Cancel) return false; return true; }这个函数的设计逻辑是:文档没改过就放行;改过就弹窗问用户,三种选择对应三种结果——保存成功才继续操作、放弃修改继续操作、取消当前操作。所有可能导致内容丢失的入口(新建、打开、关闭窗口、退出程序)都必须先过这一关。只需要一个函数,但整个程序的数据安全就托底了。
打开文件的流程也有讲究。不能用QFile::readAll一把梭然后塞进编辑器,虽然小文件确实能跑,但大文件会被一次性读进内存,界面会卡死。简易版虽然没做异步加载,但至少用QTextStream逐块读取,并统一处理编码:
bool MainWindow::loadFile(const QString &filePath) { QFile file(filePath); if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { QMessageBox::warning(this, QStringLiteral("打开失败"), file.errorString()); return false; } QTextStream in(&file); #if QT_VERSION < QT_VERSION_CHECK(6, 0, 0) in.setCodec("UTF-8"); #endif QString content = in.readAll(); file.close(); textEdit->setPlainText(content); setCurrentFile(filePath); statusBar()->showMessage(QStringLiteral("已打开 %1").arg(filePath), 3000); return true; }这里setPlainText会触发文档的undo/redo栈重置,所以必须在设置文本之后,再调用setCurrentFile把窗口标题和当前路径更新到位。顺序错了会出现标题显示正常但编辑器内容还是旧内容的诡异情况。保存的逻辑类似,另存为用QFileDialog::getSaveFileName弹出路径选择,保存到新路径后,立刻更新当前文件路径和窗口标题。
2.3 编辑操作与快捷键绑定
撤销、重做、剪切、复制、粘贴、全选这些操作,QPlainTextEdit内部已经实现了标准槽函数,理论上直接connect就能用。但这里有个新手容易忽略的点:这些操作是有条件的,没有文本时可以粘贴,没有选中内容时剪切和复制是无效的。
我试过最省事的方案——给每个操作无条件创建QAction并绑定快捷键。后果是:空白文档里Ctrl+V也能触发粘贴,虽然不会崩溃,但行为不够严谨。后来用了一个简单策略:依赖QPlainTextEdit内部的信号,在状态变化时动态设置QAction的setEnabled。
实际上,做简易版不必过度设计。我最终采用的方式是双向绑定:剪切、复制、删除这几个动作的启用状态和textEdit是否有选中文本挂钩,依靠QPlainTextEdit的copyAvailable信号更新;撤销和重做则绑定undoAvailable和redoAvailable信号。这样代价很小,但交互体验立刻专业起来。
connect(textEdit, &QPlainTextEdit::copyAvailable, cutAct, &QAction::setEnabled); connect(textEdit, &QPlainTextEdit::copyAvailable, copyAct, &QAction::setEnabled); connect(textEdit->document(), &QTextDocument::undoAvailable, undoAct, &QAction::setEnabled);快捷键绑定用QAction::setShortcut实现,整个编辑器统一在createActions里配置。我按习惯给每个动作配了标准快捷键:Ctrl+N新建,Ctrl+O打开,Ctrl+S保存,Ctrl+Shift+S另存为,Ctrl+Z撤销,Ctrl+Y重做,Ctrl+F查找,Ctrl+H替换。这里强烈建议直接沿用这些行业默认快捷键,别自创组合键,用户用顺手了换个软件就找不到键位。
2.4 查找替换的两种实现路径
查找替换的实现,我写了两个版本。第一版最简单,直接调用QPlainTextEdit自带的find方法:
void MainWindow::findNext(const QString &str) { if (!textEdit->find(str)) { QMessageBox::information(this, QStringLiteral("查找"), QStringLiteral("找不到匹配内容")); } }这个方法的内部逻辑是从当前光标位置向后搜索,找到就把选中区域移到匹配处,找不到返回false。如果用户已经在文档末尾,find实际上不会自动绕回开头,所以我会在查找前判断光标位置,如果已经到末尾或者没有选中文本,就把光标移到文档开头再搜一次。
替换功能的实现稍微复杂。我选择在一个非模态对话框里做,界面包含:查找内容输入框、替换内容输入框、查找下一个按钮、替换按钮、全部替换按钮。核心替换逻辑是:
void MainWindow::replaceOne(const QString &needle, const QString &replacement) { if (textEdit->textCursor().selectedText() == needle) textEdit->textCursor().insertText(replacement); findNext(needle); }这个逻辑的巧妙之处在于:先判断当前选中文本是不是要找的内容,是就替换掉,然后立刻查找下一个。这样用户连续点"替换"按钮,就能从头到尾逐条确认替换。全部替换更简单,写一个循环从头搜到尾,每次替换后重新定位,统计替换次数反馈给用户。记得在循环开始前用QTextCursor定位到最开始位置,否则会从当前光标位置开始搜,漏掉前面的内容。
3. 实操记录:从新建工程到功能联调
3.1 工程文件与代码结构
这个项目我用的是qmake构建,工程文件.pro的内容很干净:
QT += core gui greaterThan(QT_MAJOR_VERSION, 4): QT += widgets TARGET = txtpad TEMPLATE = app SOURCES += main.cpp \ mainwindow.cpp HEADERS += mainwindow.h源码就两个文件,main.cpp和mainwindow.cpp,外加一个mainwindow.h头文件。mainwindow.h里声明的类成员在前面的代码片段里基本已经体现了:一个QPlainTextEdit指针、一组QAction指针、两个私有槽函数组(文件操作一组、编辑操作一组)、当前文件路径和几个辅助函数。
main.cpp里做的事情很少——创建QApplication,创建MainWindow,显示主窗口,进入事件循环。这是所有Qt程序的通用入口,一般不用改太多:
#include <QApplication> #include "mainwindow.h" int main(int argc, char *argv[]) { QApplication app(argc, argv); MainWindow w; w.show(); return app.exec(); }3.2 动作创建与信号槽绑定
createActions()是整个程序最集中的一块代码,每个QAction创建时就把文本、快捷键、状态栏提示一次性配好。Qt的setStatusTip方法值得一提:鼠标悬停在菜单项或按钮上时,状态栏会自动显示预设的提示文字,这个功能几乎零成本,但对用户体验的提升很明显。
void MainWindow::createActions() { newAct = new QAction(QIcon::fromTheme(QStringLiteral("document-new")), QStringLiteral("新建(&N)"), this); newAct->setShortcuts(QKeySequence::New); newAct->setStatusTip(QStringLiteral("新建一个文档")); connect(newAct, &QAction::triggered, this, &MainWindow::newFile); openAct = new QAction(QIcon::fromTheme(QStringLiteral("document-open")), QStringLiteral("打开(&O)"), this); openAct->setShortcuts(QKeySequence::Open); openAct->setStatusTip(QStringLiteral("打开已有文档")); connect(openAct, &QAction::triggered, this, &MainWindow::openFile); // 其余动作同理 }QKeySequence::New、QKeySequence::Open这些常量是Qt预定义的跨平台快捷键映射,不同操作系统会自动适配,比如在macOS上Ctrl会变成Command。比手写字符串"Ctrl+N"更规范,强烈推荐使用。
信号槽的连接方式我统一用了新语法(指向成员函数的指针),不用旧的SIGNAL/SLOT宏。新语法的好处是编译期就能检查信号和槽是否匹配,写错了直接编译报错,不会运行时才静默失效。
3.3 工具栏和状态栏的完善
工具栏我放了常用操作:新建、打开、保存、剪切、复制、粘贴、查找。全部用QIcon::fromTheme加载系统主题图标,开发环境没安装主题图标库时可能显示空白,这个属于开发环境问题,打包成发行版后不受影响。如果你对图标有要求,可以用Qt资源系统打包自己的图标,我不建议为这个项目专门找图标资源,系统主题的足够用了。
状态栏放了两个QLabel。左边的用来显示当前打开的文件名或路径,这个label还兼任临时的操作反馈(比如"已打开 xxx");右边的用来显示光标位置,格式是"行 12,列 34"。光标位置更新靠cursorPositionChanged信号触发:
void MainWindow::updateCursorPosition() { QTextCursor cursor = textEdit->textCursor(); int line = cursor.blockNumber() + 1; int column = cursor.positionInBlock() + 1; cursorPosLabel->setText( QStringLiteral("行 %1,列 %2").arg(line).arg(column)); }还有个细节是窗口标题的维护。文档修改后标题栏要加星号提示,这是几乎所有编辑器的惯例。我连接了QTextDocument::modificationChanged信号,在updateWindowTitle里根据当前路径和修改状态拼标题。注意QTextDocument的isModified默认是false,修改后变true,但保存后要主动调用setModified(false)复位,否则保存完星号还在。
3.4 编码处理与程序打包
编码是文本编辑器绕不开的坎。Qt 5默认的QTextStream读到UTF-8文件没问题,但遇到Windows下常见的GBK/GB2312编码的txt文件,直接读就会出现乱码。我在loadFile里默认按UTF-8解码,另外加了一个简单的编码探测:先用QFile读前几个字节判断有没有UTF-8的BOM标记,没有就尝试用QTextCodec的编码转换。
Qt 5里用QTextCodec::codecForName("GBK")转码,Qt 6开始QTextCodec被挪到core5compat模块里了,这也是我在代码里用QT_VERSION_CHECK区分版本的原因。如果你用的是Qt 6,建议直接把需要的编码转换逻辑打包进core5compat依赖里。
打包环节用的windeployqt,这是Qt官方提供的部署工具。编译完Release版exe之后,在Qt命令行环境里执行:
windeployqt txtpad.exe它会自动把Qt运行时依赖的DLL和插件目录复制到exe所在文件夹。跑完这一步,整个文件夹拷到别的Windows机器上就能运行。注意两点:一是必须用Release编译模式,Debug版依赖的调试DLL体积巨大且目标机器没装Qt运行库就会崩;二是windeployqt要和你用的编译器对应,MinGW的exe必须用MinGW版Qt的工具部署,用MSVC版会乱套。
4. 常见问题与排查技巧实录
4.1 中文乱码:不只是编码转换那么简单
乱码问题我在开发过程中遇到了三类。第一类是源代码里写的中文字符串编译后变成乱码,原因是源码文件编码和编译器默认编码不一致。Qt 5默认源码编码是UTF-8,所以我把源码文件全部以UTF-8保存,中文字符串外面套QStringLiteral宏,彻底规避了问题。
第二类是打开GBK编码文档时的乱码。前面已经提到,用QTextCodec做转换可以解决。但更稳妥的做法是提供一个"编码"菜单,让用户手动选择文档类型。简易版里我用自动探测加默认为UTF-8的方案,识别不了的情况下提示用户另存为UTF-8后重新打开。
第三类最容易忽略——写文件时的编码一致性。打开的GBK文件,编辑完保存时如果按UTF-8写回,文件本身的编码就变了,用户可能会困惑"为什么打开方式不对了"。我采取的方案是:记录打开文件时检测到的编码,保存时沿用同一编码。这个细节才是编辑器对老文件友好的关键。
4.2 修改状态与撤销栈:isModified的坑
QTextDocument::isModified的语义需要注意:它标记的是"自上次保存依赖是否有过修改",但setPlainText(内容)会把这个状态重置。这就是为什么打开新文件后,isModified会变成false,哪怕加载时触发了文档内容变化。
更隐蔽的问题是撤销栈。当用户打开新文件时,QPlainTextEdit会清空撤销栈,这是好的;但当用户新建空白文档或者粘贴一大段内容时,撤销栈里可能残留之前文件的撤销记录,导致按Ctrl+Z调出旧文件的内容。解决办法是在切换文件内容之后,显式调用textEdit->document()->clearUndoRedoStacks()。
void MainWindow::setCurrentFile(const QString &filePath) { currentFilePath = filePath; textEdit->document()->setModified(false); textEdit->document()->clearUndoRedoStacks(); updateWindowTitle(); }这个函数是我踩过三次坑之后总结出来的关键清洗点。每次文件切换都必须执行这三步:标记未修改、清空撤销栈、刷新标题。顺序不能乱,乱一个就会出现"保存之后还是提示未保存"或者"撤销到上一个文件内容"的诡异状态。
4.3 文件对话框与路径处理
QFileDialog用起来不难,但路径处理有几个容易出问题的地方。第一个是对话框默认打开的目录。我一开始没设默认目录,每次打开都弹出用户当前工作目录,体验很差。后来记住lastVisitedDir这个成员变量,每次用户用对话框选定文件后,用QFileInfo(path).absolutePath()把所在目录记下来,下次对话框打开默认定位到这个目录。成本极低,体验提升明显。
第二个坑是保存对话框的覆盖确认。QFileDialog::getSaveFileName默认不会自动弹"文件已存在是否覆盖"的提示,需要在saveFileAs里自己判断:
if (QFile::exists(filePath)) { QMessageBox::StandardButton ret = QMessageBox::warning( this, QStringLiteral("确认覆盖"), QStringLiteral("文件已存在,是否覆盖?"), QMessageBox::Yes | QMessageBox::No); if (ret != QMessageBox::Yes) return false; }4.4 信号槽与内存管理的几个冷门坑
用lambda写connect时,如果lambda里捕获了this,且对象生命周期可能提前结束,就存在悬垂指针风险。简版程序里对象生命周期和主窗口一样长,风险很小,但养成习惯总是好的。需要长期连接的槽函数,统一用成员函数而不是lambda,需要传临时参数的用lambda,但捕获列表里只捕获值类型,不捕获裸指针。
另一个坑是QMessageBox或QFileDialog被new出来但没指定父对象,用完不delete。有人图省事这么写:
QMessageBox *box = new QMessageBox(this); box->exec(); delete box;exec()是阻塞的,用完delete没问题。但有些人会写QMessageBox::information这种静态方法,那个内部自动管理生命周期,不用管。最怕的是在循环里反复new对话框又不delete,一次弹十个窗口,内存悄悄涨。我自己的习惯:能用静态方法就用静态方法,需要自定义对话框时给对话框指定父对象并保证exec后立即delete。
5. 扩展方向与实操心得
5.1 从简易版往完整版走,优先加什么
做完简易版之后,我梳理了几个性价比最高的扩展方向,按投入产出比排序。
排名第一的是多标签页。QTabWidget包住QPlainTextEdit,每个标签对应一个文档,当前标签切换时同步更新标题栏和状态栏。这部分改动不复杂,但直接把"编辑器"的格局打开,从单文档工具变成真正能多任务处理的工具。
排名第二的是行号显示。QPlainTextEdit本身不带行号栏,需要自己绘制,业界常见的做法是继承QPlainTextEdit重写paintEvent,或者用一个单独的QWidget放在左边跟随滚动。不复杂,但对编辑体验的提升立竿见影。
排名第三的是语法高亮。Qt的QSyntaxHighlighter类就是干这个的,继承它重写highlightBlock函数,配合QRegularExpression做规则匹配,几百行代码就能让编辑器支持基本的C++或Python高亮。这个功能做出来,编辑器瞬间就从"纯文本工具"变成"轻量IDE雏形"。
这些扩展的共同点是不动原有架构,只是在现有主窗口的框架上做加法。这也是当初设计时把功能模块拆干净、每个模块只依赖信号槽通信的回报——加功能时不牵一发动全身。
5.2 我在这个项目里最想分享的经验
整个项目做下来,我最想划重点的其实是三件小事。
第一件:先梳理状态,再写界面。文本编辑器表面上是控件堆叠,实际上是状态机。文件路径、修改标记、撤销栈、光标位置、查找起点,这些状态互相影响,理不清状态就写界面,最后必然在各种边界条件下翻车。我现在做任何界面项目,第一件事是在纸上画状态流转图,不是画流程图,就是把所有可能的状态转移列出来。
第二件:QPlainTextEdit选对了,后面省一半事。很多教程为了演示富文本功能会带你用QTextEdit,但做纯文本编辑,QPlainTextEdit从API到性能都是为这个场景定制的。选对基础控件,比后期打补丁重要得多。
第三件:小项目也要控制函数粒度。我第一版把打开文件、解析文件、刷新界面的逻辑全写在一个函数里,调试时每个环节都要用断点确认执行到哪一步。后来拆成loadFile、setCurrentFile、updateWindowTitle三个函数,每个函数只做一件事,出问题一眼就能定位。
这个简易编辑器后续我还在继续维护,下一步准备把多标签页和行号先做了。如果你也想动手做一个,建议按我踩过的路线走:先跑通文件读写主链路,再做编辑操作,最后加查找替换和细节打磨。不要一上来就想做语法高亮,先让基础流程稳了,后面所有扩展都是水到渠成的事。