Проектирование и программирование пользовательского интерфейса на С++. Часть 1. Неужели в этом есть что-то сложное?

Каждый программист время от времени сталкивается с задачей написания пользовательского интерфейса. Любая программа, не ориентированная на экспертов, просто обязана иметь удобный и красивый UI. Иначе ею не будут пользоваться. Интересный факт: на рынке shareware программу зачастую покупают не за богатый функционал, а за прозрачные кнопочки или рисованный дизайнерами фон. Но неземных красот, как в 5-м Winamp'е, еще недостаточно: UI должен быть удобен. Та же Microsoft столкнулась с жестоким фактом: пользователи не используют и десятой доли того функционала, который предоставляет Microsoft Office. А все дело в том, что нужная сию секунду функция слишком глубоко "погрязла" в дебрях интуитивно понятного интерфейса, и нормальный пользователь ни за что в жизни там ее не найдет. В следующей версии MS Office обещают сделать адаптивный UI, что более или менее должно решить эту проблему. Но, несмотря на всю серьезность проблемы удобного UI, программисты-одиночки редко обращают на нее внимание. В результате такого подхода мне частенько приходилось видеть монструозные программы, обладающие самым невероятным функционалом (включая и научный), которые при этом совершенно невозможно было использовать, кроме как в целях демонстрации этого функционала. Поэтому практически все технологии управления созданием ПО во главу угла ставят конечного пользователя с его вполне земными потребностями в автоматизации.

В принципе, для небольших проектов интерфейс — дело десятое. Особенно если он состоит из двух кнопочек и одного поля ввода. Но если программа должна обладать интерфейсом масштабов хотя бы среды разработки типа KDevelop или даже звукового проигрывателя вроде amaroK, то задача написания удобного, стабильного, робастного и не "протекающего" UI незаметно переходит на первый план. Возьмите любой проект с общим размером исходников хотя бы 200-300 Кб и посмотрите, сколько там занимает пользовательский интерфейс, а сколько — непосредственно тот функционал, ради которого писалась программа. Например, у меня обычно это соотношение 7:3. А как обстоят дела с учебно-методической литературой? По алгоритмам — сколько угодно. Дотошный дядюшка Кнут уже целых 40 лет пишет и переписывает свой трехтомник об искусстве программирования. Даже обещает к 2010 году еще 2 тома накатать. По стандартам — RFC за глаза хватит. Для особо любопытных Стивенс с немецкой тщательностью описал реализацию в ядрах Unix многих сетевых протоколов. Перечислять можно долго. А вот по написанию интерфейсов лично мне не доводилось видеть еще не то что книжки, но даже хоть какой-нибудь толковой статейки в Internet'е. Получается интересная ситуация. Тот вид деятельности, который отнимает у многих программистов 70% усилий, нигде не преподается. А уровень мастерства в этом деле зависит лишь от личного опыта и изобретательности человека. Причем ключевую роль здесь играет, конечно, опыт. Написав с десяток проектов, человек уже будет держать в голове некоторые удачные способы реализации интерфейса и, конечно же, применит их в дальнейшем. Еще Томас Эдисон сказал: "Чтобы изобрести что-то новое, нужно иметь хорошее воображение и груду заготовок". Беда в том, что этими заготовками никто не делится. Я бы хотел на страницах КГ начать дискуссию на тему шаблонов в программировании пользовательского интерфейса. В нескольких частях я опишу пару стандартных ситуаций, с которыми постоянно сталкиваюсь сам, и как я их решаю. При этом очень хотелось бы знать мнение читателей по поводу моих решений, а также, самое важное, какие заготовки применяют те, кто сталкивается с проблемой UI. Всем, кого заинтересовала эта тема, просьба: пишите мне по адресу nop@list.ru ваши замечания и заготовки по написанию пользовательских интерфейсов. В дальнейшем они будут опубликованы.

