☰
用Qt写出不乱码的记事本:编码探测与换行符处理全解析
2026/9/26 19:08:06 网站建设 项目流程

简介:基于Qt框架的轻量级记事本源码项目,面向Qt、C++初学者,完整展示从窗口创建、菜单布局到文件打开保存的基础实现思路。界面刻意保持极简,未添加状态栏与查找替换等附加功能,适合快速了解跨平台桌面应用的工程组织方式和界面事件处理流程。压缩包共十五个文件,整体大小约一百四十三KB,其中包含C++源文件、头文件、用户界面文件、工程配置文件、资源文件以及图标和说明图片,目录结构清晰,便于按模块对照学习。目前已有三百零二人学习下载。源码将主窗口类与对话框类分离,可直观体会控件信号与槽机制;通过编译运行可验证基础编辑功能,后续也容易自行扩展状态栏或查找替换能力,适合作为课程设计或自学练手项目。

1. 用 Qt 写一个简单记事本,先别急着写代码

“Notepad_QT_简单记事本_”这个工程名字看起来平平无奇,但它背后指向的是每个 Qt 初学者都会做一遍的经典练习:把打开、保存、查找替换、字数统计这些最熟悉的桌面操作,用 Qt Widgets 重新实现一遍。做这个小项目的价值不在“记事本”本身,而在于把 QMainWindow、QPlainTextEdit、QFileDialog、QTextCursor 这四样核心组件串起来,等于把 Qt 桌面开发的主干走了一遍。这篇笔记直接按可编译的 Qt 5.15 LTS 工程来写,适合刚学完信号与槽的初学者,也适合想快速产出内部文本小工具、又不想引入复杂框架的工程师。你会发现:记事本虽小,但编码、换行符、部署这些真正折磨人的问题,一个都少不了。

2. 编辑器核心选型与主窗口骨架:为什么是 QPlainTextEdit 而不是 QTextEdit

2.1 QPlainTextEdit、QTextEdit、QTextBrowser:记事本为什么锁死 QPlainTextEdit

很多第一次动手的人会顺手放一个 QTextEdit,因为教程里出现得最多。QTextEdit 是富文本编辑器,默认支持一部分 HTML,可以插入图片、修改字体颜色、设置表格;QPlainTextEdit 则是为纯文本场景设计的,内部按块(block)组织文本,绘制和滚动都针对大段文字做过优化。同样是打开一个几 MB 的日志文件,QTextEdit 会明显卡顿,QPlainTextEdit 在多数机器上依然能流畅翻页。记事本只需要处理纯文本,所以核心组件锁死 QPlainTextEdit,这是性能和实现成本双重考虑下来的结论。

QTextBrowser 则是 QTextEdit 的只读子类,适合做帮助文档、富文本查看器,不适合做编辑器。如果你的目标是“能打字的记事本”,不需要考虑它。还有一个容易忽略的选项:如果只是做日志监控,可以在 QPlainTextEdit 上调用setMaximumBlockCount(5000),让文本区只保留最近 5000 行,这样长时间运行也不会把内存吃满——这个参数对记事本同样有意义,打开的日志文件再大,界面也不会拖死。

提示:QPlainTextEdit 的setMaximumBlockCount是行数上限,超出后自动丢弃旧块,对“打开一个大日志文件”的场景很实用,但对普通 txt 建议设为 0,0 表示不限制。

2.2 最小可运行的 QMainWindow 工程:菜单、工具栏、状态栏一起搭

我一般用 Qt Creator 新建 Qt Widgets Application,但不用它自动生成的 MainWindow.ui,而是全部手写界面。这样做的原因是手写代码能把“菜单、工具栏、状态栏、中心组件”的关系交代清楚,用 Qt Designer 拖界面虽然快,但新手很容易被.ui编译机制带偏。以下是整个工程的入口文件。

#include <QApplication> #include "MainWindow.h" int main(int argc, char *argv[]) { QApplication app(argc, argv); app.setApplicationName("SimpleNotepad"); // 这个名字影响第 6 章 QSettings 的存储路径 app.setOrganizationName("DevWorkshop"); MainWindow w; w.show(); return app.exec(); // 进入事件循环,直到窗口关闭 }

