written by SJTU-XHW

Reference: C++ GUI Programming with Qt 4 (2nd Edition)

注意:本文章将讲解 Qt 5 入门知识,需要一定的 C++ 基础

本人知识浅薄,文章难免有问题或缺漏,欢迎批评指正!

观前提示:本系列的所有完整代码在整理好后,都会存放在仓库中,有需要可以自取 ~


Chapter 4 第一个完整的 Qt 入门项目

在完成以上的学习过程后,在座诸位都具有独立写出一个极简的、较为完整 Qt 项目的能力;

以下,本人将用 纯代码方式 方式完成这个项目;

本章末,会总结到目前为止学到的所有 Qt 类的继承/思维图

观前提示:本项目的设计思路很长,完整源代码放在 仓库 里,有需要可以取出查看 ~ 本章的目的是为了学习 Qt 一些组件的用法而已 ~

项目目标:模仿 Microsoft Excel 设计一个表格应用程序

4.1 创建主窗口 UI

4.1.1 子类化 QMainWindow

创建一个主窗口最方便的方法是利用 Qt 库中已有的设计类:QMainWindow

QMainWindowQDialog 都是 QWidget 的子类,所以之前的很多方法在创建主窗口时同样有效。

编写前先认识 QMainWindow 类的对应结构:

其中中央窗口部件可以放置 Widget,使用 QMainWindow::setCentralWidget(QWidget*) 在此区域添加 Widget;

首先根据目标,声明一个主窗口类(自己起个名字,这里用 MainWindow):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
// File: MainWindow.h
#pragma once
#include<QtWidgets/QMainWindow>

// 后面才添加上的头文件
#include<QAction>
#include<QtWidgets/QLabel>
#include "FindDialog.h"
#include "HolySheet.h"

// 定义的常量
#define MaxRecentFiles 5

class MainWindow : public QMainWindow {
// Q_OBJECT
public:
MainWindow(); // 主窗口构造函数
protected:
// closeEvent 时 QWidget 类的虚函数,在 widget 关闭时可以自动调用。所以这里打算覆写此函数,达到“退出时弹出询问窗口”的功能,并保存一些设置到磁盘里
// 流程:用户点击绑定了原生槽 QWidget::close() 的控件发出的信号(内部emit),
// 然后通过内置信号-槽自动调用原生槽 void closeEvent(QCloseEvent* event).
// 注意,QCloseEvent* event 只有调用了 event->accept() 才会真正退出窗口,
// 如果调用了 event->ignore(),将忽略关闭动作。详细见下文本函数实现的代码。
void closeEvent(QCloseEvent* event) override;
private slots: // 之所以声明为私有槽,是因为它们是需要用户点击后作出相应的函数,而且不需要从类外被调用
void newFile(); // 私有槽实现新建文件
void open(); // 打开文件
bool save(); // 保存文件
bool saveAs(); // 另存为文件
void find(); // 表格查找功能,可以复用之前的 findDialog 窗口
void goToCell(); // 表格定位功能,可以复用之前的 GoToCellDialog 窗口
void sort(); // 表格排序功能
void about(); // 关于软件

// 以上部分是一开始设计出来的 -----------------------------
// 以下功能是后来加上的,所以实现到再解释 -------------------
void openRecentFile();
void updateStatusBar();
void holySheetModified();
private: // 工具函数,大多是代码创建 UI 组件、被上面函数调用的底层功能等
void createActions();
void createMenus();
void createContextMenu();
void createToolBars();
void createStatusBar();
void readSettings();
void writeSettings();
bool okToContinue();
bool loadFile(const QString& filename);
bool saveFile(const QString& filename);
void setCurrentFile(const QString& filename);
void updateRecentFileAction();
QString strippedName(const QString& fullFileName);

// 一些私有数据成员,不用管它,真正编程时,先空着用到再添加
HolySheet* holySheet; // 注:HolySheet 类是表格的数据管理类,以后实现
FindDialog* findDialog;
QLabel* locationLabel;
QLabel* formulaLabel;
QStringList recentFiles; // 新类 QStringList,QString 类型的列表
QString curFile;

QAction* recentFileActions[MaxRecentFiles];
QAction* separatorAction;

QMenu* fileMenu;
QMenu* editMenu;
QToolBar* fileToolBar;
QToolBar* newAction;
QAction* newAction;
QAction* openAction;
QAction*aboutAction;
};

现在实现以上的内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// File: MainWindow.cpp
#include<QtGui>
#include "MainWindow.h"

MainWindow::MainWindow() {
// 初始化中央窗口部件
holySheet = new HolySheet;
// QMainWindow 的方法,用于将 widget 设置在中央窗口区
setCentralWidget(holySheet);

// 创建顶部栏、侧边栏等其他部件
createActions();
createMenus();
createContextMenu();
createToolBars();
createStatusBar();

// 读取磁盘中的软件偏好设置
readSettings();

// 等调用 MainWindow::find() 后再创建
findDialog = nullptr;

// 设置窗口图标,至于为何前面是“:”,请看解析
setWindowIcon(QIcon(":/images/icon.png"));
setCurrentFile("");
}

这里需要说明的是 QMainWindow::setWindowIcon(QIcon)QIcon 类;

在应用程序需要读取一些资源的时候,通常开发者会把资源放在一个文件夹中,需要的时候通过和系统相关的路径载入,这种方法可移植性较低,并且容易因为移动而丢失资源,最讨厌的是资源路径比较麻烦

因此,比较推荐的是 Qt 的资源机制(resource mechanism),优点是比运行时载入更方便、适用于任意文件格式;推荐的使用方法是:

  1. 将资源分类放在各目录下,比如图片放在项目创建的 images 子目录下;

  2. 新建 Qt 资源系统文件 *.qrc ,名字自己起,格式是 XML ,举个例子(用相对路径):

    1
    2
    3
    4
    5
    6
    <RCC>
    <qresource>
    <file>images/icon.png</file>
    <file>images/gotocell.png</file>
    </qresource>
    </RCC>
  3. 记得在项目 *.pro 文件中添加:RESOURCE = yourFileName.qrc(如果您用 Qt Creator IDE 自动添加,就当我没说)

这样在 Qt 程序的绝大多数地方路径字符串 :/ 代表 *.qrc 存在的路径,不会出错;例如调用上面的 icon.png 时可以这么用:QIcon(":/images/icon.png")

4.1.2 实现菜单栏、上下文菜单和工具栏

现在介绍前面没提到的 QAction 类,Qt 通过 “动作” 的概念简化了有关于菜单和工具栏的编程,一个动作就是一个可以添加到任意数量的菜单和工具栏上的项;

所以在 Qt 中创建菜单和工具栏包括以下几个步骤:

  1. 创建并设置动作属性,例如文本、提示、信号-槽、图标、快捷键等;
  2. 创建菜单并将动作添加到菜单上;
  3. 创建工具栏并将动作添加到工具栏上;

以本章的项目来举个栗子🌰:

