本页内容

Qt Quick Controls - 聊天教程

本教程将演示如何使用Qt Quick Controls 编写一个基本的聊天应用程序。同时,还将讲解如何将SQL数据库集成到Qt应用程序中。

第 1 章:项目设置

在创建新项目时,最简单的方法是使用 Qt Creator。对于本项目,我们选择了“Qt Quick ”应用程序模板,该模板会生成一个包含以下文件的基本“Hello World”应用程序:

  • CMakeLists.txt - 向 CMake 说明应如何构建我们的项目
  • Main.qml - 提供一个包含空窗口的默认用户界面
  • main.cpp - 加载main.qml
  • qtquickcontrols2.conf - 告知应用程序应采用哪种样式

main.cpp

main.cpp 中的默认代码包含两个头文件:

#include <QGuiApplication>
#include <QQmlApplicationEngine>

QGuiApplication 第一个使我们能够访问QGuiApplication 。所有 Qt 应用程序都需要一个应用程序对象,但具体类型取决于应用程序的功能。对于非图形化应用程序,QCoreApplication 即可满足需求。对于不使用 Qt Widgets的图形化应用程序,而对于使用 Qt 图形库的应用程序,则必须使用QApplication 。

第二个包含语句使QQmlApplicationEngine 可用,从而允许我们加载 QML。

在main() 中,我们设置应用程序对象和 QML 引擎:

int main(int argc, char *argv[])
{
    QGuiApplication app(argc, argv);

    QQmlApplicationEngine engine;
    engine.loadFromModule("chattutorial", "Main");

    return app.exec();
}

QQmlApplicationEngine 是QQmlEngine 的便捷封装,提供了loadFromModule 函数,可轻松为应用程序加载 QML。它还为使用文件选择器增添了一些便利。

在 C++ 中完成相关配置后,我们就可以转到 QML 中的用户界面部分了。

Main.qml

让我们修改默认的 QML 代码以满足我们的需求。

import QtQuick
import QtQuick.Controls

你会注意到 Qt Quick 模块已被导入。这使我们可以访问诸如Item 、Rectangle 、Text 等图形基元。有关类型的完整列表,请参阅 Qt Quick QML Types 文档。

添加对Qt Quick Controls 模块的导入。这将提供对ApplicationWindow 的访问权限,它将取代现有的根类型Window :

ApplicationWindow {
    width: 540
    height: 960
    visible: true
    ...
}

ApplicationWindow 是一个Window ,为创建header 和footer 提供了一些额外便利。它还为popups 提供了基础,并支持一些基本样式,例如背景color 。

在使用ApplicationWindow 时,有三个属性几乎总是会被设置:width 、height 和visible 。一旦设置了这些属性,我们便获得了一个尺寸合适、内容为空的窗口,随时可以填充内容。

注意: 默认代码中的title 属性已被移除。

我们应用程序中的第一个“屏幕”将是一个联系人列表。如果能在每个屏幕的顶部显示一些描述其用途的文本,那就再好不过了。在这种情况下,ApplicationWindow 的 header 和 footer 属性可以派上用场。它们具有一些特性,使其非常适合用作应用程序每个屏幕上都应显示的元素:

  • 它们分别锚定在窗口的顶部和底部。
  • 它们占据窗口的整个宽度。

然而,当页眉和页脚的内容会根据用户当前浏览的屏幕而变化时,使用Page 会方便得多。目前,我们仅添加一个页面,但在下一章中,我们将演示如何在多个页面之间导航。

    Page {
        anchors.fill: parent
        header: Label {
            padding: 10
            text: qsTr("Contacts")
            font.pixelSize: 20
            horizontalAlignment: Text.AlignHCenter
            verticalAlignment: Text.AlignVCenter
        }
    }

首先,我们添加一个 Page,并通过anchors.fill 属性将其尺寸设置为占用窗口的全部空间。

然后,我们将Label 赋值给其header 属性。Label类通过添加样式和font 继承关系,扩展了来自Qt Quick 模块的原始Text 项。这意味着Label的外观会根据所使用的样式而有所不同,并且还可以将其像素大小传递给子元素。

为了在应用程序窗口顶部与文本之间留出一些距离,我们设置了padding 属性。这会在标签的两侧(在其边界内)分配额外的空间。我们也可以改用显式设置topPadding 和bottomPadding 属性。

我们使用qsTr() 函数设置标签的文本,这可确保文本能被Qt的翻译系统进行翻译。对于应用程序中最终用户可见的文本,遵循这一做法是良好的编程习惯。

默认情况下,文本在垂直方向上对齐于其边界的顶部,而水平对齐则取决于文本的自然方向;例如,从左到右阅读的文本将左对齐。 如果采用这些默认设置,文本将位于窗口的左上角。这对于标题而言并不理想,因此我们将文本在水平和垂直方向上均对齐到其边界的中心。

