# 与 Qt 对比

在 LarkSDK 研发的过程中,我们从行业巨头 Qt 吸收了不少设计思想与技术实现,但相应的,针对两款产品不同的定位与应用环境(尤其是国产环境),LarkSDK 也作出了大量优化性的设计,包括且不限于:

  • 重新设计了窗体-组件的二级架构并明晰语义;
  • 在上述架构的基础上,设计了完备的组件绘制机制;
  • 实现了不依赖元对象编译 (moc) 机制的信号槽;
  • 采用了更现代化的组件布局方式;
  • 合理设计 API 使得调用端的语义更明晰、代码行数更少、可维护性更高。

同时,我们也在接口设计与架构上最大限度的照顾了具备 Qt 及其他 C++ 图形界面框架经验的开发者。具备相关经验的开发者只需要较少的学习成本就能很快掌握 LarkSDK 的使用。下面我们将对 LarkSDK 和 Qt Framework 进行扼要的举例对比:

TIP

注:本文涉及的 Qt 代码以 Qt5 为准。文中引用的 Qt 源码若无特殊说明均来自官网 Qt5 文档,为突出重点起见,可能会对其源码有一定的,不影响功能的代码修改。

# 更明晰的代码语义

# Qt Framework

通过 Qt 有两条路可以实现带组件库的图形界面研发:传统的 Qt Widget (opens new window) 模块以及较新的 Qt Quick (opens new window) 模块。

Qt Widget 使用较传统的接口设计,通过纯 C++ 代码搭建界面,或借助通过辅助工具,从 XML(.ui 文件)中生成 C++ 界面代码。优势是对于 C++ 程序员而言,用户代码相对更为接近更早的界面框架,比较直观,额外的学习成本较低,且产品迭代时间长,功能非常稳定。

不过正因为产品的历史脉络较长,体现在用户代码上,就会出现稍显冗余的调用、略为模糊的语义及相对绕弯的功能实现。下面是来自官方的两个教程范例:

# 例 1

// https://doc.qt.io/qt-5/qtwidgets-tutorials-widgets-childwidget-example.html

// - QWidget 需要先创建再通过 resize() 设置窗体大小,稍显冗杂
// - QWidget (QPushButton) 通过 move() 调整位置的语义,稍微不太直观
// - 次级 QWidget 需要手动调用 show(),稍显冗杂

#include <QtWidgets>

int main(int argc, char *argv[])
{
    QApplication app(argc, argv);
    QWidget window;
    window.resize(320, 240);
    window.setWindowTitle("Child widget"));
    window.show();
    QPushButton *button = new QPushButton("Press me", &window);
    button->move(100, 100);
    button->show();
    return app.exec();
}

# 例 2

// https://doc.qt.io/qt-5/qtwidgets-tutorials-widgets-windowlayout-example.html

// 要使用布局,首先需构建布局对象,再通过 QBoxLayout 的 addWidget() 接口添加子组件,这导致两个语义问题:
// 1. 布局无法一步添加到位,因为构建布局对象到 setLayout() 调用之间必须穿插 addWidget() 调用
// 2. 因为上述原因,无法先为设定布局再添加内部元素,导致容器的构建等调用和添加布局调用在代码中间隔较远,一定程度上影响代码可读性

#include <QtWidgets>

int main(int argc, char *argv[])
{
    QApplication app(argc, argv);
    QWidget window;
    QLabel *label = new QLabel("Name:");
    QLineEdit *lineEdit = new QLineEdit();
    QHBoxLayout *layout = new QHBoxLayout();
    layout->addWidget(label);
    layout->addWidget(lineEdit);
    window.setLayout(layout);
    window.setWindowTitle("Window layout");
    window.show();
    return app.exec();
}

