本页内容

通讯录

地址簿示例演示了如何使用代理模型,基于单一模型的数据显示不同的视图。

显示联系人及地址的通讯录

本示例提供了一个通讯录,可将联系人按字母顺序分为 9 个组:ABC、DEF、GHI、……、VW、……、XYZ。这是通过对同一模型使用多个视图来实现的,每个视图都使用QSortFilterProxyModel 类的实例进行过滤。

概述

通讯录包含 4 个类:MainWindow 、AddressWidget 、NewAddressTab 和AddDialog 。MainWindow 类使用AddressWidget 作为其核心控件,并提供File 和Tools 菜单。

通讯录的主要类和元素

AddressWidget 类是QTabWidget 的子类,用于操作示例中显示的10个标签页:9个按字母分组的标签页以及一个NewAddressTab 实例。NewAddressTab 类是QWidget 的子类,仅在通讯录为空时使用,此时会提示用户添加联系人。AddressWidget 还与TableModel 的实例进行交互,以向通讯录添加、编辑和删除条目。

struct Contact 用于存储联系人,并通过QDataStream 提供比较和序列化功能:

struct Contact
{
private:
    Q_GADGET
    Q_PROPERTY(QString name MEMBER name)
    Q_PROPERTY(QString address MEMBER address)

public:
    QString name;
    QString address;

    friend bool comparesEqual(const Contact &lhs, const Contact &rhs) noexcept
    {
        return lhs.name == rhs.name && lhs.address == rhs.address;
    }

    friend Qt::strong_ordering compareThreeWay(const Contact &lhs, const Contact  &rhs) noexcept
    {
        int cmp = lhs.name.compare(rhs.name);
        if (cmp == 0)
            cmp = lhs.address.compare(rhs.address);
        return Qt::compareThreeWay(cmp, 0);
    }

    Q_DECLARE_STRONGLY_ORDERED(Contact)
};

inline QDataStream &operator<<(QDataStream &stream, const Contact &contact)
{
    return stream << contact.name << contact.address;
}

inline QDataStream &operator>>(QDataStream &stream, Contact &contact)
{
    return stream >> contact.name >> contact.address;
}

TableModel 是QAbstractTableModel 的子类,提供用于访问数据的标准模型/视图 API。它保存已添加联系人的列表。不过,这些数据不会全部显示在单个标签页中。相反,QTableView 会根据字母分组,针对同一数据提供 9 种不同的视图。

QSortFilterProxyModel 是负责为每个联系人组过滤联系人的类。每个代理模型都使用QRegularExpression 来过滤掉不属于相应字母组别的联系人。AddDialog 类用于从用户处获取通讯录信息。该QDialog 子类由NewAddressTab 实例化以添加联系人,并由AddressWidget 实例化以添加和编辑联系人。

AddressWidget 类定义

从技术上讲,AddressWidget 类是本示例中的核心类,因为它提供了添加、编辑和删除联系人,以及将联系人保存到文件和从文件加载联系人的功能。

class AddressWidget : public QTabWidget
{
    Q_OBJECT

public:
    explicit AddressWidget(QWidget *parent = nullptr);

    bool readFromFile();
    bool writeToFile();

    static QString fileName();

public slots:
    void showAddEntryDialog();
    void addEntry(const Contact &contact);
    void editEntry();
    void removeEntry();

signals:
    void selectionChanged (const QItemSelection &selected);

private:
    void setupTabs();

    NewAddressTab *newAddressTab;

    QList<Contact> contacts;
    using Adapter = decltype(QRangeModelAdapter(std::ref(contacts)));
    Adapter adapter;
};

AddressWidget 继承自QTabWidget 以容纳 10 个标签页(NewAddressTab 以及 9 个按字母分组的标签页),同时还操作table (TableModel 对象)、proxyModel (用于过滤条目的QSortFilterProxyModel 对象)以及tableView (QTableView 对象)。

它将联系人存储在QList 中,并使用QRangeModelAdapter 对其进行操作。

我们通过QRangeModel::RowOptions 的特化实现为struct Contact 提供行标题值:

template <>
struct QRangeModel::RowOptions<Contact>
{
    inline static QVariant headerData(int section, int role);
};
QVariant QRangeModel::RowOptions<Contact>::headerData(int section, int role)
{
    if (role == Qt::DisplayRole) {
        switch (section) {
        case 0:
            return AddressWidget::tr("Name");
        case 1:
            return AddressWidget::tr("Address");
        default:
            break;
        }
    }
    return {};
}

