QtWidgets to QtQuick, An Application Journey Part 2

Preparing a Safe Transition

Welcome back to this series about moving a Qt Widgets legacy code base to Qt Quick. In the previous installment, we compared the typical software architectures used by such applications. We could see how the "MVC with a twist" generally used in Qt Widgets based applications was a problem for code reuse and so a limiting factor to move to Qt Quick.

Now that we have a clearer view of our starting point architecture ("MVP with a twist") and the one we're aiming at (MVP), it's time to cover how to move from one to the other. We'll first check if there is any tooling to help us and then I'll present the approach we use at enioka Haute Couture for such large scale changes.

About the Available Tooling

The concern of porting Qt Widgets applications to Qt Quick is not a new one. So along the years some tools appeared aiming at least in part at tackling the task.

First, we have the Declarative Widgets library. It has been created by Kevin Krammer from KDAB a decade ago. It's aiming at providing a QML API for Qt Widgets. This approach would naturally push new Qt Widgets based GUIs to follow the MVP pattern when written with the support of Declarative Widgets. Unfortunately, Declarative Widgets never made it to Qt Widgets itself and is still a third party library. This likely limited its adoption because of the lack of awareness and perhaps also because it then relies on object extensions to expose the properties effectively doubling the number of objects used by a GUI when used.

Second, we have the Qt Bindable Properties which have been introduced in Qt 6. They provide property bindings but without the need to jump into QML to get the feature. But unfortunately nothing in Qt Widgets is using them so it's not really helping in our context.

That being said, even if they made it to Qt Widgets, both Declarative Widgets and Qt Bindable Properties only solve part of the problem. The one about having nicer tools to implement MVP with Qt Widgets. Even if they make it easier, they're not required to do so and they don't help for the transition between "MVC with a twist" to MVP.

Indeed, you still need to do the work of freeing the business logic from the old controller/widget and instead make it available through a proxy. This is not a small feat and it needs to be tackled responsibly.

Our Controlled Experiment

It is now time to introduce the little application we're going to use to illustrate our approach. It is inspired by the Gilded Rose code kata which I use in enioka Haute Couture courses about code legacy. It's been quite a bit modified for the purpose of the software architecture transition we're considering but the core logic stays the same.

So that you can follow along, we made all the code available in its own "GildedRoseQtWidgetsToQtQuick" repository. The commits have been split so that you can easily inspect the various stage of evolution of the code.

Now let's see how our little application looks:

EniokaBlogs-gildedrose-qtwidgets

We have a "simple" Qt Widgets based window but it shows several interaction patterns. The bottom half is a `QTableWidget` displaying the whole data of the system. It is managing a database of items which can be sold. Items have a name, a sell in date (remaining number of days) and a quality value.

In the top half, an item can be selected via a `QComboBox` and two `QSpinBox`es can be used to change the quality and remaining days values. Under those we have a `QPushButton` allowing to trigger the business rules applying how the values of an item evolve during a day. The actual details of how those values change won't matter much for the purpose of this series but let's assume this code is large, nasty, and can't be easily changed without regressions.

Now let's have a glimpse at the important parts of the code (commit 0e2cdb9).

First the `Item` interface:


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)

 

This is a simple and unsurprising value class, it just exposes the values we discussed before and an `id()`. This identifier would be for instance the primary key of the corresponding record in a database.

Next, we need to get to `Item` instances, so we have a `Repository` to fetch such items of modify them:


class Repository {
private:
    Repository();

public:
    static Repository *instance();

    [[nodiscard]] QList findAllItems() const;
    [[nodiscard]] Item findItem(quint64 id) const;
    void save(const Item &item);

private:
    // Details omitted for brevity
};

 

Again, no big surprise here, we can fetch all the items in the system, or one item based on its `id`. Of course we can also write an `Item` back in the database with the `save()` method. Note we assume here the `Repository` is following the Singleton pattern, this is to simplify things a bit for our experiment, but I would like advise against it in a real system.

Together, `Item` and `Repository` form the model of our system. The view is provided by a UI file which we've seen the result of in the screenshot. Time to look at the control part, where we'll find most of the code in a true "MVC with a twist" fashion.


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);
}

 

The constructor does the important behavior setup for the GUI. Making sure everything reacts to interactions and update `Item`s as needed. The details of the corresponding slots follow:


void Window::onCurrentItemChanged() {
    const auto id = ui->currentItemCombo->currentData().value();
    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);
}

 

As one would expect they either take values from an `Item` to apply them to the widgets or get values from the widgets and apply them to an `Item`. It is also where we find the business logic for updating `Item`s quality.

The last two slots are a by-product of not having any `QAbstractItemModel` in our system:


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++;
    }
}

 

The lack of `QAbstractItemModel` forces us to regularly copy data from the repository to the `QComboBox` and the `QTableView`. This is hopefully not too much of a common situation nowadays, but we decided to go for the worst case scenario in this series.

Altogether, this forms our Qt Widgets legacy system. Assuming we want to move it to Qt Quick how can we approach this daunting task? Here we have a few hundred lines of code but in a real system this would be two or three order of magnitudes bigger, we can't just rewrite. This would be too risky and disruptive for our users. The chances of failure of a rewrite are very high.

The Responsible Thing to Do

Since we don't have a tool acting as a silver bullet, we'll have to rely on good old discipline and rigorous method to deal with our architectural transition. In particular, we have to ensure we're not breaking the application as we make progress.

Our goal is thus to make sure we're not going to introduce regressions, while applying a large scale architecture change. This requires automated tests but most code bases we encounter in the wild don't have the appropriate set of tests for that. You basically need to have end to end tests which cover the whole feature set. This can get very expensive to produce quickly.

That said, we can be saved by the introspection features provided by `QtWidgets`, Approval Tests and doctest. This way we can produce a good test suite in the cheapest way possible.

This would give a test which looks like this (omitting the integration details between doctest, Approval Tests and Qt, [commit 6fe1647]):


QAbstractItemModel *findModel(QWidget *window) {
    QTableView *view = window->findChild();
    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();
    QSpinBox *qualitySpin = window.findChild("qualitySpinBox");
    QSpinBox *sellInSpin = window.findChild("sellInSpinBox");

    auto state = QList>();

    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);
}

 

This test goes through each of the items, select them in the combo box and check that both the GUI and the underlying data are aligned everywhere. It also simulates a click of the button in order to simulate the evolution of quality over almost 30 days. This way we have a good idea of how the business rules get the values to evolve. At the end Approval Tests compare the state we produced with a previous snapshot and fails the test in case something changed.

I won't dive deeper into the details of why we consider this the best solution and how we got there. This is mainly for avoiding making this series even longer. Just know there is a method to get there and it's not by accident.

With such a test we quickly get an excellent coverage both in term of lines and features. On a real project, while it would take months or years to produce tests by going back to the specifications, a few pin tests like the one above can be produced in a fraction of the time (probably counted in days or weeks).

What's Coming Next…

Now that we secured the system, we can responsibly transition our legacy Qt Widgets application from the traditional "MVC with a twist" architecture to an MVP one. This is what we will explore in the next post of this series.


Blog Topics:

Comments