De QtWidgets à QtQuick, la transition d’une application, partie 2 – Préparer une transition sereine

Bienvenue dans la deuxième partie de cette série consacrée à la migration d’une base de code legacy QtWidgets vers QtQuick. Dans le précédent épisode, nous avons comparé les architectures logicielles typiques utilisées par ce type d’applications. Nous avons pu constater que le modèle « MVC avec un twist » généralement utilisé dans les applications basées sur QtWidgets posait un problème pour la réutilisation du code et constituait donc un facteur limitant pour la migration vers QtQuick.

Maintenant que nous avons une vision plus claire de notre architecture de départ (« MVP avec un twist ») et de celle que nous visons (MVP), il est temps d’aborder la manière de passer de l’une à l’autre. Nous allons d’abord vérifier s’il existe des outils pour nous aider, puis je vous présenterai l’approche que nous utilisons chez enioka Haute Couture pour des changements à grande échelle de ce type.

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

À propos des outils disponibles

La question du portage des applications QtWidgets vers QtQuick n’est pas nouvelle. Au fil des ans, plusieurs outils ont donc vu le jour, visant au moins en partie à accomplir cette tâche.

Tout d’abord, nous avons la bibliothèque Declarative Widgets. Elle a été créée il y a dix ans par Kevin Krammer de KDAB. Elle vise à fournir une API QML pour QtWidgets. Cette approche inciterait naturellement les nouvelles interfaces graphiques basées sur QtWidgets à suivre le modèle MVP lorsqu’elles sont écrites à l’aide de Declarative Widgets.

Malheureusement, Declarative Widgets n’a jamais été intégré à QtWidgets et reste une bibliothèque tierce. Cela a probablement limité son adoption en raison d’un manque de notoriété et peut-être aussi parce qu’il repose sur des extensions d’objets pour exposer les propriétés, ce qui double le nombre d’objets utilisés par une interface graphique lorsqu’il est utilisé.

Deuxièmement, nous avons les Qt Bindable Properties qui ont été introduites dans Qt6. Elles fournissent des property bindings, mais sans avoir besoin de passer par QML pour obtenir cette fonctionnalité. Malheureusement, rien dans QtWidgets ne les utilise, ce qui ne nous aide pas vraiment dans notre contexte.

Cela dit, même s’ils avaient été intégrés à QtWidgets, les Declarative Widgets et les Qt Bindable Properties ne résoudraient qu’une partie du problème. À savoir celui de disposer d’outils plus adaptés pour implémenter le MVP avec QtWidgets. Même s’ils facilitent les choses, ils ne sont pas obligatoires et ne facilitent pas la transition entre le « MVC avec un twist » et le MVP.

En effet, vous devez toujours libérer la logique métier de l’ancien contrôleur/widget et la rendre disponible via un proxy. Ce n’est pas une mince affaire et cela doit être abordé de manière réfléchie.

Notre expérience contrôlée

Il est à présent temps d’introduire la petite application que nous allons utiliser pour illustrer notre approche. Elle s’inspire du code kata Gilded Rose que j’utilise dans les formations enioka Haute Couture sur le code legacy. Elle a été quelque peu modifiée pour illustrer la transition d’architecture qui nous concerne dans cette série, mais la logique de base reste la même.

Afin que vous puissiez la suivre, nous avons mis tout le code à disposition dans son propre dépôt « GildedRoseQtWidgetsToQtQuick ». Les commits ont été découpés pour faciliter l’inspection des différentes étapes de l’évolution du code.

Voyons maintenant à quoi ressemble notre petite application :

Nous avons une fenêtre « simple » basée sur QtWidgets, mais elle présente plusieurs types d’interaction. La moitié inférieure est un QTableWidget affichant l’ensemble des données du système. Il gère une base de données d’articles pouvant être vendus. Les articles ont un nom, une date de vente (nombre de jours restants) et une valeur de qualité.

Dans la moitié supérieure, un élément peut être sélectionné via une QComboBox et deux QSpinBox peuvent être utilisées pour modifier les valeurs de qualité et de jours restants. Sous celles-ci, nous avons un QPushButton permettant de déclencher les règles métier qui déterminent l’évolution des valeurs d’un élément au cours d’une journée. Les détails précis de la façon dont ces valeurs évoluent n’ont pas beaucoup d’importance dans le cadre de cette série, mais supposons que ce code soit volumineux, complexe et difficile à modifier sans régressions.

Jetons maintenant un coup d’œil aux parties importantes de ce code (commit 0e2cdb9).

Tout d’abord, voici l’interface d’Item :