AddressWidget 类实现

AddressWidget 的构造函数接受一个父控件,并实例化NewAddressTab 、TableModel 和QSortFilterProxyModel 。添加NewAddressTab 对象(用于指示地址簿为空),并通过setupTabs() 设置其余 9 个标签页。

AddressWidget::AddressWidget(QWidget *parent)
    : QTabWidget(parent),
      newAddressTab(new NewAddressTab(this)),
      adapter(std::ref(contacts))
{
    connect(newAddressTab, &NewAddressTab::triggered, this, &AddressWidget::showAddEntryDialog);

    addTab(newAddressTab, tr("Address Book"));

    setupTabs();
}

使用setupTabs() 函数在AddressWidget 中设置9个字母分组标签页、表格视图和代理模型。每个代理模型依次使用不区分大小写的QRegularExpression 对象,根据相应的字母分组过滤联系人姓名。表格视图还通过对应代理模型的sort()函数按升序排序。

每个表格视图的selectionMode 均设置为QAbstractItemView::SingleSelection ,而selectionBehavior 则设置为QAbstractItemView::SelectRows ,从而允许用户一次性选中同一行中的所有项目。每个QTableView 对象都会自动获得一个QItemSelectionModel ,用于跟踪已选中的索引。

void AddressWidget::setupTabs()
{
    const auto groups = { "ABC"_L1, "DEF"_L1, "GHI"_L1, "JKL"_L1, "MNO"_L1, "PQR"_L1,
                          "STU"_L1, "VW"_L1, "XYZ"_L1 };

    for (QLatin1StringView str : groups) {
        const auto regExp = QRegularExpression(QLatin1StringView("^[%1].*").arg(str),
                                               QRegularExpression::CaseInsensitiveOption);

        auto *proxyModel = new QSortFilterProxyModel(this);
        proxyModel->setSourceModel(adapter.model());
        proxyModel->setFilterRegularExpression(regExp);
        proxyModel->setFilterKeyColumn(0);

        auto *tableView = new QTableView;
        tableView->setModel(proxyModel);
        tableView->setSelectionBehavior(QAbstractItemView::SelectRows);
        tableView->horizontalHeader()->setStretchLastSection(true);
        tableView->verticalHeader()->hide();
        tableView->setEditTriggers(QAbstractItemView::NoEditTriggers);
        tableView->setSelectionMode(QAbstractItemView::SingleSelection);
        tableView->setSortingEnabled(true);

        connect(tableView->selectionModel(), &QItemSelectionModel::selectionChanged,
                this, &AddressWidget::selectionChanged);

        connect(this, &QTabWidget::currentChanged, this, [this, tableView](int tabIndex) {
            if (widget(tabIndex) == tableView)
                emit selectionChanged(tableView->selectionModel()->selection());
        });

        addTab(tableView, str);
    }
}

QItemSelectionModel 类提供了一个selectionChanged 信号,该信号连接到了AddressWidget 的selectionChanged() 信号上。我们还将QTabWidget::currentChanged()信号连接到一个lambda表达式,该表达式也会触发AddressWidget 的selectionChanged() 信号。这些连接对于启用MainWindow “工具”菜单中的Edit Entry... 和Remove Entry 操作是必要的。具体实现将在MainWindow 中进一步说明。

通讯录中的每个表格视图都会作为标签页添加到QTabWidget 中,并带有从组QStringList 获取的相关标签。

地址簿中的信号与时隙连接

我们提供了两个addEntry() 函数:一个用于接收用户输入,另一个则负责将新条目添加到通讯录中。我们将添加条目的职责分为两部分,以便newAddressTab 能够插入数据而无需弹出对话框。

第一个addEntry() 函数是一个与MainWindow 的Add Entry... 动作关联的插槽。该函数会创建一个AddDialog 对象,然后调用第二个addEntry() 函数,从而将联系人实际添加到table 中。

void AddressWidget::showAddEntryDialog()
{
    AddDialog aDialog(this);

    if (aDialog.exec() == QDialog::Accepted)
        addEntry(aDialog.contact());
}

基本验证在第二个addEntry() 函数中进行,以防止通讯录中出现重复条目。正如在TableModel 中提到的,这也是我们要求使用getContacts() 获取器方法的部分原因。

