在第 5 章、数据中,我们创建了一个框架来捕获和保存内存中的数据。然而,这只是故事的一半,因为如果不将数据保存到某个外部目的地,一旦我们关闭应用,数据就会丢失。在本章中,我们将在之前工作的基础上,将数据保存到 SQLite 数据库中的磁盘上,以便它可以在应用的生命周期之外继续运行。保存后,我们还将构建查找、编辑和删除数据的方法。为了在我们的各种数据模型中免费获得所有这些操作,我们将扩展我们的数据实体,以便它们可以自动加载并保存到我们的数据库中,而无需我们在每个类中编写样板代码。我们将涵盖以下主题:
- SQLite
- 主键
- 创建客户端
- 寻找客户
- 编辑客户端
- 删除客户端
近年来,随着 NoSQL 和 Graph 数据库的爆炸式增长,通用数据库技术已经支离破碎。然而,在许多应用中,SQL 数据库仍然是非常合适的选择。Qt 内置了对几种 SQL 数据库驱动程序类型的支持,并且可以通过自定义驱动程序进行扩展。MySQL 和 PostgreSQL 都是非常流行的开源 SQL 数据库引擎,默认情况下都支持,但它们是打算在服务器上使用的,需要管理,这使得它们对于我们的目的来说有点不必要的复杂。相反,我们将使用轻量级得多的 SQLite,它通常用作客户端数据库,并且由于占地面积小而在移动应用中非常受欢迎。
根据 https://www.sqlite.org 官方网站“SQLite 是一个独立的、高可靠性的、嵌入式的、全功能的、公共领域的 SQL 数据库引擎。SQLite 是世界上使用最多的数据库引擎”。与 Qt 的 SQL 相关类结合在一起,创建一个数据库并存储您的数据非常容易。
我们需要做的第一件事是将 SQL 模块添加到我们的库项目中,以访问 Qt 的所有 SQL 优点。在cm-lib.pro
中,增加以下内容:
QT += sql
接下来,我们将利用上一章中讨论的内容,在接口后面实现我们的数据库相关功能。在cm-lib/source/controllers
中创建新的i-database-controller.h
头文件:
#ifndef IDATABASECONTROLLER_H
#define IDATABASECONTROLLER_H
#include <QJsonArray>
#include <QJsonObject>
#include <QList>
#include <QObject>
#include <QString>
#include <cm-lib_global.h>
namespace cm {
namespace controllers {
class CMLIBSHARED_EXPORT IDatabaseController : public QObject
{
Q_OBJECT
public:
IDatabaseController(QObject* parent) : QObject(parent){}
virtual ~IDatabaseController(){}
virtual bool createRow(const QString& tableName, const QString& id,
const QJsonObject& jsonObject) const = 0;
virtual bool deleteRow(const QString& tableName, const QString& id)
const = 0;
virtual QJsonArray find(const QString& tableName, const QString&
searchText) const = 0;
virtual QJsonObject readRow(const QString& tableName, const
QString& id) const = 0;
virtual bool updateRow(const QString& tableName, const QString& id,
const QJsonObject& jsonObject) const = 0;
};
}}
#endif
在这里,我们实现了(创建、读取、更新、删除 ) CRUD 的四个基本功能,这些功能一般都与持久存储相关,而不仅仅是 SQL 数据库。我们用一个额外的find()
方法来补充这些函数,我们将使用该方法根据提供的搜索文本来查找匹配客户端的数组。
现在,让我们创建一个接口的具体实现。在cm-lib/source/controllers
中创建新的DatabaseController
类。
database-controller.h
:
#ifndef DATABASECONTROLLER_H
#define DATABASECONTROLLER_H
#include <QObject>
#include <QScopedPointer>
#include <controllers/i-database-controller.h>
#include <cm-lib_global.h>
namespace cm {
namespace controllers {
class CMLIBSHARED_EXPORT DatabaseController : public IDatabaseController
{
Q_OBJECT
public:
explicit DatabaseController(QObject* parent = nullptr);
~DatabaseController();
bool createRow(const QString& tableName, const QString& id, const
QJsonObject& jsonObject) const override;
bool deleteRow(const QString& tableName, const QString& id) const
override;
QJsonArray find(const QString& tableName, const QString&
searchText) const override;
QJsonObject readRow(const QString& tableName, const QString& id)
const override;
bool updateRow(const QString& tableName, const QString& id, const
QJsonObject& jsonObject) const override;
private:
class Implementation;
QScopedPointer<Implementation> implementation;
};
}}
#endif
现在,让我们浏览一下database-controller.cpp
中的每个关键实现细节:
class DatabaseController::Implementation
{
public:
Implementation(DatabaseController* _databaseController)
: databaseController(_databaseController)
{
if (initialise()) {
qDebug() << "Database created using Sqlite version: " +
sqliteVersion();
if (createTables()) {
qDebug() << "Database tables created";
} else {
qDebug() << "ERROR: Unable to create database tables";
}
} else {
qDebug() << "ERROR: Unable to open database";
}
}
DatabaseController* databaseController{nullptr};
QSqlDatabase database;
private:
bool initialise()
{
database = QSqlDatabase::addDatabase("QSQLITE", "cm");
database.setDatabaseName( "cm.sqlite" );
return database.open();
}
bool createTables()
{
return createJsonTable( "client" );
}
bool createJsonTable(const QString& tableName) const
{
QSqlQuery query(database);
QString sqlStatement = "CREATE TABLE IF NOT EXISTS " +
tableName + " (id text primary key, json text not null)";
if (!query.prepare(sqlStatement)) return false;
return query.exec();
}
QString sqliteVersion() const
{
QSqlQuery query(database);
query.exec("SELECT sqlite_version()");
if (query.next()) return query.value(0).toString();
return QString::number(-1);
}
};
从私有实现开始,我们将初始化分成了两个操作:initialise()
用名为cm.sqlite
的文件实例化了一个到 SQLite 数据库的连接,如果数据库文件还不存在,这个操作将首先为我们创建它。该文件将被创建在与应用可执行文件createTables()
相同的文件夹中,然后创建数据库中不存在的任何我们需要的表。最初,我们只需要一个名为 client 的表,但这可以在以后轻松扩展。我们将创建命名表的实际工作委托给createJsonTable()
方法,这样我们就可以在多个表中重用它。
传统的规范化关系数据库方法是将我们的每个数据模型保存在它们自己的表中,其中的字段与类的属性相匹配。回想一下第五章数据中的车型图,如下:
我们可以创建一个包含“引用”和“名称”字段的客户端表,一个包含“类型”、“地址”和其他字段的联系人表。然而,我们将利用已经实现的 JSON 序列化代码,实现一个伪文档样式的数据库。我们将使用单个客户机表,该表将存储客户机的唯一标识以及序列化为 JSON 的整个客户机对象层次结构。
最后,我们还添加了一个sqliteVersion()
实用程序方法来识别数据库使用的是哪个版本的 SQLite:
bool DatabaseController::createRow(const QString& tableName, const QString& id, const QJsonObject& jsonObject) const
{
if (tableName.isEmpty()) return false;
if (id.isEmpty()) return false;
if (jsonObject.isEmpty()) return false;
QSqlQuery query(implementation->database);
QString sqlStatement = "INSERT OR REPLACE INTO " + tableName + "
(id, json) VALUES (:id, :json)";
if (!query.prepare(sqlStatement)) return false;
query.bindValue(":id", QVariant(id));
query.bindValue(":json",
QVariant(QJsonDocument(jsonObject).toJson(QJsonDocument::Compact)));
if(!query.exec()) return false;
return query.numRowsAffected() > 0;
}
bool DatabaseController::deleteRow(const QString& tableName, const QString& id) const
{
if (tableName.isEmpty()) return false;
if (id.isEmpty()) return false;
QSqlQuery query(implementation->database);
QString sqlStatement = "DELETE FROM " + tableName + " WHERE
id=:id";
if (!query.prepare(sqlStatement)) return false;
query.bindValue(":id", QVariant(id));
if(!query.exec()) return false;
return query.numRowsAffected() > 0;
}
QJsonObject DatabaseController::readRow(const QString& tableName, const QString& id) const
{
if (tableName.isEmpty()) return {};
if (id.isEmpty()) return {};
QSqlQuery query(implementation->database);
QString sqlStatement = "SELECT json FROM " + tableName + " WHERE
id=:id";
if (!query.prepare(sqlStatement)) return {};
query.bindValue(":id", QVariant(id));
if (!query.exec()) return {};
if (!query.first()) return {};
auto json = query.value(0).toByteArray();
auto jsonDocument = QJsonDocument::fromJson(json);
if (!jsonDocument.isObject()) return {};
return jsonDocument.object();
}
bool DatabaseController::updateRow(const QString& tableName, const QString& id, const QJsonObject& jsonObject) const
{
if (tableName.isEmpty()) return false;
if (id.isEmpty()) return false;
if (jsonObject.isEmpty()) return false;
QSqlQuery query(implementation->database);
QString sqlStatement = "UPDATE " + tableName + " SET json=:json
WHERE id=:id";
if (!query.prepare(sqlStatement)) return false;
query.bindValue(":id", QVariant(id));
query.bindValue(":json",
QVariant(QJsonDocument(jsonObject).toJson(QJsonDocument::Compact)));
if(!query.exec()) return false;
return query.numRowsAffected() > 0;
}
CRUD 操作都是围绕QSqlQuery
类进行的,并准备sqlStatements
。在所有情况下,我们首先对参数进行一些敷衍的检查,以确保我们没有试图做一些愚蠢的事情。然后,我们将表名连接成一个 SQL 字符串,用:myParameter
语法表示参数。准备好语句后,随后在查询对象上使用bindValue()
方法替换参数。
在创建、删除或更新行时,我们只需返回一个查询执行的true
/ false
成功指示符。假设查询准备并执行无误,我们检查受操作影响的行数是否大于0
。读取操作返回一个 JSON 对象,该对象从存储在匹配记录中的 JSON 文本中解析而来。如果没有找到记录或者无法解析 JSON,那么我们返回一个默认的 JSON 对象:
QJsonArray DatabaseController::find(const QString& tableName, const QString& searchText) const
{
if (tableName.isEmpty()) return {};
if (searchText.isEmpty()) return {};
QSqlQuery query(implementation->database);
QString sqlStatement = "SELECT json FROM " + tableName + " where
lower(json) like :searchText";
if (!query.prepare(sqlStatement)) return {};
query.bindValue(":searchText", QVariant("%" + searchText.toLower()
+ "%"));
if (!query.exec()) return {};
QJsonArray returnValue;
while ( query.next() ) {
auto json = query.value(0).toByteArray();
auto jsonDocument = QJsonDocument::fromJson(json);
if (jsonDocument.isObject()) {
returnValue.append(jsonDocument.object());
}
}
return returnValue;
}
最后,find()
方法本质上和 CRUD 操作一样,但是编译一个 JSON 对象数组,因为可能有多个匹配。请注意,我们在 SQL 语句中使用like
关键字,结合%
通配符,来查找包含搜索文本的任何 JSON。我们还将比较的两边都转换为小写,以使搜索有效地不区分大小写。
这些操作中的大部分操作都需要一个标识参数作为我们表中的主键。为了使用这个新的数据库控制器来支持我们的实体的持久性,我们需要向我们的Entity
类添加一个属性,该属性唯一地标识该实体的一个实例。
在entity.cpp
中,给Entity::Implementation
增加一个成员变量:
QString id;
然后,在构造函数中初始化它:
Implementation(Entity* _entity, IDatabaseController* _databaseController, const QString& _key)
: entity(_entity)
, databaseController(_databaseController)
, key(_key)
, id(QUuid::createUuid().toString())
{
}
当我们实例化一个新的Entity
时,我们需要生成一个新的唯一 ID,我们使用 QUuid 类用createUuid()
方法为我们实现这个。通用唯一标识符 ( UUID ) 本质上是一个随机生成的数字,然后我们将其转换为“{ xxxxxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxx }”格式的字符串,其中“x”是十六进制数字。你需要#include <QUuid>
。
接下来,为它提供一个公共访问器方法:
const QString& Entity::id() const
{
return implementation->id;
}
现在的挑战是,如果我们正在创建一个已经有一个 ID 的Entity
(例如,从数据库加载一个客户端),我们需要一些机制来用已知的值覆盖生成的 ID 值。我们将通过update()
方法来实现:
void Entity::update(const QJsonObject& jsonObject)
{
if (jsonObject.contains("id")) {
implementation->id = jsonObject.value("id").toString();
}
…
}
同样,当我们将对象序列化为 JSON 时,我们也需要包含标识:
QJsonObject Entity::toJson() const
{
QJsonObject returnValue;
returnValue.insert("id", implementation->id);
…
}
太好了。这为我们的所有数据模型提供了自动生成的唯一标识,我们可以将其用作数据库表中的主键。但是,数据库表的一个常见用例是,实际上有一个非常适合用作主键的现有字段,例如,国家保险或社会保险号、帐户参考或站点标识。让我们添加一种机制来指定一个数据装饰器,作为将覆盖默认 UUID 的标识(如果设置的话)。
在我们的Entity
类中,在Implementation
中添加一个新的私有成员:
class Entity::Implementation
{
...
StringDecorator* primaryKey{nullptr};
...
}
您将需要#include``StringDecorator
标题。添加受保护的 mutator 方法来设置它:
void Entity::setPrimaryKey(StringDecorator* primaryKey)
{
implementation->primaryKey = primaryKey;
}
然后,如果合适,我们可以调整我们的id()
方法,返回主键值,否则默认为生成的 UUID 值:
const QString& Entity::id() const
{
if(implementation->primaryKey != nullptr && !implementation->primaryKey->value().isEmpty()) {
return implementation->primaryKey->value();
}
return implementation->id;
}
然后,在client.cpp
构造函数中,在我们实例化了所有数据装饰器之后,我们可以指定我们想要使用引用字段作为我们的主键:
Client::Client(QObject* parent)
: Entity(parent, "client")
{
...
setPrimaryKey(reference);
}
让我们添加几个测试来验证这个行为。我们将验证如果设置了一个参考值,id()
方法返回该值,否则它返回一个生成的松散的“{ xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxx }”格式的 UUID。
在cm-tests
项目的client-tests.h
中,在私有槽范围内添加两个新的测试:
void id_givenPrimaryKeyWithNoValue_returnsUuid();
void id_givenPrimaryKeyWithValue_returnsPrimaryKey();
然后,执行client-tests.cpp
中的测试:
void ClientTests::id_givenPrimaryKeyWithNoValue_returnsUuid()
{
Client testClient(this);
// Using individual character checks
QCOMPARE(testClient.id().left(1), QString("{"));
QCOMPARE(testClient.id().mid(9, 1), QString("-"));
QCOMPARE(testClient.id().mid(14, 1), QString("-"));
QCOMPARE(testClient.id().mid(19, 1), QString("-"));
QCOMPARE(testClient.id().mid(24, 1), QString("-"));
QCOMPARE(testClient.id().right(1), QString("}"));
// Using regular expression pattern matching
QVERIFY(QRegularExpression("\\{.{8}-(.{4})-(.{4})-(.{4})-(.
{12})\\}").match(testClient.id()).hasMatch());
}
void ClientTests::id_givenPrimaryKeyWithValue_returnsPrimaryKey()
{
Client testClient(this, QJsonDocument::fromJson(jsonByteArray).object());
QCOMPARE(testClient.reference->value(), QString("CM0001"));
QCOMPARE(testClient.id(), testClient.reference->value());
}
请注意,在第一次测试中,检查被有效地执行了两次,只是为了演示您可以采取的几种不同的方法。首先,我们使用单个字符匹配(“{”、“-”和“}”)进行检查,这相当冗长,但其他开发人员很容易阅读和理解。然后,我们使用 Qt 的正则表达式助手类再次执行检查。对于不会说正则表达式语法的正常人来说,这要短得多,但更难解析。
构建和运行测试,它们应该验证我们刚刚实现的变更。
让我们使用我们的新基础设施并连接CreateClientView
。如果你还记得,我们提供了一个保存命令,当点击时,调用CommandController
上的onCreateClientSaveExecuted()
。为了能够执行任何有用的操作,CommandController
需要序列化和保存客户端实例的可见性,并实现IDatabaseController
接口来为我们执行创建操作。
将它们注入command-controller.h
中的构造函数,包括任何必要的标题:
explicit CommandController(QObject* _parent = nullptr, IDatabaseController* databaseController = nullptr, models::Client* newClient = nullptr);
正如我们已经看到的几次,添加成员变量到Implementation
:
IDatabaseController* databaseController{nullptr};
Client* newClient{nullptr};
通过CommandController
构造函数传递给实现构造函数:
Implementation(CommandController* _commandController, IDatabaseController* _databaseController, Client* _newClient)
: commandController(_commandController)
, databaseController(_databaseController)
, newClient(_newClient)
{
...
}
CommandController::CommandController(QObject* parent, IDatabaseController* databaseController, Client* newClient)
: QObject(parent)
{
implementation.reset(new Implementation(this, databaseController, newClient));
}
现在我们可以更新onCreateClientSaveExecuted()
方法来创建我们的新客户端:
void CommandController::onCreateClientSaveExecuted()
{
qDebug() << "You executed the Save command!";
implementation->databaseController->createRow(implementation->newClient->key(), implementation->newClient->id(), implementation->newClient->toJson());
qDebug() << "New client saved.";
}
我们的客户端实例为我们提供了将信息保存到数据库所需的所有信息,数据库控制器执行数据库交互。
我们的CommandController
现在已经准备好了,但是我们实际上还没有注入数据库控制器或者新的客户端,所以前往master-controller.cpp
并添加一个DatabaseController
的实例,就像我们对CommandController
和NavigationController
所做的那样。添加私有成员、访问器方法和Q_PROPERTY
。
在Implementation
构造函数中,我们需要确保在初始化CommandController
之前,我们初始化了新的客户端和DatabaseController
,然后传递指针:
Implementation(MasterController* _masterController)
: masterController(_masterController)
{
databaseController = new DatabaseController(masterController);
navigationController = new NavigationController(masterController);
newClient = new Client(masterController);
commandController = new CommandController(masterController, databaseController, newClient);
}
构建并运行cm-ui
,您应该会在应用输出中看到来自新实例化的DatabaseController
的消息,告诉您它已经创建了数据库和表:
Database created using Sqlite version: 3.20.1
Database tables created
看看你的二进制文件所在的输出文件夹,你会看到一个新的cm.sqlite
文件。
如果您导航到创建客户端视图,输入名称,然后单击保存按钮,您将看到进一步的输出,确认新客户端已成功保存:
You executed the Save command!
New client saved
让我们看看我们的数据库内部,看看为我们做了哪些工作。有几个 SQLite 浏览应用和网络浏览器插件可用,但我倾向于使用的是在http://sqlitebrowser.org/找到的。下载并安装此软件或您为操作系统选择的任何其他客户端,并打开cm.sqlite
文件:
正如我们所要求的,您将看到我们有一个客户端表,它有两个字段:id 和 json。浏览客户机表的数据,您将看到我们新创建的记录,其名称属性是我们在用户界面上输入的:
太棒了,我们在数据库中创建了第一个客户。注意DatabaseController
初始化方法是幂等的,所以可以重新启动应用,现有的数据库不会受到影响。同样,如果手动删除cm.sqlite
文件,那么启动应用会为你创建一个新版本(没有旧数据),这是删除测试数据的一种简单方法。
让我们快速调整一下,添加客户端的reference
属性。在CreateClientView
中,复制绑定到ui_name
的StringEditorSingleLine
组件,并将新控件绑定到ui_reference
。构建、运行和创建新客户端:
我们的新客户端很乐意使用指定的客户端引用作为唯一的主键:
现在,让我们稍微充实一下我们的CreateClientView
,这样我们就可以实际保存一些有意义的数据,而不仅仅是一堆空字符串。我们仍有许多字段需要添加,因此我们将对这些字段进行一些分解,并从视觉上将数据从不同的模型中分离出来,方法是将它们封装在带有描述性标题和阴影的谨慎面板中,为我们的用户界面增添一些活力:
我们将从创建一个通用面板组件开始。在名为Panel.qml
的cm-ui/components
中创建新的 QML 文件。更新components.qrc
和qmldir
,就像我们对所有其他组件所做的那样:
import QtQuick 2.9
import assets 1.0
Item {
implicitWidth: parent.width
implicitHeight: headerBackground.height +
contentLoader.implicitHeight + (Style.sizeControlSpacing * 2)
property alias headerText: title.text
property alias contentComponent: contentLoader.sourceComponent
Rectangle {
id: shadow
width: parent.width
height: parent.height
x: Style.sizeShadowOffset
y: Style.sizeShadowOffset
color: Style.colourShadow
}
Rectangle {
id: headerBackground
anchors {
top: parent.top
left: parent.left
right: parent.right
}
height: Style.heightPanelHeader
color: Style.colourPanelHeaderBackground
Text {
id: title
text: "Set Me!"
anchors {
fill: parent
margins: Style.heightDataControls / 4
}
color: Style.colourPanelHeaderFont
font.pixelSize: Style.pixelSizePanelHeader
verticalAlignment: Qt.AlignVCenter
}
}
Rectangle {
id: contentBackground
anchors {
top: headerBackground.bottom
left: parent.left
right: parent.right
bottom: parent.bottom
}
color: Style.colourPanelBackground
Loader {
id: contentLoader
anchors {
left: parent.left
right: parent.right
top: parent.top
margins: Style.sizeControlSpacing
}
}
}
}
这是一个极其动态的组件。不像我们的其他组件,我们传入一个字符串,甚至可能是一个自定义类,这里我们传入面板的全部内容。我们使用Loader
组件来实现这一点,该组件按需加载 QML 子树。我们给sourceComponent
属性取别名,以便调用元素可以在运行时注入它们想要的内容。
由于内容的动态性质,我们不能将组件设置为固定大小,因此我们利用implicitWidth
和implicitHeight
属性,根据标题栏的大小加上动态内容的大小,告诉父元素组件想要多大。
为了渲染阴影,我们绘制了一个简单的Rectangle
,通过将它放在文件顶部附近来确保它首先被渲染。然后,我们使用x
和y
属性将它从其余元素中偏移出来,稍微上下移动它。然后,标题条和面板背景的剩余Rectangle
元素被绘制在阴影的顶部。
为了支持这里的样式,我们需要添加一组新的Style
属性:
readonly property real sizeControlSpacing: 10
readonly property color colourPanelBackground: "#ffffff"
readonly property color colourPanelBackgroundHover: "#ececec"
readonly property color colourPanelHeaderBackground: "#131313"
readonly property color colourPanelHeaderFont: "#ffffff"
readonly property color colourPanelFont: "#131313"
readonly property int pixelSizePanelHeader: 18
readonly property real heightPanelHeader: 40
readonly property real sizeShadowOffset: 5
readonly property color colourShadow: "#dedede"
接下来,让我们添加一个用于地址编辑的组件,这样我们就可以在供应和计费地址中重用它。在名为AddressEditor.qml
的cm-ui/components
中创建新的 QML 文件。如前所述更新components.qrc
和qmldir
。
我们将使用新的Panel
组件作为根元素,并添加一个Address
属性,这样我们就可以传入一个任意的数据模型来绑定:
import QtQuick 2.9
import CM 1.0
import assets 1.0
Panel {
property Address address
contentComponent:
Column {
id: column
spacing: Style.sizeControlSpacing
StringEditorSingleLine {
stringDecorator: address.ui_building
anchors {
left: parent.left
right: parent.right
}
}
StringEditorSingleLine {
stringDecorator: address.ui_street
anchors {
left: parent.left
right: parent.right
}
}
StringEditorSingleLine {
stringDecorator: address.ui_city
anchors {
left: parent.left
right: parent.right
}
}
StringEditorSingleLine {
stringDecorator: address.ui_postcode
anchors {
left: parent.left
right: parent.right
}
}
}
}
在这里,您可以看到我们新的Panel
组件在运行中的灵活性,这要归功于嵌入的Loader
元素。我们可以传入我们想要的任何 QML 内容,它将在面板中呈现。
最后,我们可以更新我们的CreateClientView
来添加我们新的重构地址组件。我们还会将客户端控件移到它们自己的面板上:
import QtQuick 2.9
import QtQuick.Controls 2.2
import CM 1.0
import assets 1.0
import components 1.0
Item {
property Client newClient: masterController.ui_newClient
Column {
spacing: Style.sizeScreenMargin
anchors {
left: parent.left
right: parent.right
top: parent.top
margins: Style.sizeScreenMargin
}
Panel {
headerText: "Client Details"
contentComponent:
Column {
spacing: Style.sizeControlSpacing
StringEditorSingleLine {
stringDecorator: newClient.ui_reference
anchors {
left: parent.left
right: parent.right
}
}
StringEditorSingleLine {
stringDecorator: newClient.ui_name
anchors {
left: parent.left
right: parent.right
}
}
}
}
AddressEditor {
address: newClient.ui_supplyAddress
headerText: "Supply Address"
}
AddressEditor {
address: newClient.ui_billingAddress
headerText: "Billing Address"
}
}
CommandBar {
commandList: masterController.ui_commandController.ui_createClientViewContextCommands
}
}
在我们构建和运行之前,我们只需要调整我们的StringEditorSingleLine
textLabel
的背景颜色,使其与它们现在显示的面板相匹配:
Rectangle {
width: Style.widthDataControls
height: Style.heightDataControls
color: Style.colourPanelBackground
Text {
id: textLabel
…
}
}
继续创建一个新的客户端并检查数据库。您现在应该看到供应和帐单地址详细信息已成功保存。现在,我们的 CRUD 中的 C 已经运行,所以让我们继续“R”。
我们刚刚成功地将第一批客户端保存到数据库中,现在让我们看看如何找到并查看这些数据。我们将在cm-lib
中的一个专用类中封装我们的搜索功能,所以继续在cm-lib/source/models
中创建一个名为ClientSearch
的新类。
client-search.h
:
#ifndef CLIENTSEARCH_H
#define CLIENTSEARCH_H
#include <QScopedPointer>
#include <cm-lib_global.h>
#include <controllers/i-database-controller.h>
#include <data/string-decorator.h>
#include <data/entity.h>
#include <data/entity-collection.h>
#include <models/client.h>
namespace cm {
namespace models {
class CMLIBSHARED_EXPORT ClientSearch : public data::Entity
{
Q_OBJECT
Q_PROPERTY( cm::data::StringDecorator* ui_searchText READ
searchText CONSTANT )
Q_PROPERTY( QQmlListProperty<cm::models::Client> ui_searchResults
READ ui_searchResults NOTIFY searchResultsChanged )
public:
ClientSearch(QObject* parent = nullptr,
controllers::IDatabaseController* databaseController = nullptr);
~ClientSearch();
data::StringDecorator* searchText();
QQmlListProperty<Client> ui_searchResults();
void search();
signals:
void searchResultsChanged();
private:
class Implementation;
QScopedPointer<Implementation> implementation;
};
}}
#endif
client-search.cpp
:
#include "client-search.h"
#include <QDebug>
using namespace cm::controllers;
using namespace cm::data;
namespace cm {
namespace models {
class ClientSearch::Implementation
{
public:
Implementation(ClientSearch* _clientSearch, IDatabaseController*
_databaseController)
: clientSearch(_clientSearch)
, databaseController(_databaseController)
{
}
ClientSearch* clientSearch{nullptr};
IDatabaseController* databaseController{nullptr};
data::StringDecorator* searchText{nullptr};
data::EntityCollection<Client>* searchResults{nullptr};
};
ClientSearch::ClientSearch(QObject* parent, IDatabaseController* databaseController)
: Entity(parent, "ClientSearch")
{
implementation.reset(new Implementation(this, databaseController));
implementation->searchText = static_cast<StringDecorator*>(addDataItem(new StringDecorator(this, "searchText", "Search Text")));
implementation->searchResults = static_cast<EntityCollection<Client>*>(addChildCollection(new EntityCollection<Client>(this, "searchResults")));
connect(implementation->searchResults, &EntityCollection<Client>::collectionChanged, this, &ClientSearch::searchResultsChanged);
}
ClientSearch::~ClientSearch()
{
}
StringDecorator* ClientSearch::searchText()
{
return implementation->searchText;
}
QQmlListProperty<Client> ClientSearch::ui_searchResults()
{
return QQmlListProperty<Client>(this, implementation->searchResults->derivedEntities());
}
void ClientSearch::search()
{
qDebug() << "Searching for " << implementation->searchText->value() << "...";
}
}}
我们需要从用户那里获取一些文本,使用这些文本搜索数据库,并将结果显示为匹配客户端的列表。我们使用StringDecorator
来容纳文本,实现search()
方法来为我们执行搜索,最后,添加一个EntitityCollection<Client>
来存储结果。这里另一个有趣的点是,当搜索结果发生变化时,我们需要向用户界面发出信号,以便它知道它需要重新绑定列表。为此,我们使用信号searchResultsChanged()
进行通知,并将该信号直接连接到EntityCollection
内置的collectionChanged()
信号。现在,每当EntityCollection
中隐藏的列表更新时,用户界面会自动收到更改通知,并根据需要重新绘制自己。
接下来,向MasterController
添加一个ClientSearch
的实例,就像我们为新的客户端模型所做的那样。添加一个名为clientSearch
的ClientSearch*
类型的私有成员变量,并在Implementation
构造函数中初始化它。记得将databaseController
依赖项传递给构造函数。现在我们传递了越来越多的依赖项,我们需要小心初始化顺序。ClientSearch
依赖于DatabaseController
,当我们在CommandController
中实现搜索命令时,就会依赖于ClientSearch
。所以确保你在ClientSearch
之前初始化DatabaseController
,并且CommandController
在两者之后。要完成对MasterController
的更改,请添加一个clientSearch()
访问器方法和一个名为ui_clientSearch
的Q_PROPERTY
。
像往常一样,我们需要在 QML 子系统中注册新类,然后才能在用户界面中使用它。在main.cpp
、#include <models/client-search.h>
中,注册新类型:
qmlRegisterType<cm::models::ClientSearch>("CM", 1, 0, "ClientSearch");
有了这些,我们就可以连线了:
import QtQuick 2.9
import assets 1.0
import CM 1.0
import components 1.0
Item {
property ClientSearch clientSearch: masterController.ui_clientSearch
Rectangle {
anchors.fill: parent
color: Style.colourBackground
Panel {
id: searchPanel
anchors {
left: parent.left
right: parent.right
top: parent.top
margins: Style.sizeScreenMargin
}
headerText: "Find Clients"
contentComponent:
StringEditorSingleLine {
stringDecorator: clientSearch.ui_searchText
anchors {
left: parent.left
right: parent.right
}
}
}
}
}
我们通过MasterController
访问ClientSearch
实例,并创建一个带有属性的快捷方式。我们还再次利用了我们的新Panel
组件,它在很少工作的情况下为我们提供了跨视图的一致外观和感觉:
下一步是为我们添加一个命令按钮,以便能够发起搜索。我们在CommandController
重新做这个。在我们进入命令之前,我们对ClientSearch
实例有一个额外的依赖,所以给构造函数添加一个参数:
CommandController::CommandController(QObject* parent, IDatabaseController* databaseController, Client* newClient, ClientSearch* clientSearch)
: QObject(parent)
{
implementation.reset(new Implementation(this, databaseController, newClient, clientSearch));
}
将参数传递给Implementation
类,并将其存储在私有成员变量中,就像我们对newClient
所做的那样。短暂跳回MasterController
并将clientSearch
实例添加到CommandController
初始化中:
commandController = new CommandController(masterController, databaseController, newClient, clientSearch);
接下来,在CommandController
中,复制并重命名我们为创建客户端视图添加的私有成员变量、访问器和Q_PROPERTY
,这样您就有了一个可供用户界面使用的ui_findClientViewContextCommands
属性。
创建一个额外的公共槽,onFindClientSearchExecuted()
,当我们点击搜索按钮时将调用它:
void CommandController::onFindClientSearchExecuted()
{
qDebug() << "You executed the Search command!";
implementation->clientSearch->search();
}
现在,我们有一个空的命令列表,用于查找视图,还有一个当我们单击按钮时要调用的委托;我们现在需要做的就是给Implementation
构造函数添加一个搜索按钮:
Command* findClientSearchCommand = new Command( commandController, QChar( 0xf002 ), "Search" );
QObject::connect( findClientSearchCommand, &Command::executed, commandController, &CommandController::onFindClientSearchExecuted );
findClientViewContextCommands.append( findClientSearchCommand );
这就是命令管道;我们现在可以很容易地给FindClientView
添加一个命令栏。将以下内容作为根项目中的最后一个元素插入:
CommandBar {
commandList: masterController.ui_commandController.ui_findClientViewContextCommands
}
输入一些搜索文本并单击按钮,您将在应用输出控制台中看到一切都按预期触发:
You executed the Search command!
Searching for "Testing"...
太好了,现在我们需要做的是获取搜索文本,查询 SQLite 数据库中的结果列表,并在屏幕上显示这些结果。幸运的是,我们已经完成了查询数据库的基础工作,因此我们可以轻松地实现它:
void ClientSearch::search()
{
qDebug() << "Searching for " << implementation->searchText->value()
<< "...";
auto resultsArray = implementation->databaseController-
>find("client", implementation->searchText->value());
implementation->searchResults->update(resultsArray);
qDebug() << "Found " << implementation->searchResults-
>baseEntities().size() << " matches";
}
在 UI 端要显示结果还有一点工作要做。我们需要绑定到ui_searchResults
属性,并为列表中的每个客户端动态显示某种 QML 子树。我们将使用一个新的 QML 组件ListView
,为我们做繁重的工作。让我们从简单的演示原理开始,然后从那里开始构建。在FindClientView
中,在面板元素后立即添加以下内容:
ListView {
id: itemsView
anchors {
top: searchPanel.bottom
left: parent.left
right: parent.right
bottom: parent.bottom
margins: Style.sizeScreenMargin
}
clip: true
model: clientSearch.ui_searchResults
delegate:
Text {
text: modelData.ui_reference.ui_label + ": " +
modelData.ui_reference.ui_value
font.pixelSize: Style.pixelSizeDataControls
color: Style.colourPanelFont
}
}
ListView
的两个关键属性如下:
model
,是您想要显示的项目列表delegate
,这是您希望如何直观地表示每个项目
在我们的例子中,我们将模型绑定到我们的ui_searchResults
上,并用一个简单的Text
元素表示每个项目,该元素显示客户端参考号。这里特别重要的是modelData
属性,它被神奇地注入到我们的委托中,并公开了基础项(在本例中是一个客户端对象)。
为您到目前为止创建的一个测试客户端构建、运行并搜索一段您知道存在于 JSON 中的文本,您会看到为每个结果显示了参考号。如果您得到一个以上的结果,但它们的布局不正确,请不要担心,因为我们无论如何都会替换该委托:
为了保持整洁,我们将编写一个新的自定义组件作为委托。在cm-ui/components
中创建SearchResultDelegate
,照常更新components.qrc
和qmldir
:
import QtQuick 2.9
import assets 1.0
import CM 1.0
Item {
property Client client
implicitWidth: parent.width
implicitHeight: Math.max(clientColumn.implicitHeight,
textAddress.implicitHeight) + (Style.heightDataControls / 2)
Rectangle {
id: background
width: parent.width
height: parent.height
color: Style.colourPanelBackground
Column {
id: clientColumn
width: parent / 2
anchors {
left: parent.left
top: parent.top
margins: Style.heightDataControls / 4
}
spacing: Style.heightDataControls / 2
Text {
id: textReference
anchors.left: parent.left
text: client.ui_reference.ui_label + ": " +
client.ui_reference.ui_value
font.pixelSize: Style.pixelSizeDataControls
color: Style.colourPanelFont
}
Text {
id: textName
anchors.left: parent.left
text: client.ui_name.ui_label + ": " +
client.ui_name.ui_value
font.pixelSize: Style.pixelSizeDataControls
color: Style.colourPanelFont
}
}
Text {
id: textAddress
anchors {
top: parent.top
right: parent.right
margins: Style.heightDataControls / 4
}
text: client.ui_supplyAddress.ui_fullAddress
font.pixelSize: Style.pixelSizeDataControls
color: Style.colourPanelFont
horizontalAlignment: Text.AlignRight
}
Rectangle {
id: borderBottom
anchors {
bottom: parent.bottom
left: parent.left
right: parent.right
}
height: 1
color: Style.colourPanelFont
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
hoverEnabled: true
onEntered: background.state = "hover"
onExited: background.state = ""
onClicked: masterController.selectClient(client)
}
states: [
State {
name: "hover"
PropertyChanges {
target: background
color: Style.colourPanelBackgroundHover
}
}
]
}
}
这里并没有什么新的东西,我们只是结合了其他组件中涉及的技术。注意MouseArea
元素会在masterController
上触发一个我们还没有实现的方法,所以如果你运行这个并在你点击其中一个客户端时得到一个错误,不要担心。
使用modelData
属性设置client
,用我们的新组件替换FindClientView
中的旧Text
委托:
ListView {
id: itemsView
...
delegate:
SearchResultDelegate {
client: modelData
}
}
现在,让我们在MasterController
上实现selectClient()
方法:
We can just emit the goEditClientView()
signal directly from the SearchResultDelegate
and bypass MasterController
entirely. This is a perfectly valid approach and is indeed simpler; however, I prefer to route all the interactions through the business logic layer, even if all the business logic does is to emit the navigation signal. This means that if you need to add any further logic later on, everything is already wired up and you don’t need to change any of the plumbing. It’s also much easier to debug C++ than QML.
在master-controller.h
中,我们需要添加我们的新方法作为公共槽,因为它将直接从 UI 中调用,这将没有常规公共方法的可见性:
public slots:
void selectClient(cm::models::Client* client);
在master-controller.cpp
中提供实现,简单调用导航协调器上的相关信号,通过客户端传递:
void MasterController::selectClient(Client* client)
{
implementation->navigationController->goEditClientView(client);
}
随着搜索和选择的到位,我们现在可以将注意力转向编辑客户端。
现有的客户端已经从数据库中找到并加载,我们需要一种机制来查看和编辑数据。让我们首先创建我们将在编辑视图中使用的上下文命令。重复我们为查找客户端视图采取的步骤,并在CommandController
中添加一个名为editClientViewContextCommands
的新命令列表,以及一个访问器方法和Q_PROPERTY
。
创建用户在编辑视图中保存更改时要调用的新槽:
void CommandController::onEditClientSaveExecuted()
{
qDebug() << "You executed the Save command!";
}
向列表中添加一个新的 save 命令,该命令在执行时调用插槽:
Command* editClientSaveCommand = new Command( commandController, QChar( 0xf0c7 ), "Save" );
QObject::connect( editClientSaveCommand, &Command::executed, commandController, &CommandController::onEditClientSaveExecuted );
editClientViewContextCommands.append( editClientSaveCommand );
我们现在有了一个可以呈现给编辑客户端视图的命令列表;然而,我们现在需要克服的一个挑战是,当我们执行这个命令时,CommandController
不知道它需要使用哪个客户端实例。我们不能像对待新客户端那样,将选定的客户端作为依赖项传递给构造函数,因为我们不知道用户会选择哪个客户端。一种选择是将编辑命令列表移出CommandController
并进入客户端模型。然后,每个客户端实例都可以向用户界面呈现自己的命令。然而,这意味着命令功能被断开,我们失去了命令控制器给我们的良好封装。它也膨胀了客户端模型的功能,它不应该关心。相反,我们将当前选择的客户端添加为CommandController
中的成员,并在用户导航到editClientView
时进行设置。在CommandController::Implementation
中,添加以下内容:
Client* selectedClient{nullptr};
添加新的公共插槽:
void CommandController::setSelectedClient(cm::models::Client* client)
{
implementation->selectedClient = client;
}
现在我们有了可用的选定客户端,我们可以继续并完成存储槽的实施。同样,我们已经在DatabaseController
和客户端类中做了大量的工作,所以这个方法非常简单:
void CommandController::onEditClientSaveExecuted()
{
qDebug() << "You executed the Save command!";
implementation->databaseController->updateRow(implementation->selectedClient->key(), implementation->selectedClient->id(), implementation->selectedClient->toJson());
qDebug() << "Updated client saved.";
}
从用户界面的角度来看,编辑一个现有的客户端本质上与创建一个新的客户端是一样的。事实上,我们甚至可以使用相同的视图,在每种情况下只传入不同的客户端对象。然而,我们将把这两个函数分开,只是复制和调整我们已经为创建一个客户端而编写的 QML。更新EditClientView
:
import QtQuick 2.9
import QtQuick.Controls 2.2
import CM 1.0
import assets 1.0
import components 1.0
Item {
property Client selectedClient
Component.onCompleted: masterController.ui_commandController.setSelectedClient(selectedClient)
Rectangle {
anchors.fill: parent
color: Style.colourBackground
}
ScrollView {
id: scrollView
anchors {
left: parent.left
right: parent.right
top: parent.top
bottom: commandBar. top
margins: Style.sizeScreenMargin
}
clip: true
Column {
spacing: Style.sizeScreenMargin
width: scrollView.width
Panel {
headerText: "Client Details"
contentComponent:
Column {
spacing: Style.sizeControlSpacing
StringEditorSingleLine {
stringDecorator:
selectedClient.ui_reference
anchors {
left: parent.left
right: parent.right
}
}
StringEditorSingleLine {
stringDecorator: selectedClient.ui_name
anchors {
left: parent.left
right: parent.right
}
}
}
}
AddressEditor {
address: selectedClient.ui_supplyAddress
headerText: "Supply Address"
}
AddressEditor {
address: selectedClient.ui_billingAddress
headerText: "Billing Address"
}
}
}
CommandBar {
id: commandBar
commandList: masterController.ui_commandController.ui_editClientViewContextCommands
}
}
我们更改客户端属性以匹配在Connections
元素中设置的selectedClient
属性MasterView
。我们使用Component.onCompleted
插槽接通CommandController
并设置当前选择的客户端。最后,我们更新CommandBar
以引用我们刚刚添加的新上下文命令列表。
构建并运行,现在您应该能够对选定的客户端进行更改,并使用“保存”按钮更新数据库。
我们 CRUD 操作的最后一部分是删除一个现有的客户端。让我们通过EditClientView
上的一个新按钮来触发它。我们将从添加当按钮被按到CommandController
时将被调用的插槽开始:
void CommandController::onEditClientDeleteExecuted()
{
qDebug() << "You executed the Delete command!";
implementation->databaseController->deleteRow(implementation->selectedClient->key(), implementation->selectedClient->id());
implementation->selectedClient = nullptr;
qDebug() << "Client deleted.";
implementation->clientSearch->search();
}
这遵循与其他插槽相同的模式,除了这次我们还清除了selectedClient
属性,因为尽管客户端实例仍然存在于应用内存中,但它已经被用户从语义上删除了。我们还会刷新搜索,以便从搜索结果中删除已删除的客户端。按照这种方法,我们已经执行了正确的数据库交互,但是对于他们刚刚要求删除的客户端,用户将留在editClientView
上。我们想要的是将用户导航回仪表板。为了做到这一点,我们需要添加NavigationController
作为我们的CommandController
类的附加依赖。复制我们为DatabaseController
依赖所做的,以便我们可以将它注入到构造函数中。记得更新MasterController
并传入导航控制器实例。
有了可用的数据库控制器实例,我们就可以将用户发送到仪表板视图:
void CommandController::onEditClientDeleteExecuted()
{
...
implementation->navigationController->goDashboardView();
}
现在我们有了可用的导航控制器,我们也可以在创建新客户端时改善体验。让我们搜索新创建的客户端 ID 并导航到结果,而不是将用户留在新的客户端视图中。如果他们希望查看或编辑,则可以轻松选择新客户端:
void CommandController::onCreateClientSaveExecuted()
{
...
implementation->clientSearch->searchText()-
>setValue(implementation->newClient->id());
implementation->clientSearch->search();
implementation->navigationController->goFindClientView();
}
删除槽完成后,我们现在可以向CommandController
中的editClientContextCommands
列表添加新的删除命令:
Command* editClientDeleteCommand = new Command( commandController, QChar( 0xf235 ), "Delete" );
QObject::connect( editClientDeleteCommand, &Command::executed, commandController, &CommandController::onEditClientDeleteExecuted );
editClientViewContextCommands.append( editClientDeleteCommand );
我们现在可以选择删除现有客户端:
如果删除客户端,您将看到该行已从数据库中删除,并且用户已成功导航回仪表板。但是,您还会看到应用输出窗口充满了类似qrc:/views/EditClientView:62: TypeError: Cannot read property 'ui_billingAddress' of null
的 QML 警告。
原因是编辑视图绑定到了一个客户端实例,该实例是搜索结果的一部分。当我们刷新搜索时,我们删除了旧的搜索结果,这意味着编辑视图现在绑定到nullptr
并且不能再访问数据。由于用于执行导航的信号/槽的异步特性,即使您在刷新搜索之前导航到仪表板,这种情况也会继续发生。修复这些警告的一种方法是对视图中的所有绑定添加 null 检查,如果主对象为 null,则绑定到本地临时对象。考虑以下示例:
StringEditorSingleLine {
property StringDecorator temporaryObject
stringDecorator: selectedClient ? selectedClient.ui_reference :
temporaryObject
anchors {
left: parent.left
right: parent.right
}
}
所以,如果selectedClient
不为空,绑定到该的ui_reference
属性,否则绑定到temporaryObject
。您甚至可以向根客户端属性添加一个间接层,并替换整个客户端对象:
property Client selectedClient
property Client localTemporaryClient
property Client clientToBindTo: selectedClient ? selectedClient : localTemporaryClient
在这里,selectedClient
会被家长设置为正常;localTemporaryClient
不会被设置,因此将在本地创建一个默认实例。clientToBindTo
将选择合适的对象使用,所有子控件都可以绑定到该对象。由于这些绑定是动态的,如果selectedClient
在加载视图后被删除(如我们的情况),那么clientToBindTo
将自动切换。
由于这只是一个示范项目,我们可以安全地忽略警告,因此我们不会在这里采取任何行动来简化事情。
在本章中,我们为客户端模型添加了数据库持久性。我们使它变得通用和灵活,这样我们可以通过简单地向我们的DatabaseController
类添加一个新表来轻松地持久化其他模型层次结构。我们涵盖了所有核心的 CRUD 操作,包括与整个 JSON 对象相匹配的自由文本搜索功能。
在第 8 章、 Web 请求中,我们将继续探讨在我们的应用之外获取数据的主题,并研究另一个极其常见的业务线应用需求,即向 Web 服务发出 HTTP 请求。