class Item {
public:
Item();
Item(quint64 id, const QString &name, int sellIn, int quality);
[[nodiscard]] quint64 id() const;
[[nodiscard]] bool isValid() const;
bool operator==(const Item &other) const;
QString name;
int sellIn;
int quality;
private:
quint64 m_id;
};
Q_DECLARE_METATYPE(Item)

Il s’agit d’une classe de valeurs simple et classique, qui expose simplement les valeurs dont nous avons parlé précédemment et un identifiant id(). Cet identifiant correspondrait par exemple à la clé primaire de l’enregistrement correspondant dans une base de données.

Ensuite, nous devons obtenir des instances d’Item. Nous disposons donc d’un Repository pour récupérer ces items ou les modifier :

class Repository {
private:
Repository();
public:
static Repository *instance();
[[nodiscard]] QList<Item> findAllItems() const;
[[nodiscard]] Item findItem(quint64 id) const;
void save(const Item &item);
private:
// Details omitted for brevity
};

Encore une fois, rien de surprenant ici, nous pouvons récupérer tous les items du système, ou un seul item en fonction de son id. Bien sûr, nous pouvons également enregistrer un Item dans la base de données à l’aide de la méthode save(). Notez que nous supposons ici que le Repository suit le modèle Singleton, afin de simplifier un peu les choses pour notre expérience, mais je déconseille cette approche dans un système réel.

Ensemble, Item et Repository forment le modèle de notre système. La vue est fournie par un fichier ui dont nous avons vu le résultat dans la capture d’écran. Il est temps de se pencher sur la partie contrôle, où nous trouverons la plupart du code dans un véritable style « MVC avec un twist ».

Window::Window(QWidget *parent)
: QWidget(parent)
, ui(new Ui::Window)
, m_repository(Repository::instance())
{
ui->setupUi(this);
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);
updateComboBox();
updateTable();
onCurrentItemChanged();
ui->tableWidget->horizontalHeader()->resizeSections(QHeaderView::ResizeToContents);
}

Le constructeur met en place les comportements importants pour l’interface graphique. Il s’assure que tout réagit aux interactions et met à jour les éléments Items lorsque c’est nécessaire. Voici les détails des slots correspondants :

void Window::onCurrentItemChanged() {
const auto id = ui->currentItemCombo->currentData().value<quint64>();
m_currentItem = m_repository->findItem(id);
updateFields();
}
void Window::onQualityChanged() {
if (m_currentItem.quality == ui->qualitySpinBox->value()) {
return;
}
m_currentItem.quality = ui->qualitySpinBox->value();
m_repository->save(m_currentItem);
updateTable();
}
void Window::onSellInChanged() {
if (m_currentItem.sellIn == ui->sellInSpinBox->value()) {
return;
}
m_currentItem.sellIn = ui->sellInSpinBox->value();
m_repository->save(m_currentItem);
updateTable();
}
void Window::onUpdateCurrentItemQuality() {
updateQuality(m_currentItem);
}
void Window::updateQuality(Item &item) {
// Long and complex logic to update item quality field and decrease sellIn by one
// Followed by:
m_repository->save(item);
updateFields();
updateTable();
}
void Window::updateFields() {
ui->qualitySpinBox->setValue(m_currentItem.quality);
ui->sellInSpinBox->setValue(m_currentItem.sellIn);
}

Comme on peut s’y attendre, ils prennent soit les valeurs d’un Item pour les appliquer aux widgets, soit les valeurs des widgets pour les appliquer à un Item. C’est également là que se trouve la logique métier permettant de mettre à jour la qualité des Item.

Les deux derniers slots sont une conséquence de l’absence de QAbstractItemModel dans notre système :

void Window::updateComboBox() {
const auto items = m_repository->findAllItems();
ui->currentItemCombo->clear();
foreach (const Item &item, items) {
ui->currentItemCombo->addItem(item.name, item.id());
}
}
void Window::updateTable() {
const auto items = m_repository->findAllItems();
ui->tableWidget->clear();
ui->tableWidget->setRowCount(items.size());
ui->tableWidget->setColumnCount(3);
ui->tableWidget->setHorizontalHeaderLabels(QStringList() << "Name" << "Sell In" << "Quality");
int row = 0;
foreach (const Item &item, items) {
ui->tableWidget->setItem(row, 0, new QTableWidgetItem(item.name));
ui->tableWidget->setItem(row, 1, new QTableWidgetItem(QString::number(item.sellIn)));
ui->tableWidget->setItem(row, 2, new QTableWidgetItem(QString::number(item.quality)));
row++;
}
}

L’absence de QAbstractItemModel nous oblige à copier régulièrement les données du repository vers QComboBox et QTableView. Nous espérons que cette situation n’est plus très courante aujourd’hui, mais nous avons décidé d’envisager le pire cas de figure dans cette série.