同时由于底层设计原因, Qt Widgets 无法轻易的实现诸如背景透明、阴影混合等效果(需要借助 QSS 且有一定的性能损失)。相对而言,Qt Quick 引入了 QML 语言构建界面,不过 QML 支持直接绘制,一定程度上解决了 Qt Widget 的一些痛点。但 QML 语言本身是一门完全新设计的界面标记语言,和主流的基于 XML 的界面描述语言差别较大,要掌握 Qt Quick 需要投入较高的学习成本。本文暂不讨论 QML。

// https://doc.qt.io/qt-5/qtdoc-demos-calqlatr-example.html

// 具备完全独立语法设计的 QML 语言
// 虽然设计较为优秀,代码直观简洁,但仍需要一定的学习时间投入

Grid {
    columns: 3
    columnSpacing: 32
    rowSpacing: 16

    signal buttonPressed

    Button { text: "7" }
    Button { text: "8" }
    Button { text: "9" }
    Button { text: "4" }
    
    ...

}

# LarkSDK

LarkSDK 在设计之初就明确吸收 Qt 的优点,设计尽量靠近 Qt 的程序接口及调用方式。但在细节处理上进行了打磨,使其在代码语义上更清晰明了,相近功能的调用更为集中,在代码行数上也更为精简。例如上述例 1 可以用 LarkSDK 改写为:

// 基于 LarkSDK 构建界面

// - LWindow 在创建时可以直接指定尺寸
// - 组件 LButton (LComponent) 和窗体 LWindow 设计分离,语义更直观易懂

#include <QtWidgets>

int main(int argc, char *argv[])
{
    LWindowApplication app;
    LWindow window(320, 240);
    window.setTitle("Child widget");
    window.show();
    LButton *button = new LButton("Press me", window.rootComponent());
    button->setPosition(100, 100);
    return app.exec();
}

注意到在 LarkSDK 中,窗体 LWindow 和组件 LButton(基本组件类 LComponent 的派生类)的语义是完全分离的,这意味着用户不需要再纠结 QWidget 到底代表窗体还是组件之类的问题,同时也允许了 LarkSDK 针对窗体和组件合理设计不同的接口(比如设计接口允许 LWindow 构建时可直接指定尺寸等),以降低用户的理解学习成本。

事实上 LWindow 也是派生自抽象基础窗体类 LAbstractWindow 的具体实现子类。LarkSDK 设计了如下的继承树以表示不同的窗体类型语义:

  • LAbstractWindow - 抽象的基础窗体
    • LDrawableWindow - 可绘制窗体(有图形输出的窗体)
      • LWindow - 普通顶层窗体(LTopWindow 的别名)
        • LDialog - 对话框
          • LMessageBox - 消息框
          • LInputDialog - 输入框
          • LProgressDialog - 进度框
          • ...
        • LDrawWindow - 直接绘制窗体(可用于直接绘图)
      • LPopupWindow - 弹出式窗体
        • LMenu - 菜单
        • LSplashScreen - 启动画面
    • LInteractArea - 组件交互区域,用于接受用户输入

TIP

注:交互区域 LInteractArea 为用户无需直接接触的内部类,本质上是封装的无图形输出的平台窗口。用户只需要关注和使用基本组件类 LComponent 即可。

根据具体需要,还可以设计更多的窗体类,如对话框 LDialog 派生自 LWindow,除了具备普通顶层窗体的行为之外,还具备模态支持、对话框返回值、本地事件循环等对话框特有的行为。

注意上面的代码范例中,LButton 构造函数的第二个参数 window.rootComponent()LTopLevelWindow 提供的根组件。根组件是以窗体类作为父对象的基本组件,窗体内所有界面组件均以根组件作为其共同祖先。根组件是连接窗体和内部组件的桥梁。

可绘制窗体也可以选择不提供根组件,如上面提及的 LDrawWindow。这类窗体可以让用户直接在窗体上绘制。

# 更灵活的布局方式

# Qt Framework

Qt 提供多种布局方式,包括盒子布局 QBoxLayout、层叠布局 QStackedLayout、网格布局 QGridLayout 和表单布局 QFormLayout。其中网格和表单布局主要为特定界面设计所常用,层叠布局和层叠式组件容器 QStackedWidget 功能融合。一般最常用于构建界面的为盒子布局模式,分为纵向的 QVBoxLayout 和横向的 QHBoxLayout

