本篇目标

前面的章节我们认识了不少控件,但都停留在”单个控件”的层面。本篇把它们组装成一个完整可运行的记事本程序:带菜单栏、能打开/保存文件、关闭前会提醒你保存未修改的内容。做完这个项目,你将掌握 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

几个设计要点:

  1. closeEvent 放在 protected 下覆写。它是 QWidget 的事件处理函数,用户点右上角 × 或者调用 close() 时都会触发,这是实现”关闭拦截”的唯一正道。
  2. saveFile() 返回 bool。保存可能失败(磁盘只读、用户取消),返回值要向上传递给 maybeSave() 用来决定”是否允许继续关闭窗口”。
  3. 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_);
}

解释几个关键点:

  1. menuBar() / statusBar() 是懒创建的。QMainWindow 自带这两个函数,第一次调用会自动创建并安放好对应区域,不需要你 new
  2. QKeySequence::New 这类标准键序会自动映射到各平台的习惯快捷键(Windows 是 Ctrl+N,macOS 是 Cmd+N),比自己硬编码 Ctrl+N 更规范。
  3. 菜单文字里的 &N 是助记符,Windows 上按 Alt+F 就能展开”文件”菜单;tr() 包裹所有界面文字是国际化惯例,现在用不上也要养成习惯。
  4. modificationChangedsetWindowModified 的联动配合标题里的 [*] 占位符:文档一被修改,标题栏自动出现 *,一保存就消失,全程零手工维护。

六、文件读写:新建、打开、保存

这一块是记事本的业务核心:

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));
}

说明:

  1. QIODevice::Text 标志会让换行符跨平台自动转换(Windows 的 \r\n 读进来变 \n,写出去再变回去),这正是文本编辑器想要的行为。
  2. 打开对话框的第三参是”过滤器”,用 ;; 分隔多组后缀,写法是 描述 (*.后缀1 *.后缀2)
  3. 保存失败的分支必须处理saveFileAs() 里用户点取消返回 falsemaybeSave() 收到 false 就不会继续关闭窗口,逻辑链条是通的。
  4. 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> 示例程序,用于学习菜单栏与文件读写。"));
}

理解这段代码只需要抓住两点:

  1. maybeSave() 是”守门员”:新建、打开、退出前都要过它。它返回 true 表示”可以继续做你要做的事”,false 表示”用户反悔了,什么也别做”。把询问逻辑收敛到一个函数里,三个入口才不会各写一套、行为不一致。
  2. 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() 里的中文在部分编译器下会乱码。

值得记住的三个习惯

  1. 界面文字一律 tr() 包裹,快捷键用 QKeySequence::Xxx 标准键序;
  2. 把”该不该继续”的判断收敛成单一函数(如本篇的 maybeSave()),而不是在每个入口复制粘贴;
  3. 文件操作永远检查 open() 返回值,并展示 errorString() 给用户——比一句笼统的”打开失败”友好得多。

十、扩展练习

如果想继续打磨这个记事本,可以尝试:

  1. 编辑菜单:给编辑区加撤销/重做,提示——QPlainTextEdit 自带 undo() / redo() 槽,直接连接即可;
  2. 字数统计:连接 textChanged 信号,在状态栏常驻一个 QLabel 显示当前字数;
  3. 记住上次目录:用 QSettings 保存最后一次打开的路径,下次 getOpenFileName 从这里开始;
  4. 拖拽打开:覆写 dragEnterEvent / dropEvent,允许把文件拖进窗口打开;
  5. 语法高亮:把 QPlainTextEdit 换成 QPlainTextEdit + QSyntaxHighlighter,做一个简易代码编辑器。