Tout cela constitue notre système legacy basé sur QtWidgets. En supposant que nous voulions le migrer vers QtQuick, comment aborder cette tâche délicate ? Nous avons ici quelques centaines de lignes de code, mais dans un système réel, ce serait deux ou trois ordres de grandeur supérieurs, nous ne pouvons pas simplement réécrire. Ce serait trop risqué et nuisible pour nos utilisateurs. Les chances d’échec d’une telle réécriture sont très élevées.

La démarche responsable

Comme nous ne disposons pas d’outil miracle, nous devons nous appuyer sur la bonne vieille discipline et une méthode rigoureuse pour mener à bien notre transition architecturale. Nous devons notamment veiller à ne pas endommager l’application au fur et à mesure de notre progression.

Notre objectif est donc de nous assurer que nous n’introduirons pas de régressions, tout en mettant en œuvre un changement d’architecture. Cela nécessite des tests automatisés, mais la plupart des bases de code que nous rencontrons dans la pratique ne disposent pas de l’ensemble de tests approprié pour cela. Vous devez essentiellement disposer de tests de bout en bout qui couvrent l’ensemble des fonctionnalités. Cela peut s’avérer très coûteux à produire rapidement.

Cela dit, nous pouvons être sauvés par les fonctionnalités d’introspection fournies par QtWidgets, Approval Tests et doctest. De cette façon, nous pouvons produire une bonne suite de tests de la manière la moins coûteuse possible.

Cela donnerait un test qui ressemblerait à ceci (en omettant les détails d’intégration entre doctest, Approval Tests et Qt, commit 6fe1647) :

QAbstractItemModel *findModel(QWidget *window) {
QTableView *view = window->findChild<QTableView*>();
Q_ASSERT(view);
QAbstractItemModel *model = view->model();
Q_ASSERT(model);
return model;
}
TEST_CASE("PinTest") {
Window window;
QAbstractItemModel *model = findModel(&window);
QComboBox *itemCombo = window.findChild<QComboBox*>();
QSpinBox *qualitySpin = window.findChild<QSpinBox*>("qualitySpinBox");
QSpinBox *sellInSpin = window.findChild<QSpinBox*>("sellInSpinBox");
auto state = QList<QList<Item>>();
for (int day = 0; day < 30; day++) {
const auto items = Repository::instance()->findAllItems();
for (int row = 0; row < model->rowCount(); row++) {
const auto itemValues = QVariantList{
items.at(row).name,
items.at(row).sellIn,
items.at(row).quality
};
itemCombo->setCurrentIndex(row);
const auto widgetValues = QVariantList{
itemCombo->currentText(),
sellInSpin->value(),
qualitySpin->value()
};
for (int col = 0; col < model->columnCount(); col++) {
REQUIRE((widgetValues.at(col) == itemValues.at(col)));
QModelIndex index = model->index(row, col);
REQUIRE((index.data() == itemValues.at(col)));
}
REQUIRE(QMetaObject::invokeMethod(&window, "onUpdateCurrentItemQuality"));
}
state << items;
}
ApprovalTests::Approvals::verifyAll(state);
}

Ce test parcourt chacun des éléments, les sélectionne dans la liste déroulante et vérifie que l’interface graphique et les données sous-jacentes sont toujours alignées. Il clique également sur le bouton afin de simuler l’évolution de la qualité sur près de 30 jours. De cette façon, nous avons une bonne idée de la manière dont les règles métier font évoluer les valeurs. À la fin, les approval tests comparent l’état que nous avons produit avec un snapshot précédent et échouent si quelque chose a changé.

Je ne vais pas m’étendre sur les raisons pour lesquelles nous considérons qu’il s’agit de la meilleure solution et sur la manière dont nous y sommes parvenus. Je souhaite éviter d’allonger encore davantage cette série. Sachez simplement qu’il existe une méthode pour y parvenir et que ce n’est pas le fruit du hasard.

Avec un tel test, nous obtenons rapidement une excellente couverture, tant en termes de lignes que de fonctionnalités. Dans le cadre d’un projet réel, alors qu’il faudrait des mois, voire des années, pour produire des tests en repartant des spécifications, quelques pin tests comme celui ci-dessus peuvent être réalisés en une fraction du temps (probablement en quelques jours ou semaines).

Ce qui va suivre…

Maintenant que nous avons sécurisé le système, nous pouvons faire passer de manière réfléchie notre application QtWidgets existante de l’architecture traditionnelle « MVC avec un twist » à une architecture MVP. C’est ce que nous verrons dans le prochain article de cette série.

Fediverse Reactions

Publié

dans

par

Étiquettes :

Commentaires

4 responses to “De QtWidgets à QtQuick, la transition d’une application, partie 2 – Préparer une transition sereine”

En savoir plus sur enioka

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

Poursuivre la lecture