盒子布局为 Qt 界面设计的主要布局方式,它的主要思路是将容器内元素排成一行(或一列),通过控制各元素的尺寸策略(QSizePolicy)来计算元素的绘制位置和尺寸,是一种设计较为完善的布局方式。

例如实现如下经典的组件布局,组件横向排列,中央组件宽度自适应,同时分别设置组件颜色:

FlexLayout

#include <QApplication>
#include <QWidget>
#include <QPalette>
#include <QHBoxLayout>

int main(int argc, char *argv[])
{
    QApplication app(argc, argv);
    QWidget w;
    w.resize(400, 300);
    w.setWindowTitle("弹性布局测试");
    QPalette pal;
    pal.setColor(QPalette::Window, QColor(0xccdeff));
    com1.setAutoFillBackground(true); 
    com1.setPalette(pal);

    QWidget com1;
    com1.setMinimumSize(100, 100);
    QPalette pal1;
    pal1.setColor(QPalette::Window, QColor(0x007777));
    com1.setAutoFillBackground(true); 
    com1.setPalette(pal1);

    QWidget com2;
    com2.setMinimumSize(100, 100);
    QPalette pal2;
    pal2.setColor(QPalette::Window, QColor(0x770077));
    com2.setAutoFillBackground(true); 
    com2.setPalette(pal2);
    com2.setSizePolicy(QSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed));
    
    QWidget com3;
    com3.setMinimumSize(100, 100);
    QPalette pal3;
    pal3.setColor(QPalette::Window, QColor(0x777700));
    com3.setAutoFillBackground(true); 
    com3.setPalette(pal3);

    QHBoxLayout layout(&w);
    layout.addWidget(&com1);
    layout.addWidget(&com2);
    layout.addWidget(&com3);

    w.show();
    return app.exec();
}

可以看到,Qt 通过调用 QWidgetsetSizePolicy() 接口来配合 QHBoxLayout 布局对象,以控制组件的宽度自适应。上述代码中,我们需要先创建布局,再通过布局对象的接口添加组件,也就是说布局对象的管理混杂在组件树的构建(即为容器组件添加元素子组件)过程中,且在添加元素组件时,调用的是布局对象的 addWidget() 接口,再通过布局对象和容器组件的关系隐含的将元素组件的父组件设定为容器组件。代码的可读性受到一定影响。

# LarkSDK

在 LarkSDK 中,布局的操作和组件树的构建是解耦的。我们可以先替容器组件设置好布局,再如同常规构建组件树那样,通过容器组件本身的接口(而非布局对象的接口)来添加元素子组件,尽最大可能保证语义的明晰简洁:

#include <lwindowapplication.h>
#include <lwindow.h>
#include <lcomponent.h>
#include <lflexlayout.h>

int main()
{
    LWindowApplication app;
    LWindow w(400, 300);
    w.setTitle("弹性布局测试");

    LComponent *pRoot = w.rootComponent();
    pRoot->setColor(LPalette::GeneralBase, LColor(0xccdeff));
    pRoot->setLayout(LFlexLayout());  // 布局方向默认横向+轴向居中+离轴居中

    LComponent com1(pRoot);
    com1.setSize(100, 100);
    com1.setDrawBackground(true);
    com1.setColor(LPalette::GeneralBase, LColor(0x007777));

    LComponent com2(pRoot);
    com2.setSize(100, 100);
    com2.setDrawBackground(true);
    com2.setColor(LPalette::GeneralBase, LColor(0x770077));
    com2.setFlexGrowth(1);

    LComponent com3(pRoot);
    com3.setSize(100, 100);
    com3.setDrawBackground(true);
    com3.setColor(LPalette::GeneralBase, LColor(0x777700));

    w.show();
    return app.exec();
}