项目文件

CMakeLists.txt 文件包含CMake将我们的项目构建为可运行可执行文件所需的所有信息。

有关此文件的详细说明,请参阅《构建 QML 应用程序》。

以下是当前运行时应用程序的外观:

带有“联系人”标题的空白应用程序窗口

第 2 章:列表

在本章中,我们将讲解如何使用 `ListView ` 和 `ItemDelegate` 创建一个交互式项目列表。

ListView Qt Quick 来自 模块,用于显示由模型填充的项目列表。 来自 模块,提供了一个标准视图项,可用于 和 等视图及控件中。例如,每个 都可以显示文本、被选中或取消选中,并响应鼠标点击。ItemDelegate Qt Quick Controls ListView ComboBox ItemDelegate

以下是我们的ListView :

        ...

        ListView {
            id: listView
            anchors.fill: parent
            topMargin: 48
            leftMargin: 48
            bottomMargin: 48
            rightMargin: 48
            spacing: 20
            model: ["Albert Einstein", "Ernest Hemingway", "Hans Gude"]
            delegate: ItemDelegate {
                id: contactDelegate
                text: modelData
                width: listView.width - listView.leftMargin - listView.rightMargin
                height: avatar.implicitHeight
                leftPadding: avatar.implicitWidth + 32

                required property string modelData

                Image {
                    id: avatar
                    source: "images/" + contactDelegate.modelData.replace(" ", "_") + ".png"
                }
            }
        }
        ...

尺寸和定位

首先,我们需要为视图设置大小。它应填满页面上的可用空间,因此我们使用anchors.fill 。请注意,Page 会确保为其页眉和页脚预留足够的空间,因此在此情况下,该视图将位于页眉下方,例如。

接下来,我们在 `ListView ` 周围设置 `margins `,以便在它与窗口边缘之间留出一定距离。这些 `margin` 属性在视图边界内预留了空间,这意味着用户仍然可以“轻扫”这些空白区域。

项目应在视图内均匀分布,因此将spacing 属性设置为20 。

模型

为了快速向视图中填充一些项目,我们使用了一个 JavaScript 数组作为模型。QML 最大的优势之一就是能够极快地进行应用程序原型设计,本例正是对此的体现。 此外,还可以直接为模型属性赋值一个数字,以指定所需的项目数量。例如,如果将10 赋值给model 属性,则每个项目的显示文本将是一个从0 到9 之间的数字。

然而,一旦应用程序走出原型阶段,很快就会需要使用真实数据。为此,最好使用subclassing QAbstractItemModel 提供的正规 C++ 模型。

委托

接下来是delegate 。我们将模型中的对应文本赋值给ItemDelegate 的text 属性。模型数据如何具体提供给每个委托,取决于所使用的模型类型。更多信息请参阅 Qt Quick 中的“模型与视图”部分。

在我们的应用程序中,视图中每个项目的宽度应与视图的宽度相同。这确保了用户在列表中选择联系人时有充足的操作空间,这对配备小尺寸触摸屏的设备(如手机)而言尤为重要。 然而,视图的宽度包含48 像素的边距,因此我们在设置 width 属性时必须将此因素考虑在内。

接下来,我们定义一个Image 。它将显示用户联系人的图片。该图片宽度为40 像素,高度为40 像素。我们将根据图片的高度来确定委托的高度,以避免出现垂直方向的空白区域。

显示三个带有头像的联系人条目的联系人列表

第 3 章:导航

在本章中,您将学习如何使用StackView 在应用程序的页面之间进行导航。以下是修改后的main.qml :

import QtQuick.Controls

ApplicationWindow {
    id: window
    width: 540
    height: 960
    visible: true

    StackView {
        id: stackView
        anchors.fill: parent
        initialItem: ContactPage {}
    }
}

顾名思义,StackView 提供基于堆栈的导航。最后“压入”堆栈的项目将最先被“弹出”,而最顶层的项目始终是可见的。

与处理 Page 时相同,我们让StackView 填满应用程序窗口。接下来唯一需要做的是通过initialItem 向其提供要显示的项目。StackView 接受items 、components 和URLs 作为参数。

你会注意到,我们将联系人列表的代码移到了ContactPage.qml 中。一旦你大致确定了应用程序将包含哪些屏幕,就应尽早进行此操作。这样做不仅使代码更易于阅读,还能确保仅在绝对必要时才从给定组件实例化项,从而减少内存占用。

注意: Qt Creator 为 QML 提供了若干便捷的快速修复功能,其中一项可让你将一段代码移动到单独的文件中(Alt + Enter > Move Component into Separate File )。