Firstly,要创建菜单栏的项并设置属性:实现上面的 MainWindow::createActions() 函数;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
void MainWindow::createActions() {
// 创建文本为“New”、父控件为 MainWindow 的动作(这样就不用手动释放了)
newAction = new QAction(tr("&New"), this);
newAction->setIcon(QIcon(":/image/new.png")); // 设置动作的图标
newAction->setShortcut(QKeySequence::New); // 设置动作的快捷键
// 设置提示性文本
newAction->setStatusTip(tr("Create a new holySheet file"));
// 将动作项与底层函数连接
connect(newAction, SINGAL(triggered()), this, SLOT(newFile()));

// 以下省略 Open、Save、Save As 的动作编写,因为几乎一模一样
// ... ...

// Recent Open Files 动作
for (int i = 0; i < MaxRecentFiles; ++i) {
recentFileActions[i] = new QAction(this);
// 这里设置隐藏,因为第一次打开没有最近文件
recentFileActions[i]->setVisible(false);
// 将动作和底层的 openRecentFile() 连接
connect(recentFileActions[i], SIGNAL(triggered()),
this, SLOT(openRecentFile()));
}

// Exit 动作
exitAction = new QAction(tr("&Exit"), this);
exitAction->setShortcut(tr("Ctrl+Q"));
exitAction->setStatusTip(tr("Exit the Application"));
// 这里的 close() 是 QWidget::close() 自带槽
connect(exitAction, SIGNAL(triggered()), this, SLOT(close()));

// Select All 动作
selectAllAction = new QAction(tr("&All"), this);
selectAllAction->setShortcut(QKeySequence::SelectAll);
selectAllAction->setStatusTip(tr("Select all the cells in the sheet"));
// 注意,这里 holySheet 是设计成 QTableWidget 的子类,因为需要看表;
// 而 QTableWidget 具有内置槽 selectAll(),因此无需自行实现
connect(selectAllAction, SIGNAL(triggered()),
holySheet, SLOT(selectAlll()));

// 设置中的 Show Grid 动作,是否展示表格边框
showGridAction = new Action(tr("&Show Grid"), this);
// 可勾选模式
showGridAction->setCheckable(true);
// 今后需要在 holySheet 中实现的方法,同步勾选状态 和 显示状态
showGridAction->setChecked(holySheet->showGrid());
showGridAction->setStatusTip(tr("Show or hide the holySheet's grid"));
// 又一个今后需要在 holySheet 中实现的槽,同步勾选动作 和 设置显示槽
connect(showGridAction, SIGNAL(toggled(bool)),
holySheet, SLOT(setShowGrid(bool)));

// 此处省略 Auto Recalculate 动作
// ... ...

// About 动作
aboutAction = new Action(tr("About"), this);
aboutAction->setStatusTip(tr("Show the information about the app"));
connect(aboutAction, SIGNAL(triggered()), this, about());
// About Qt 动作
aboutQtAction = new Action(tr("About &Qt"), this);
aboutQtAction->setStatusTip("Show the Qt library's About Box");
// 这里的 qApp 将设置为全局变量,QApplication 类,包含 aboutQt() 方法
connect(aboutQtAction, SIGNAL(triggered()), qApp, SLOT(aboutQt()));
}
  1. 其中 QKeySequence 类提供了一套标准化的键盘快捷键的对应 enum 值,可以通过查看文档中 enum QKeySequence::StandardKey 的枚举值来找到运行平台上正确的快捷键表示;

    标准键如:全选QKeySequence::SelectAll => "Ctrl+A",复制 Ctrl+C 等;

    可惜的是,上面的 退出 就没有标准化快捷键,只能自定义:"Ctrl+Q"

  2. 还可以通过 QActionGroup 类来实现相互排斥的勾选 Action,这里不需要就不演示了;

Secondly,需要创建菜单,并且把创建好的菜单项放进菜单中:实现 MainWindow::createMenus()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
void MainWindow::createMenus() {
// 注意:QMainWindow 和 QMenu 类进行了组合,本身有 QMainWindow::menuBar(),
// 调用即返回内嵌于 QMainWindow 中的菜单栏,位置见上面 QMainWindow 结构展示
fileMenu = menuBar()->addMenu(tr("&File"));
fileMenu->addAction(newAction);
fileMenu->addAction(openAction);
fileMenu->addAction(saveAction);
fileMenu->addAction(saveAsAction);
separatorAction = fileMenu->addSeparator();

for (int i = 0; i < MaxRecentFile; ++i)
fileMenu->addAction(recentFileActions[i]);
fileMenu->addSeparator();
fileMenu->addSeparator(exitAction);

editMenu = menuBar()->addMenu(tr("&Edit"));
editMenu->addAction(cutAction);
editMenu->addAction(copyAction);
editMenu->addAction(pasteAction);
editMenu->addAction(deleteAction);

selectSubMenu = editMenu->addMenu(tr("&Select"));
selectSubMenu->addAction(selectRowAction);
selectSubMenu->addAction(selectColAction);
selectSubMenu->addAction(selectAllAction);

editMenu->addSeparator();
editMenu->addAction(findAction);
editMenu->addAction(goToCellAction);

toolsMenu = menuBar()->addMenu(tr("&Tools"));
toolsMenu->addAction(recalculationAction);
toolsMenu->addAction(sortAction);

optionsMenu = menuBar()->addMenu(tr("&Options"));
optionsMenu->addAction(showGridAction);
optionsMenu->addAction(autoRecalculation);

// 这是菜单间间隔的写法,menuBar() 没有 addMenu,有些系统呈现的是
// 将之后的菜单放在最右侧,有些系统会直接忽略,有些系统仅仅显示一个分割线
menuBar()->addSeparator();

helpMenu = menuBar()->addMenu(tr("&Help"));
helpMenu->addAction(aboutAction);
helpMenu->addAction(aboutQtAction);
}

Thirdly,实现完菜单栏,再关注 上下文菜单栏(context menu 直译,或译作 “右键菜单”):实现 MainWindow::createContextMenu()

实现上下文菜单的重要机制是:Qt 的上下文菜单策略(context menu policy)

任何 QWidget 都有一个与之相关联的 QAction 列表,也有上下文菜单策略枚举变量:enum Qt::ContextMenuPolicy;当设置上下文菜单策略为枚举量 Qt::ActionsContextMenu(=2) 时,会以该 QAction 列表为 widget 的上下文菜单,当右击这个 widget 时,会展开上下文菜单;

1
2
3
4
5
6
void MainWindow::createContextMenu() {
holySheet->addAction(cutAction);
holySheet->addAction(copyAction);
holySheet->addAction(pasteAction);
holySheet->setContextMenuPolicy(Qt::ActionsContextMenu);
}

还有一种设计的方法比较繁琐,不重用之前的 QAction,即上下文菜单策略是Qt::DefaultContextMenu(=1) 时,重新实现:QWidget::contextMenuEvent() 函数,在其中创建一个 QMenu,添加,再对该部件调用 exec(),感兴趣可以尝试,这里不做演示;

Last but not the least,关注工具栏:实现 MainWindow::createToolBars();注意,如果你之前设置的 Action 有设置图标的话,可以直接复用,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void MainWindow::createToolBars() {
fileToolBar = addToolBar(tr("&File"));
fileToolBar->addAction(newAction);
fileToolBar->addAction(openAction);
fileToolBar->addAction(saveAction);

editToolBar = addToolBar(tr("&Edit"));
editToolBar->addAction(cutAction);
editToolBar->addAction(copyAction);
editToolBar->addAction(pasteAction);
editToolBar->addSeparator();
editToolBar->addAction(findAction);
editToolBar->addAction(goToCellAction);
}

4.1.3 设置状态栏

什么是状态栏?大家看 Microsoft Word 中左下角每当进行一些操作时,会显示不同文字,如下,这就是状态栏:

下面实现 MainWindow::createStatusBar()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void MainWindow::createStatusBar() {
locationLabel = new QLabel(" w999 ");
locationLabel->setAlignment(Qt::AlignHCenter);
locationLabel->setMinimumSize(locationLabel->sizeHint());

formulaLabel = new QLabel;
formulaLabel->setIndent(3);

statusBar()->addWidget(locationLabel);
statusBar()->addWidget(formulaLabel, 1);

connect(holySheet, SIGANL(currentCellChanged(int, int, int, int)),
this, SLOT(updateStatusBar()));
connect(holySheet, SIGNAL(modified()),
this, SLOT(holySheetModified()));
updateStatusBar();
}

这部分状态栏的实现大致可以分为两个部分:

  1. 创建控件(大多是 QLabel),设置控件的布局;

    • 初始的 locationLabel 设置内容 “ w999 ” 表示最大单元格位置,配合 locationLabel->setMinmumSize(locationLabel->sizeHint()) 可以确定位置状态标签大小满足要求;
    • locationLabel->setAlignment(Qt::AlignHCenter) 确保文本居中对齐;
    • formulaLabel->setIndent(3) 表示标签内添加文本缩进,这是实现之后进行的微调,确保美观;
    • statusBar()->addWidget(QWidget*, int) 中第二个参数是伸展因子,参数为 0 表示紧贴模式(默认),参数为 1 表示最大占用模式(还剩多少就占多少,类似 CSS 中的 auto 参数)
  2. 为状态栏的变化建立连接;

    • 先利用将要实现的 holySheet 信号 currentCellChanged(int, int, int, int),和更新状态栏的槽 updateStatusBar() 绑定;MainWindow::updateStatusBar() 实现如下:

      1
      2
      3
      4
      void MainWindow::updateStatusBar() {
      locationLabel->setText(holySheet->currentLocation());
      formulaLabel->setText(holySheet->currentFormula());
      }
    • 此外,为了实现 “已修改后会改变窗口标题”(很多软件在文件修改后,会在窗口标题加一个记号,如 *) ,还要实现 MainWindow::holySheetModified()

      1
      2
      3
      4
      5
      void MainWindow::holySheetModified() {
      // QMainWindow 的固有槽
      setWindowModified(true);
      updateStatusBar();
      }

      注意,每个 QWidget 都有一个 windowModified 属性 ~

4.2 主窗口的底层函数实现

函数比较多,还是从窗口的菜单功能下手;

4.2.1 File 菜单

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 新文件创建
void MainWindow::newFile() {
// 如果当前窗口有没保存的改动,提醒是否保存
if (okToContinue()) {
holySheet->clear(); // holySheet 等待实现的函数,清屏
setCurrentFile(""); // 当前文件名
}
}

