IT Notes

Проектирование гибкого ООП-кода на примере

Вопросами ООП-проектирования и гибкости программного кода мы уже занимались. В этот раз поработаем над более практичным примером.

Допустим, в приложение требуется добавить эффект мигания текста для QLabel. Подойти к решению задачи можно по разному. Займемся анализом.

В статье QTimer: Примеры использования я упоминал возможность применения QTimer для достижения нужной нам цели. Но куда его вставить?

Неудачные варианты

QTimer можно поместить в виджет, который содержит QLabel. Проблема: если та же возможность понадобится в другом виджете, то мы в пролете. Не подходит.

Можно создать наследник класса QLabel. Проблема: мы окажемся привязаны к QLabel. Если такой же эффект понадобится в другом месте, то мы ничего не сможем сделать. Не подходит.

Решение: для каждой точки потенциальных изменений создаем по классу-обертке.

Proxy-класс доступа к текстовым данным

Для доступа к текстовым данным заведем интерфейс TextProxy:

class TextProxy {
public:
    virtual ~TextProxy() {}

    virtual QString get() const = 0;
    virtual void set( const QString& str ) = 0;
};

В него можно будет завернуть все, что угодно, а не только QLabel. В качестве первого шага создадим шаблонную реализацию этого интерфейса в расчете на QLabel:

template< typename T >
class GenericTextProxy : public TextProxy {
public:
    GenericTextProxy( T* txt ) : m_txt( txt ) {}

    QString get() const {
        return m_txt->text();
    }

    void set( const QString& str ) {
        m_txt->setText( str );
    }

private:
    T* m_txt;
};

Класс текстового эффекта

Нет смысла ограничиваться лишь эффектом мигания текста. Нам могут понадобиться и другие. Заведем следующий интерфейсный класс:

class TextEffect : public QObject {
    Q_OBJECT
public:
    virtual ~TextEffect() { }

    virtual void update( const std::shared_ptr< TextProxy >& proxy ) = 0;

signals:
    void updateRequired();
};

В нем имеется сигнал с запросом о необходимости обновления и функция-член update(), которая и должна реализовать текстовый эффект.

Поскольку большинство эффектов привязано к сигналу таймера, имеет смысл создать вспомогательный абстрактный класс:

class TimeOutTextEffect : public TextEffect {
public:
    TimeOutTextEffect( int msec ) {
        connect( &m_timer, SIGNAL( timeout() ), SIGNAL( updateRequired() ) );
        m_timer.start( msec );
    }

private:
    QTimer m_timer;
};

Наконец мы можем добавить реализацию эффекта мигания текста:

class BlinkTextEffect : public TimeOutTextEffect {
    struct BlinkState {
        BlinkState() {}
        BlinkState( const QString& s ) : str( s ) {}

        bool blinked = false;
        QString str;
    };

public:
    BlinkTextEffect( int msec = 400 ) : TimeOutTextEffect( msec ) { }

    void update( const std::shared_ptr< TextProxy >& proxy ) {
        if( !m_map.contains( proxy.get() ) ) {
            m_map[ proxy.get() ] = BlinkState( proxy->get() );
        }

        auto& state = m_map[ proxy.get() ];

        state.blinked = !state.blinked;
        proxy->set( state.blinked ? "" : state.str );
    }

private:
    QHash< TextProxy*, BlinkState > m_map;
};

Мигание осуществляется за счет попеременного вывода в TextProxy пустой строки и исходного текстового значения. Для этого нам приходится хранить состояние в хэш-карте, ключом которой является указатель на TextProxy.

Эту реализацию нельзя считать законченной и оптимальной, но она работает. Но для первой итерации этого достаточно.

Связующий класс TextAnimator

Добавим небольшой класс, который упростит привязку эффекта к прокси-объекту:

class TextAnimator : public QObject {
    Q_OBJECT

public:
    TextAnimator() : m_proxy( nullptr ) {}

    void setEffect( const std::shared_ptr< TextEffect >& effect ) {
        if( m_effect ) {
            // При смене эффекта отключаем сигналы старого
            m_effect.get()->disconnect();
        }

        m_effect = effect;
        connect( effect.get(), SIGNAL( updateRequired() ), SLOT( onUpdateRequired() ) );
    }

    void bind( const std::shared_ptr< TextProxy >& proxy ) {
        m_proxy =  proxy;
    }

    void unbind() {
        m_proxy.reset();
    }

private slots:
    void onUpdateRequired() {
        if( m_proxy ) {
            if( auto effect = qobject_cast< TextEffect* >( sender() ) ) {
                effect->update( m_proxy );
            }
        }
    }

private:
    std::shared_ptr< TextProxy > m_proxy;
    std::shared_ptr< TextEffect > m_effect;

};

Испытаем его на QLabel, как и хотели:

class MainWidget : public QWidget {
    Q_OBJECT

public:
    MainWidget( QWidget* parent = 0 ) {
        QLabel* lbl = new QLabel( "Test string" );
        QBoxLayout* l = new QVBoxLayout;
        l->addWidget( lbl );
        setLayout( l );

        m_animator.bind( std::make_shared< GenericTextProxy< QLabel > >( lbl ) );
        m_animator.setEffect( std::make_shared< BlinkTextEffect >() );
    }

private:
    TextAnimator m_animator;
};