void AddressWidget::addEntry(const Contact &contact)
{
    if (contacts.contains(contact)) {
        QMessageBox::information(this, tr("Duplicate Name"),
                                 tr("The name \"%1\" already exists.").arg(contact.name));
        return;
    }

    adapter.insertRow(adapter.rowCount(), contact);

    removeTab(indexOf(newAddressTab));

    const QChar firstChar = contact.name.at(0).toUpper();
    for (int t = 0, tabCount = count(); t < tabCount; ++t) {
        if (tabText(t).contains(firstChar)) {
            setCurrentIndex(t);
            break;
        }
    }
}

如果模型中尚不存在同名的条目,我们会调用setData() 将姓名和地址分别插入到第一和第二列中。否则,我们会显示一个QMessageBox 来通知用户。

注意: 一旦添加了联系人, newAddressTab 就会 被移除,因为此时通讯录已不再为空。

编辑条目仅用于更新联系人的地址,因为本示例不允许用户更改现有联系人的姓名。

首先,我们通过QTabWidget::currentWidget()获取当前活动标签页的QTableView 对象。然后从tableView 中提取selectionModel ,以获取选中的索引。

void AddressWidget::editEntry()
{
    auto *tableView = static_cast<QTableView *>(currentWidget());
    auto *proxy = static_cast<QSortFilterProxyModel *>(tableView->model());
    QItemSelectionModel *selectionModel = tableView->selectionModel();

    const QModelIndexList indexes = selectionModel->selectedRows();
    if (indexes.isEmpty())
        return;

    const int row = proxy->mapToSource(indexes.constFirst()).row();
    const Contact contact = contacts.at(row);

接下来,我们从用户打算编辑的行中提取数据。这些数据将显示在一个窗口标题不同的AddDialog 实例中。只有当aDialog 中的数据发生更改时,table 才会被更新。

    AddDialog aDialog(this);
    aDialog.setWindowTitle(tr("Edit a Contact"));
    aDialog.editAddress(contact);

    if (aDialog.exec() == QDialog::Accepted) {
        const Contact newContact = aDialog.contact();
        if (newContact != contact)
            adapter[row] = newContact;
    }
}

用于编辑姓名和地址的对话框

使用removeEntry() 函数删除条目。通过QItemSelectionModel 对象(selectionModel )访问所选行并将其删除。只有当用户删除了地址簿中的所有联系人时,才会将newAddressTab 重新添加到AddressWidget 中。

void AddressWidget::removeEntry()
{
    auto *tableView = static_cast<QTableView *>(currentWidget());
    auto *proxy = static_cast<QSortFilterProxyModel *>(tableView->model());
    QItemSelectionModel *selectionModel = tableView->selectionModel();

    const QModelIndexList indexes = selectionModel->selectedRows();
    if (indexes.isEmpty())
        return;

    const int row = proxy->mapToSource(indexes.constFirst()).row();
    adapter.removeRow(row);

    if (adapter.rowCount() == 0)
        insertTab(0, newAddressTab, tr("Address Book"));
}

writeToFile() 函数用于保存一个包含通讯录中所有联系人的文件。该文件以自定义的.dat 格式保存。联系人列表的内容将通过QDataStream 写入file 。如果无法打开文件,将显示QMessageBox 并附带相关错误信息。

bool AddressWidget::writeToFile()
{
    QFile file(fileName());

    if (!file.open(QIODevice::WriteOnly)) {
        QMessageBox::information(this, tr("Unable to open file"),
                                 tr("Cannot write to %1: %2").arg(QDir::toNativeSeparators(fileName()),
                                                                  file.errorString()));
        return false;
    }

    auto sortedContacts = contacts;
    std::sort(sortedContacts.begin(), sortedContacts.end());

    QDataStream out(&file);
    out << sortedContacts;
    return true;
}

readFromFile() 函数用于加载一个包含通讯录中所有联系人的文件,该文件此前已使用writeToFile() 保存。通过QDataStream 将.dat 文件的内容读取到联系人列表中,并使用addEntry() 将每个联系人添加到列表中。

bool AddressWidget::readFromFile()
{
    QFile file(fileName());

    if (!file.open(QIODevice::ReadOnly)) {
        QMessageBox::information(this, tr("Unable to open file"),
                                 tr("Cannot open %1: %2").arg(QDir::toNativeSeparators(fileName()),
                                                              file.errorString()));
        return false;
    }

    QList<Contact> contactsIn;
    QDataStream in(&file);
    in >> contactsIn;

    if (contactsIn.isEmpty()) {
        QMessageBox::information(this, tr("No contacts in file"),
                                 tr("The file you are attempting to open contains no contacts."));
    }

    for (const auto &contact : std::as_const(contactsIn))
        addEntry(contact);

    return true;
}

