Software Architecture and State
In this blog post, we examine our recent work with the Joulescope User Interface (UI). We step back from current and voltage measurements to discuss our challenges and how we improved the software architecture for future success.
Software architecture is how software is structured and how its components interact. Choosing an appropriate architecture is a critical aspect of creating quality software. With an appropriate architecture, developers find that features are easy to add and to maintain. With an inappropriate architecture, the software crumbles under its own weight and nearly any change results in new errors in other parts of the software.
Like most software, the Joulescope UI started small. It grew over time as we added features and functions. Recently, our customers were requesting features, such as configuring the software and changing preferences, that were becoming increasingly difficult and error-prone to add.
The fundamental issue is the application state. The Joulescope UI allows the same control (such as the device's current range setting) to appear in several locations. The UI also displays the same information in several places, such as the values in the multimeter display and the single value display. To further complicate matters, the UI must also remember these settings between invocations. All of these cases require that the UI correctly maintain state across its internal components.
The UI is a Python application that uses Qt (a C++ library) through the PySide2 bindings. Qt is a very well-structured, mature, cross-platform library. The Qt GUI widgets connect using signals and slots. The application can connect signals of one Qt object to slots of another Qt object. When the value of a widget changes, such as a combobox selection, it emits a signal. The Qt framework then calls each connected slot with the emitted data. The object with the signal does not have to know about the connected slots. Likewise, the object with the slot does not have to know about the source signal. This abstraction goes a long way towards managing the UI complexity and allowing child widgets to communicate with their parents or their underlying model. Qt's signals and slots are an implementation of the Observer pattern.
The weakness of signals and slots is that some portion of the application must connect each signal to each slot. As the application grows, the complexity of these connections grow. If you want to add a new source for a signal, you must connect it to all of the other slots that supply this same information. Since sources often need to keep synchronized, they also need a slot for signals from other widgets. Pretty soon, you have to create a fully connected graph of signals and slots:
When adding a new widget that needs the same information, it must get connected to all existing widgets with that same information. As the application grows, it gets challenging to connect new widgets correctly, which results in strange and difficult to troubleshoot behavior. Signals and slots are great for local information transfer between closely related objects but do not scale across the application. This situation leaves many unanswered questions.
- How can we better decouple our components and widgets?
- How can we save the application state and restore it?
- How do we implement a central way for the user to configure preferences?
- How can we support multiple "Views" (preferences profiles)?
- How do we implement undo/redo for commands?
We solved this by implementing the Publish / Subscribe pattern (often called PubSub) combined with the Command pattern.
At first glance, PubSub is very similar to the Observer pattern. Widgets publish updates, just like a signal, and any widget can subscribe to updates, like with a slot. However, PubSub has a critical difference. Each Widget can publish to whatever topics it wants, without requiring a third party to connect it. Likewise, each widget can subscribe to any topics it wants, also without requiring a third party to connect it. Every widget needs to know the PubSub instance, but that's it. All connections are made simply by the topic. A new subscriber subscribes to a topic once regardless of how many other widgets publish on that topic. By inserting the global PubSub instance, we now have a much cleaner architecture:
The application no longer needs to know how to cross-connect the signals and slots for each new widget.
Within the Joulescope UI, topics are strings. Each topic defines its value type, which includes strings, integers, maps, lists, colors, fonts, or any Python data type. By using PubSub, components within the application become decoupled, which solves the scalability problem!
Let's see how it solves the other issues.
The Joulescope UI defines two primary topic types:
- Commands: actions that manipulate the application state in a way that requires a custom inverse (or undo). A command has only one registered handler. All command topics either start with or end with "!".
- Preferences: simpler actions that change the value. The undo is republishing the previous value.
- Standard: user-meaningful preferences that can be set by the user and persist across program invocations.
- Hidden: internal preferences that persist between program invocations. Topic section starts with "_".
- Temporary: internal preferences that do not persist. Topic contains "#".
Application components can subscribe to commands and preferences, and any application component is free to define its preferences and commands. Here is an example preference definition:
cmdp.define( topic='General/log_level', brief='The logging level', dtype='str', options=['OFF', 'CRITICAL', 'ERROR', 'WARNING', 'INFO', 'DEBUG', 'ALL'], default='INFO')
The PubSub instance stores the preferences definitions, along with the current value. This centralized storage makes saving the state and restoring the state very easy.
The PubSub instance also implements a mechanism to inspect and traverse the preferences, which also enables a preferences dialog. With a little code, it looks something like:
This global Preferences dialog provides automatic support for all new preferences as they are added to the application. The new implementation also adds profiles, which you can see at the top of the Preferences dialog. Profiles allow the user to switch between different settings quickly. Profiles allow more flexible support of the UI's Multimeter and Oscilloscope views. In addition to allowing you to customize and switch between views, the UI can also resume where you left off, at least once we finish adding support to all the widgets.
The final feature is undo/redo support. For preferences, undo is publishing the previous value. What about more complicated commands, such as adding a signal to the waveform widget? Here is its example command registration:
c.register('!Widgets/Waveform/Signals/add', self._cmd_waveform_signals_add, brief='Add a signal to the waveform.', detail='value is list of signal name string and position. -1 inserts at end')
The command handler (_cmd_waveform_signals_add in this case) can return any one of:
- None: no undo needed - for commands with no undo (like file save) or failed commands.
- The (undo_topic, undo_data) to undo the command. Most commands provide this return value format.
- The ((redo_topic, redo_data), (undo_topic, undo_data) to both undo and then redo the command. Commands sometimes process their data, so specifically recording the processed data ensures that the command processor can redo the command exactly.
When a command runs, the Command pattern implementation stores the redo and undo information to the command history. It also implements the !undo and !redo commands, which move forward or backward through command history. Undo would have been nearly impossible to implement with our previous architecture. With the right architecture (our new centralized Command processor) undo and redo become straightforward.
Switching to this new architecture required modification to much of the application "glue code.", which is the objective. The new architecture has better component decoupling, eliminates manually connecting widgets, and allows the application to be more customizable. It also fixes several other known problems. The PubSub instance intentionally forces the Qt Event Thread to execute all commands and preference changes, which eliminates a race condition that left the waveform markers partially deleted. The new PubSub instance automatically creates weak references to the subscriber callbacks, which ensures that the Python garbage collector still deletes the objects as needed. Using weakrefs mostly avoids the strange cases where the underlying Qt C++ objects get deleted but the corresponding Python object lives on. The PubSub instance automatically and safely handles references that were deleted.
We are pleased with this new architecture so far. Although switching has been time-consuming, we will be able to better maintain and extend the Joulescope UI going forward. The Joulescope UI is open-source under the permissive Apache 2.0 license, and you can benefit from our work. If you are interested, you can start by checking out:
All of our Joulescope customers will find these changes in the upcoming 0.7 release, due out in early December 2019.