Введение
К этому моменту цикла мы рассмотрели больше десятка отдельных входных модулей (джойстик, кнопки, кнопочная матрица, потенциометр, датчики). В статье разберём, как организовать единый, согласованный Qt-интерфейс, агрегирующий ввод от нескольких разных модулей одновременно, не превращая код в нечитаемый набор разрозненных, специфичных для каждого модуля обработчиков.
Концепция
Идея агрегации — единый интерфейс InputSource, через который любой конкретный модуль ввода (джойстик, клавиатура, кнопка) сообщает о событиях в унифицированной форме, и центральный InputAggregator, подписывающийся на все источники и предоставляющий остальной части приложения единую точку получения пользовательского ввода, не зависящую от того, какой конкретно физический модуль его сгенерировал — аналогично паттерну Repository из цикла про базы данных, здесь применённому к агрегации входных данных вместо доступа к данным БД.
Пример кода
// inputsource.h — единый интерфейс для любого источника пользовательского ввода
#pragma once
#include <QObject>
enum class InputAction { Up, Down, Left, Right, Confirm, Cancel };
class InputSource : public QObject
{
Q_OBJECT
signals:
void actionTriggered(InputAction action);
};
// joystickinputsource.h — джойстик как конкретная реализация InputSource
#pragma once
#include "inputsource.h"
#include "arduinoconnection.h"
class JoystickInputSource : public InputSource
{
Q_OBJECT
public:
explicit JoystickInputSource(ArduinoConnection *connection, QObject *parent = nullptr)
: InputSource(parent)
{
connect(connection, &ArduinoConnection::lineReceived, this, &JoystickInputSource::onLineReceived);
}
private slots:
void onLineReceived(const QString &line)
{
if (line.startsWith("JOYSTICK:") && !line.contains("BUTTON")) {
const QStringList parts = line.mid(9).split(',');
if (parts.size() != 2) return;
const int x = parts[0].toInt(), y = parts[1].toInt();
const int DEAD_ZONE = 20;
if (x > DEAD_ZONE) emit actionTriggered(InputAction::Right);
else if (x < -DEAD_ZONE) emit actionTriggered(InputAction::Left);
if (y > DEAD_ZONE) emit actionTriggered(InputAction::Down);
else if (y < -DEAD_ZONE) emit actionTriggered(InputAction::Up);
} else if (line == "JOYSTICK:BUTTON:PRESSED") {
emit actionTriggered(InputAction::Confirm);
}
}
};
// keypadinputsource.h — кнопочная матрица как другая реализация того же InputSource
#pragma once
#include "inputsource.h"
#include "arduinoconnection.h"
class KeypadInputSource : public InputSource
{
Q_OBJECT
public:
explicit KeypadInputSource(ArduinoConnection *connection, QObject *parent = nullptr)
: InputSource(parent)
{
connect(connection, &ArduinoConnection::lineReceived, this, &KeypadInputSource::onLineReceived);
}
private slots:
void onLineReceived(const QString &line)
{
if (!line.startsWith("KEYPAD:")) return;
const QChar key = line.mid(7).at(0);
// Маппинг клавиш матрицы 4х4 на ту же единую систему действий, что и у джойстика
if (key == 'A') emit actionTriggered(InputAction::Up);
else if (key == 'B') emit actionTriggered(InputAction::Down);
else if (key == '#') emit actionTriggered(InputAction::Confirm);
else if (key == '*') emit actionTriggered(InputAction::Cancel);
}
};
// Использование — остальная часть приложения работает с абстрактным InputAction,
// не зная и не заботясь, какой конкретно модуль (джойстик или клавиатура) его сгенерировал
void GameController::setupInputSources(ArduinoConnection *connection)
{
auto *joystick = new JoystickInputSource(connection, this);
auto *keypad = new KeypadInputSource(connection, this);
connect(joystick, &InputSource::actionTriggered, this, &GameController::onAction);
connect(keypad, &InputSource::actionTriggered, this, &GameController::onAction);
}
void GameController::onAction(InputAction action)
{
switch (action) {
case InputAction::Up: movePlayerUp(); break;
case InputAction::Down: movePlayerDown(); break;
case InputAction::Confirm: confirmSelection(); break;
default: break;
}
}
Пояснения к коду
InputSource объявляет единственный сигнал actionTriggered(InputAction) — абстрактную, не зависящую от конкретного физического устройства, единицу пользовательского намерения; JoystickInputSource и KeypadInputSource — две совершенно разные по внутренней реализации (одна разбирает аналоговые координаты с мёртвой зоной, другая — единичные символы клавиш), но обе сообщающие о событиях через один и тот же унифицированный интерфейс.
GameController::setupInputSources() подключает оба конкретных источника к одному и тому же обработчику onAction() — это ключевая выгода паттерна: добавление третьего источника ввода (например, ИК-пульта из статьи 302) требует написания только новой реализации InputSource для конкретного модуля, без единого изменения в GameController, который продолжает обрабатывать абстрактные действия одинаково, независимо от их физического происхождения.
Подводные камни
- Слишком грубая абстракция входных событий, теряющая важные для конкретного приложения детали — например, если приложению важна не просто «кнопка вправо нажата», а конкретная сила отклонения джойстика (для аналогового, плавного управления скоростью, а не просто дискретного направления), унификация до простого перечисления
InputActionтеряет эту информацию; для таких случаев абстракция должна быть спроектирована шире, передавая, например, не только направление, но и аналоговую интенсивность действия. - Одновременная активация нескольких источников ввода, конфликтующих друг с другом (например, джойстик отклонён влево в тот же момент, когда на клавиатуре нажата клавиша, соответствующая действию «вправо») — без явной политики приоритета источников или какого-то правила разрешения конфликтов, порядок обработки сигналов может зависеть от порядка подключения
connect(), что не всегда предсказуемо и интуитивно для конечного поведения приложения. - Расширение перечисления
InputActionновыми значениями по мере роста приложения требует синхронного обновления всех существующих реализацийInputSource, которые потенциально должны как-то отреагировать на новое действие (даже если просто не генерировать его) — для очень большого количества разных источников ввода это может стать source кода, требующим внимательной синхронизации при каждом расширении набора поддерживаемых действий. - Тестирование агрегированной логики через mock-реализацию
InputSource(аналогично mock-репозиториям из цикла про базы данных) упрощается именно благодаря этой абстракции — без неё тестирование логикиGameControllerпотребовало бы реального физического оборудования или сложной симуляции конкретного протокола Arduino, тогда как с абстракцией достаточно простого тестовогоInputSource, испускающего нужныеactionTriggeredсигналы напрямую из тестового кода.