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_OBJECTpublic: 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_itemModeldansWindowpour référencer le modèle, - passer d’un
QTableWidgetà unQTableViewdans 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_currentItemest supprimé au profit d’une instance deItemProxyappeléem_itemProxy,- tous les appels qui passaient auparavant par
m_currentItempassent désormais parm_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_ELEMENTpublic: 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é parPageProxy
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.

