De QtWidgets à QtQuick, la transition d’une application, partie 3 – Une approche pour mener à bien la transition de l’architecture logicielle

Voici la troisième partie de notre série consacrée à la migration d’une base de code legacy de QtWidgets vers QtQuick. Dans les articles précédents, nous avons comparé les architectures logicielles typiques utilisées par les applications Qt et déterminé que nous souhaitions nous orienter vers le modèle MVP. Nous avons également abordé la manière de sécuriser une application legacy avant un changement d’architecture.

Maintenant que notre application est sécurisée, il est temps de se lancer dans la transition de l’architecture logicielle à proprement parlé. Il s’agit d’un processus en plusieurs étapes que nous allons vous présenter. Il sera illustré à l’aide de l’exemple d’application que nous avons présenté et sécurisé à l’aide de tests dans l’article précédent.

Cet article est déjà apparu en anglais sur le Qt blog.

Introduire les QAbstractItemModel manquants

Une surprise possible dans notre projet d’exemple est l’utilisation de QTableWidget et QComboBox sans item model séparé. On pourrait supposer que personne ne fait plus cela de nos jours, mais dans la pratique, on trouve des endroits dans certaines applications où nous avons décidé de faire des concessions pour gagner du temps. Même si ce n’est pas une situation très répandue, c’est quelque chose que nous devons savoir gérer.

Pour une transition réussie, nous vous conseillons de commencer par vous attaquer à ces item models manquants. Dans le cas de notre exemple d’application, cela signifie supprimer les méthodes updateComboBox() et updateTable() de notre classe Window.

Cela peut être aussi simple qu’un adaptateur léger au-dessus de notre classe Repository, comme ceci (commit 18d01e3) :

class ItemTableModel : public QAbstractTableModel {
Q_OBJECT
public:
explicit ItemTableModel(QObject *parent = nullptr);
void reloadData();
int rowCount(const QModelIndex &parent = QModelIndex()) const override;
int columnCount(const QModelIndex &parent = QModelIndex()) const override;
QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override;
QVariant headerData(int section, Qt::Orientation orientation, int role = Qt::DisplayRole) const override;
QHash<int, QByteArray> roleNames() const override;
private:
Repository *m_repository;
};

En termes d’implémentation, la plupart des méthodes sont triviales, mais nous allons nous concentrer sur certaines d’entre elles afin de faciliter la compréhension.

Tout d’abord, la méthode data() qui montre comment nous encapsulons les données provenant de Repository :

QVariant ItemTableModel::data(const QModelIndex &index, int role) const {
if (!index.isValid() || index.parent().isValid()) {
return {};
}
if (role != Qt::DisplayRole && role != Qt::UserRole) {
return {};
}
const auto item = m_repository->findAllItems().value(index.row());
if (role == Qt::UserRole) {
return item.id();
}
switch (index.column()) {
case 0:
return item.name;
case 1:
return item.sellIn;
case 2:
return item.quality;
default:
return {};
}
Q_UNREACHABLE();
}

L’implémentation proposée n’est pas particulièrement efficace, mais cela vise principalement à simplifier le code (on souhaiterait disposer d’une mise en cache et/ou de fonctions de requête plus riches sur le Repository). Cela dit, elle montre assez bien comment nous mappons simplement chaque item à une ligne et chaque champ à une colonne. Nous utilisons également le UserRole pour l’id() des items. Ce n’est pas strictement nécessaire pour l’instant, mais cela sera important plus tard.

Comme nous utilisons UserRole, il est préférable de lui donner un nom pour faciliter l’introspection :

QHash<int, QByteArray> ItemTableModel::roleNames() const {
auto result = QAbstractItemModel::roleNames();
result.insert(Qt::UserRole, "user");
return result;
}

Enfin, nous devons couvrir l’implémentation de reloadData() :

void ItemTableModel::reloadData() {
const auto topLeft = index(0, 0);
const auto bottomRight = index(rowCount() - 1, columnCount() - 1);
emit dataChanged(topLeft, bottomRight);
}

Cette méthode demande simplement aux vues de relire l’ensemble des données. Encore une fois, il s’agit d’une implémentation très naïve visant à simplifier les choses. Dans un système réel, nous aurions probablement besoin d’une implémentation plus fine.

Maintenant que nous disposons d’un item model utilisable et encapsulant notre Repository, il est temps de l’utiliser dans Window (commit ce4c3f2). Concrètement, cela signifie trois choses :

  • introduire une variable membre m_itemModel dans Window pour référencer le modèle,
  • passer d’un QTableWidget à un QTableView dans la vue,
  • faire en sorte que les widgets utilisent le ItemModel.

De ce fait, le constructeur de notre Window change légèrement :

