Qt Widgets から Qt Quick へ:アプリケーション移行記 - 第2部

このブログは「QtWidgets to QtQuick, An Application Journey Part 2」の抄訳です。
 

安全な移行の準備

Qt WidgetsのレガシーコードベースをQt Quickへ移行する本シリーズへようこそ。前回の記事では、この種のアプリケーションで一般的に用いられるソフトウェアアーキテクチャを比較しました。Qt Widgetsベースのアプリケーションで広く採用されている「ひねりを加えたMVC」が、コード再利用の妨げとなり、Qt Quick移行の障壁となる問題点を確認できました。

出発点となるアーキテクチャ(「MVPに工夫を加えたもの」)と目指すアーキテクチャ(MVP)が明確になったところで、次は両者の移行方法について説明します。まず移行を支援するツールの有無を確認し、その後 enioka Haute Couture で大規模変更時に採用している手法を紹介します。

利用可能なツールについて

Qt WidgetsアプリケーションをQt Quickへ移植する課題は、新しいものではありません。長年にわたり、この課題の少なくとも一部を解決することを目的としたツールがいくつか登場してきました。

まず第一に、Declarative Widgetsライブラリが挙げられます。これは10年前にKDABのKevin Krammer氏によって作成され、Qt Widgets向けのQML APIを提供することを目的としています。このアプローチにより、Declarative Widgetsのサポート下で記述される新しいQt WidgetsベースのGUIは、必然的にMVPパターンに従うことになります。残念ながら、Declarative WidgetsはQt Widgets本体のライブラリに組み込まれることはなく、サードパーティ製ライブラリのままです。認知度の低さに加え、プロパティを公開するためにオブジェクト拡張に依存し、GUIで使用されるオブジェクト数を実質的に倍増させるという事実から、その普及は制限されていると考えられます。

次に、Qt 6で導入されたQt Bindable Propertiesがあります。これらはQMLを使用せずにプロパティバインディングを提供します。しかし、Qt Widgets内でこれらが使用されることはなく、当面の文脈では役立ちません。

ただし、仮にQt Widgetsに組み込まれたとしても、Declarative WidgetsとQt Bindable Propertiesのいずれも問題の一部しか解決しません。具体的には、Qt WidgetsでMVPを実装するためのより優れたツールに関する課題には対応していません。実装は容易になりますが、必須ではなく、「MVCの変形版」からMVPへの移行を促進するものではありません。

ビジネスロジックを従来のコントローラー/ウィジェットから解放し、プロキシを通じて利用可能にする必要があります。これは決して容易な作業ではなく、慎重に取り組む必要があります。

対照実験の実施

ここで、我々のアプローチを説明するために使用するアプリケーションを紹介します。これは、コードレガシーに関するenioka Haute Coutureコースで私が使用しているGilded Rose code kataに着想を得ています。検討中のソフトウェアアーキテクチャ移行の目的のために大幅に修正されていますが、中核となるロジックは変わりません。

理解を深めていただくため、全コードを「GildedRoseQtWidgetsToQtQuick」リポジトリで公開しています。コミットは分割されており、コードの進化過程を段階的に確認できます。

それでは、この小さなアプリケーションを見ていきましょう。

EniokaBlogs-gildedrose-qtwidgets

Qt Widgets ベースの「シンプルな」ウィンドウを用意し、いくつかのインタラクションパターンを表示します。下半分は QTableWidget で、システム全体のデータを表示します。これは販売可能なアイテムのデータベースを管理します。各アイテムには名前、販売期限、品質値があります。

上部ではQComboBoxでアイテムを選択し、2つのQSpinBoxで品質値と残存日数を変更できます。その下には、アイテムの値が1日の経過とともにどのように変化するかを決定するビジネスルールをトリガーするQPushButtonがあります。これらの値がどのように変化するかの詳細は本シリーズの目的上重要ではありませんが、このコードは膨大かつ複雑であり、エラーを発生させずに簡単に変更できないものと仮定してください。

それでは、コードの重要な部分(コミット 0e2cdb9)を見ていきましょう。

まず、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)