Теперь, прежде чем начать, пару слов об исходных данных. Язык программирования — C++, библиотека элементов управления — Qt4 (отдельно будет рассказано, как распространить решения на MFC и OWL). Язык визуального моделирования — UML. Литература, которой я пользовался при подготовке статьи следующая. 1. "Приемы объектно-ориентированного проектирования. Паттерны проектирования", Э. Гамма, Р. Хелм, Р. Джонсон и Дж. Влиссидес. Книга выпущена в оригинале в 1995 году и является первым печатным изданием с серьезным описанием такой технологии объектноориентированного программирования, как паттерны. Ее авторов часто называют "бандой четырех" ("Gang of Four"). В этой же книге есть пример проектирования пользовательского интерфейса. Единственный толковый, который мне приходилось встречать. Впрочем, даже в нем не приводятся исходники. 2. "Применение UML и шаблонов проектирования", Крэг Ларман. Эту книгу я бы назвал номером один: с нее и нужно изучать объектноориентированный анализ и проектирование. Кроме того, автор часто ссылается на "банду четырех" и серьезно дополняет их труд. 3. "Современное проектирование на С++", Андрей Александреску. Вот эта книга уже значительно больше приближена к реальности, чем первые две (в них в основном теория). Автор использует шаблоны "банды четырех" и тонкости языка С++ для написания поистине изумительных программ. В ней описываются любопытные технологии, которые сам Александреску называет "шаблонные шаблоны". 4. Thinking in C++, Bruce Eckel. Самая полезная, на мой взгляд, книга по С++. Содержит описание множества тонкостей языка. 5. "UML. Руководство пользователя", Грейди Буч, Джеймс Рамбо, Айвар Джекобсон. Книгу написали разработчики языка UML, которых в народе окрестили "три товарища". Они же авторы технологии менеджмента ПО — RUP. В дальнейшем я почти не буду ссылаться на эти книги. А привел их только для того, чтобы читатель не подумал, будто я причисляю себя к списку авторов классических шаблонов проектирования.

Итак, поехали. Первый пример я опишу очень подробно (включая и используемые понятия), последующие — в зависимости от сложности. Задача, с которой при написании UI сталкиваются чаще всего — разработка главного меню. Не будет лишним повторить, что ухищрения, о которых пойдет речь, абсолютно не нужны в небольших проектах. Но там, где размеры исходника зашкаливают за мегабайты, робастность — очень важное свойство системы. Оно подразумевает как стабильность работы, так и устойчивость к изменениям и отказам отдельных компонент.

Постановка задачи. Нужно разработать проектное решение для реализации главного меню со следующими свойствами. 1. Пользовательский интерфейс меню отделен от реализации функционала так, чтобы функционал был доступен и для других элементов управления. 2. Элементы главного меню связаны со своими двойниками в контекстном меню и на панелях инструментов так, что в них используются одинаковый текст, одинаковые иконки и одинаковое состояние (активен/заблокирован). 3. В архитектуру меню должна быть заложена возможность сериализации состояния. 4. Главное меню будет часто меняться в процессе разработки программы. Поэтому добавление и удаление пунктов меню должно требовать минимальных изменений в исходном коде. 5. Архитектурные изменения в программе не должны влиять на главное меню. 6. Исходный код решения должен быть в общем понятен человеку, не участвовавшему в разработке. 7. Максимально возможная скорость работы и минимально возможная используемая память.

Обсуждение требований. Отделение меню от реализации функционала приложения приводит к возможности его (функционала) повторного использования. Пример: нужно сложить 2 числа и вывести результат в MessageBox'е. Где реализовывать этот функционал? Традиционно начинающие программисты реализуют его прямо в меню, т.е. в методе обработки сообщения о выборе пункта меню. Такой подход приводит ко множеству нежелательных последствий. Во-первых, представьте себе, что эти же операции придется выполнить не только из меню, но и по нажатии какой-нибудь кнопки. Тогда придется выбирать из двух вариантов: Copy-Paste кода или же вызова метода меню, в котором эта функциональность реализована. Способ Copy-Paste отбрасываем сразу: он раздувает текст программы, усложняет ее читабильность, усложняет возможные изменения функционала и т.д., — все то, что связано с отсутствием повторного использования кода. Второй способ чуть лучше, т.к. это все-таки повторное использование кода. Но если присмотреться внимательнее, то и в нем есть множество недостатков. Меню — оно и есть меню — просто выпадающая "портянка" с надписями. И когда все методы объекта меню отвечают исключительно за правильную прорисовку элементов меню, тогда его код в основном ясен и читабилен. А если этому же классу, который занимается только "рисованием", назначить дополнительные обязанности... Пусть не сложить два числа, а, например, выполнить быстрое преобразование Фурье, вэйвлет-фильтрацию, передачу результатов в базу данных и по сети. И все это — в отдельных пунктах меню одного подменю. Код класса меню в этом случае не поймет и Кевин Митник.

