本篇目标
前面的章节我们认识了不少控件,但都停留在”单个控件”的层面。本篇把它们组装成一个完整可运行的记事本程序:带菜单栏、能打开/保存文件、关闭前会提醒你保存未修改的内容。做完这个项目,你将掌握 QMainWindow 的骨架用法、QAction 与快捷键、QFileDialog/QMessageBox 的标准用法,以及 closeEvent 事件拦截——这套套路几乎可以复用到所有”文档类”桌面程序上。
一、项目目标
我们要做的记事本具备以下功能:
- 新建:清空编辑区,窗口标题显示”未命名”;
- 打开:弹出文件对话框,把文本文件内容读入编辑区;
- 保存 / 另存为:把编辑区内容写回磁盘,保存过一次后
Ctrl+S直接覆盖原文件; - 退出:正常退出程序;
- 未保存拦截:文档被修改后,标题栏出现
*标记,此时关闭窗口或打开新文件会弹出”保存 / 不保存 / 取消”三选一对话框; - 状态栏:短暂显示”已打开 / 已保存”等提示信息。
程序不大,但五脏俱全:菜单、对话框、文件 IO、事件拦截、状态提示,全在一个 200 行左右的项目里。
二、工程搭建
用 Qt Creator 新建一个 Qt Widgets Application,项目名取 QtNotepad,基类选 QMainWindow,取消勾选”Generate form”(本篇全部用代码搭界面,不依赖 .ui 文件)。
如果使用 qmake,.pro 文件如下:
# QtNotepad.pro
QT += core gui
# Qt4 才需要判断,Qt5/Qt6 中 widgets 模块必加
greaterThan(QT_MAJOR_VERSION, 4): QT += widgets
CONFIG += c++17
TEMPLATE = app
TARGET = QtNotepad
SOURCES += main.cpp mainwindow.cpp
HEADERS += mainwindow.h如果项目用 CMake 管理,对应的 CMakeLists.txt 写法是(QT NAMES Qt6 Qt5 的写法能同时兼容 Qt5 和 Qt6):
cmake_minimum_required(VERSION 3.16)
project(QtNotepad LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_AUTOMOC ON)
find_package(QT NAMES Qt6 Qt5 REQUIRED COMPONENTS Widgets)
find_package(Qt${QT_VERSION_MAJOR} REQUIRED COMPONENTS Widgets)
add_executable(QtNotepad
main.cpp
mainwindow.cpp
mainwindow.h
)
target_link_libraries(QtNotepad PRIVATE Qt${QT_VERSION_MAJOR}::Widgets)忘记链接 Widgets 模块
新建纯代码项目时最常见的报错是
QMainWindow: No such file or directory。十有八九是QT += widgets(qmake)或COMPONENTS Widgets(CMake)漏掉了——Qt5 之后 widgets 已经从 gui 模块中拆分出来,必须显式引入。
三、界面布局
记事本的界面是典型的 QMainWindow 三段式结构,布局如下图:
对应到代码,各区域的职责是:
- QMenuBar(菜单栏):容纳”文件 / 编辑 / 帮助”几个 QMenu,每个菜单项是一个 QAction;
- QPlainTextEdit(中心部件):占满剩余空间的多行纯文本编辑器,是记事本的主角;
- QStatusBar(状态栏):显示瞬时提示,几秒后自动消失。
本篇不用 Qt Designer 拖 .ui 文件,而是在构造函数里用代码搭界面。控件数量少时代码搭建更直观,也方便你逐行理解每个部件是怎么”挂”到窗口上的。
四、头文件 mainwindow.h
先写出类的整体骨架,每块代码之后紧跟着解释。
#ifndef MAINWINDOW_H
#define MAINWINDOW_H
#include <QMainWindow>
// 前置声明:头文件里只用到指针,不包含完整定义,可以加快编译
class QPlainTextEdit;
class QAction;
class QCloseEvent;
class MainWindow : public QMainWindow
{
Q_OBJECT
public:
explicit MainWindow(QWidget *parent = nullptr);
protected:
// 覆写窗口关闭事件:拦截"未保存就关闭"的情况
void closeEvent(QCloseEvent *event) override;
private slots:
void newFile(); // 新建
void openFile(); // 打开
bool saveFile(); // 保存
bool saveFileAs(); // 另存为
void about(); // 帮助 -> 关于
private:
// 内部工具函数
void createActions(); // 创建动作
void createMenus(); // 创建菜单栏
bool maybeSave(); // 有未保存修改时询问用户
bool loadFile(const QString &fileName); // 从磁盘读文件
bool saveFileTo(const QString &fileName); // 写文件到磁盘
void setCurrentFile(const QString &fileName); // 更新当前文件与标题
// 成员变量
QPlainTextEdit *editor_ = nullptr; // 中心编辑区
QString currentFile_; // 当前打开的文件路径,空表示未命名
// 菜单动作(创建后要被菜单引用,所以做成成员变量)
QAction *newAct_ = nullptr;
QAction *openAct_ = nullptr;
QAction *saveAct_ = nullptr;
QAction *saveAsAct_ = nullptr;
};
#endif // MAINWINDOW_H几个设计要点:
closeEvent放在protected下覆写。它是 QWidget 的事件处理函数,用户点右上角 × 或者调用close()时都会触发,这是实现”关闭拦截”的唯一正道。saveFile()返回bool。保存可能失败(磁盘只读、用户取消),返回值要向上传递给maybeSave()用来决定”是否允许继续关闭窗口”。currentFile_存完整路径。它为空说明文档从未保存过,此时”保存”应自动转为”另存为”;非空时Ctrl+S直接静默覆盖。
五、构造函数与菜单
接下来是 mainwindow.cpp。先看构造函数和菜单搭建:
#include "mainwindow.h"
#include <QAction>
#include <QCloseEvent>
#include <QFile>
#include <QFileInfo>
#include <QFileDialog>
#include <QMenuBar>
#include <QMessageBox>
#include <QPlainTextEdit>
#include <QStatusBar>
#include <QTextStream>
MainWindow::MainWindow(QWidget *parent)
: QMainWindow(parent)
{
// 1. 创建中心编辑区,占满窗口剩余空间
editor_ = new QPlainTextEdit(this);
setCentralWidget(editor_);
// 2. 文档一旦被修改,窗口标题自动带 [*] 标记(配合下方标题里的 [*] 占位符)
connect(editor_->document(), &QTextDocument::modificationChanged,
this, &QWidget::setWindowModified);
// 3. 搭菜单
createActions();
createMenus();
// 4. 初始标题 + 状态栏提示
setCurrentFile(QString());
statusBar()->showMessage(tr("就绪"), 3000);
}
void MainWindow::createActions()
{
// 每个动作:文字 + 快捷键 + 状态栏提示 + 触发后的槽函数
QAction *newAct = new QAction(tr("新建(&N)"), this);
newAct->setShortcut(QKeySequence::New); // 跨平台标准快捷键 Ctrl+N
newAct->setStatusTip(tr("新建一个空文档"));
connect(newAct, &QAction::triggered, this, &MainWindow::newFile);
QAction *openAct = new QAction(tr("打开(&O)..."), this);
openAct->setShortcut(QKeySequence::Open); // Ctrl+O
openAct->setStatusTip(tr("打开一个已有的文本文件"));
connect(openAct, &QAction::triggered, this, &MainWindow::openFile);
QAction *saveAct = new QAction(tr("保存(&S)"), this);
saveAct->setShortcut(QKeySequence::Save); // Ctrl+S
saveAct->setStatusTip(tr("保存文档到磁盘"));
connect(saveAct, &QAction::triggered, this, &MainWindow::saveFile);
QAction *saveAsAct = new QAction(tr("另存为(&A)..."), this);
saveAsAct->setStatusTip(tr("保存文档到一个新的文件"));
connect(saveAsAct, &QAction::triggered, this, &MainWindow::saveFileAs);
QAction *exitAct = new QAction(tr("退出(&X)"), this);
exitAct->setShortcut(QKeySequence::Quit); // Ctrl+Q
exitAct->setStatusTip(tr("退出程序"));
connect(exitAct, &QAction::triggered, this, &QWidget::close);
QAction *aboutAct = new QAction(tr("关于(&A)"), this);
aboutAct->setStatusTip(tr("关于本程序"));
connect(aboutAct, &QAction::triggered, this, &MainWindow::about);
}
void MainWindow::createMenus()
{
QMenu *fileMenu = menuBar()->addMenu(tr("文件(&F)"));
fileMenu->addAction(newAct); // 省略:把上面创建的各 QAction 依次加入
fileMenu->addAction(openAct);
fileMenu->addSeparator();
fileMenu->addAction(saveAct);
fileMenu->addAction(saveAsAct);
fileMenu->addSeparator();
fileMenu->addAction(exitAct_);
QMenu *helpMenu = menuBar()->addMenu(tr("帮助(&H)"));
helpMenu->addAction(aboutAct_);
}解释几个关键点:
menuBar()/statusBar()是懒创建的。QMainWindow 自带这两个函数,第一次调用会自动创建并安放好对应区域,不需要你new。QKeySequence::New这类标准键序会自动映射到各平台的习惯快捷键(Windows 是 Ctrl+N,macOS 是 Cmd+N),比自己硬编码Ctrl+N更规范。- 菜单文字里的
&N是助记符,Windows 上按Alt+F就能展开”文件”菜单;tr()包裹所有界面文字是国际化惯例,现在用不上也要养成习惯。 modificationChanged→setWindowModified的联动配合标题里的[*]占位符:文档一被修改,标题栏自动出现*,一保存就消失,全程零手工维护。
六、文件读写:新建、打开、保存
这一块是记事本的业务核心:
void MainWindow::newFile()
{
if (!maybeSave()) // 当前文档有修改?先问用户
return;
editor_->clear();
setCurrentFile(QString()); // 空路径表示"未命名"
}
void MainWindow::openFile()
{
if (!maybeSave())
return;
// 静态函数:弹模态文件对话框,返回完整路径;取消则返回空串
const QString fileName = QFileDialog::getOpenFileName(
this,
tr("打开文件"),
QString(),
tr("文本文件 (*.txt *.h *.c *.cpp *.md);;所有文件 (*)"));
if (fileName.isEmpty())
return;
loadFile(fileName);
}
bool MainWindow::saveFile()
{
if (currentFile_.isEmpty())
return saveFileAs(); // 从没保存过 => 转为另存为
return saveFileTo(currentFile_);
}
bool MainWindow::saveFileAs()
{
const QString fileName = QFileDialog::getSaveFileName(
this,
tr("另存为"),
QString(),
tr("文本文件 (*.txt);;所有文件 (*)"));
if (fileName.isEmpty())
return false; // 用户取消,向上层报告"保存未完成"
return saveFileTo(fileName);
}
bool MainWindow::loadFile(const QString &fileName)
{
QFile file(fileName);
if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) {
QMessageBox::warning(this, tr("记事本"),
tr("无法打开文件 %1:\n%2")
.arg(QFileInfo(fileName).fileName(), file.errorString()));
return false;
}
QTextStream in(&file); // 文本流负责字节 -> QString 的解码
editor_->setPlainText(in.readAll());
setCurrentFile(fileName);
statusBar()->showMessage(tr("已打开 %1").arg(fileName), 3000);
return true;
}
bool MainWindow::saveFileTo(const QString &fileName)
{
QFile file(fileName);
if (!file.open(QIODevice::WriteOnly | QIODevice::Text | QIODevice::Truncate)) {
QMessageBox::warning(this, tr("记事本"),
tr("无法写入文件 %1:\n%2")
.arg(QFileInfo(fileName).fileName(), file.errorString()));
return false;
}
QTextStream out(&file);
out << editor_->toPlainText();
if (!file.flush()) { // 确认数据真正落盘
QMessageBox::warning(this, tr("记事本"), tr("写入文件失败!"));
return false;
}
setCurrentFile(fileName);
statusBar()->showMessage(tr("已保存 %1").arg(fileName), 3000);
return true;
}
void MainWindow::setCurrentFile(const QString &fileName)
{
currentFile_ = fileName;
editor_->document()->setModified(false); // 重置"已修改"状态
setWindowModified(false);
const QString shownName = currentFile_.isEmpty()
? tr("未命名")
: QFileInfo(currentFile_).fileName();
// [*] 是占位符:文档被修改时会被替换成 "*"
setWindowTitle(tr("%1[*] - Qt 记事本").arg(shownName));
}说明:
QIODevice::Text标志会让换行符跨平台自动转换(Windows 的\r\n读进来变\n,写出去再变回去),这正是文本编辑器想要的行为。- 打开对话框的第三参是”过滤器”,用
;;分隔多组后缀,写法是描述 (*.后缀1 *.后缀2)。 - 保存失败的分支必须处理:
saveFileAs()里用户点取消返回false,maybeSave()收到false就不会继续关闭窗口,逻辑链条是通的。 QMessageBox::warning的第二参是标题、第三参是正文,arg()按序号填充%1、%2,这是 Qt 字符串拼接的标准姿势。
最佳实践
写文件后调用一次
file.flush()并检查返回值。write()成功只代表数据进了缓冲区,flush()成功才说明真正落盘——对”保存”这种用户期待强一致的语义,这一步不能省。
七、关闭拦截:maybeSave 与 closeEvent
现在处理最有价值的部分——未保存修改的拦截:
bool MainWindow::maybeSave()
{
// 文档没有被修改,直接放行
if (!editor_->document()->isModified())
return true;
// 三选一对话框:保存 / 放弃 / 取消
const QMessageBox::StandardButton choice =
QMessageBox::warning(this,
tr("记事本"),
tr("文档已被修改,是否保存更改?"),
QMessageBox::Save | QMessageBox::Discard | QMessageBox::Cancel);
switch (choice) {
case QMessageBox::Save: return saveFile(); // 用户要求保存:保存成功才放行
case QMessageBox::Discard: return true; // 放弃修改:直接放行
default: return false; // 取消:不放行
}
}
void MainWindow::closeEvent(QCloseEvent *event)
{
if (maybeSave())
event->accept(); // 同意关闭,窗口真正退出
else
event->ignore(); // 拒绝关闭,窗口保持原样
}
void MainWindow::about()
{
QMessageBox::about(this, tr("关于"),
tr("<b>Qt 记事本</b> 示例程序,用于学习菜单栏与文件读写。"));
}理解这段代码只需要抓住两点:
maybeSave()是”守门员”:新建、打开、退出前都要过它。它返回true表示”可以继续做你要做的事”,false表示”用户反悔了,什么也别做”。把询问逻辑收敛到一个函数里,三个入口才不会各写一套、行为不一致。closeEvent里的accept()/ignore()决定窗口的命运。覆写这个事件函数后,系统不再自动关窗,而是把决定权交给你:accept()才会真正关闭。这就是 Qt 事件模型的典型用法——事件到达时先问子类要不要处理。
忘记处理"取消"分支
有些初学者把询问对话框写成”保存 / 不保存”两个按钮,结果用户想反悔时没有退路,点 × 反而丢文件。凡是可能造成数据丢失的交互,一定要留 Cancel 出口,并且让 Cancel 中断整个流程。
八、main.cpp 与完整代码清单
最后是程序入口,非常短:
#include "mainwindow.h"
#include <QApplication>
int main(int argc, char *argv[])
{
QApplication app(argc, argv); // Qt 程序有且仅有一个 QApplication 实例
MainWindow win;
win.resize(800, 600);
win.show(); // show() 之后窗口才进入事件循环可见
return app.exec(); // 进入消息循环,直到窗口关闭
}到此三个文件的完整代码都已给出。整体结构回顾:
| 文件 | 职责 |
|---|---|
main.cpp | 创建 QApplication 与主窗口,进入消息循环 |
mainwindow.h | 声明窗口类:槽函数、工具函数、成员变量 |
mainwindow.cpp | 界面搭建 + 菜单动作 + 文件读写 + 关闭拦截 |
在 Qt Creator 里按 Ctrl+R 运行,依次测试:输入文字看标题 * 号 → Ctrl+S 走另存为 → 再改再存直接覆盖 → 打开新文件验证拦截 → 点 × 验证关闭询问。
九、常见坑与最佳实践
Windows 下的编码问题
如果打开别的软件生成的 txt 出现乱码,多半是对方文件是 GBK 编码,而 Qt6 的 QTextStream 默认按 UTF-8 解码。可以给用户文件加个”编码”菜单,用
QTextStream::setEncoding()(Qt6,基于 QStringConverter)或setCodec()(Qt5,基于 QTextCodec)显式指定编码。另外,源代码文件本身务必保存为 UTF-8,否则tr()里的中文在部分编译器下会乱码。
值得记住的三个习惯
- 界面文字一律
tr()包裹,快捷键用QKeySequence::Xxx标准键序;- 把”该不该继续”的判断收敛成单一函数(如本篇的
maybeSave()),而不是在每个入口复制粘贴;- 文件操作永远检查
open()返回值,并展示errorString()给用户——比一句笼统的”打开失败”友好得多。
十、扩展练习
如果想继续打磨这个记事本,可以尝试:
- 编辑菜单:给编辑区加撤销/重做,提示——QPlainTextEdit 自带
undo()/redo()槽,直接连接即可; - 字数统计:连接
textChanged信号,在状态栏常驻一个 QLabel 显示当前字数; - 记住上次目录:用
QSettings保存最后一次打开的路径,下次getOpenFileName从这里开始; - 拖拽打开:覆写
dragEnterEvent/dropEvent,允许把文件拖进窗口打开; - 语法高亮:把 QPlainTextEdit 换成 QPlainTextEdit + QSyntaxHighlighter,做一个简易代码编辑器。
自测:三个问题检验掌握程度
- 用户点窗口右上角的 × 时,Qt 的事件传递顺序是什么?我们如何让窗口”暂时不关”?
saveFile()在什么条件下会自动转为”另存为”?为什么要这样设计?- 标题栏里的
[*]占位符是靠哪两个东西配合实现自动打星号的? (答案都在上文)