これは単純で予想通りな値クラスであり、以前に説明した値とid()メソッドを公開するだけです。この識別子は、例えばデータベース内の対応するレコードの主キーとなります。

次に、Itemインスタンスを取得する必要があるため、そのようなアイテムを取得または変更するためのRepositoryを用意します。

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

ここでも特に珍しいことではなく、システム内の全アイテムを取得したり、`id`を基に単一のアイテムを取得したりできます。もちろんsave()メソッドでItemをデータベースに書き戻すことも可能です。ここでRepositoryがシングルトンパターンに従っていると仮定している点に注意してください。これは実験を簡素化するためですが、実際のシステムでは避けることをお勧めします。

ItemRepositoryが組み合わさって、システムのモデルを形成します。ビューは、スクリーンショットで結果を確認したUIファイルによって提供されます。次は制御部分を見てみましょう。ここには、真の「ひねりを加えたMVC」スタイルで、コードの大半が配置されています。

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

コンストラクタはGUIの重要な動作設定を行います。すべての要素が操作に反応し、必要に応じてItemを更新することを保証します。対応するスロットの詳細は以下の通りです。

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

予想通り、これらはItemから値を取得してウィジェットに適用するか、ウィジェットから値を取得してItem に適用します。また、Itemqualityを更新するビジネスロジックもここに存在します。

最後の2つのスロットは、システム内にQAbstractItemModelが存在しないことによる副産物です。

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

QAbstractItemModelの欠如により、リポジトリからQComboBoxQTableViewへ定期的にデータをコピーせざるを得ません。現代ではあまり一般的ではない状況ですが、本シリーズでは最悪のケースを想定することにしました。

以上がQt Widgetsレガシーシステムの全体像です。これをQt Quickに移行すると仮定した場合、この困難な課題にどう取り組めばよいでしょうか?ここでは数百行のコードですが、実際のシステムでは桁違いに大規模になります。単純な書き換えは不可能です。ユーザーにとってリスクが高く、混乱を招くためです。書き換えの失敗確率は非常に高いと言えます。

責任ある対応

万能薬となるツールが存在しない以上、アーキテクチャ移行には古き良き規律と厳密な手法に頼らざるを得ません。特に、進捗に伴いアプリケーションを破壊しないよう確実にしなければなりません。

したがって我々の目標は、大規模なアーキテクチャ変更を実施しながら、回帰バグを導入しないことを確実にすることである。これには自動テストが必要だが、実環境で遭遇するコードベースの大半は、そのための適切なテストセットを備えていません。基本的に、全機能セットをカバーするエンドツーエンドテストが必要となります。これを迅速に作成するには非常にコストがかかりがちです。

とはいえ、QtWidgets が提供する内省機能、Approval Testsdoctestによって救われる可能性があります。これにより、可能な限り低コストで優れたテストスイートを構築できます。

これにより、以下のようなテストが実現します(doctest、Approval Tests、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);
}

 

このテストでは各項目を順に処理し、コンボボックスで選択しながらGUIと基盤データの整合性を全箇所で確認します。さらにボタンのクリックをシミュレートし、約30日間にわたる品質の推移を再現します。これによりビジネスルールが値をどのように変化させるかを把握できます。最終段階では承認テストが、生成した状態と過去のスナップショットを比較し、変更があった場合にテストを失敗させます。

なぜこれが最良の解決策と考えるか、またその到達過程の詳細については深く掘り下げません。本シリーズをさらに長引かせないためです。ただ、そこに至る方法論が存在し、偶然ではないことをご理解ください。

このようなテストにより、行数と機能の両面で優れたカバレッジを迅速に得られます。実際のプロジェクトでは仕様書に戻ってテストを生成するのに数か月あるいは数年かかる一方、上記のようなピンポイントテストはごく短時間(おそらく数日~数週間)で作成可能です。

今後の展開

システムの基盤が整ったことで、従来の「ひねりを加えたMVC」アーキテクチャからMVPアーキテクチャへ、レガシーなQt Widgetsアプリケーションを責任を持って移行できます。このシリーズの次回記事では、この移行について探っていきます。



Blog Topics:

Comments