NewAddressTab 类定义

NewAddressTab 类提供了一个信息标签页,用于告知用户通讯录为空。该标签页会根据通讯录的内容显示或隐藏,具体实现如AddressWidget 类中所述。

用于添加新联系人的选项卡

NewAddressTab 类继承自QWidget ,并包含QLabel 和QPushButton 。

class NewAddressTab : public QWidget
{
    Q_OBJECT

public:
    explicit NewAddressTab(QWidget *parent = nullptr);

signals:
    void triggered();
};

NewAddressTab 类的实现

构造函数会实例化addButton 和descriptionLabel ,并将addButton 的信号连接到triggered() 信号。

NewAddressTab::NewAddressTab(QWidget *parent)
    : QWidget(parent)
{
    auto *descriptionLabel = new QLabel(tr("There are currently no contacts in your address book. "
                                           "\nClick Add to add new contacts."));

    auto *addButton = new QPushButton(tr("Add"));

    connect(addButton, &QAbstractButton::clicked, this, &NewAddressTab::triggered);

    auto *mainLayout = new QVBoxLayout(this);
    mainLayout->addWidget(descriptionLabel, 0, Qt::AlignCenter);
    mainLayout->addWidget(addButton, 0, Qt::AlignCenter);
}

用于添加新联系人的函数调用

AddDialog 类的定义

AddDialog 类继承自QDialog ,并为用户提供QLineEdit 和QTextEdit ,以便向地址簿输入数据。

class AddDialog : public QDialog
{
    Q_OBJECT

public:
    explicit AddDialog(QWidget *parent = nullptr);

    Contact contact() const;
    void setContact(const Contact &c);

    void editAddress(const Contact &c);

private slots:
    void updateEnabled();

private:
    QDialogButtonBox *buttonBox;
    QLineEdit *nameText;
    QPlainTextEdit *addressText;
};

添加新姓名和地址的对话框

AddDialog 类的实现

AddDialog 的构造函数负责设置用户界面,创建必要的控件并将其放置在布局中。

AddDialog::AddDialog(QWidget *parent)
    : QDialog(parent),
      buttonBox(new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, Qt::Horizontal, this)),
      nameText(new QLineEdit),
      addressText(new QPlainTextEdit)
{
    auto *formLayout = new QFormLayout;
    formLayout->addRow(tr("Name"), nameText);
    formLayout->addRow(tr("Address"), addressText);

    auto *mainLayout = new QVBoxLayout(this);
    mainLayout->addLayout(formLayout);
    mainLayout->addWidget(buttonBox);

    connect(buttonBox, &QDialogButtonBox::accepted, this, &QDialog::accept);
    connect(buttonBox, &QDialogButtonBox::rejected, this, &QDialog::reject);
    connect(nameText, &QLineEdit::textChanged, this, &AddDialog::updateEnabled);
    connect(addressText, &QPlainTextEdit::textChanged, this, &AddDialog::updateEnabled);

    setWindowTitle(tr("Add a Contact"));

    updateEnabled();
}

void AddDialog::updateEnabled()
{
    Contact c = contact();
    const bool valid = !c.name.isEmpty() && c.name.front().isLetter() && !c.address.isEmpty();
    buttonBox->button(QDialogButtonBox::Ok)->setEnabled(valid);
}

Contact AddDialog::contact() const
{
    return { nameText->text().trimmed(), addressText->toPlainText().trimmed() };
}

void AddDialog::setContact(const Contact &c)
{
    nameText->setText(c.name);
    addressText->setPlainText(c.address);
    updateEnabled();
}

void AddDialog::editAddress(const Contact &c)
{
    nameText->setReadOnly(true);
    setContact(c);
}

为了使对话框具备所需的行为,我们将OK 和Cancel 按钮分别连接到对话框的accept()和reject()槽。由于该对话框仅作为姓名和地址信息的容器,因此无需为其实现其他功能。

MainWindow 类定义

MainWindow 类继承自QMainWindow ,并实现了操作通讯录所需的菜单和操作。

“文件”菜单中的“打开”、“保存”和“退出”选项“工具”菜单中的“添加条目”、“编辑条目”和“删除条目”
class MainWindow : public QMainWindow
{
    Q_OBJECT

public:
    MainWindow();

private slots:
    void updateActions(const QItemSelection &selection);
    void openFile();
    void saveFile();

private:
    void createMenus();

