Qt 6 Plugins

Adapting Embedded Devices with Qt 6 Plugins: A Coffee Machine Case Study

By Christopher Probst

Try running an application without a shared library it depends on, and you'll be met with a frustrating error message. An application is nothing without its shared libraries—unless, of course, those libraries are plugins. Plugins can be loaded and unloaded at will, their absence goes unnoticed, and their presence, in most cases, enhances your software.

The World of Plugins

Plugins are widely used, with numerous examples: add-ons in web browsers, audio/video codecs in media players, wizard-based code generators in IDEs, and document loaders in text editors. Not to mention that Qt Creator and Qt Designer also rely on them.

The excellent Qt documentation covers plugins in detail and includes an example of a document viewer widget capable of loading multiple document formats. However, that example is implemented using widgets leveraging plugins to load multiple document formats. 

In this blog, I will focus on plugins that extend QML applications, and take you through the steps involved in designing an application and its plugins. Our example is a basic mock coffee machine. 

Coffee Machine Example

The main user interface (UI) displays a set of icons, each representing a different type of coffee. Clicking the cappuccino icon invokes its associated plugin, which provides a customized UI and recipe. Currently, there are only two plugins, but the goal is to enable others to develop more plugins for additional coffee types.

Defining the Plugins

For the main application to accommodate plugins, it defines an interface as a set of classes with pure virtual functions. In our case, an example of such a function is returning the QML url of the screen to display when the plugin is invoked or specifying the icon that identifies the plugin. All plugins provide customized implementations of these methods for their specific coffee flavor.

This interface consists of two classes: CoffeePlugin and CoffeeWorkflowController. The CoffeePlugin class is loaded by the main application at initialization and stored in an organized container. When a plugin is invoked, it is looked up in the container, and an instance of CoffeeWorkflowController is created. This instance is responsible for providing the QML screens that extend the main user interface.

class CoffeePlugin {
public:
  virtual ~CoffeePlugin() = default;
  virtual QUrl getCoffeeIcon() const = 0;
  virtual CoffeeWorkflowController *createCoffeeWorkFlowController() = 0;
};
QT_BEGIN_NAMESPACE
#define CoffeePlugin_iid "ICS.CoffeePlugin/1.0"
Q_DECLARE_INTERFACE(CoffeePlugin, CoffeePlugin_iid)
QT_END_NAMESPACE

The Q_DECLARE_INTERFACE macro informs Qt's meta-object system that the CoffeePlugin class is intended to be used as a plugin. It also assigns a unique identifier to the interface. To be loaded correctly, all plugins must use the same identifier.

The Main User Interface

Along with displaying the icons of available coffee flavors, the QML main application has two main purposes: loading all plugins at initialization and invoking them when required.

At startup, the application scans for available plugins (shared objects on Linux, DLLs on Windows). For each plugin found, a QPluginLoader object is instantiated. The QPluginLoader::instance() method attempts to load the plugin and, if successful, returns a QObject. The main application then tries to cast this object to a CoffeePlugin instance using qobject_cast. Successfully loaded plugins are stored in a Qt container— in our case, a QMap— making them readily available for invocation.

QPluginLoader *pluginLoader = new QPluginLoader( pluginFile, this);
QObject *pluginElement = pluginLoader->instance();
 if (pluginElement) {
       CoffeePlugin *coffeePlugin = qobject_cast<CoffeePlugin *>(pluginElement);
        if (coffeePlugin) {
          m_container.insert(pluginDir, coffeePlugin);
        }
   }

Implementing the Plugin

With the provided interface, developing a plugin involves implementing the interface's pure virtual methods. All plugins and the main application share the user interface headers.

To illustrate, let's examine the rudimentary Cappuccino plugin. Its project's CMakeLists.txt file uses the qt_add_plugin directive, which is required for any Qt plugin. The CappuccinoPlugin class inherits from both the CoffeePlugin class and QObject.

class CappuccinoPlugin : public QObject, public CoffeePlugin {
  Q_OBJECT
  Q_PLUGIN_METADATA(IID "ICS.CoffeePlugin/1.0" FILE     "cappuccinoplugin.json") 
  Q_INTERFACES(CoffeePlugin)
 public:
    explicit CappuccinoPlugin();
  QUrl getCoffeeIcon() const override;
  CoffeeWorkflowController *createCoffeeWorkFlowController() override;
};

The plugin project includes its own .qrc resource file, which contains the QML screens and icons provided by the plugin. Once the plugin is loaded, the main application automatically gains access to these resources. However, it is necessary to call the Q_INIT_RESOURCE method in the plugin's constructor to initialize the resource system.

void injectResources() { Q_INIT_RESOURCE(cappuccinoscreens); }
CappuccinoPlugin::CappuccinoPlugin() { injectResources(); }

The plugin can provide a json file containing user-specified metadata the main application can leverage.

The CappuccinoPluginController class provides the workflow, the QML screens, and its context property when the plugin is invoked. The main application needs all three elements in order to invoke the plugin. 

Invoking the Plugin

Along with displaying icons, the main application includes an idle QML Loader item, ready to load plugin screens. When the user selects a coffee icon, the associated plugin is looked up in the container. If the plugin is found, it generates an instance of its own CoffeeController object.

Using the controller's mainPanelQmlUrl() method, the main application retrieves the QML screen URL and assigns it to the Loader's source property. Additionally, the controller's qmlPresenterData() method provides a temporary context property, making the C++ objects referenced by the plugin's QML accessible.

void PluginHandler::handlePlugin(const QString &pluginName) {
  CoffeePlugin *coffeePlugin = m_pluginsContainer->coffeePlugin(pluginName);
  m_currentCoffeeWorkflowController =
      coffeePlugin->createCoffeeWorkFlowController();
  if (m_currentCoffeeWorkflowController) {
    m_qmlContext->setContextProperty(
        "controller", m_currentCoffeeWorkflowController->qmlPresenterData());
    m_currentCoffeeWorkflowController->setBeginUiWorkflowCallback([this]() {
      setCurrentScreen(m_currentCoffeeWorkflowController->mainPanelQmlUrl());
    });
    m_currentCoffeeWorkflowController->setEndUiWorkflowCallback([this]() {
      emit pluginDone();
      m_currentCoffeeWorkflowController->destroy();
      m_currentCoffeeWorkflowController = nullptr;
    });
    m_currentCoffeeWorkflowController->startWork(QVariantMap());
  }
}

Notice how the plugin's QML screen is loaded within the BeginUIWorkflow callback. When BeginUIWorkflow is called, the plugin's controller takes control of the screen, and it relinquishes control upon calling EndUIWorkflow. Once the coffee is brewed, the controller is destroyed.

Final Thoughts: Maximizing Scalability with Qt 6 Plugins

Adapting embedded devices with Qt 6 plugins offers a flexible and dynamic approach to enhance the functionality of applications. By leveraging plugins, you can ensure that your software remains modular, allowing you to load and unload features as needed without causing disruptions. Our coffee machine case study illustrates how plugins can be applied to a real-world embedded system, making it easier to customize and extend functionality without the need for constant rebuilding or restarting. This ability to extend QML applications in embedded environments provides significant advantages, especially in resource-constrained systems where efficiency and scalability are key.

For more on Qt 6 plugins, download ICS' on-demand webinar Future-Proofing Embedded Device Capabilities with the Qt 6 Plugin Mechanism.