逻辑说明:QApplication 是每个 Qt GUI 程序必须创建的对象,它负责事件循环和全局设置;setApplicationName和setOrganizationName看起来是元数据,实际上决定第 6 章 QSettings 默认写入的注册表或配置文件位置,不设置的话 QSettings 可能无法正常工作。MainWindow 的构造里把菜单、工具栏、状态栏和中心编辑区一次性搭好。

MainWindow::MainWindow(QWidget *parent) : QMainWindow(parent) { setWindowTitle(tr("记事本 - 未命名")); resize(1000, 680); editor = new QPlainTextEdit(this); setCentralWidget(editor); // 编辑区占满主窗口剩余空间 connect(editor, &QPlainTextEdit::textChanged, this, &MainWindow::onTextChanged); // 文本变化后刷新状态栏和标题标记 statusBar()->showMessage(tr("就绪"), 3000); createActions(); createMenus(); createToolBar(); }

参数说明:resize(1000, 680)是初始窗口尺寸,没有设置最小尺寸,用户仍可自由拖拽;statusBar()->showMessage(msg, 3000)的第二个参数是显示毫秒数,3000 表示 3 秒后自动清空。createActions、createMenus、createToolBar三个方法按名字拆分,避免构造函数写得太长。

动作和菜单的完整代码如下:

void MainWindow::createActions() { openAction = new QAction(tr("打开..."), this); openAction->setShortcut(QKeySequence::Open); // 自动映射 Ctrl+O connect(openAction, &QAction::triggered, this, &MainWindow::openFile); saveAction = new QAction(tr("保存"), this); saveAction->setShortcut(QKeySequence::Save); // 自动映射 Ctrl+S connect(saveAction, &QAction::triggered, this, &MainWindow::saveFile); quitAction = new QAction(tr("退出"), this); quitAction->setShortcut(QKeySequence::Quit); connect(quitAction, &QAction::triggered, this, &QWidget::close); } void MainWindow::createMenus() { QMenu *fileMenu = menuBar()->addMenu(tr("文件(&F)")); fileMenu->addAction(openAction); fileMenu->addAction(saveAction); fileMenu->addSeparator(); fileMenu->addAction(quitAction); } void MainWindow::createToolBar() { QToolBar *toolBar = addToolBar(tr("常用")); toolBar->setMovable(false); toolBar->addAction(openAction); toolBar->addAction(saveAction); }

QKeySequence::Open 和 QKeySequence::Save 是 Qt 内置的标准快捷键枚举,在 Windows 上映射为 Ctrl+O、Ctrl+S,在 macOS 上自动变成 Command 键,比硬编码QKeySequence("Ctrl+O")更稳。QToolBar 的setMovable(false)是给工具栏上锁,防止用户不小心把它拖出来变成悬浮窗口;如果是高级用户使用的工具,可以把这行去掉,允许自由排版。

2.3 字体与缩进的 3 个常用参数:Monospace 字体和 Tab 距离

记事本的核心体验是“代码和配置文件的缩进要整齐”,因此字体必须选等宽字体,Tab 键距离要固定。以下是三个必调参数:

QFont mono("Courier New"); mono.setStyleHint(QFont::Monospace); // 系统没有 Courier New 时自动替换为其他等宽字体 mono.setPointSize(10); editor->setFont(mono); editor->setLineWrapMode(QPlainTextEdit::WidgetWidth); editor->setTabStopDistance(4 * QFontMetricsF(editor->font()).horizontalAdvance(QLatin1Char(' ')));

参数说明:setStyleHint(QFont::Monospace)很关键,在 Linux 或 macOS 上没有 Courier New 时,Qt 会根据这个提示自动选择 DejaVu Sans Mono、Menlo 等系统等宽字体,避免回退到中文字体导致对齐失败。setLineWrapMode(QPlainTextEdit::WidgetWidth)是自动换行,文本超过窗口宽度时自动折行;如果做日志工具,很多人喜欢NoWrap加水平滚动条,记事本默认用自动换行更贴近 Windows 记事本的行为。setTabStopDistance在 Qt 5.10 以后是重载函数,参数是像素,写成 4 个空格宽度,按下 Tab 后光标正好跳 4 个空格;老代码里的setTabStopWidth在 Qt 5.10 起被标记废弃,不要混用。

到这里,一个能打字、能显示菜单栏和状态栏的记事本骨架已经跑起来了。剩下的问题全部集中在“文件怎么打开、怎么保存、怎么防止乱码”上,这是记事本项目里最容易翻车的一段。

3. 打开与保存的完整实现:编码探测、换行符统一与 BOM 处理

3.1 打开文件时怎么判断 UTF-8 还是 GBK:BOM 优先,非法 UTF-8 兜底

记事本最常见的翻车现场就是乱码。Qt 5 之后内部字符串默认是 UTF-8,但 Windows 旧版记事本保存中文时默认用 ANSI,也就是 GBK/GB18030;网上下载的配置文件可能是 UTF-8 无 BOM;Linux 过来的文件换行符还是 LF。所以打开文件不能只做QFile::readAll()加toUtf8(),要先做编码探测和换行符统一。下面是完整的 openFile 实现。

bool MainWindow::openFile(const QString &filePath) { QFile file(filePath); if (!file.open(QIODevice::ReadOnly)) { QMessageBox::warning(this, tr("打开失败"), file.errorString()); return false; } QByteArray data = file.readAll(); // 一次性读入,文件不大时最简单 file.close(); // 先把三类换行符统一成 \n,后面所有逻辑只处理 \n data.replace("\r\n", "\n"); data.replace('\r', '\n'); QByteArray utf8Bom = QByteArray::fromHex("efbbbf"); QTextCodec *codec = nullptr; if (data.startsWith(utf8Bom)) { data.remove(0, 3); // 去掉 BOM 后再解码,否则首行会出现不可见字符 codec = QTextCodec::codecForName("UTF-8"); } else { QString utf8Guess = QString::fromUtf8(data); // 按 UTF-8 尝试解码 if (!utf8Guess.contains(QChar::ReplacementCharacter)) { codec = QTextCodec::codecForName("UTF-8"); } else { codec = QTextCodec::codecForName("GB18030"); // 兼容 GBK/GB2312 } } editor->setPlainText(codec->toUnicode(data)); editor->document()->setModified(false); setWindowModified(false); setWindowTitle(tr("%1 - 记事本").arg(QFileInfo(filePath).fileName())); return true; }

逻辑说明:data.replace("\r\n", "\n")必须写在replace('\r', '\n')之前,因为如果先把\r替换成\n,原来的\r\n会变成\n\n,这就翻车了。这段代码先处理 CRLF,再处理单独的 CR,能把 Windows、Unix、老 Mac 三种换行符统一成 LF。编码探测的核心是QString::fromUtf8(data):如果字节流不是合法的 UTF-8,解码结果里会出现 U+FFFD 替换符,通过判断替换符是否存在来决定是否切到 GB18030。这个方案比“带 BOM 就 UTF-8,不带就当 GBK”更稳,因为很多 Linux 生成的文本是没有 BOM 的 UTF-8。

需要注意的差异:QTextCodec 在 Qt 6 中被移到了 Qt5Compat 模块,使用 Qt 6 时需要在 .pro 文件里加QT += core5compat,否则#include <QTextCodec>会报找不到头文件。GB18030 是 GBK 的超集,向下兼容,所以用它做兜底不会损失旧文件。这个方案还有个理论盲区:如果原文件本身就是 UTF-8 编码的替换符字符 U+FFFD,会被误判成 GB18030,实际工程中概率极低,不必为此增加复杂度。

3.2 保存成 Windows 记事本能认的文件:写回时统一换行符和可选 BOM

保存同样不能只写toPlainText().toUtf8()。原因是 QPlainTextEdit 内部的换行符统一是\n,但 Windows 记事本默认用\r\n;如果直接写文件,Windows 老版本记事本会把整个文本显示成一行。另外,要不要写 UTF-8 BOM,决定了文件在 Windows 记事本里能否被正确识别为 UTF-8。

bool MainWindow::saveFile(const QString &filePath) { QFile file(filePath); if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) { QMessageBox::warning(this, tr("保存失败"), file.errorString()); return false; } QByteArray data = editor->toPlainText().toUtf8(); #ifdef Q_OS_WIN data.replace("\n", "\r\n"); // 仅 Windows 下把 LF 转回 CRLF #endif data.prepend(QByteArray::fromHex("efbbbf")); // 写入 UTF-8 BOM,兼容老版 Windows 记事本 file.write(data); file.close(); editor->document()->setModified(false); setWindowModified(false); setWindowTitle(tr("%1 - 记事本").arg(QFileInfo(filePath).fileName())); return true; }

参数说明:QIODevice::WriteOnly | QIODevice::Truncate表示只写并清空原文件,缺了 Truncate 时,如果新内容比原文件短,文件末尾会残留旧数据。#ifdef Q_OS_WIN是编译期判断,在 Windows 上才做\n到\r\n的转换,交叉编译到 Linux 或 macOS 时不会误转。data.prepend(...)是往字节数组头部插入 BOM:EF BB BF。新版 Windows 10/11 记事本已经能自动识别无 BOM 的 UTF-8,但老版本和部分第三方编辑器不行,所以保存时默认带 BOM 最稳。

这套默认策略对绝大多数场景够用。如果你的工具要给程序员用,可以在保存对话框里加一个“编码”下拉框,提供 UTF-8 with BOM、UTF-8 without BOM、GB18030 三个选项,然后在 saveFile 里用 QTextCodec 按选择转码。简单记事本不必做这么细,但存储这个设计点,后续扩展有方向。

3.3 文件打开失败的 4 个边界:只读、大文件、占用、空文件

打开文件失败不是只有“路径不存在”一种情况,实际使用中更常遇到下面四种,每一种的提示方式都不同。

场景现象处理方式
文件被其他程序占用QFile::open 返回 false,errorString 是 Permission denied弹窗提示文件正被占用,不要直接覆盖保存
文件只读能打开但保存会失败打开前用 QFileInfo::isWritable() 检测,弹窗告知以只读方式打开
超大型文件几十 MB 的 txt 打开时界面卡顿数秒超过 10MB 时提示用户,并提供分块读取或“继续打开”选项
空文件readAll 返回空字节,编辑器残留上一次内容setPlainText 空字符串即可,打开前先确认当前未保存修改

只读检测可以放在 openFile 前面,代码就一行:

if (!QFileInfo(filePath).isWritable()) { QMessageBox::information(this, tr("提示"), tr("文件为只读,打开后保存可能失败。")); }

这里容易忽略的问题是:第一次打开文件成功后,用户没关窗口又打开另一个文件,前一个文件的未保存内容会直接丢掉。正确做法是在 openFile 开始时先判断editor->document()->isModified(),为真就先走保存确认流程,再加载新文件。这个逻辑虽然听起来啰嗦,但它是“记事本会不会丢用户数据”的分水岭,建议在写打开功能时第一时间带上。

4. 查找替换与字数统计:直接把 QTextCursor 用成一套可复用方法

4.1 查找下一个:这次与下次的高亮与光标处理

查找功能是记事本里复杂度最高的交互,核心是 QTextDocument::find 和 QTextCursor 的配合。容易踩的坑是“连续点击查找下一个”时,光标停留在匹配文本中间,下次查找会原地返回同一个结果,看起来像卡住了。下面是能正确循环查找的实现。

bool MainWindow::findNext(const QString &keyword, bool caseSensitive, bool backward) { QTextDocument *doc = editor->document(); if (keyword.isEmpty()) return false; QTextCursor cursor = editor->textCursor(); QTextDocument::FindFlags flags; if (caseSensitive) flags |= QTextDocument::FindCaseSensitively; if (backward) flags |= QTextDocument::FindBackward; // 关键处理:从选区末尾开始找,避免连续点击时卡在同一个匹配上 QTextCursor searchStart = cursor; int fromPos = backward ? cursor.selectionStart() : cursor.selectionEnd(); searchStart.setPosition(fromPos); QTextCursor found = doc->find(keyword, searchStart, flags); if (found.isNull()) { // 找不到时从文档另一头再找一次,实现循环查找 QTextCursor restart = backward ? QTextCursor(doc->end()) : QTextCursor(doc->begin()); found = doc->find(keyword, restart, flags); if (found.isNull()) { statusBar()->showMessage(tr("找不到:%1").arg(keyword), 3000); return false; } statusBar()->showMessage(backward ? tr("已循环到文档末尾") : tr("已循环到文档开头"), 3000); } editor->setTextCursor(found); editor->ensureCursorVisible(); return true; }

逻辑说明:QTextCursor::setPosition是移动到指定字符位置,这里用selectionEnd()作为向前查找起点、selectionStart()作为向后查找起点,本质是“跳过当前选区”。如果用户没有选区,selectionEnd 和 selectionStart 都是光标当前位置,行为正常。doc->find(keyword, searchStart, flags)的重载从 searchStart 位置开始搜索,找到后返回一个新的光标,指向匹配文本并选中它。循环查找的 restart 分支用QTextCursor(doc->end())作为向后查找的起点,注意这个光标的构造方式,直接在位置构造比doc->end()返回的迭代器再转光标要简洁。

这里还有一个小坑:如果 keyword 是空字符串,QTextDocument::find 会返回无效光标,所以函数开头做了 empty 判断。statusBar 提示不要用tr("找不到:%1")的字符串拼接代替arg,Qt 的翻译机制依赖 arg 方式,硬拼接会导致中文环境下翻译失效。

4.2 全部替换与高亮所有匹配:ExtraSelection 的正确用法

替换功能通常分两步:先查找目标,再点击替换。批量替换直接遍历 QTextDocument 最高效。下面是一组“替换一个”和“高亮所有匹配”的代码。

bool MainWindow::replaceOne(const QString &findText, const QString &replaceText) { QTextCursor cursor = editor->textCursor(); if (!cursor.hasSelection() || cursor.selectedText() != findText) return false; cursor.insertText(replaceText); findNext(findText, caseSensitiveChecked, backwardChecked); return true; } void MainWindow::highlightAll(const QString &keyword) { QList<QTextEdit::ExtraSelection> selections; editor->setExtraSelections(selections); if (keyword.isEmpty()) return; QTextDocument *doc = editor->document(); QTextCursor it; int count = 0; while ((it = doc->find(keyword, it, QTextDocument::FindCaseSensitively)).isNull() == false) { if (++count > 1000) break; // 防止匹配过多导致界面卡顿 QTextEdit::ExtraSelection sel; sel.cursor = it; sel.format.setBackground(QColor("#FFF2A8")); sel.format.setForeground(Qt::black); selections.append(sel); } editor->setExtraSelections(selections); }

参数说明:doc->find(keyword, it)的第二个参数是查找起点。第一次传入默认构造的 QTextCursor,等价于从文档开头找;每次匹配后返回的光标已经移动到匹配文本后面,所以循环不会无限重复。QTextEdit::ExtraSelection包含一个 cursor 和一个 format,代表“文档中一块要被特殊渲染的选区”。setExtraSelections是一次性替换全部,所以每次重新搜索前要先清空上一次的列表,否则旧高亮会叠加。

这里要特别注意cursor.selectedText() != findText的比较:QTextCursor::selectedText 会把段落分隔符转成 U+2029,直接和普通字符串比较,遇到跨行匹配会不相等。不过记事本的查找串一般不含换行,这个判断够用。替换后调用insertText会自动重写选区,光标保持在插入位置,下一步要继续查找的话,需要像 4.1 那样从选区末尾接着搜。

4.3 状态栏字数、行数、行列号统计的显示策略

文本统计是记事本最直观的反馈。很多实现用editor->toPlainText().length()数字符,这会把段落符也算进去,导致行尾多一个字符。正确做法是基于 QTextDocument 的 characterCount。

void MainWindow::refreshStatusBar() { QTextDocument *doc = editor->document(); int chars = doc->characterCount() - 1; // characterCount 包含最后一个隐式段落符 int lines = doc->lineCount(); int words = countWords(editor->toPlainText()); statusBar()->showMessage(tr("字符:%1 字数:%2 行数:%3") .arg(chars).arg(words).arg(lines)); } int MainWindow::countWords(const QString &text) { QRegularExpression re("[A-Za-z0-9_]+|[\\u4e00-\\u9fa5]"); QRegularExpressionMatchIterator it = re.globalMatch(text); int count = 0; while (it.hasNext()) { it.next(); ++count; } return count; }

说明:characterCount() - 1是去掉文档末尾隐藏的段落标记,得到真正的字符数。lineCount 直接读取文档结构,比text.split('\n').size()更高效,尤其在大文件上优势明显。countWords 用正则把“连续英文数字下划线”算一个词,单个汉字算一个词,这符合中文软件的常见习惯,但不适合统计中文词组。这个函数里正则表达式每次调用都会编译,性能敏感时可以把它定义为静态成员变量。refreshStatusBar 挂在编辑器的 textChanged 信号上,每次按键都会触发,对于 10MB 级大文件会有轻微开销;真碰到超大文件,可以改成 QTimer 300ms 去抖,只在用户停止输入 300ms 后才刷新。

5. Qt 记事本最容易踩的 5 个坑:版本混用、平台插件与闪退排查

5.1 fatal: cannot mix incompatible qt library (version ex50601) with this librar

这是 Qt 开发里出现频率极高的编译期报错,完整提示是cannot mix incompatible qt library (version ex50601) with this librar。现象是编译链接时报错,甚至可能在运行qmake后就立刻出现。原因基本可以锁定为“编译工具链看到了两套 Qt”:最常见的情况是系统 PATH 里先排到了旧版本的 Qt bin 目录,而 Qt Creator 的 Kit 用的是新版本;或者你手动安装了 Qt 5.6 和 Qt 5.15,qmake.exe 被环境变量指到了旧的那一份。还有一种发生在库文件层面:链接器先找到了 Qt5Core.dll,又找到另一个目录的 Qt5Widgets.dll,版本号一个 5.6 一个 5.15,于是直接罢工。

排查办法是在命令行里先确认实际生效的 qmake:

where qmake qmake -v

Windows 用where,Linux 用which。如果 qmake 的路径和 Qt Creator Kit 里选的版本对不上,那就把环境变量 PATH 里旧 Qt 的 bin 目录删掉。还有一类是“同一个 Qt 安装目录下既有 MSVC 构建的库又有 MinGW 构建的库”,这种情况最容易发生在反复重装之后。遇到它没什么优雅解法,我一般直接卸载 Qt,删除安装目录,清掉残留的环境变量,然后重装一套 5.15 LTS,并固定用同一套编译器。这套操作看起来像玄学,但确实比手动改一处漏一处省时间。

5.2 qt.qpa.plugin: Could not find the qt platform plugin "linuxfb"

把 Qt 程序部署到树莓派或 ARM 嵌入式板上运行时,终端经常会打出qt.qpa.plugin: Could not find the qt platform plugin "linuxfb",然后程序直接退出。这个报错的本质是 Qt 的 QPA 插件没加载到。Qt 的 GUI 在 Linux 上依赖平台插件来对接不同的显示系统,xcb 对应 X11,linuxfb 对应对接帧缓冲。运行时 Qt 会去 plugins/platforms 目录查找插件,找不到就报这个错。

解决方法是给程序指定插件路径和平台类型:

export QT_QPA_PLATFORM_PLUGIN_PATH=/opt/Qt/5.15.2/plugins/platforms export QT_QPA_PLATFORM=linuxfb ./your_app

如果只想在代码里兜底,可以在 main 函数最前面加:

qputenv("QT_QPA_PLATFORM_PLUGIN_PATH", "/opt/Qt/5.15.2/plugins/platforms");

注意 qputenv 必须在 QApplication 构造之前执行,否则插件加载已经走完了,再设置就来不及。交叉编译场景下还要检查一件事:libqlinuxfb.so 依赖的 libQt5Gui、libQt5Core 是否也一起拷到板子上,可以用ldd libqlinuxfb.so查看。很多树莓派部署翻车不是因为路径没写对,而是依赖库不全,导致加载插件时静默失败。

5.3 Windows 11 打开旧文件中文乱码:编码探测比事后转码省事

很多人在 Windows 10/11 上用自写的 Qt 记事本打开以前 Windows 记事本保存的 txt,发现中文全是乱码。原因很明确:Windows 老版本记事本“另存为”时的默认编码是 ANSI,中文环境下就是 GBK;而 Qt 5 内部默认按 UTF-8 解码字符串。高版本 Windows 记事本本身已经默认 UTF-8,但历史遗留的老文件编码不会因此自动升级。

解决思路不在事后转码,而是在打开文件时做编码探测,也就是第 3.1 节那段逻辑:BOM 优先,其次按 UTF-8 严格解码,出现替换符再退回 GB18030。很多教程直接教QTextCodec::codecForLocale(),这在 Qt 5 的多语言环境里不可靠,codecForLocale 返回的编码随系统区域设置变化,并不是一个稳定的“中文编码”。自己写探测逻辑反而更可控。保存时默认带 BOM 也是配合这个策略:带 BOM 的文件再次打开时,不需要猜测编码,直接走 UTF-8 分支,能在很大程度上降低乱码概率。

5.4 双击 exe 崩溃 0xc0000005:dll 没打包全的踩坑记录

Qt 程序在开发机 Qt Creator 里跑得好好的,把 exe 拷到别的机器双击后直接闪退,Windows 事件日志里记录异常代码0xc0000005,这是 Access Violation。这个错误在 Qt 开发里的排名极高,根源九成是依赖的 Qt 动态库没有部署完整。Qt 的 Release 模式 exe 依赖 Qt5Core.dll、Qt5Gui.dll、Qt5Widgets.dll,还依赖 platforms/qwindows.dll,缺任何一个都可能闪退。更隐蔽的是把 Debug 版本的 Qt5Cored.dll 当成 Release 库复制了过去,文件名带 d 结尾,和 Release exe 一起用就会出问题。

解决办法是使用 Qt 官方提供的部署工具 windeployqt,在 Qt 命令行里执行:

windeployqt --release --no-translations your_app.exe

它会自动分析 exe 的依赖,把需要的 Qt dll 和 platforms 插件复制到 exe 旁边。执行完后检查 exe 同目录下是否有platforms/qwindows.dll和 Qt5Core.dll、Qt5Widgets.dll。最重要的经验是:不要从别的机器或别的编译器环境手工拷贝 dll,MinGW 构建的 dll 和 MSVC 构建的 dll 混用,后续大概率会引出 5.1 那种版本不兼容报错。如果程序还依赖第三方库,比如 OpenSSL,用 windeployqt 部署后还要再单独处理。

5.5 关闭前提示保存:closeEvent 与 windowModified 的组合

记事本最容易流失的数据不是文件损坏,而是用户改了一堆内容后直接点关闭按钮走人。默认情况下 QMainWindow 关闭不会询问保存,所以必须重写 closeEvent。

void MainWindow::closeEvent(QCloseEvent *event) { if (!editor->document()->isModified()) { event->accept(); return; } QMessageBox::StandardButton ret = QMessageBox::question( this, tr("未保存"), tr("文件内容已修改,是否保存?"), QMessageBox::Save | QMessageBox::Discard | QMessageBox::Cancel); if (ret == QMessageBox::Save) { if (saveFile()) { event->accept(); } else { event->ignore(); } } else if (ret == QMessageBox::Discard) { event->accept(); } else { event->ignore(); } }

逻辑说明:editor->document()->isModified()是 Qt 内置的文档修改标记,编辑器内容一旦变化就自动置真,调用setModified(false)会复位,不需要自己维护 bool 变量。QMessageBox 的三个按钮对应保存、不保存、取消:保存失败时不能关闭窗口,所以调用event->ignore();用户选取消同样必须忽略关闭事件。这段代码配合标题栏的setWindowModified使用,标题会显示一个星号,让用户在视觉上也知道当前处于未保存状态。

6. 把换行符替换与编码选择做成可切换功能:跨系统不乱码的收尾技巧

6.1 换行符替换菜单:只用一次遍历完成 CRLF、LF、CR 互相转换

不同来源的文本换行符不一致,是很多人搜“notepad 换行符替换”的真正需求。Windows 记事本老版本认 CRLF,Linux 工具链和 Git 默认 LF,老 Mac 用 CR。在记事本里做成一个“换行符转换”菜单项,只需一个函数:

void MainWindow::convertLineEnding(const QString &target) { QString text = editor->toPlainText(); text.replace("\r\n", "\n"); // 先把 CRLF 统一成 LF text.replace('\r', '\n'); // 再处理单独 CR if (target == QLatin1String("CRLF")) text.replace('\n', "\r\n"); else if (target == QLatin1String("CR")) text.replace('\n', '\r'); editor->setPlainText(text); }

参数说明:这个函数先统一成 LF,再按目标格式转换,避免\r\n被二次替换。比如直接replace("\r\n", "\r")会把一个 CRLF 变成一个单独的 CR,逻辑没问题;如果反过来先替换单独的\r就会翻车。函数不做 undo,因为它直接操作纯文本,QPlainTextEdit 的撤销栈会被清空;如果一定要支持撤销,可以改用编辑器提供的 replace 接口或者 QTextCursor 的 beginEditBlock/endEditBlock 包一层。

6.2 记录上次目录与编码:QSettings 让记事本记住用户习惯

每次打开文件都从头翻目录,体验并不好。QSettings 能记住上次打开的目录,这个细节能让工具显得成熟很多:

QSettings settings; QString lastDir = settings.value("lastOpenDir", QDir::homePath()).toString(); QString path = QFileDialog::getOpenFileName( this, tr("打开"), lastDir, tr("文本文件 (*.txt);;所有文件 (*)")); if (!path.isEmpty()) { settings.setValue("lastOpenDir", QFileInfo(path).absolutePath()); openFile(path); }

注意:QSettings 默认构造要求 QApplication 已经设置了 organizationName 和 applicationName,否则会找到无效的存储位置。第 2.2 节里 main 函数那两行setApplicationName和setOrganizationName在这里才真正发挥作用。保存路径也一样记录,用户在“打开→修改→保存”之后,第二次打开自动停在同一个目录。这个交互微不足道,但实际使用频率极高,属于典型的低成本高收益功能。

6.3 发布前自测清单:用 4 个样本文件验证编码和换行符

记事本最怕的问题是小样本看着正常,换一批文件就乱码。我每次做完这类工具都会准备 4 个测试样本:UTF-8 无 BOM 中文文本、GBK 编码中文文本(可以用 Windows 记事本“另存为 ANSI”生成)、CRLF 换行的 Windows 文本、LF 换行的 Linux 文本。

验证顺序是:用自写记事本打开第一个文件,中文不乱码且字数正确;打开第二个文件,不乱码;打开第三个文件,状态栏行数与源文件一致;保存后到“记事本”里重新打开,显示正常。如果四个样本全过,就再测一次 10MB 级大文件打开是否卡死,以及只读文件打开时是否给出提示。这一套走完,基本可以放心把程序发给同事用了。

我自己的习惯是把“打开前检测未保存修改”和“关闭前询问保存”这两段逻辑最先写好,因为它们决定工具会不会害人丢数据;至于界面多漂亮、动画多流畅,反而是最后才考虑的事。文本工具这种日用品,稳定性永远排在功能前面。希望这份笔记能帮你在做记事本类 Qt 工具时少走几个弯路。

本文还有配套的精品资源,点击获取

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

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

立即咨询