    AddressWidget *addressWidget;
    QAction *editAct;
    QAction *removeAct;
};

MainWindow Tools 类使用 作为其核心控件,并在“文件”菜单中提供了 、 和 操作,同时在“地址簿”菜单中提供了 、 和 操作。AddressWidget Open Close Exit Add Entry... Edit Entry... Remove Entry

MainWindow 类的实现

MainWindow 的构造函数会实例化 AddressWidget,将其设置为中央控件,并调用createMenus() 函数。

MainWindow::MainWindow()
    : QMainWindow(),
      addressWidget(new AddressWidget)
{
    setCentralWidget(addressWidget);
    createMenus();
    setWindowTitle(tr("Address Book"));
}

createMenus() 函数负责设置File 和Tools 菜单,并将相关操作连接到各自的槽函数。Edit Entry... 和Remove Entry 这两个操作默认处于禁用状态,因为在地址簿为空时无法执行这些操作。只有在添加了一个或多个联系人后,这些操作才会被启用。

void MainWindow::createMenus()
{
    QMenu *fileMenu = menuBar()->addMenu(tr("&File"));

    auto *openAct = new QAction(QIcon::fromTheme(QIcon::ThemeIcon::DocumentOpen),
                                tr("&Open..."), this);
    openAct->setShortcut(QKeySequence(QKeySequence::Open));
    fileMenu->addAction(openAct);
    connect(openAct, &QAction::triggered, this, &MainWindow::openFile);
    ...

    editAct = new QAction(tr("&Edit Entry..."), this);
    editAct->setEnabled(false);
    toolMenu->addAction(editAct);
    connect(editAct, &QAction::triggered, addressWidget, &AddressWidget::editEntry);

    toolMenu->addSeparator();

    removeAct = new QAction(tr("&Remove Entry"), this);
    removeAct->setEnabled(false);
    toolMenu->addAction(removeAct);
    connect(removeAct, &QAction::triggered, addressWidget, &AddressWidget::removeEntry);

    connect(addressWidget, &AddressWidget::selectionChanged,
            this, &MainWindow::updateActions);
}

除了将所有操作的信号连接到各自的插槽外,我们还将AddressWidget 的selectionChanged() 信号连接到其updateActions() 插槽。

openFile() 函数会打开一个包含通讯录联系人的自定义addressbook.dat 文件。该函数是一个与File 菜单中openAct 槽位相连的槽位。

void MainWindow::openFile()
{
    if (addressWidget->readFromFile())
        statusBar()->showMessage(tr("Read %1").arg(QDir::toNativeSeparators(AddressWidget::fileName())));
}

“saveFile() ”功能会保存一个自定义的addressbook.dat 文件,其中将包含通讯录中的联系人。该功能是一个与File 菜单中的saveAct 关联的槽。

void MainWindow::saveFile()
{
    if (addressWidget->writeToFile())
        statusBar()->showMessage(tr("Wrote %1").arg(QDir::toNativeSeparators(AddressWidget::fileName())));
}

updateActions() 函数会根据通讯录的内容启用或禁用Edit Entry... 和Remove Entry 。如果通讯录为空,则禁用这些操作;否则,则启用。该函数是一个与AddressWidget 的selectionChanged() 信号相连的槽。

void MainWindow::updateActions(const QItemSelection &selection)
{
    const QModelIndexList indexes = selection.indexes();

    removeAct->setEnabled(!indexes.isEmpty());
    editAct->setEnabled(!indexes.isEmpty());
}

main() 函数

通讯录的主函数会在运行事件循环之前,实例化QApplication 并打开MainWindow 。

int main(int argc, char *argv[])
{
    QApplication app(argc, argv);
    MainWindow mw;
    const auto availableGeometry = mw.screen()->availableGeometry();
    mw.resize(availableGeometry.width() / 3, availableGeometry.height() / 3);
    mw.show();
    return QApplication::exec();
}

示例项目 @ code.qt.io

© 2026 The Qt Company Ltd. Documentation contributions included herein are the copyrights of their respective owners. The documentation provided herein is licensed under the terms of the GNU Free Documentation License version 1.3 as published by the Free Software Foundation. Qt and respective logos are trademarks of The Qt Company Ltd. in Finland and/or other countries worldwide. All other trademarks are property of their respective owners.