Window::Window(QWidget *parent)
: QWidget(parent)
, ui(new Ui::Window)
, m_repository(Repository::instance())
, m_itemModel(new ItemTableModel(this)) // the new model field
{
ui->setupUi(this);
ui->currentItemCombo->setModel(m_itemModel); // using the model in the combo box
ui->tableView->setModel(m_itemModel); // now a table view
connect(ui->currentItemCombo, &QComboBox::currentIndexChanged,
this, &Window::onCurrentItemChanged);
connect(ui->qualitySpinBox, &QSpinBox::valueChanged,
this, &Window::onQualityChanged);
connect(ui->sellInSpinBox, &QSpinBox::valueChanged,
this, &Window::onSellInChanged);
connect(ui->updateButton, &QPushButton::clicked,
this, &Window::onUpdateCurrentItemQuality);
m_itemModel->reloadData(); // Needs to be done everywhere we were calling updateTable() before
onCurrentItemChanged();
ui->tableView->horizontalHeader()->resizeSections(QHeaderView::ResizeToContents);
}

Au-delà du constructeur, nous devons supprimer les appels à updateComboBox() et updateTable() partout dans la classe et les remplacer par m_itemModel->reloadData(). Ainsi, le passage à notre sous-classe de QAbstractItemModel est terminé.

Wrapper les objets et les règles métier dans des proxys

Il est maintenant temps de commencer à introduire nos proxys. Nous en aurons besoin de deux dans notre exemple d’application : un pour l’objet Item et un autre que nous utiliserons pour recevoir le reste de la logique métier cachée dans Window.

Le proxy d’objet métier

Le proxy le plus simple à mettre en place est celui qui sert à encapsuler les objets métier, ici simplement Item. Comme nous l’avons vu dans la première partie de cette série, il doit hériter de QObject et, dans notre cas, refléter toutes les informations disponibles sur un Item en s’assurant qu’elles sont exposées en tant que Q_PROPERTY bindables (commit 8050846).

L’interface de la classe se présente donc comme suit :

class ItemProxy : public QObject {
Q_OBJECT
Q_PROPERTY(quint64 itemId READ itemId WRITE setItemId NOTIFY itemIdChanged)
Q_PROPERTY(QString name READ name WRITE setName NOTIFY nameChanged)
Q_PROPERTY(int sellIn READ sellIn WRITE setSellIn NOTIFY sellInChanged)
Q_PROPERTY(int quality READ quality WRITE setQuality NOTIFY qualityChanged)
public:
explicit ItemProxy(QObject *parent = nullptr);
[[nodiscard]] Item item() const;
void reloadData();
[[nodiscard]] quint64 itemId() const;
void setItemId(quint64 id);
[[nodiscard]] QString name() const;
void setName(const QString &name);
[[nodiscard]] int sellIn() const;
void setSellIn(int sellIn);
[[nodiscard]] int quality() const;
void setQuality(int quality);
signals:
void itemIdChanged(quint64 itemId);
void nameChanged(const QString &name);
void sellInChanged(int sellIn);
void qualityChanged(int quality);
private:
Item m_item;
Repository *m_repository;
};

Les setters et getters se contentent de faire suivre à l’Item sous-jacent en notifiant des modifications si nécessaire :

QString ItemProxy::name() const {
return m_item.name;
}
void ItemProxy::setName(const QString &name) {
if (m_item.name == name) {
return;
}
m_item.name = name;
emit nameChanged(m_item.name);
}

La partie intéressante est reloadData() :

void ItemProxy::reloadData() {
const auto newItem = m_repository->findItem(itemId());
if (m_item == newItem) {
return;
}
m_item = newItem;
emit sellInChanged(m_item.sellIn);
emit qualityChanged(m_item.quality);
}

Celui-ci actualise l’Item encapsulé en interrogeant directement le Repository. Et s’il constate que l’Item a effectivement été modifié, il signale lesdites modifications.

Cela nous permettra d’apporter nos premières modifications importantes à Window (commit 2ed0152) :

  • m_currentItem est supprimé au profit d’une instance de ItemProxy appelée m_itemProxy,
  • tous les appels qui passaient auparavant par m_currentItem passent désormais par m_itemProxy,
  • updateFields() est complètement supprimé et nous connectons à la place les spin boxes directement au proxy.

Le constructeur Window se présente désormais comme suit :

Window::Window(QWidget *parent)
: QWidget(parent)
, ui(new Ui::Window)
, m_repository(Repository::instance())
, m_itemProxy(new ItemProxy(this))
, m_itemModel(new ItemTableModel(this))
{
ui->setupUi(this);
// The two following connects are new and allow to get rid of updateFields()
connect(m_itemProxy, &ItemProxy::sellInChanged,
ui->sellInSpinBox, &QSpinBox::setValue);
connect(m_itemProxy, &ItemProxy::qualityChanged,
ui->qualitySpinBox, &QSpinBox::setValue);
ui->currentItemCombo->setModel(m_itemModel);
ui->tableView->setModel(m_itemModel);
connect(ui->currentItemCombo, &QComboBox::currentIndexChanged,
this, &Window::onCurrentItemChanged);
connect(ui->qualitySpinBox, &QSpinBox::valueChanged,
this, &Window::onQualityChanged);
connect(ui->sellInSpinBox, &QSpinBox::valueChanged,
this, &Window::onSellInChanged);
connect(ui->updateButton, &QPushButton::clicked,
this, &Window::onUpdateCurrentItemQuality);
updateTable();
onCurrentItemChanged();
ui->tableView->horizontalHeader()->resizeSections(QHeaderView::ResizeToContents);
}