Следующий подводный камень, связанный с реализацией функционала прямо в меню, перекликается с 4-м пунктом требований. В процессе разработки программы часто меняются требования к ней, а значит, и интерфейс. Представим ситуацию, что код, который складывал 2 числа и выводил результат в MessageBox'е, должен вызываться также и из других элементов управления. Например, по нажатию кнопок в разных диалоговых окнах. Затем приходит самый главный босс, для которого вы и пишете проект, и говорит, что этот пункт меню нужно убрать. Получается, что код обработки нужно вынести из меню в другой объект. И тогда вы сталкиваетесь с ситуацией, когда изменения в меню повлекут за собой лавину изменений во множестве других файлов. А это, согласитесь, очень неудобно и чревато ошибками. Есть, конечно, и другие недостатки этой реализации, о которых я расскажу позже. Но для начала достаточно двух: они очень серьезные. Поэтому единственный способ избежать описанных проблем — убрать функциональность подальше от UI в общем и меню в частности. Второй пункт требований — одинаковое состояние, которое будет у двойников меню. Предположим, некоторый объект предоставляет сервис сканирования изображения. Сам процесс сканирования занимает обычно достаточно длительное время, порой несколько минут. Все это время повторный вызов сервиса должен быть невозможен, и пользователь должен быть оповещен об этом. Самый лучший способ — просто заблокировать все элементы управления, которые вызывают сервис сканирования. Таким образом, появляется новая проблема: как заблокировать одновременно все элементы UI, использующие сервис? И сопутствующая задача: все эти элементы должны иметь одинаковую иконку, одинаковую надпись и одинаковую всплывающую подсказку. Третий пункт требований — возможность сериализации состояния. Она нужна в случаях, когда меню чересчур большое и разветвленное, и пользователю дается возможность его настройки. Разумеется, после того, как пользователь настроил меню под свои потребности, должна быть возможность сохранить настройки и загрузить их в последующем. Остальные пункты требований понятны и без пояснений.

Возможные варианты проектных решений. В этом разделе статьи я покажу ход своих мыслей, который привел меня к окончательному решению задачи. На мой взгляд, очень удачному. И параллельно опишу понятия, без которых невозможно заниматься объектноориентированным анализом и проектированием. В основном речь пойдет о фундаментальных принципах ООАиП, описанных в книге Крэга Лармана под названием шаблонов GRASP (General Responsibility Assignment Software Patterns, общие шаблоны распределения обязанностей в программных системах). Шаблоны проектирования, описанные "бандой четырех", называются паттернами и под этим же именем будут далее упоминаться в статье. Каждое проектное решение состоит из классов, которые нужно оценивать с точки зрения связывания и зацепления. Функциональное зацепление — это мера связанности, сфокусированности, направленности обязанностей класса. Элемент обладает высокой степенью зацепления, если его обязанности тесно связаны между собой, и он не выполняет непомерных объемов работы. Класс с низкой степенью зацепления выполняет много разнородных функций или несвязанных между собой обязанностей. При разработке класса всегда нужно стремиться повысить степень зацепления, т.к. это облегчает понимание, упрощает поддержку и повторное использование, увеличивает надежность, делает класс минимально подверженным изменениям. Классы со слабым зацеплением, как правило, являются слишком "абстрактными" или выполняют обязанности, которые можно легко распределить между другими объектами. На практике уровень зацепления не рассматривают отдельно от других принципов ООАиП. Пример сильного зацепления: класс отвечает за некоторую обработку данных, а их сериализацию обеспечивает другой класс. Пример слабого зацепления: класс обрабатывает данные, сериализует и отправляет в базу данных. Обычно используются классы со средним зацеплением, т.е. такие, в которых среднее количество функций, связанных с концепцией класса, но не связанных между собой. Принцип сильного зацепления нежелательно применять лишь в двух случаях. Первый — когда код группируется в один класс для удобства его поддержки одним человеком. Второй — паттерн-фасад, который предоставляет высокоуровневый доступ к функционалу множества объектов. Связывание — это такое отношение между классами, при котором один класс зависит от другого.

Например, класс А знает об интерфейсе класса Б и пользуется его функционалом. Существуют следующие стандартные способы связывания объектов А и Б:
1. Объект А содержит атрибут, который ссылается на объект класса Б.
2. Объект А вызывает методы объекта Б.
3. Объект А содержит метод, который каким-либо образом ссылается на объект класса Б (обычно это подразумевает использование параметров). 4. Класс А является прямым или непрямым подклассом Б.
5. Класс Б является интерфейсом, а класс А реализует этот интерфейс.

Сильная связанность класса означает, что он пользуется функционалом большого числа классов. Это ведет к следующим проблемам:
1. Изменения в связанных классах приводят к локальным изменениям в данном классе.
2. Затрудняется понимание каждого класса в отдельности.
3. Усложняется повторное использование, поскольку для этого требуется дополнительный анализ классов, с которыми связан данный класс. Таким образом, целью проектирования является также слабое связывание. Единственный случай, когда сильное связывание оправдано — это связывание с неизменяемыми классами. Например, библиотечными. Теперь попробуем в общем случае спроектировать решение. Предполагается, что есть два главных класса: класс меню и класс, реализующий функционал. Назовем их MyMenu и MyFunctions. Распределим обязанности между ними.

