Qt Widgets to Qt Quick, An Application Journey Part 3

An Approach to Drive the Software Architecture Transition

Time for the third part in our series about moving a QtWidgets legacy code base to QtQuick. In the previous installments, we compared the typical software architectures used by Qt applications and determined we'd like to aim toward the MVP pattern, we also covered how to secure a legacy application prior to an architecture change.

Now that we secured our application, it's time to really get started on the software architecture transition. This is a multi-step process that we will present now. It will be illustrated on the example application we introduced and secured with tests in the previous post.

Introduce Missing QAbstractItemModels

One possible surprise in our example project is the use of QTableWidget and QComboBox without a separated item model. We could assume nobody does this anymore nowadays but in practice you find areas in applications where we decided to cut a corner for speed. Even though it might not be a very widespread situation, it is something we need to know how to handle.

Our advice for a good transition is to start by tackling such missing item models. In the case of our example application this means getting rid of the updateComboBox() and updateTable() methods of our Window class.

It can be as simple as a thin adapter on top of our Repository class like so (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 roleNames() const override;

private:
    Repository *m_repository;
};

 

Implementation wise, most methods are trivial but we'll focus on a couple of them to help the understanding.

First the data() method which shows how we wrap the data coming from 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();
}

 

The proposed implementation isn't especially efficient but this is mostly to keep the code simple (one would want caching and/or richer querying abilities on the Repository). That said it shows quite well how we're simply mapping each item to a row and each field to a column. We're also using the UserRole to the id() of the items. This is not strictly necessary now, but this will be important later.

Because we use UserRole, it is good form to give it a name for easier introspection:

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

 

And last, we have to cover the implementation of reloadData():

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

 

This method just notifies views to reread the whole data. Again, this is a very naive implementation to keep things simple, we likely want a finer implementation in a real system.

Now that we have a working item model wrapping our Repository, it's time to make use of it in Window (commit ce4c3f2). In practice it means three things:

  • Introduce a m_itemModel field in Window to hold the model,

  • Switch from a QTableWidget to a QTableView in the view,

  • Have the widgets use the ItemModel.

Due to this the constructor of our Window changes slightly:

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


Beyond the constructor we need to remove the updateComboBox() and updateTable() calls everywhere in the class and replace them with m_itemModel->reloadData(). And with that the switch to our QAbstractItemModel subclass is complete.

Wrap Domain Objects and Business Rules in Proxies

Now it is time to start introducing our proxies. We'll need two in our example application: one for the Item domain object, and one which we'll use to receive the remaining business logic hiding in Window.

The Domain Object Proxy

The easier proxy to setup is the one needed to wrap domain objects, in our case simply Item. As we've seen in the first part of this series, it needs to inherit from QObject and in our case will mirror all the information available on an Item making sure they are exposed as bindable Q_PROPERTY (commit 8050846).

The interface thus looks like this:

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


The setters and getters are simply forwarding to the underlying Item notifying the change if needed:

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


The interesting part is 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);
}


This one refreshes the wrapped Item by querying the Repository directly. And if it finds the Item actually changed, it notifies of said changes.

This will allow us to bring our first radical changes to Window (commit 2ed0152):

  • m_currentItem is removed in favor of an instance of ItemProxy called m_itemProxy,

  • All the calls previously going through m_currentItem goes through m_itemProxy instead,

  • updateFields() is completely removed and instead we connect the spin boxes directly to the proxy.

The Window constructor thus now look like this:

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


Now it is time to introduce another proxy aimed at receiving the business rules.

The Business Rules Proxy

At this point, we have displayed in our Window the values of the current Item (which concept is now embodied by the ItemProxy) and the data of all the Items available in the system (represented by our ItemTableModel).

They have a clear relationship in the context of our GUI, so we introduce PageProxy to tie them together (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;
};


The implementation is rather short still, but some bits are important to cover to see how this class really ties things together.

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


First the constructor shows how we want to react to any change advertised by the ItemProxy. Let's see onValueChanged():

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


So whenever a value changes in the current Item, it will be notified by ItemProxy. In turn, PageProxy will react to this by saving the current item change via the Repository and asking the ItemTableModel to reload its data. This is a rather simple rule but we have to start somewhere.

Even if simple, it already brings interesting changes to Window (commit 948ec57), most notably in its constructor:

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


With those changes some more methods are gone in Window:

  • Most of the old slots are gone replaced by the connects we have now in the constructor (only onUpdateCurrentItemQuality() remains),

  • updateTable() is gone, since its role is handled by PageProxy.

We made a big step towards emptying Window, but the move isn't complete yet. We really just grouped ItemTableModel and ItemProxy together. We still need to move the bulk of the remaining business logic over to PageProxy.

What's Coming Next…

In the next and last part of this series, we'll finish moving the business rules over to our brand new proxies. Then we will see how this transition to MVP helps us with our GUI in practice.

 

 


About enioka Haute Couture

Businesses of all kinds have become highly dependent on their IT systems. And
yet, many of them have lost control of this essential part of their assets. enioka Haute Couture specializes in mastering complex software development, be it because of the domain, the organization or the timing of the project.

enioka Haute Couture works in close collaboration with customer teams to build software tailored
to the specific needs. And if developing it, enioka goes the extra mile to ensure the customer has it under control. enioka helps customer teams adopt the right organization, technologies and tools. Finally, every time it is suitable, enioka helps to setup Free and Open Source Software projects or interact with existing ones.

With enioka Haute Couture, customers have a trusted provider who facilitates the development needs while keeping full control of their systems. No more vendor or service provider lock-in.


Blog Topics:

Comments