使用ListView 时还需考虑的是,是通过id 引用它,还是使用附带的ListView.view 属性。最佳做法取决于几个不同因素。为视图指定 ID 将产生更简短、更高效的绑定表达式,因为附带属性会带来极小的开销。 然而,如果您计划在其他视图中复用该委托,最好使用附加属性,以避免将委托与特定视图绑定。例如,使用附加属性后,委托中的width 赋值将变为:

width: ListView.view.width - ListView.view.leftMargin - ListView.view.rightMargin

在第 2 章中,我们在标题下方添加了一个 `ListView `。如果你运行该章节的应用程序,会发现视图的内容可以滚动到标题上方:

这种效果并不理想,特别是当委托中的文本足够长,以至于覆盖了标题栏中的文本时。我们理想中的方案是在标题栏文本下方、视图上方设置一块纯色块。这样可以确保列表视图的内容不会在视觉上干扰标题栏的内容。 请注意,也可以通过将视图的clip 属性设置为true 来实现这一点,但这样做can affect performance 。

ToolBar 是完成此任务的理想工具。它既包含全局操作,也包含上下文相关的操作和控件,例如导航按钮和搜索字段。最重要的是,它的背景色一如既往地来自应用程序样式。以下是它的实际应用效果:

    header: ToolBar {
        Label {
            text: qsTr("Contacts")
            font.pixelSize: 20
            anchors.centerIn: parent
        }
    }

它本身没有布局,因此我们需要自行将标签居中显示。

其余代码与第 2 章中的完全相同,只是我们利用了clicked 信号将下一页推入堆栈视图:

            onClicked: root.StackView.view.push("ConversationPage.qml", { inConversationWith: modelData })

将Component 或url 推入StackView 时,通常需要使用某些变量来初始化(最终)实例化的项。StackView 的push()函数通过将JavaScript对象作为第二个参数来处理这一点。 我们利用这一点向下一页提供联系人的姓名,该页面随后会利用该姓名显示相关的对话记录。请注意root.StackView.view.push 的语法;由于附加属性的工作原理,这种写法是必要的。

让我们逐步分析ConversationPage.qml ,首先从导入部分开始:

import QtQuick
import QtQuick.Layouts
import QtQuick.Controls

这些与之前相同,只是新增了QtQuick.Layouts 的导入,我们稍后会详细说明。