bool MainWindow::okToContinue() {
// 先判断当前窗口是否有没有保存的修改,也是 QMainWindow 的方法
if (isWindowModified()) {
int res = QMessageBox::warning(
this, tr("HolySheet"),
tr("The document has been modified.\n"
"Do you want to save your changes?"),
QMessageBox::Yes | QMessageBox::No | QMessageBox::Cancel
);

if (res == QMessageBox::Yes) return save(); // 保存更改
else if (res == QMessageBox::Cancel) return false;
}
return true; // 确认立即继续
}

以上值得一提的是,QMessageBox 提供了很多标准的信息对话框模板,例如:提示信息、提问、警告、严重错误,语法:QMessageBox::warning(QWidget* parent, QString title, QString message, (enum)QMessageBox::StandardButtons buttons)

其中枚举类型 QMessageBox::StandardButtons 含有枚举值 QMessageBox::YesQMessageBox::NoQMessageBox::CancelQMessageBox::Apply等等,有特殊需要可以参考官方文档,这里设计非常巧妙,枚举值设置为 16 进制数码,使用多个 buttons 时,利用按位或 | (参考上面代码),达到类似 Linux 权限掩码的多选效果

除了 warning 警告对话框,还有 information 提示对话框、question 提问对话框、critical 严重错误对话框;语法是相同的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 打开已有文件
void MainWindow::open() {
if (okToContinue()) {
QString fileName = QFileDialog::getOpenFileName(
this, tr("Open sheet"), ".",
tr("HolySheet file (*.hs)")
);
if (!fileName.isEmpty())
loadFile(fileName);
}
}

// 底层实现:载入指定文件,之所以不和 MainWindow::open() 合在一起,就是为了在“打开最近文件”的功能中重用
bool MainWindow::loadFile(const QString& fileName) {
if (!holySheet->readFile(fileName)) { // holySheet 等待实现的函数
statusBar()->showMessage(tr("Loading canceled"), 2000);
return false;
}

setCurrentFile(fileName);
statusBar()->showMessage(tr("File loaded"), 2000);
return true;
}
  • 借助上面的代码,再补充一些重要的点,QFileDialog 类提供各种打开文件名(“浏览”)的对话窗口,和前面介绍的 QMessageBox 都继承于 QDialog;比较常用的是 QFileDialog::getOpenFileName(QWdiget* parent, QString title, QString startPath, QString filter)

    还有 QFileDialog::getSaveFileName(...),语法相同,之和代码会介绍;

    注意:① 这里的QFileDialog 一般窗口优先级都比父窗口的高,显示在其上层;② 文件过滤器参数 filter 参数的格式是 “<description> (*.suffix)”,如果允许多个文件类型,则用换行符分割:“<desc1> (*.suffix1)\n<desc2> (*.suffix2)”;

  • QStatusBar::showMessage(QString text, int milisec) 的调用不会影响到 QStatusBar 中添加的 widget,会单独再显示消息;第二个参数 milisec 表示消息停留的毫秒数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
bool MainWindow::save() {
if (curFile.isEmpty()) return saveAs(); // 当前不存在文件
else return saveFile(curFile);
}

bool MainWindow::saveFile(const QString& fileName) {
if (!holySheet->writeFile(fileName)) { // holySheet 等待实现的函数
statusBar()->showMessage(tr("Saving canceled", 2000));
return false;
}
setCurrentFile(fileName); // 保存成功,则设置当前文件为指定文件
statusBar()->showMessage(tr("File saved"), 2000);
return true;
}
bool MainWindow::saveAs() {
QString fileName = QFileDialog::getSaveFileName(
this, tr("Save sheet"), ".",
tr("HolySheet file (*.hs)")
);
if (fileName.isEmpty()) return false;

return saveFile(fileName);
}

上面的 QFileDialog::getSaveFileNameQFileDialog::getOpenFileName 不同的是,如果给定的文件已经存在,会自动弹出对话框提醒是否需要覆盖;也可以传递附加参数: QFileDialog::DontConfirmOverwrite来改变这个行为;

1
2
3
4
5
6
7
void MainWindow::closeEvent(QCloseEvent* event) {
if (okToContinue()) {
writeSettings(); // 写入配置
event->accept();
}
else event->ignore();
}

还记得之前在声明这个函数的时候的注释内容吗?

此外,QApplication 默认 quitOnLastWindowClosed = true,如果想要**关闭最后一个窗口后,QApplication 仍然运行(例如托盘运行),那么可以将它设置为 false

顺便实现内部函数 setCurrentFilestrippedName。前者用于指定当前文件(curFile 私有变量)、修改窗口标题、修改 “最近文件” 列表、修改窗口是否改动的状态,后者用于得到文件除去后缀的名字。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
void MainWindow::setCurrentFile(const QString& fileName) {
curFile = fileName;
setWindowModified(false);

QString shownName = tr("Untitled");
if (!curFile.isEmpty()) {
shownName = strippedName(curFile);
recentFiles.removeAll(curFile);
recentFiles.prepend(curFile);
updateRecentFileActions();
}
// Qt 的格式化字符串!
setWindowTitle(tr("%1 [*] - %2").arg(shownName).arg(tr("HolySheet")));
}

QString MainWindow::strippedName(const QString& fullFileName) {
//
return QFileInfo(fullFileName).fileName();
}

上面提到了更新 “最近文件” 列表,还有之前说到的 “最近文件” 列表的动作绑定,现在来实现 updateRecentFileActions

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
void MainWindow::updateRecentFileActions() {
QMutableStringListIterator iter(recentFiles);
while (iter.hasNext()) {
if (!QFile::exists(iter.next()))
iter.remove(); // 移除还在列表中,但已不存在的文件名
}
for (int i = 0; i < MaxRecentFiles; ++i) {
if (i < recentFiles.count()) {
QString text = tr("&%1 %2"
.arg(i + 1)
.arg(strippedName(recentFiles[i]))
);
recentFileActions[i]->setText(text);
recentFileActions[i]->setData(recentFiles[i]);
recentFileActions[i]->setVisible(true);
}
else recentFileActions[i]->setVisible(false);
}
seperatorAction->setVisible(!recentFiles.isEmpty());
}

首先 QMutableStringIterator 可以看作是 QStringListrecentFiles 的类型)的可变的迭代器;

其次,对于 recentFileActions[i]->setData(recentFiles[i]) 这一步,其实 QAction 类的函数 setData(QVariant qvar) 对应设置了 QVariant QAction::data 数据成员,它可以保存很多种 C++ / Qt 的数据类型,包括 QString、一切 numericalboolQStringList(以后会进一步讨论);

下面实现 openRecentFile

1
2
3
4
5
6
void MainWindow::openRecentFile() {
if (okToContinue()) {
QAction* action = qobject_cast<QAction*>(sender());
if (action) loadFile(action()->data().toString());
}
}

QObject::sender() 返回 QObject*,用于查出是哪个信号 / 动作调用了当前的槽(所以在槽函数内使用);这里知道调用 openRecentFile() 函数的一定是 QAction 的动作,所以强制转换到它的指针类型,并且读出 data 数据,进而找到点击的 “最近文件” 并调用 loadFile()

4.2.2 主窗口调用 Dialog:模态和非模态

这里会用到之前所写的 findDialoggoToCellDialogsortDialog 等对话框,进行功能的拼接,同时会制作简单的 about 对话框。

首先是之前的 findDialog 对话框。由于希望用户可以在主窗口、子窗口间自由切换(毕竟只是查找功能),所以 findDialog 必须是 非模态的(modeless)——在运行的程序中,这种窗口独立于其他窗口,不会覆盖或阻挡,实现方法如下:

1
2
3
4
5
6
7
8
9
10
11
12
void MainWindow::find() {
if (!findDialog) { // 从未创建过 findDialog,懒加载模式
findDialog = new FindDialog(this); // 设置父对象可以不用另外析构
// 将 findDialog 的两个信号绑定到 holySheet 的两个槽上进行查找处理
connect(findDialog, SIGNAL(findNext(const QString&, Qt::CaseSensitivity)), holySheet, SLOT(findNext(const QString&, Qt::CaseSensitivity)));
connect(findDialog, SIGNAL(findPrev(const QString&, Qt::CaseSensitivity)), holySheet, SLOT(const QString&, Qt::CaseSensitivity));
}

findDialog->show();
findDialog->raise();
findDialog->activateWindow();
}

通过点击 Edit->Find 菜单来调出 find() 函数有 3 种情况:

  1. 用户第一次调用这个对话框:由于懒加载,会先创建它,再打开;
  2. 用户曾经打开过,现在是关闭(visible=false)状态:直接 show() 就足以完成显示、置顶、激活这 3 个状态;
  3. 用户曾经打开,仍没有关闭:这时 show() 不能完成置顶、激活这两个状态,分别需要 raise()activateWindow() 来完成;