Класс MyMenu единолично содержит следующие данные:
1. Строки названий элементов меню.
2. Строки подсказок к элементам меню.
3. Иконки элементов меню.
4. Состояния элементов меню.
5. Имя файла с состоянием меню.

Класс MyMenu выполняет следующие обязанности:
1. Загружает из файла (если таковой на диске присутствует) свое состояние.
2. Создает и инициализирует все элементы меню.
3. Перенаправляет запросы на вызов функциональности в класс MyFunctions.
4. Каким-то образом отслеживает состояние связанных с меню элементов-двойников (например, элементов контекстного меню).
5. По завершении работы программы сохраняет свое состояние.

Что касается класса MyFunctions, то он просто реализует функционал приложения и предоставляет высокоуровневые сервисы, которые и вызываются из меню. Посмотрим на класс MyMenu с позиций связывания и зацепления. Мы сразу увидим, что в нем нарушаются абсолютно все описанные выше принципы ООАиП. Класс содержит много разнородных данных, выполняет различные задачи из разных областей и при этом связан с большим числом классов, состояние которых он должен отслеживать. Попробуем разбить класс на подклассы так, чтобы усилить зацепление и ослабить связывание. Во-первых, вынесем из класса MyMenu все, что связано с сохранением/загрузкой его состояния на диск: имя файла состояния и методы чтения/записи файла. Объединим их в одном объекте MyMenuSerializer и забудем о нем на время. Построение меню на основе состояния или без него — тоже достаточно сложная операция. Пусть ее выполнит отдельный класс — MyMenuBuilder. Все данные, касающиеся одного элемента меню, также должны быть объединены в одном классе — MyMenuItem. Сюда входят название элемента меню, всплывающая подсказка, иконка и состояние. Кроме того, здесь должен присутствовать метод, который перенаправляет вызов функциональности на класс MyFunctions. В конечном итоге класс MyMenuItem должен быть достаточно универсальным, чтобы его можно было использовать при построении прочих элементов UI, которые дублируют меню. Тогда классу MyMenu остается только содержать в себе набор объектов MyMenuItem и управлять их состоянием.

Рис. 1. Диаграмма последовательности создания меню

Рассмотрим последовательность действий, которые выполняют объекты при создании меню (см. рис. 1). Во-первых, функция main() вызывает метод объекта MyMenuSerializer для загрузки меню. Собственно, на этом задача функции main() заканчивается. И, обратите внимание, main() ничего не знает о существовании классов, кроме MyMenuSerializer. Таким образом, что касается main(), здесь обеспечена идеально низкая степень связанности и максимально возможное зацепление: больше на эту функцию никаких задач пока не возлагается. Далее. Класс MyMenuSerializer вызывает собственный метод loadFromFile(), при помощи которого постарается восстановить состояние меню. Класс MyMenuSerializer должен быть достаточно надежен. Он не должен "падать" только потому, что файла не существует, и предусматривает два варианта развития событий: файл существует и прочитан и файл не может быть прочитан (не существует или неизвестен формат файла). В этой части статьи мы рассмотрим самый простой вариант: файл не существует или не может быть прочитан. В этом случае все дальнейшие полномочия по конструированию меню ложатся на класс MyMenuBuilder. Иначе, если файл состояния прочитан, то каждый элемент меню строится по отдельности при помощи все того же MyMenuBuilder'а. Класс MyMenuBuilder создает экземпляры классов MyMenuItem и добавляет их в объект класса MyMenu. Как видите, в таком достаточно общем случае обеспечена высокая степень зацепления классов и их слабая связанность. Соблюден (см. рис. 2) закон Деметры (другое название — принцип "не разговаривайте с незнакомцами").


Рис. 2. Диаграмма классов меню

Перечень объектов, которым можно отправлять сообщения или вызывать методы согласно закону Деметры:
1. Объекту this.
2. Параметру текущего метода.
3. Атрибуту объекта this.
4. Элементу коллекции, являющемуся атрибутом объекта this.
5. Объекту, созданному внутри метода.
Рассмотренное проектное решение является самым общим случаем. Его, возможно, используют программисты (и не только в языке C++), потому что оно представляется достаточно простым и само напрашивается на ум. А вот как именно "подогнать" этот проект для решения поставленной выше задачи на языке C++ с применением библиотеки Qt4 и паттернов "банды четырех", я расскажу в следующей части статьи.

Дмитрий Бушенко, nop@list.ru


Компьютерная газета. Статья была опубликована в номере 45 за 2005 год в рубрике программирование :: разное

©1997-2024 Компьютерная газета