Page {
    id: root

    property string inConversationWith

    header: ToolBar {
        ToolButton {
            text: qsTr("Back")
            anchors.left: parent.left
            anchors.leftMargin: 10
            anchors.verticalCenter: parent.verticalCenter
            onClicked: root.StackView.view.pop()
        }

        Label {
            id: pageTitle
            text: root.inConversationWith
            font.pixelSize: 20
            anchors.centerIn: parent
        }
    }
    ...

该组件的根项是另一个 Page,它拥有一个名为inConversationWith 的自定义属性。目前,该属性仅用于确定页眉中的标签显示内容。稍后,我们将在填充对话中消息列表的 SQL 查询中使用它。

为了让用户能够返回“联系人”页面,我们添加了一个ToolButton ,点击时会调用pop()。ToolButton 在功能上与Button 类似,但其外观更适合在ToolBar 中使用。

在 QML 中,有两种方式可以布局项目:项目定位器与 Qt Quick Layouts。项目定位器(Row 、Column 等)适用于项目大小已知或固定,且仅需将其整齐地排列成特定布局的情况。Qt Quick Layouts 中的布局既能定位项目也能调整其大小,因此非常适合可调整大小的用户界面。下面,我们将使用ColumnLayout 来垂直排列一个ListView 和一个Pane :

    ColumnLayout {
        anchors.fill: parent

        ListView {
            id: listView
            Layout.fillWidth: true
            Layout.fillHeight: true
            ...

        }
        ...

        Pane {
            id: pane
            Layout.fillWidth: true
            ...
    }

Pane 基本上是一个矩形,其颜色来自应用程序的样式。它与Frame 类似,唯一的区别在于其边框没有描边。

作为布局的直接子元素,各项可使用多种attached properties 。 我们在ListView 上使用Layout.fillWidth 和Layout.fillHeight ,以确保它在ColumnLayout 中占据尽可能大的空间。对于Pane也采用同样的处理方式。由于ColumnLayout 是垂直布局,每个子项的左右两侧都没有其他项,因此每个项将占据布局的整个宽度。

另一方面,ListView 中的Layout.fillHeight 语句将使该容器能够占据在容纳 Pane 之后剩余的空间。

让我们详细看看这个列表视图:

        ListView {
            id: listView
            Layout.fillWidth: true
            Layout.fillHeight: true
            Layout.margins: pane.leftPadding + messageField.leftPadding
            displayMarginBeginning: 40
            displayMarginEnd: 40
            verticalLayoutDirection: ListView.BottomToTop
            spacing: 12
            model: 10
            delegate: Row {
                id: messageDelegate
                anchors.right: sentByMe ? listView.contentItem.right : undefined
                spacing: 6

                required property int index
                readonly property bool sentByMe: index % 2 == 0

                Rectangle {
                    id: avatar
                    width: height
                    height: parent.height
                    color: "grey"
                    visible: !messageDelegate.sentByMe
                }

                Rectangle {
                    width: 80
                    height: 40
                    color: messageDelegate.sentByMe ? "lightgrey" : "steelblue"

                    Label {
                        anchors.centerIn: parent
                        text: messageDelegate.index
                        color: messageDelegate.sentByMe ? "black" : "white"
                    }
                }
            }

            ScrollBar.vertical: ScrollBar {}
        }

在填充其父视图的宽度和高度后,我们还为该视图设置了一些边距。这样就能与“撰写消息”字段中的占位符文本完美对齐:

列表视图的边距与下方的撰写消息框对齐

接下来,我们设置了displayMarginBeginning 和displayMarginEnd 。这些属性可确保在视图边缘滚动时,位于视图边界之外的委托不会消失。最直观的理解方式是将这些属性注释掉,然后观察滚动视图时会发生什么。

然后,我们将视图的垂直方向翻转,使第一个项目位于底部。委托对象之间的间距为 12 像素,并为测试目的分配了一个“虚拟”模型,直到我们在第 4 章中实现真正的模型为止。

在委托内部,我们声明一个Row 作为根项,因为我们希望头像后面紧跟着消息内容,如上图所示。

用户发送的消息应与联系人发送的消息区分开来。目前,我们设置了一个名为sentByMe 的占位属性,它仅通过委托的索引在不同的发件人之间切换。利用该属性,我们通过以下三种方式区分不同的发件人:

  • 通过将anchors.right 设置为listView.contentItem.right ,将用户发送的消息对齐到屏幕右侧。
  • 通过根据 `sentByMe` 设置头像(目前仅是一个矩形)的 `visible ` 属性,我们仅在消息由联系人发送时才显示该头像。
  • 我们会根据发件人身份改变矩形的颜色。由于我们不希望在深色背景上显示深色文字,反之亦然,因此我们还会根据发件人身份设置文字颜色。在第 5 章中,我们将看到样式(Styling)如何为我们处理此类问题。

在屏幕底部,我们放置了一个TextArea 控件以支持多行文本输入,并添加了一个发送消息的按钮。我们使用Pane来覆盖这两个控件下方的区域,这与我们使用ToolBar 来防止列表视图的内容干扰页面页眉的方式相同:

        Pane {
            id: pane
            Layout.fillWidth: true
            Layout.fillHeight: false

            RowLayout {
                width: parent.width

                TextArea {
                    id: messageField
                    Layout.fillWidth: true
                    placeholderText: qsTr("Compose message")
                    wrapMode: TextArea.Wrap
                }

                Button {
                    id: sendButton
                    text: qsTr("Send")
                    enabled: messageField.length > 0
                    Layout.fillWidth: false
                }
            }
        }

TextArea 应填满屏幕的可用宽度。我们设置一些占位文本,为用户提供视觉提示,使其知道应从何处开始输入。输入区域内的文本会自动换行,以确保其不会超出屏幕范围。

最后,只有当确实有消息需要发送时,该按钮才会启用。

第 4 章:模型

在第 4 章中,我们将带您逐步了解如何使用 C++ 创建只读和可读写两种 SQL 模型,并将其暴露给 QML 以填充视图。

QSqlQueryModel

为了简化本教程,我们选择将用户联系人列表设为不可编辑。QSqlQueryModel是实现此目的的理想选择,因为它为 SQL 结果集提供了一个只读数据模型。

让我们来看看继承自QSqlQueryModel 的SqlContactModel 类:

#include <QQmlEngine>
#include <QSqlQueryModel>

class SqlContactModel : public QSqlQueryModel
{
    Q_OBJECT
    QML_ELEMENT

public:
    SqlContactModel(QObject *parent = nullptr);
};

这里的内容并不多,因此让我们继续查看.cpp 文件:

#include "sqlcontactmodel.h"

#include <QDebug>
#include <QSqlError>
#include <QSqlQuery>

static voidcreateTable()
{
    if(QSqlDatabase::database().tables().contains(QStringLiteral("Contacts"))) {
        // 表已存在;无需执行任何操作。
        return;
    }

    QSqlQuery query;
   if(!query.exec(
        "CREATE TABLE IF NOT EXISTS 'Contacts' ("
        "   'name' TEXT NOT NULL,"
        "   PRIMARY KEY(name)"
        ")")) {
        qFatal("Failed to query database: %s", qPrintable(query.lastError().text()));
    }

    query.exec("INSERT INTO Contacts VALUES('Albert Einstein')");
    query.exec("INSERT INTO Contacts VALUES('Ernest Hemingway')");
    query.exec("INSERT INTO Contacts VALUES('Hans Gude')");
}

我们导入了本类的头文件以及从 Qt 所需的其他头文件。随后,我们定义了一个名为createTable() 的静态函数,用于创建 SQL 表(如果该表尚不存在),并向其中插入一些示例联系人。

对database() 的调用可能看起来有些令人困惑,因为我们尚未设置特定的数据库。如果未向该函数传递连接名称,它将返回一个“默认连接”,关于其创建方法,我们稍后会详细介绍。

SqlContactModel::SqlContactModel(QObject*parent) :
    QSqlQueryModel(parent)
{
    createTable();

    QSqlQuery query;
    if(!query.exec("SELECT * FROM Contacts"))
        qFatal("Contacts SELECT query failed: %s", qPrintable(query.lastError().text()));

    setQuery(std::move(query));
    if(lastError().isValid())
        qFatal("Cannot set query on SqlContactModel: %s", qPrintable(lastError().text()));
}

在构造函数中,我们调用了createTable() 。随后,我们构建了一个用于填充模型的查询。在此情况下,我们仅关注Contacts 表中的所有行。

QSqlTableModel

SqlConversationModel 则更为复杂:

#include <QQmlEngine>
#include <QSqlTableModel>

class SqlConversationModel : public QSqlTableModel
{
    Q_OBJECT
    QML_ELEMENT
    Q_PROPERTY(QString recipient READ recipient WRITE setRecipient NOTIFY recipientChanged)

public:
    SqlConversationModel(QObject *parent = nullptr);

    QString recipient() const;
    void setRecipient(const QString &recipient);

    QVariant data(const QModelIndex &index, int role) const override;
    QHash<int, QByteArray> roleNames() const override;

    Q_INVOKABLE void sendMessage(const QString &recipient, const QString &message);

signals:
    void recipientChanged();

private:
    QString m_recipient;
};

我们同时使用了Q_PROPERTY 和Q_INVOKABLE 宏,因此必须通过使用Q_OBJECT 宏来通知moc。

recipient 属性将由 QML 设置,以便让模型知道应为哪次对话检索消息。

我们重写了data() 和roleNames() 函数,以便在 QML 中使用自定义角色。

我们还定义了sendMessage() 函数,以便从QML中调用它,因此使用了Q_INVOKABLE 宏。

让我们来看看.cpp 文件:

#include "sqlconversationmodel.h"

#include <QDateTime>
#include <QDebug>
#include <QSqlError>
#include <QSqlRecord>
#include <QSqlQuery>

static const char *conversationsTableName = "Conversations";

static voidcreateTable()
{
    if(QSqlDatabase::database().tables().contains(conversationsTableName)) {
        // 表已存在;无需执行任何操作。
        return;
    }

    QSqlQuery query;
    if(!query.exec(
        "CREATE TABLE IF NOT EXISTS 'Conversations' ("
        "'author' TEXT NOT NULL,"
        "'recipient' TEXT NOT NULL,"
       "'timestamp' TEXT NOT NULL,"
        "'message' TEXT NOT NULL,"
        "FOREIGN KEY('author') REFERENCES Contacts ( name ),"
       "FOREIGN KEY('recipient') REFERENCES Contacts ( name )"
        ")")) {
        qFatal("Failed to query database: %s", qPrintable(query.lastError().text()));
    }

    query.exec("INSERT INTO Conversations VALUES('我', '欧内斯特·海明威', '2016-01-07T14:36:06', '你好!')");
    query.exec("INSERT INTO Conversations VALUES('欧内斯特·海明威', '我', '2016-01-07T14:36:16', '下午好。')");
    query.exec("INSERT INTO Conversations VALUES('Me', '阿尔伯特·爱因斯坦', '2016-01-01T11:24:53', '嗨!')");
    query.exec("INSERT INTO Conversations VALUES('阿尔伯特·爱因斯坦', '我', '2016-01-07T14:36:16', '早上好。')");
    query.exec("INSERT INTO Conversations VALUES('汉斯·古德', '我', '2015-11-20T06:30:02', '早上好。你收到我的画了吗?')");
    query.exec("INSERT INTO Conversations VALUES('Me', 'Hans Gude', '2015-11-20T08:21:03', '早上好,汉斯。是的,这幅画非常漂亮。 非常感谢!"
               "你花了多少小时画这幅画?')");
}

这与sqlcontactmodel.cpp 非常相似,唯一的区别在于我们现在操作的是Conversations 表。此外,我们还将conversationsTableName 定义为静态常量变量,因为在整个文件中有多处会用到它。

SqlConversationModel::SqlConversationModel(QObject *parent) :
    QSqlTableModel(parent)
{
    createTable();
    setTable(conversationsTableName);
    setSort(2, Qt::DescendingOrder);
    // Ensures that the model is sorted correctly after submitting a new row.
    setEditStrategy(QSqlTableModel::OnManualSubmit);
}

与SqlContactModel 一样,我们在构造函数中首先要做的是创建表。 我们通过setTable() 函数向QSqlTableModel 指定将要使用的表名。为了确保对话中的最新消息优先显示,我们按timestamp 字段对查询结果进行降序排序。这与将ListView 的verticalLayoutDirection 属性设置为ListView.BottomToTop 密切相关(我们在第 3 章中已介绍过)。

QString SqlConversationModel::recipient() const
{
    return m_recipient;
}

void SqlConversationModel::setRecipient(const QString &recipient)
{
    if (recipient == m_recipient)
        return;

    m_recipient = recipient;

    const QString filterString = QString::fromLatin1(
        "(recipient = '%1' AND author = 'Me') OR (recipient = 'Me' AND author='%1')").arg(m_recipient);
    setFilter(filterString);
    select();

    emit recipientChanged();
}

在setRecipient() 中,我们对数据库返回的结果设置了过滤条件。

QVariant SqlConversationModel::data(const QModelIndex &index, int role) const
{
    if (role < Qt::UserRole)
        return QSqlTableModel::data(index, role);

    const QSqlRecord sqlRecord = record(index.row());
    return sqlRecord.value(role - Qt::UserRole);
}

如果角色不是自定义用户角色,data() 函数会回退到QSqlTableModel 的实现。如果角色是用户角色,我们可以从该角色中减去Qt::UserRole 来获取该字段的索引,然后利用该索引查找需要返回的值。

QHash<int, QByteArray> SqlConversationModel::roleNames() const
{
    QHash<int, QByteArray> names;
    names[Qt::UserRole] = "author";
    names[Qt::UserRole + 1] = "recipient";
    names[Qt::UserRole + 2] = "timestamp";
    names[Qt::UserRole + 3] = "message";
    return names;
}

在roleNames() 中,我们返回一个将自定义角色值映射到角色名称的映射表。这使我们能够在 QML 中使用这些角色。声明一个枚举类型来存储所有角色值可能会很有用,但由于我们在该函数之外的代码中并未引用任何特定值,因此我们就不做此操作了。

voidSqlConversationModel::sendMessage(constQString&recipient, constQString&message)
{
    constQString timestamp=QDateTime::currentDateTime().toString(Qt::ISODate);

    QSqlRecord newRecord=record();
    newRecord.setValue("author", "Me");
    newRecord.setValue("recipient",recipient);
    newRecord.setValue("timestamp",timestamp);
    newRecord.setValue("message",message);
   if(!insertRecord(rowCount(),newRecord)) {
        qWarning() << "Failed to send message:" << lastError().text();
       return;
    }

sendMessage() 函数使用给定的recipient 和message 将一条新记录插入数据库。由于我们使用了QSqlTableModel::OnManualSubmit ,因此必须手动调用submitAll()。

使用 QML 连接数据库并注册类型

既然我们已经定义了模型类,接下来让我们看看main.cpp :

#include <QtCore>
#include <QGuiApplication>
#include <QSqlDatabase>
#include <QSqlError>
#include <QtQml>

static voidconnectToDatabase()
{
    QSqlDatabase database=QSqlDatabase::database();
    if(!database.isValid()) {
        database=QSqlDatabase::addDatabase("QSQLITE");
        if(!database.isValid())
            qFatal("Cannot add database: %s", qPrintable(database.lastError().text()));
    }

    constQDir writeDir=QStandardPaths::writableLocation(QStandardPaths::AppDataLocation);
    if(!writeDir.mkpath("."))
        qFatal("Failed to create writable directory at %s", qPrintable(writeDir.absolutePath()));

   // 确保在所有设备上都有可写入的位置。
    constQString fileName=writeDir.absolutePath()+ "/chat-database.sqlite3";
   // 使用 SQLite 驱动程序时,如果 SQLite 数据库不存在,open() 方法会自动创建该数据库。
    database.setDatabaseName(fileName);
    if(!database.open()) {
        qFatal("Cannot open database: %s", qPrintable(database.lastError().text()));
        QFile::remove(fileName);
    }
}

intmain(intargc, char *argv[])
{
    QGuiApplication app(argc,argv);

    connectToDatabase();

    QQmlApplicationEngine engine;
    engine.loadFromModule("chattutorial", "Main");
    if(engine.rootObjects().isEmpty())
        return-1;

    returnapp.exec();
}

connectToDatabase() 建立与 SQLite 数据库的连接,如果数据库文件不存在,则创建该文件。

在main() 中,我们调用qmlRegisterType()将模型作为类型注册到QML中。

在 QML 中使用模型

现在,这些模型已作为 QML 类型可用,我们需要对 `ContactPage.qml` 进行一些微小的修改。为了能够使用这些类型,我们必须首先使用在 `main.cpp` 中设置的 URI 来导入它们:

import chattutorial

然后,我们将占位模型替换为正确的模型:

        model: SqlContactModel {}

在委托内部,我们使用不同的语法来访问模型数据:

            text: model.display

在ConversationPage.qml 中,我们添加了相同的chattutorial 导入语句,并替换了占位模型:

            model: SqlConversationModel {
                recipient: root.inConversationWith
            }

在模型中,我们将recipient 属性设置为当前页面所显示的联系人的姓名。

根委托项从“行”(Row)更改为“列”(Column),以便在每条消息下方显示我们想要显示的时间戳:

            delegate: Column {
                id: conversationDelegate
                anchors.right: sentByMe ? listView.contentItem.right : undefined
                spacing: 6

                required property string author
                required property string recipient
                required property date timestamp
                required property string message
                readonly property bool sentByMe: recipient !== "Me"

                Row {
                    id: messageRow
                    spacing: 6
                    anchors.right: conversationDelegate.sentByMe ? parent.right : undefined

                    Image {
                        id: avatar
                        source: !conversationDelegate.sentByMe
                            ? "images/" + conversationDelegate.author.replace(" ", "_") + ".png" : ""
                    }

                    Rectangle {
                        width: Math.min(messageText.implicitWidth + 24,
                            listView.width - (!conversationDelegate.sentByMe ? avatar.width + messageRow.spacing : 0))
                        height: messageText.implicitHeight + 24
                        color: conversationDelegate.sentByMe ? "lightgrey" : "steelblue"

                        Label {
                            id: messageText
                            text: conversationDelegate.message
                            color: conversationDelegate.sentByMe ? "black" : "white"
                            anchors.fill: parent
                            anchors.margins: 12
                            wrapMode: Label.Wrap
                        }
                    }
                }

                Label {
                    id: timestampText
                    text: Qt.formatDateTime(conversationDelegate.timestamp, "d MMM hh:mm")
                    color: "lightgrey"
                    anchors.right: conversationDelegate.sentByMe ? parent.right : undefined
                }
            }

聊天消息在消息正文下方显示时间戳

现在我们已经有了一个合适的模型,就可以在sentByMe 属性的表达式中使用其recipient 角色了。

原先用于头像的 Rectangle 已被转换为 Image。该图像具有自己的隐式尺寸,因此我们无需显式指定。与之前一样,我们仅在作者不是当前用户时才显示头像,不同之处在于,这次我们将图像的 `source ` 属性设置为空 URL,而非使用 `visible ` 属性。

我们希望每条消息的背景比其文本略宽(每侧宽 12 像素)。 但是,如果消息过长,我们希望将其宽度限制在列表视图的边缘,因此使用了Math.min() 。当消息并非由我们发送时,其前面总会显示一个头像,因此我们通过减去头像的宽度和行间距来对此进行调整。

例如,在上图中,消息文本的隐式宽度是较小的那个值。但在下图中,消息文本相当长,因此选择了较小的值(即视图的宽度),以确保文本在屏幕的另一侧边缘停止:

将长消息内容折行,使其适应视图宽度

为了显示前面提到的每条消息的时间戳,我们使用了一个 Label 控件。日期和时间通过 `Qt.formatDateTime()` 并采用自定义格式进行格式化。

现在,“发送”按钮必须能够响应点击操作:

                Button {
                    id: sendButton
                    text: qsTr("Send")
                    enabled: messageField.length > 0
                    Layout.fillWidth: false
                    onClicked: {
                        listView.model.sendMessage(root.inConversationWith, messageField.text)
                        messageField.text = ""
                    }
                }

首先,我们调用模型的可调用函数sendMessage() ,该函数会向 Conversations 数据库表中插入一行新数据。然后,我们清空文本框,为后续输入腾出空间。

第 5 章:样式设置

Qt Quick Controls 中的样式设计旨在适用于任何平台。在本章中,我们将进行一些细微的视觉调整,以确保应用程序在使用“基本”、“Material”和“通用”样式运行时都能呈现良好的视觉效果。

到目前为止,我们仅使用“Basic”样式对应用程序进行了测试。例如,如果我们使用“Material”样式运行它,就会立即发现一些问题。以下是“联系人”页面:

采用“Material”风格设计的“联系人”页面存在文本对比度不足的问题

页眉文本为黑色,背景为深蓝色,导致文字非常难以辨认。对话页面也出现了同样的问题:

采用 Material 风格的对话页面,文本对比度较低

解决方法是告知工具栏应使用“深色”主题,这样该信息就会传递给其子控件,使它们能够将文本颜色切换为更浅的颜色。实现此目的最简单的方法是直接导入 Material 样式,并使用 Material 附加属性:

import QtQuick.Controls.Material 2.12

// ...

header: ToolBar {
    Material.theme: Material.Dark

    // ...
}

然而,这会带来对 Material 样式的硬依赖;即使目标设备并不使用该样式,也必须将Material 样式插件与应用程序一同部署,否则 QML 引擎将无法找到该导入。

相反,最好依赖于Qt Quick Controls 对基于样式的文件选择器的内置支持。为此,我们必须将ToolBar 移出并单独放在一个文件中。 我们将它命名为ChatToolBar.qml 。这将是该文件的“默认”版本,这意味着当使用Basic 样式(即未指定样式时使用的样式)时,将调用此文件。新文件内容如下:

import QtQuick.Controls

ToolBar {
}

由于我们仅在此文件中使用ToolBar 类型,因此只需导入Qt Quick Controls 。代码本身与ContactPage.qml 中的内容并无变化,这正是应有的状态;对于文件的默认版本,无需进行任何修改。

回到ContactPage.qml 文件中,我们将代码更新为使用新类型:

    header: ChatToolBar {
        Label {
            text: qsTr("Contacts")
            font.pixelSize: 20
            anchors.centerIn: parent
        }
    }

现在我们需要添加工具栏的 Material 版本。文件选择器期望文件的变体位于与文件默认版本并列的、命名恰当的目录中。这意味着我们需要在 ChatToolBar.qml 所在的同一目录(即根目录)中添加一个名为“+Material”的文件夹:QFileSelector 要求使用“+”前缀,以此确保不会意外触发选择功能。

以下是+Material/ChatToolBar.qml 的代码:

import QtQuick.Controls
import QtQuick.Controls.Material

ToolBar {
    Material.theme: Material.Dark
}

我们将对ConversationPage.qml 进行相同的修改:

    header: ChatToolBar {
        ToolButton {
            text: qsTr("Back")
            anchors.left: parent.left
            anchors.leftMargin: 10
            anchors.verticalCenter: parent.verticalCenter
            onClicked: root.StackView.view.pop()
        }

        Label {
            id: pageTitle
            text: root.inConversationWith
            font.pixelSize: 20
            anchors.centerIn: parent
        }
    }

现在这两个页面都显示正常了:

采用“Material”风格且已修正页头对比度的“联系我们”页面

采用 Material 风格且已修正页眉对比度的对话页面

让我们试用一下“通用”样式:

采用通用样式的“联系我们”页面

采用通用样式的对话页面

这里没有问题。对于像这样相对简单的应用程序,切换样式时通常只需进行极少的调整。

现在让我们试用每种样式的深色主题。Basic样式没有深色主题,因为这会给本就设计为追求最高性能的样式带来轻微的性能开销。我们将先测试Material样式,因此请在qtquickcontrols2.conf 中添加一条条目,指定使用其深色主题:

[Material]
Primary=Indigo
Accent=Indigo
Theme=Dark

完成此操作后,构建并运行应用程序。您应该会看到如下界面:

采用 Material 风格且启用深色主题的“联系我们”页面

采用 Material 风格的对话页面(深色主题)

两个页面看起来都很正常。现在为“通用”样式添加一条配置项:

[universal]
Theme=Dark

构建并运行应用程序后,你应该会看到以下结果:

采用通用样式的深色主题“联系”页面

采用通用样式且启用深色主题的对话页面

总结

在本教程中,我们带您完成了使用Qt Quick Controls 编写一个基本应用程序的以下步骤:

  • 使用Qt Creator 创建新项目。
  • 设置一个基本的ApplicationWindow 。
  • 使用 Page 定义页眉和页脚。
  • 在ListView 中显示内容。
  • 将组件重构到各自的文件中。
  • 使用StackView 在屏幕之间导航。
  • 使用布局使应用程序能够平滑地调整大小。
  • 实现自定义只读和可写模型,将 SQL 数据库集成到应用程序中。
  • 通过Q_PROPERTY 、Q_INVOKABLE 和qmlRegisterType() 将 C++ 与 QML 集成。
  • 测试和配置多种样式。

© 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.