按照这个逻辑,后面 3 行还可以写成:(不过没必要

1
2
3
4
5
if (findDialog->isHidden()) findDialog->show();
else {
findDialog->raise();
findDialog->activateWindow();
}

再来看 goToCellDialog,我们希望用户弹出、使用、关闭它,不希望在使用它的期间去碰主窗口,这种子窗口就是 模态的(modal)——可弹出并阻塞应用程序的对话框:

1
2
3
4
5
6
7
8
9
void MainWindow::goToCell() {
// 这里为什么不 new,而只是在栈中建立一个 dialog,是由对话框目的决定;
// 这里用完就可以销毁,没必要 new + delete.
GoToCellDialog dialog(this);
if (dialog.exec()) {
QString str = dialog.lineEdit->text().toUpper();
holySheet->setCurrentCell(str.mid(1).toInt() - 1, str[0].unicode() - 'A');
}
}

注意:模态对话框不能使用 show(),否则变成非模态对话框!应该使用 QDialog::exec() 阻塞获取对话框返回结果(由 accept()reject() 决定,QDialog::Accepted=trueQDialog::Rejected=false);

另外,HolySheet::setCurrentCell(int x, int y) 等待实现;

QString::mid(int) 指提取从第 int 个字符串以后的子串、toInt() 不解释,其正确性由之前 GoToCellDialog::lineEdit 中设置的 QRegExpValidator 来保证;

再来看 sortDialog,它也是一个模态对话框,允许用户在当前选定区域使用给定的列进行排序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
void MainWindow::sort() {
SortDialog dialog(this);
// 注意:这个函数没写错,就是之后需要在 HolySheet 类中实现的一个函数
// 它不是 QTableWidget 的原生函数 selectRanges()
QTableWidgetSelectionRange range = holySheet->selectedRange();
dialog.setColumnRange('A' + range.leftColumn(),
'A' + range.rightColumn());
if (dialog.exec()) {

HolySheetCompare compare;
compare.keys[0] = dialog.primaryColumnCombo->currentIndex();
compare.keys[1] = dialog.secondaryColumnCombo->currentIndex() - 1;
compare.keys[2] = dialog.tertiaryColumnCombo->currentIndex() - 1;
compare.ascending[0] =
(dialog.primaryColumnCombo->currentIndex() == 0);
compare.ascending[1] =
(dialog.secondaryColumnCombo->currentIndex() == 0);
compare.ascending[2] =
(dialog.tertiaryColumnCombo->currentIndex() == 0);

holySheet->sort(compare);
}
}

上面我们看到了 2 个新类。一个是 Qt 原生的 QTableWidgetSelectionRange,用来记录 QTableWidget 选中的区域信息,可以由 QTableWidget::selectRange() 得到当前选中的范围。含有 leftColumn()rightColumn() 方法查到区域的列;

注意:SortDialog::setColumnRange 是之前我们自己定义为了 column ComboBox 的取值范围而设置的函数。

另一个类是 HolySheetCompare,和 HolySheet 一样,是之后我们自己会实现的类。这个类存储主键、第二键、第三键以及它们的排序顺序(keys 数组存储键的列号,ascending 数组按 bool 格式存储每个键相关顺序),这个对象可以被 HolySheet::sort() 使用,用于两行间的比较;

注意:QComboBox::currentIndex() 返回当前选定项的索引值,第一个为 0;

上面的 “-1” 指去除前面的 “None” 设置值。

本节最后,我们制作一个简单的 About 对话框,只需要静态函数 QMessageBox::about 就行:

1
2
3
4
5
6
7
8
9
void MainWindow::about() {
QMessageBox::about(this, tr("About HolySheet"),
tr("<h2>HolySheet 0.1</h2>"
"<p>Copyright &copy; 2023 SJTU-XHW Inc.</p>"
"<p>HolySheet is a small application that "
"demonstrate QAction, QMainWindow, QMenuBar, "
"QStatusBar, QTableWidget, QToolBar and many "
"other Qt classes."));
}

QMessageBox::about(QWidget* parent, const QString& title, const QString& contents)warning/information/critical 不同的是,它的图标取决于父控件的图标

4.2.3 设置的持久化

现在关注 readSettings()writeSettings() 函数。

1
2
3
4
5
6
7
8
9
void MainWindow::writeSettings() {
QSettings settings("SJTU-XHW Inc.", "HolySheet");
// 这里的 saveGeometry() 是 QWidget 类的原生方法,
// 返回 QByteArray,可以被 QVariant 存储
settings.setValue("geometry", saveGeometry());
settings.setValue("recentFiles", recentFiles);
settings.setValue("showGrid", showGridAction->isChecked());
settings.setValue("autoRecalc", autoRecalcAction->isChecked());
}

上面的 QSettings 类的值设置非常类似 Python 的字典键值对,值也是用 QVariant 存储的;默认情况下,QSettings 在系统上存储方法和系统种类有关(Windows 上就存在注册表,UNIX 存在文本文件中,MacOS 存在 Core Foundation Preferences 编程接口中),详细内容如下表:

Platform Format Scope Path
Windows Native User HKEY_CURRENT_USER\Software\*
System HKEY_LOCAL_MACHINE\Software\*
INI User %APPDATA%\*.ini
System %COMMON_APPDATA%\*.ini
Unix Native User $HOME/.config/*.conf
System /etc/xdg/*.conf
INI User $HOME/.config/*.ini
System /etc/xdg/*.ini
Mac OS X Native User $HOME/Library/Preferences/com.*.plist
System /Library/Preferences/com.*.plist
INI User $HOME/.config/*.ini
System /etc/xdg/*.ini

QSettings 类的构造函数的参数分别是组织名、程序名,为的是方便设置的读取、查找

此外,QSettings 的设置还能以路径形式指定子键的值(如 findDialog/matchCase)或者用 beginGroup(QString)endGroup() 的形式:

1
2
3
4
settings.beginGroup("findDialog");
settings.setValue("matchCase", caseCheckBox->isChecked());
settings.setValue("searchBackward", backwardCheckBox->isChecked());
settings.endGroup();

还有 readSettings

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void MainWindow::readSettings() {
// 按组织名 + 程序名读取设置
QSettings settings("SJTU-XHW Inc.", "HolySheet");
restoreGeometry(settings.value("geometry").toByteArray());
recentFiles = settings.value("recentFiles").toStringList();
updateRecentActions(); // 更新 “最近文件” 菜单栏

// 第二参数是默认值,类似 Python dict 类的 setdefault()
bool showGrid = settings.value("showGrid", true).toBool();
showGridAction->setChecked(showGrid);

bool autoRecalc = settings.value("autoRecalc", true).toBool();
autoRecalcAction->setChecked(autoRecalc);
}

您还可以在应用程序的其他任何地方使用 QSettings 方便地查询、修改设置。

到目前为止,MainWindow 及其 UI 界面已经几乎完全实现,主要的任务就是实现 HolySheet 类和 HolySheetCompare 类了。

4.2.4 锦上添花:多文档模式

现在我们可以准备编写程序的 main 函数:

1
2
3
4
5
6
7
8
9
10
// File: main.cpp
#include <QApplication>
#include "mainWindow.h"

int main(int argc, char* argv[]) {
QApplication app(argc, argv);
MainWindow win; // 这里不用 new 创建
win.show();
return app.exec();
}

考虑这个问题:我们想要同一时间打开、处理多个表格,怎么办?

肯定不能让用户重复打开程序——这是不方便的。应该像浏览器打开页面一样,提供多个窗口。这就是程序的多窗口(文档)模式

这时,我们就需要对 File 菜单进行一些改动,使得一个应用程序实例,能够处理多个文档

  • File->New 操作不再是使用原先存在的窗口,而是创建一个空文档窗口,已存在的窗口需要手动关闭;

  • File->Close 关闭当前的主窗口,而不是清除内容;

    注:原来的 MainWindow 没有这个 Close 选项,因为是单窗口,它的作用和 Exit 相同

  • File->Exit 关闭所有窗口,而不是关闭当前仅有的窗口。

想要具有多窗口功能,需要使用 new 来统一创建、销毁窗口。所以 main.cpp 改为:

1
2
3
4
5
6
7
8
9
10
// File: main.cpp
#include <QApplication>
#include "mainWindow.h"

int main(int argc, char* argv[]) {
QApplication app(argc, argv);
MainWindow* win = new MainWindow;
win->show();
return app.exec();
}

紧接着修改 newFile() 槽和 Actions 的组成和提示信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// File: mainWindow.cpp
// ...

void MainWondow::newFile() {
MainWindow* otherWin = new MainWindow;
otherWin->show();
}

// ...
void MainWindow::createActions() {
// ...

closeAction = new QAction(tr("&Close"), this);
closeAction->setShortCut(QKeySequence::Close);
closeAction->setStatusTip(tr("Close this window"));
connect(closeAction, SIGNAL(triggered()), this, SLOT(close()));

exitAction = new QAction(tr("E&xit"), this);
exitAction->setShortcut(tr("Ctrl+Q"));
exitAction->setStatusTip(tr("Exit the application"));
connect(exitAction, SIGNAL(triggered()), qApp, SLOT(closeAllWindows()));

// ...
}

需要注意的是,QApplication 的槽 closeAllWindows() 会关闭所有在 app 循环中创建的窗口,并且不用担心释放问题,每个窗口都会调用 closeEvent() 处理。

同时需要注意另一个问题,是创建窗口过多的问题。这时就需要考虑 MainWindow 的析构问题了。解决方法很简单:

1
2
3
4
5
MainWindow::MainWindow() {
// ...
setAttribute(Qt::WA_DeleteOnClose);
// ...
}

这个设置会使得所有 close() 的控件立即被析构。可是有个更需要解决的问题:“最近文件” 列表的问题——多个窗口需要共享一个 “最近文件” 的列表。

这个问题可以通过将 recentFiles 改成静态变量的方法解决,读者可以自行思考。

实现本节 “多文档” 的目标不止这一种,还可以采用 QtMDI(multiple document interface)管理方法,以后介绍。

4.2.5 锦上添花:程序启动画面

这个需求可能是因为甲方的要求,又或是要掩饰程序启动慢的事实(手动狗头);很简单,只需要在 main 函数中使用 QSplashScreen 类就能解决:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
int main(int argc, char* argv[]) {
QApplication app(argc, argv);

QSplashScreen* splash = new QSplashScreen;
splash->setPixmap(QPixmap(":/images/splash.png")); // 设置启动界面背景图
splash->show(); // 不管主程序有没有启动,先启动“启动画面”再说

Qt::Alignment topRight = Qt::AlignRight | Qt::AlignTop;

splash->showMessage(QObject::tr("Setting up the main window..."),
topRight, Qt::black);
MainWindow win;

splash->showMessage(QObject::tr("Testing windows (5s)..."),
topRight, Qt::black);
QTime timer;
timer.start();
while (timer.elapsed() < 5000)
app.processEvents();

win.show();
splash->finish(&win); // 完成,把位置让给 MainWindow
delete splash;

return app.exec();
}

4.3 程序中央窗口实现:QTableWidget

中央窗口部件常见的设计方法有下面几种:

  1. 使用一个标准的 Qt 窗口部件(或继承于其的控件)作为 centralWidget,例如 QTableWidgetQTextEdit 等,本工程就是用这种方法;
  2. 使用一个自定义窗口部件 作为 centralWidget,以后会介绍怎么自定义窗口部件(继承得到的不叫自定义部件);
  3. 使用一个带布局管理器的普通 QWidget 作为 centralWidget,这也是初学者最喜欢用的方法;
  4. 使用切分窗口(QSplitter,利用切分条(splitter handle)控制它们的尺寸;
  5. 使用MDI。上一节说了,以后再讨论。

本项目使用的是标准 Qt 窗口部件 QTableWidget,并采用继承的方法来给 HolySheet 类添加一些必要的功能。先搞清 QTableWidget 的继承关系:

1
2
3
4
QObject -> QWidget -> QTableView -> QTableWidget -> HolySheet(自定义)

// 这是个纯粹的数据类,就是存放数据、为 QTableWidget 类服务
QTableWidgetItem -> Cell(自定义)

再了解一下 QTableWidget 类对象的组成:

4.3.1 表格的定义

最后开始设计 HolySheet

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
// File: HolySheet.h
#pragma once
#include <QtWidgets/QTableWidget>

// 之后会说 Cell 类的定义和实现
#include "Cell.h"

// 定义一些常量
namespace holysheet {
constexpr int MagicNumber = 0x7F51C883;
constexpr int RowCount = 999;
constexpr int ColumnCount = 26;
};
using namespace holysheet;

// 这个类是自己写的,先声明以供 HolySheet 类使用
class HolySheetCompare;

class HolySheet : public QTableWidget {
Q_OBJECT
public:
HolySheet(QWidget* parent = nullptr);
// 这些函数几乎全是之前在 MainWindow 类里面要用的,可以回忆一下它们被用在哪里
bool isAutoRecalc() const { return autoRecalc; }
QString currentLocation() const;
QString currentFormula() const;
QTableWidgetSelectionRange selectedRange() const;
// 之所以写 clear 的原因是,不想“全部清除”
// 想要留下表头提示信息等,所以需要自定义
void clear();
bool readFile(const QString& fileName);
bool writeFile(const QString& fileName);
// 后面实现的时候再说 HolySheetCompare 的定义和实现
void sort(const HolySheetCompare& compare);
public slot:
void cut();
void copy();
void paste();
void del();
void selectCurrentRow();
void selectCurrentColumn();
void recalculate();
void setAutoRecalculate(bool recalc);
void findNext(const QString& text, Qt::CaseSensitivity cs);
void findPrev(const QString& text, Qt::CaseSensitivity cs);
signals:
void modified();
private slots:
void somethingChanged();
private:
bool autoRecalc;

Cell* cell(int row, int col) const;
QString text(int row, int col) const;
QString formula(int row, int col) const;
void setFormula(int row, int col, const QString& formula);
};

4.3.2 表格的 “文本” 和 “公式”

下面正式开始实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// File: HolySheet.cpp
#include "HolySheet.h"

HolySheet::HolySheet(QWidget* parent)
: QTableWidget(parent) {

autoRecalc = true; // 默认选中
// 自定义表格每个格子的内容模式,参数类型需要 QTableWidgetItem
// 这里用的是继承的 Cell 实例
setItemPrototype(new Cell);
// 设置 QTableWidget 的选择方法,这里仅限连续格子的选择
setSelectionMode(ContiguousSelection);

// 原生信号 QTableWidget::itemChanged(QTableWidgetItem*)
// 为何不直接连接 modified 信号,而非要经过 somethingChanged 槽?
// 因为 somethingChanged 槽中需要检查 autoRecalc 并且自动更新!(实现会说)
connect(this, SIGNAL(itemChanged(QTableWidgetItem*)),
this, SLOT(somethingChanged()));
// 第一次启动需要清空格子内容
clear();
}

void HolySheet::clear() {
// 清除行列数(为了删除内容)并重设行列总数
setRowCount(0);
setColumnCount(0);
setRowCount(RowCount);
setColumnCount(ColumnCount);

// 这里是给每列第一行加上表头
// 无需为每行第一列考虑,它们默认数字编号
for (int i = 0; i < ColumnCount; ++i) {
QTableWidgetItem* item = new QTableWidgetItem;
item->setText(QString(QChar('A' + i)));
// 原生函数,设置指定内容的 QTableWidgetItem 实例为每一列的表头
setHorizontalHeaderItem(i, item);
}
// 将选中的位置回归到原点
setCurrentCell(0, 0);
}

声明中的 cell() 函数是为了给定行列,返回一个 Cell 指针,目的和 QTableWidget::item()QTableWidgetItem 一样。只不过之前设置了每个格子类型为 Cell,所以这里自定义:

1
2
3
Cell* HolySheet::cell(int row, int col) const {
return static_cast<Cell*>(item(row, col));
}

更方便的是,可以直接取单元格的数据为字符串、公式:

1
2
3
4
5
6
7
8
9
10
11
QString HolySheet::text(int row, int col) const {
Cell* c = cell(row, col);
if (c) return c->text();
else return "";
}

QString HolySheet::formula(int row, int col) const {
Cell* c = cell(row, col);
if (c) return c->formula(); // Cell 类等待实现:Cell::formula()
else return "";
}

⚠ 注意:“公式” 和 “文本” 数据在每个 Cell 中存储在不同地方(机理和 QTableWidgetItem 类有关,由 role 参数控制,等实现 Cell 类再说。目前只需要知道看文本用 text()、看公式 formula(),而公式数据可以按照计算结果来修改本格的文本数据。

这里需要搞清楚,很多情况下,公式和文本是相同的,它的处理规则如下:

  • 公式是普通文本时,和文本数据一样,不会计算。例如公式 “Hello” 等价于文本 “Hello”;

  • 公式是数字的时候,公式的数字会被认为双精度浮点数(double),而非文本;

  • 公式以单引号开始(Excel 中把这个叫做转义内容),那么剩余部分会被认为是文本,例如:公式 “ ‘12345 ” 就是文本,等价于字符串 “12345”;
  • 公式以等号开始,那么公式会被认为是算数公式,例如公式 “ =A1+A2 ” 会计算 A1 和 A2 单元格的文本(转化为数字)之和的值,并填入 Cell 的文本数据中;

这里 由公式计算出值 的过程会由 Cell 类完成,HolySheet 类不会涉及。

上面实现的是取数据的函数,下面实现设定数据的函数:

1
2
3
4
5
6
7
8
9
10
void HolySheet::setFormula(int row, int col, const QString& formula) {
Cell* c = cell(row, col);
if (!c) { // 当前格子还没有创建对应的 Cell 对象
// 不用担心释放问题,这里 QTableWidget 会自动
// 取得新的 QTableWidgetItem 的所有权,并且在合适的时机自动析构
c = new Cell;
setItem(row, col, c);
}
c->setFormula(formula); // Cell 类来设定、计算公式
}

细心的读者会发现,这里只有 setFormula,没有 setText,刚刚不是说两个分开存储吗?对,这里还是和 QTableWidgetItem 类有关。我们会在 “QTableWidgetItem 机理简介” 一节进行解释

另外能看出来的是,我们存储表并不是使用二维数组或字符串列表,而是存储为项(item),有助于节省空间、加快运行速度等。这种方法在 QListWidgetQTreeWidget 类中也能看到(对应 QListWidgetItemQTreeWidgetItem 类);

此外,对于更大数据量的应用场景,Qt 还支持模型/视图类(model/view),以后介绍。

再来实现其他用到的函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
QString HolySheet::currentLocation() const {
// currentRow() 和 currentColumn() 还是 QTableWidget 的原生函数
return QString('A' + currentColumn()
+ QString::number(currentRow() + 1));
}

// 原本在 MainWindow 中的用途是展示在 formulaLabel 中的
QString HolySheet::currentFormula() const {
return formula(currentRow(), currentColumn()); // 上面的取公式函数
}

void HolySheet::somethingChanged() {
// 为什么不直接 emit modified() 的原因在这
if (autoRecalc) recalculate();
emit modified();
}

4.3.3 表格数据的存取

再来看表数据的存储和读取的问题。我们要用一种自定义的二进制格式来实现 HolySheet 文件的读取和存储。这时就用到了 QFile 类和 QDataStream

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
bool HolySheet::writeFile(const QString& fileName) {
QFile file(fileName);
// 如果只写的打开方式失败,说明文件不存在或有权限问题等
if (!file.open(QIODevice::WriteOnly)) {
QMessageBox::warning(this, tr("HolySheet"),
tr("Cannot write file %1:\n%2.")
.arg(file.fileName())
.arg(file.errorString()));
return false;
}

QDataStream out(&file);
// 这个版本看你的 Qt 版本。例如 5.12 版本就用 QDataStream::Qt_5_12
out.setVersion(QDataStream::Qt_5_14);
out << quint32(MagicNumber); // 混淆作用,你可以随便改 MagicNumber

// 更改鼠标指针样式为 “等待”
QApplication::setOverrideCursor(Qt::WaitCursor);

for (int row = 0; row < RowCount; ++row) {
for (int col = 0; col < ColumnCount; ++col) {
QString str = formula(row, col);
if (!str.isEmpty())
out << quint16(row) << quint16(col) << str;
}
}
// 还原鼠标指针样式
QApplication::restoreOverrideCursor();
return true;
}

注意,QDataStream 模仿了标准 C++ 的流(stream)类,都使用 >>/<< 运算符来输入输出流,例如:in >> x >> y;out << x << y;

这里还考虑到 C++ 的基本类型在不同平台下的空间大小不同,所以将它们强制转换为 qint8/quint8/...(16、32)/qint64/quint64,其中 quint64 最安全,但占用空间也比较多,所以结合实际数据范围,上面的代码以 quint16 为例。

在上面的例子中,HolySheet 程序的文件(*.hs)存储格式为:

1
MagicNumber(混淆码,32 bits, 4 Bytes) | row(16 bits) | column(16 bits) | string (32 bits) | row | column | string | ...

而具体存储方式由 QDataStream 内部决定。例如,quint16 会被 QDataStream小端序存成 2 Bytes,QString 会被存成 字符串长度 + Unicode字符 形式;

而且不同版本的 Qt ,存储方式不同,这也是为什么上面要指定 QDataStream 的版本(setVersion);

QDataStream 除了使用在 QFile 上,还能用在 QBufferQProcessQTcpSocketQUdpSocketQSslSocket 等类中。

如果仅仅是读取文本,还可以用 QTextStream 来代替 QDataStream。以后会深入介绍。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
bool HolySheet::readFile(const QString& fileName) {
QFile file(fileName);
if (!file.open(QIODevice::ReadOnly)) {
QMessageBox::warning(this, tr("HolySheet"),
tr("Cannot read file %1:\n%2.")
.arg(file.fileName())
.arg(file.errorString()));
return false;
}

QDataStream in(&file);
in.setVersion(QDataStream::Qt_5_14);

quint32 magic;
in >> magic;
if (magic != MagicNumber) {
QMessageBox::warning(this, tr("HolySheet"),
tr("The file is broken / not a HolySheet file."));
return false;
}
clear(); // 读取文件之前已经确认过,所以这里直接清空表格

quint16 row, column;
QString str;

QApplication::setOverrideCursor(Qt::WaitCursor);
while (!in.atEnd()) {
in >> row >> column >> str;
setFormula(row, column, str);
}
QApplication::restoreOverrideCursor();
return true;
}

4.3.4 表格编辑功能

到现在为止,HolySheet 除 “公式处理功能” 以外的其他功能已经基本具备(准确地说是接口做好了,可以当作 Cell 类已经实现),现在把 Edit 菜单中对应的诸如 复制、粘贴、剪切、删除 等功能补充完全:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// File: HolySheet.cpp ------------------------------------
void HolySheet::cut() {
copy();
del();
}

void HolySheet::copy() {
QTableWidgetSelectionRange range = selectedRange();
QString str;

// QTableWidgetSelectionRange::rowCount 和 columnCount 是原生方法
for (int i = 0; i < range.rowCount(); ++i) {
if (i > 0) str += "\n";
for (int j = 0; j < range.columnCount(); ++j) {
if (j > 0) str += "\t";
// QTableWidgetSelectionRange::topRow 和 bottomRow 也是原生方法
str += formula(range.topRow() + i, range.leftColumn() + j);
}
}
// 设置系统剪切板的静态方法
QApplication::clipboard()->setText(str);
}

上面的复制方法的结果认为,每列每格间隔一个制表符(\t),每行间格一个换行符(\n)——这是几乎全球通用的格式Microsoft Excel 也这么用,所以可以直接粘贴到其他多数软件上。

对于粘贴、删除(剪切包含在其中)的操作,会导致modified

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// 这里考验信息提取的简单方法的设计
void HolySheet::paste() {
QTableWidgetSelectionRange range = selectedRange();
QString str = QApplication::clipboard()->text();
// 下面解析剪切板中的内容
QStringList rows = str.split('\n'); // 和 Python 很像
int numRows = rows.count();
int numColumns = rows.first().count('\t') + 1;
// 区域合法性检验
if (range.rowCount() * range.columnCount() != 1
&& (range.rowCount() != numRows || range.columnCount() != numColumn)) {
QMessageBox::warning(this, tr("HolySheet"),
tr("The contents in the clipboard cannot be"
" pasted because the contents and the "
"selected area aren't the same size."));
return;
}
for (int i = 0; i < numRows; ++i) {
QStringList columns = rows[i].split('\t');
for (int j = 0; j < numColumns; ++j) {
int row = range.topRow() + i;
int column = range.leftColumn() + j;
if (row < RowCount && column < ColumnCount)
setFormula(row, column, columns[j]);
}
}
somethingChanged(); // 手动触发槽,其中 emit modified() 信号
}

void HolySheet::del() {
// QTableWidget::selectedItems 原生函数
QList<QTableWidgetItem*> items = selectedItems();
if (!items.isEmpty()) {
// Qt 自带的 foreach,模仿 Java
foreach (QTableWidgetItem* item, items)
delete item;
somethingChanged();
}
}

上面用了那么多次自定义的 selectedRange() 函数,现在看看如何实现:

1
2
3
4
5
6
7
8
9
10
QTableWidgetSelectRange HolySheet::selectedRange() const {
// QTableWidget::selectedRanges() 原生函数
// 返回放置所有选中的、连续区域对应的 QTableWidgetSelectionRange
QList<QTableWidgetSelectionRange> ranges = selectedRanges();
if (ranges.isEmpty())
return QTableWidgetSelectRange();
// 还记得吗?之前设置 setSelectionMode(ContiguousSelection)
// 只会选择连续区域的单元格,所以 ranges 的 size 只会是 0 或 1
return range.first();
}

再来看 MainWindow 的菜单栏中 select 子菜单中,有两个功能(另外一个 selectAll 函数就是 QTableWidget 的原生函数,不用写,直接 connect 就行),一个是 select column,另一个是 select row,它们就是选定当前选中单元格所在的列 / 行,只需要交给原生的 QTableWidget::selectColumn/selectRow(int) 就行:

1
2
3
4
5
6
7
void HolySheet::selectCurrentRow() {
selectRow(currentRow());
}

void HolySheet::selectCurrentColumn() {
selectColumn(currentColumn());
}

再实现心心念念的 findNextfindPrev(早在 findDialog 的编写上就看到了):

注意:无论是向前找,还是向后找,都是从当前选中位置开始的,这是共识

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
void HolySheet::findNext(const QString& str, Qt::CaseSensitivity cs) {
// 因为要找下一个,所以从同一行、下一列开始
// 思考:为什么这里不用考虑出界?——有 while 循环条件保证
int row = currentRow(), column = currentColumn() + 1;

while (row < RowCount) {
while (column < ColumnCount) {
// QString::contains(QString, Qt::CaseSensitivity) 原生函数
if (text(row, column).contains(str, cs)) {
clearSelection(); // 清除当前选择
setCurrentCell(row, column); // 选择到下一个符合要求的格子
activateWindow(); // 焦点落回表格
return;
}
++column;
}
column = 0; ++row;
}
// 使系统鸣叫一下,表示没找到
QApplication::beep();
}

void HolySheet::findPrev(const QString& str, Qt::CaseSensitivity cs) {
// 因为要查找上一个,所以从同一行、上一列开始
int row = currentRow(), column = currentColumn() - 1;

while (row >= 0) {
while (column >= 0) {
if (text(row, column).contains(str, cs)) {
clearSelection();
setCurrentCell(row, column);
activateWindow();
return;
}
--column;
}
column = ColumnCount - 1;
--row;
}
QApplication::beep();
}

再来实现支持 MainWindowToolsOptions 菜单的功能:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 此函数是将所有单元格全部遍历一遍,并标记存在数据的 cell 实例,
void HolySheet::recalculate() {
for (int row = 0; row < RowCount; ++row) {
for (int column = 0; column < ColumnCount; ++column) {
// Cell::setDirty() 等待实现
if (cell(row, column)) cell(row, column)->setDirty();
}
}
// 上面对于所有有数据的格子都调用了 setDirty(),
// 接下来调用原生函数 QTableWidget::viewpoint() 获得全局视口
// 对视口(类型)更新重新绘制 update(),调用每一个 “dirty” 的格子重新计算 text
viewport()->update();
}

// 设置自动重计算
void HolySheet::setAutoRecalculate(bool recalc) {
autoRecalc = recalc;
if (autoRecalc) recalculate(); // 设置后立即先生效一次
}

4.3.5 表格的排序功能

接下来解释 HolySheetCompare 类 和 HolySheet::sort() 排序的实现原理。

首先,我们要想起 sort 操作的用户使用方法就是:先选中一个区域,然后在弹出的 sortDialog 对话框中选择第一、二、三键(比较的列数)和升降序的要求,最终程序按数据和要求排序

我们简单设计给表格排序的算法

  1. 将选中的区域分割成 行列表(每一行在一个列表中,不同列的作为列表的不同元素),对这些行列表进行编号;

  2. 使用 Qt 自带的 qStableSort 算法(看到名字就知道是稳定的排序算法),它的参数为:

    1
    2
    3
    4
    5
    6
    template <class elemT>
    qStableSort(
    QList<elemT>::iterator iter_start,
    QList<elemT>::iterator iter_end,
    bool (comp)(elemT obj1, elemT obj2) // 这不是正规写法,源代码是typename
    )

    其中 comp 函数指针一定要对传入的所有可能的 elemT 类数据都能比较,要求:如果第一参数小于第二参数,则返回 true,反之无论如何返回 false,那么最后是升序排序

    如果想降序,只要让 comp 指向的函数在大于关系时返回 true 就行;

    这里利用传入的将行列表作为排序的最小单元,所以传给 qStableSort 的第一、二参数都会是 QList<QStringList> 类型;第三个参数的函数指针是设计的核心,类型要求:bool (comp)(QStringList, QStringList)并且能够按 QStringList 中指定的键进行依此排序

  3. 最后只需把排序好的 QList<QStringList> 重新写入当前列表,并且标记为 “modified” 就完成了。

为了完成上面的第二步的要求,我们设计出了 HolySheetCompare 类,使其不仅能够保存用户排序的需求信息,还能并且能够按用户指定的键进行元素先后判别

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// File: HolySheet.h
namespace hsCompare {
constexpr int KeyCount = 3;
};
using namespace hsCompare;

class HolySheetCompare {
public:
// 这里重载括号运算符,使得这个类能有函数一样的行为:compare(A, B) -> bool
bool operator()(const QStringList& row1, const QStringList& row2) const;
// 保存第 n 键在 QStringList 中的索引
int keys[KeyCount];
// 保存第 n 键是否为升序
bool ascending[KeyCount];
};

这里是实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// File: HolySheet.cpp
bool HolySheetCompare::operator()(const QStringList& row1, const QStringList& row2) const {
// 从第一键到第三键,如果当前键比较的数据不同,那么可以得出结果,结束单次比较;
// 否则,进入下一键的比较;
for (int i = 0; i < KeyCount; ++i) {
int column = keys[i]; // 当前比较的键的列索引
// -1 的含义是用户没有要求这个键(对应 comboBox 界面中的 None)
// column != -1 说明用户选择了这个键(要比较,否则就不比了)
if (column != -1) {
if(row1[column] != row2[column]) { // 说明这个键就能得出结果
// qStableSort 的要求。详细见上面的参数分析
if (ascending[i]) return row1[column] < row2[column];
else return row1[column] > row2[column];
}
// 如果本键相同,说明无法比较,需要进行下一个键的比较
}
}
return false; // 所有能比较的键都比完了,还是相同(不是小于),所以返回 false
}

这样,我们就能完整实现 HolySheet::sort 函数了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
void HolySheeet::sort(const HolySheetCompare& compare) {
QList<QStringList> rows; // 行列表
QTableWidgetSelectionRange range = selectedRange();
int i; // 提出来,因为频繁用到计数变量

// 遍历每一行
for (i = 0; i < range.rowCount(); ++i) {
QStringList curRow; // 当前读到的行
// 对当前行的每一列遍历
for (int j = 0; j < range.columnCount(); ++i)
curRow.append(formula(range.topRow() + i,
range.leftColumn() + j));
rows.append(curRow);
}
qStableSort(rows.begin(), rows.end(), compare);
// 把行列表放回原位置
for (i = 0; i < range.rowCount(); ++i) {
for (int j = 0; j < range.columnCount(); ++j)
setFormula(range.topRow() + i,
range.leftColumn() + j,
rows[i][j]);
}
clearSelection(); // 排序完清除选中
somethingChanged();
}

4.3.6 表格的公式计算实现

到此为止,HolySheet 的全部要求都实现完毕,现在进行 Cell 的定义和实现,一举解决公式计算问题。

根据上面 HolySheet 类对于 Cell 的请求,Cell 的定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// File: Cell.h
#pragma once
#include <QtWidgets/QTableWidgetItem>

// 由于 Cell 类不需要信号-槽机制,所以就不需要 Q_OBJECT 宏
// 更何况 QTableWidgetItem 并不是由 QObject 派生而来
// 这也是为了让项(item)的开销尽可能地低、访问效率尽可能地高
class Cell : public QTableWidgetItem {
public:
Cell();

void setFormula(const QString& formula);
QString formula() const;
void setDirty();

// 以下没见过的函数 / 数据成员,会在后面讲它们的含义
QTableWidgetItem* clone() const override;
void setData(int role, const QVariant& value) override;
QVariant data(int role) const override;
private:
QVariant value() const;

// 当前缓存的单元格的值
mutable QVariant cachedValue;
// 这个值是否不是最新的,即:是否需要更新
mutable bool cacheIsDirty;
};

首先解释基础 C++ 知识(和 Qt 没关系)——什么是 mutable 关键字。在 C++ 面向对象中,const(constant)mutable 是一对含义恰好相反的关键字。

考虑这个场景:如果我有个函数(func_1),只想改变一个数据成员(A),其他(B/C/D/...)变量都不允许它改变,想要保护起来。为了强调 “保护数据成员” 这一要求,我们可能会想把 func_1 声明为 const 常量函数,但是怎么能改到 A 呢?

没错!把 A 声明为 mutable 变量就行了!所谓 mutable 关键字的作用就是:类中的、凡被此关键字修饰的数据成员,都可以被常量函数修改!这样做可以起到一个作用:强调 “这个静态函数不会修改其他任何数据” 的特点。

1
2
3
4
5
6
// File: Cell.cpp
#include "Cell.h"

Cell::Cell() { setDirty(); } // 构造函数意思是单元格刚创建时,就需要更新

void Cell::setDirty() { cacheIsDirty = true; } // 当前 cell 设置需要更新

这里没必要传递父对象,因为当你 new 一个 Cell 对象,并且用 setItem() 插入到 QTableWidget 当中时,QTableWidget自动获得对该 Cell 对象的控制权,也会自动帮您析构

4.3.7 补充:QTableWidgetItem 类的机理简介

现在也不得不回答,之前提出的问题:“为什么在 HolySheetCell 里面都只有 setFormula,没有 setText,但却说 ‘公式和文本分开存储’ 呢?”

实际上,在上面 Cell.h 的声明中也能看到,Cell 和它的父类都采用 role 参数来控制 Data 的方法,而这就是 QTableWidgetItem 的设计。

在 Qt 中有几个枚举值:Qt::DisplayRoleQt::EditRoleQt::TextAlignmentRole,用来指定 QTableWidgetItem 存储数据的 “不同模式”:

  • Qt::EditRole 表示编辑模式,指的是单元格的原始内容(raw);
  • Qt::DisplayRole 表示显示模式,指的是单元格的显示内容;
  • Qt::TextAlignmentRole 表示对齐样式模式,指的是单元格的对齐方式;

无论是原式内容,还是显示内容,都由 QTableWidgetItem::data(Qt::role) 这个原生原生函数统一返回。在默认情况下,QTableWidgetItem 的显示内容和原始内容是一模一样的。

但是我们的程序是模仿 Excel 表格,要处理公式——于是乎,我们自定义了 data(Qt::role) 函数,使得公式所在单元格的显示内容和原始内容可能不一样

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
QVariant Cell::data(int role) const {
// 调用方想要获得显示模式(已计算)的信息
if (role == Qt::DisplayRole) {
if (value().isValid()) return value().toString();
else return "####"; // 计算失败,不符语法的公式
}
// 调用方想要获得显示模式中,内容(已计算)显示的格式
else if (role == Qt::TextAlignmentRole) {
// 如果里面存的是字符串(已计算)
if (value().type() == QVariant::String) {
return int(Qt::AlignLeft | Qt::AlignVCenter); //垂直居中,左对齐
}
// 如果里面存的是数据(已计算)
else {
return int(Qt::AlignRight | Qt::AlignVCenter);//垂直居中,右对齐
}
}
// 调用方想要获得表格的原始内容
else {
// 调用父类的方法,将格子中的所有数据原封不动地给调用方
return QTableWidgetItem::data(role);
}
}

首先解释一下上面的 if (value().isValid()):这个 Cell::value() 是等会会实现的自定义函数,用来从 EditRole 的原始内容计算出 DisplayRole 的展示内容,换言之,这个函数用来计算公式的!!!

只有当 “文本是公式,并且不符合语法“的,value() 函数才会返回 QVariant(),其他的无论是纯文本(原封不动),还是符合语法的公式(解析得到答案)都会返回有数据的 QVariant 实例。

大家可能会奇怪,QVariant::isValid() 是用来判断什么有效的?事实上,只有使用 QVariant 的默认构造函数构造(无参数)出来的实例,其 isValid() 返回值才是 false,其他含有数据的实例都是 true

现在就能解释 “为什么只有 setFormula 没有 setText” 的问题。

因为 QTableWidgetItem::text() 是原生函数(这里 Cell 类没有覆写,因为满足要求),在内部会调用 QTableWidgetItem::data(Qt::DisplayRole)。也就是说:text() 获取的是 “展示模式” 的数据,formula() 获取的是 原始数据

所以,我们只需要设置原始数据(Cell::setFormula,更底层调用 Cell::setData)、设置原始数据的计算方式(Cell::value 及相关函数),就能让展示模式、原始内容各自呈现出我们想要的内容!

既然我们现在知道了 setFormulaformulavaluesetData 的作用,现在我们分别实现它们:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
void Cell::setFormula(const QString& formula) {
// 调用底层自定义的 data 设置原始内容
setData(Qt::EditRole, formula);
}

QString Cell::formula() const {
// 调用自定义的 data 以 QString 形式返回原始数据
return data(Qt::EditRole).toString();
}

// 覆写底层的 QTableWidgetItem::setData,添加一个 “标记 dirty” 的功能
void Cell::setData(int role, const QVariant& value) {
// 设置数据的功能和父类一样
QTableWidgetItem::setData(role, value);
// 增添的是标记功能
if (role == Qt::EditRole)
setDirty();
}

对于 Cell::setDataif (role == Qt::EditRole),有同学可能会问,为啥要判断一下?既然修改了,直接 setDirty 不就行?其实不是这样。

前面说了,用户只能改原始数据 (Qt::EditRole);而展示数据(Qt::DisplayRole)是程序内部计算公式 / 纯文本的时候才会更改。setDirty 只需要,也只能 标记用户的修改行为。一旦标记了机器的修改行为,轻则浪费系统资源,重则会陷入 “机器更新展示数据 -> setDirty -> 机器更新展示数据 -> …” 的死循环中。

继续:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
// 这里偷个懒,用我很早以前写的简易计算器程序,支持+-*/^()运算
// 简易计算器不在这里展示,详情请看项目源码
#include "calc.h"

// value() 底层函数,用于计算原始数据
// 是纯文本 / 符合语法的公式 -> return QVariant(计算结果)
// 是不符合语法的公式 -> return QVariant(),可以被 isValid() 发现
QVariant Cell::value() const {
if (cacheIsDirty) {
cacheIsDirty = false;

QString formulaStr = formula();
if (formulaStr.startsWith('\'')) // 单引号引起的内容代表纯文本
cachedValue = formulaStr.mid(1);
else if (formulaStr.startsWith('=')) { // 以等号开头的是公式
cachedValue = QVariant();
// 将中缀表达式中所有变量(正则式如下)全部递归替换为已计算内容
QRegularExpression exp("([a-zA-Z][1-9][0-9]{0,2})");
QRegularExpressionMatch curMatch;
while (true) {
curMatch = exp.match(formulaStr);
if (!curMatch.hasMatch()) break;
QString var = curMatch.captured(1);
QChar tmp = var[0] - 'A';
int column = tmp.toInt(), row = var.mid(1).toInt() - 1;
Cell* varCell = static_cast<Cell*>(
tableWidget()->item(row, column)
);
QString cur;
if (varCell) cur = varCell->text(); // 递归替换内容
else cur = "0.0";
formulaStr.replace(
formulaStr.indexOf(var), // offset
var.size(), // length
cur // new string
);
}
try {
calc calculator(formulaStr.toStdString().c_str());
cachedValue = calculator.getResult();
}
catch (exprErr err) { // 公式计算错误
QMessageBox::warning(this, tr("HolySheet"),
tr(err.what()));
}
}
else { // 是纯文本,但不清楚是不是纯数字
bool ok; //能否转换为数字
double d = formulaStr.toDouble(&ok);
if (ok) cachedValue = d; // 赋值为双精度浮点数
else cachedValue = formulaStr; // 只能作为字符串
}
}
return cachedValue;
}

最后可能需要覆写一个父类里的函数 QTableWidget::clone(),它的作用是,当用户在表格中从未创建过的单元格中写入数据时,自动调用 clone() 传递给之前的 QTableWidget::setItemPrototype(),这里需要用 Cellnew,不能用父类来:

1
2
3
QTableWidgetItem* Cell::clone() const {
return new Cell(*this);
}

这样返回的虽然是父类指针,但是指向子类的对象,在 setItemPrototype 函数里会转换回去,所以满足要求。


至此,CellHolySheet 类已经全部完成,现在只需要回到 MainWindow 中,把剩下还没加入的一些 Action 添加进去,整个程序就完成了

如果你不是 IDE 用户,别忘了写一写 CMakeLists.txt/Makefile 或者运行运行 qmake,把项目跑起来吧 ~

4.4 章末总结 & 下文预告

下面是本人总结的 Qt 5 类图,和上次总结相比,非常非常地长。需要 *.xmind/*.pdf/*.png 格式的同学可以联系本人 ~

同系列下一篇文章预告:将会是关于 Qt 的 event(事件)、图形绘制 和 游戏的内容。