LarkSDK 所实现的弹性布局 LFlexLayout,吸收自 Web 前端 CSS 中“弹性盒子布局 (opens new window)”的思路与方式,加以简化后适应 C++ 桌面开发环境。目前支持横纵向弹性布局、元素自适应增长、轴向与离轴的布局策略设置、容器内边距、元素间隔等功能。

注意上述代码中,由于布局对象并不参与组件树的建立,所以可以直接在 setLayout() 的调用中匿名构建。同时通过设置组件的弹性增长因子 setFlexGrowth() 实现了中央组件的宽度自适应。

TIP

关于 LarkSDK 弹性布局的详细说明,请参阅弹性布局系统说明一文。

# 可直接使用的信号槽

# Qt Framework

作为最元初的设计者和推广者,信号槽机制 (opens new window)是 Qt 最引以为傲的特性之一。当然市面上除 Qt 之外还有其他的信号槽实现,如 Boost.Signals (opens new window)

Qt 的信号槽基于其元对象系统 (opens new window),功能强大,应用广泛。然而元对象机制需要借助元对象编译机制 (opens new window)对源码进行预处理,需要用户在代码中嵌入指定的宏(Q_OBJECTsignalsslots 等),破坏了 C++ 代码的“纯净度”,同时也可能会对自动化编译流程的建立(持续集成等)造成一定的影响。

#include <QObject>

class Counter : public QObject
{
    Q_OBJECT  // 宏

public:
    Counter() { m_value = 0; }
    int value() const { return m_value; }

public slots:  // 宏
    void setValue(int value);

signals:  // 宏
    void valueChanged(int newValue);

private:
    int m_value;

};

# LarkSDK

LarkSDK 提供了更简单、易用、轻量级(核心代码不到 200 行)的信号槽实现,客观说目前功能尚不如 Qt 信号槽全面(如暂不支持 sender() 接口),但已能满足大多数情况下的应用:

#include <lobject.h>
#include <lsignal.h>

class Counter : public LObject
{

public:
    Counter() { m_value = 0; }
    int value() const { return m_value; }
    void setValue(int value);
    LSignal<int> valueChanged;

private:
    int m_value;

}

可以看到,LarkSDK 并未区分槽函数和普通函数,事实上任意函数都能作为槽函数使用,也就是说,LarkSDK 的信号可以连接到任意函数。LarkSDK 的信号也并非形如 Qt 信号的函数声明形式,而是一个模板类,模板类的类型参数就是信号所携带的参数类型清单。

// LarkSDK 信号槽机制调用范例

Counter sender;
Counter receiver;

void slot(int value) { ... }

// 支持连接到类的成员函数
sender.valueChanged.connect(&receiver, &Counter::setValue);

// 支持连接到普通函数
sender.valueChanged.connect(&slot);

// 支持连接到 Lambda 表达式
sender.valueChanged.connect([](int value) { ... })

LarkSDK 的信号槽机制不依赖任何的代码预处理,引入 lsignal.h 头文件后即可直接使用。

# 更简洁直观的构建流程

目前而言,LarkSDK 对外只提供头文件、静态库和少量资源文件,目录结构非常简洁,使用感受较为清爽。用户只需简单设置包含目录和静态库连接目录,通过简易的配置文件指定资源文件目录(用于读取图标、默认字体等),就可以直接开始使用。

上述特征使得 LarkSDK 可以非常方便并入各种编译工具链。用户可以自由选择自己习惯的开发环境。

# 小结

在前文中,我们简要举例讨论了 LarkSDK 对比 Qt Framework 的不同之处。作为一款完全独立自主设计的基础 C++ 开发类库,LarkSDK 在吸收 Qt 优点的基础上构建,至今已经在很多设计与实现方面与 Qt 走上了不同的道路,上述几个点只是其中很小的一部分而已。

LarkSDK 的终极目的,是构建完整的基于 C++ 语言的国产应用程序开发生态。随着产品版本迭代研发,后续将慢慢会增添更多不同于甚至高于 Qt 的新特性。