Il est maintenant temps de présenter un autre proxy destiné à recevoir les règles métier.

Le proxy des règles métier

À ce stade, nous avons affiché dans notre Window les valeurs de l’Item actuel (dont le concept est désormais incarné par l’ItemProxy) et les données de tous les Item disponibles dans le système (représentés par notre ItemTableModel).

Ils ont une relation claire dans le contexte de notre interface graphique, nous introduisons donc PageProxy pour les relier entre eux (commit 020620a) :

class PageProxy : public QObject {
Q_OBJECT
Q_PROPERTY(ItemProxy* currentItem READ item CONSTANT)
Q_PROPERTY(ItemTableModel* model READ model CONSTANT)
QML_ELEMENT
public:
explicit PageProxy(QObject *parent = nullptr);
[[nodiscard]] ItemProxy *item() const;
[[nodiscard]] ItemTableModel *model() const;
private slots:
void onValueChanged();
private:
ItemProxy *m_itemProxy;
ItemTableModel *m_itemModel;
Repository *m_repository;
};

L’implémentation est encore assez courte, mais certains éléments sont importants à aborder pour comprendre comment cette classe relie réellement les choses entre elles.

PageProxy::PageProxy(QObject *parent)
: QObject(parent)
, m_itemProxy(new ItemProxy(this))
, m_itemModel(new ItemTableModel(this))
, m_repository(Repository::instance())
{
connect(m_itemProxy, &ItemProxy::qualityChanged,
this, &PageProxy::onValueChanged);
connect(m_itemProxy, &ItemProxy::sellInChanged,
this, &PageProxy::onValueChanged);
}

Tout d’abord, le constructeur montre comment nous voulons réagir à tout changement signalé par ItemProxy. Voyons onValueChanged() :

void PageProxy::onValueChanged() {
m_repository->save(m_itemProxy->item());
m_itemModel->reloadData();
}

Ainsi, chaque fois qu’une valeur change dans l’élément actuel Item, cela sera signalé par ItemProxy. À son tour, PageProxy réagira en enregistrant la modification de l’élément actuel via le Repository et en demandant à ItemTableModel de recharger ses données. Il s’agit d’une règle assez simple, mais il faut bien commencer quelque part.

Même si elle est simple, elle apporte déjà des changements intéressants à Window (commit 948ec57), en particulier dans son constructeur :

Window::Window(QWidget *parent)
: QWidget(parent)
, ui(new Ui::Window)
, m_repository(Repository::instance())
, m_pageProxy(new PageProxy(this))
{
ui->setupUi(this);
// Here we folded `onCurrentItemChanged()` into a lambda
connect(ui->currentItemCombo, &QComboBox::currentIndexChanged,
[=] {
const auto id = ui->currentItemCombo->currentData().value<quint64>();
m_pageProxy->item()->setItemId(id);
});
// We can connect both ways to the ItemProxy in the PageProxy now
connect(m_pageProxy->item(), &ItemProxy::sellInChanged,
ui->sellInSpinBox, &QSpinBox::setValue);
connect(ui->sellInSpinBox, &QSpinBox::valueChanged,
m_pageProxy->item(), &ItemProxy::setSellIn);
connect(m_pageProxy->item(), &ItemProxy::qualityChanged,
ui->qualitySpinBox, &QSpinBox::setValue);
connect(ui->qualitySpinBox, &QSpinBox::valueChanged,
m_pageProxy->item(), &ItemProxy::setQuality);
// This didn't change
connect(ui->updateButton, &QPushButton::clicked,
this, &Window::onUpdateCurrentItemQuality);
// updateTable() is gone
ui->currentItemCombo->setModel(m_pageProxy->model());
ui->tableView->setModel(m_pageProxy->model());
ui->tableView->horizontalHeader()->resizeSections(QHeaderView::ResizeToContents);
}

Avec ces changements, certaines méthodes ont disparu dans Window :

  • la plupart des anciens slots ont été remplacés par les connexions que nous avons maintenant dans le constructeur (seul onUpdateCurrentItemQuality() reste),
  • updateTable() a disparu, car son rôle est désormais assuré par PageProxy

Nous avons fait un grand pas en avant pour vider Window, mais le transfert n’est pas encore terminé. Nous avons en fait simplement regroupé ItemTableModel et ItemProxy. Il nous reste encore à transférer l’essentiel de la logique métier restante vers PageProxy.

Ce qui va suivre…

Dans la prochaine et dernière partie de cette série, nous terminerons le transfert des règles métier vers nos tout nouveaux proxys. Nous verrons ensuite comment cette transition vers MVP nous aide concrètement avec notre interface graphique.

Fediverse Reactions

Publié

dans

par

En savoir plus sur enioka

Abonnez-vous pour poursuivre la lecture et avoir accès à l’ensemble des archives.

Poursuivre la lecture