Все работает. Поэтому теперь проверим получившуюся архитектуру на гибкость.

Эффект бегущей строки

Создадим эффект бегущей строки:

class ScrollingTextEffect : public TimeOutTextEffect {
    struct ScrollingState {
        ScrollingState() {}
        ScrollingState( const QString& s ) : str( s ) {}

        int shift = 0;
        QString str;
    };

public:
    ScrollingTextEffect( int maxCharWidth = -1, int msec = 200 ) :
        TimeOutTextEffect( msec ), m_maxCharWidth( maxCharWidth ) {}

    void update( const std::shared_ptr< TextProxy >& proxy ) {
        if( m_maxCharWidth < 0 || proxy->get().length() < m_maxCharWidth ) {
            proxy->set( proxy->get().mid( 1 ) + proxy->get().left( 1 ) );
        } else {
            if( !m_map.contains( proxy.get() ) ) {
                m_map[ proxy.get() ] = ScrollingState( proxy->get() );
            }

            auto& state = m_map[ proxy.get() ];

            QString output = state.str.mid( state.shift, m_maxCharWidth );
            if( output.length() < m_maxCharWidth && m_maxCharWidth < state.str.length() ) {
                output += state.str.left( m_maxCharWidth - output.length() );
            }

            proxy->set( output );

            ++state.shift;
            state.shift %= state.str.length();
        }
    }

private:
    int m_maxCharWidth;
    QHash< TextProxy*, ScrollingState > m_map;
};

В целом идея реализации напоминает класс BlinkTextEffect. Но есть и свои особенности. Эффект бегущей строки характеризуется максимальным количеством символов, которые мы хотим отображать в каждый момент времени. За это отвечает параметр maxCharWidth. Отрицательные значения для него означают, что нужно отображать всю строку целиком. В этом случае на каждом шаге просто перемещаем первый символ в конец строки:

if( m_maxCharWidth < 0 || proxy->get().length() < m_maxCharWidth ) {
    proxy->set( proxy->get().mid( 1 ) + proxy->get().left( 1 ) );
}

Чтобы использовать новый эффект вместо старого, достаточно поменять всего одну строку:

class MainWidget : public QWidget {
    Q_OBJECT

public:
    MainWidget( QWidget* parent = 0 ) {
        QLabel* lbl = new QLabel( "Test string" );
        QBoxLayout* l = new QVBoxLayout;
        l->addWidget( lbl );
        setLayout( l );

        m_animator.bind( std::make_shared< GenericTextProxy< QLabel > >( lbl ) );

        // !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
        // Нужно поменять только эту строку:
        m_animator.setEffect( std::make_shared< ScrollingTextEffect >() );
        // !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
    }

private:
    TextAnimator m_animator;
};

Все получилось практически без изменения существующего кода. Это успех! Закрепим его еще одной демонстрацией возможностей нашей архитектуры.

Proxy для заголовка виджета

Если заголовок слишком длинный, то наш эффект бегущей строки придется весьма кстати. Добавим обертку, которая поможет нам в этом:

class WidgetTitleProxy : public TextProxy {
public:
    WidgetTitleProxy( QWidget* wgt ) : m_wgt( wgt ) { }

    QString get() const {
        return m_wgt->windowTitle();
    }

    void set( const QString& str ) {
        m_wgt->setWindowTitle( str );
    }

private:
    QWidget* m_wgt;
};

А теперь испытаем ее:

class MainWidget : public QWidget {
    Q_OBJECT

public:
    MainWidget( QWidget* parent = 0 ) {
        setWindowTitle( "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor "
                    "incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud "
                    "exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute "
                    "irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla "
                    "pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia "
                    "deserunt mollit anim id est laborum. " );

        m_animator.bind( std::make_shared< WidgetTitleProxy >( this ) );
        m_animator.setEffect( std::make_shared< ScrollingTextEffect >( 30 ) );
    }

private:
    TextAnimator m_animator;
};

И снова все работает без особых проблем.

Выводы

В результате мы пришли к вполне гибкой и пригодной для расширения ООП-архитектуре. Но ее еще можно улучшать. Остается два основных недостатка:

  1. TextEffect рассчитан на работу со статическими текстовыми полями. Любые изменения в обернутом с помощью TextProxy объекте после запуска анимации будут игнорироваться;
  2. Имеется дублирование логики хранения состояния в хэш-картах. А ведь мы разработали всего два эффекта. Логично предположить, что такая же возможность понадобится и в других.

Решение заключается в том, чтобы доработать класс TextProxy. Первую проблему можно устранить, если сделать допущение, что любые изменения текста обернутого объекта должны вноситься только через его proxy-объект.

Второй недостаток устраняется путем переноса карты в сам TextProxy. Поскольку разным эффектам требуется хранить разные данные, то для этого лучше использовать QVariant.

Похожие публикации