«Давайте напишем Windows!» Белорусский iOS-разработчик — о том, как решать сложные задачи

Игорь Шавловский, Lead iOS-разработчик в neoviso, написал для dev.by колонку о том, как решать сложные задачи, разделяя их на простые.

Давайте напишем Windows! Ну, не совсем её, разбираться в ядре будет скучно, да и я абсолютно не эксперт в архитектуре операционных систем. Зато в любой ОС есть «весёлые» части — те, с которыми работает конечный пользователь. Прежде всего, это интерфейс.

Итак, предлагаю написать GUI для операционной системы.

Как к этому подойти? Такая здоровенная махина и так мало знаний о внутреннем устройстве операционной системы.

Я уже говорил о необходимости анализировать, строить алгоритмы и ломать вещи на части. Но что же надо ломать здесь? Посмотрите на свой компьютер и найдите на нём графический интерфейс. Он, конечно, будет светиться на мониторе. Возьмите молоток и сломайте его! Перед вами будет жидкокристаллическая матрица.  Теперь берите лупу и внимательно рассмотрите матрицу. Вы увидите, что она состоит из своих минимальных частей — пикселей. Сама матрица — это двумерный массив пикселей, таблица, в ячейках которой записаны значения цветов в RGB-формате. Именно с этой таблицей будет взаимодействовать наш GUI, она будет единственным устройством вывода графической информации.

Это значит,  для того, чтобы «нарисовать» графический интерфейс, нам, в первую очередь, нужен доступ к пикселям, то есть надо уметь переводить монитор в графический режим, устанавливать разрешение и менять цвет пикселей.

Упрощённо GUI — это программа, которая меняет цвета пикселей монитора в зависимости от действий пользователя. Какая же единственная операция нужна нам для того, чтобы иметь возможность нарисовать любое изображение? Это операция записи данных в таблицу пикселей.

void setPixelColor(int x, int y, int r, int g, int b); — вот она. Мы можем нарисовать на мониторе что угодно,  вызывая лишь эту простейшую функцию и указывая ей позицию пикселя в таблице (x, y) и цвет, который на нём надо зажечь (r, g, b). Это будет нашей главной и единственной операцией вывода данных.

Зажигаем пиксели один за одним

Любое «рисование» на экране можно свести к последовательному зажиганию нужных пикселей один за одним. Современные GUI, конечно же, функционируют не так: они используют аппаратное ускорение и функции, работающие с массивами данных. Пока нам достаточно лишь зажигания пикселей, позже мы можем добавить более сложные функции рисования для геометрических фигур, заливки линий, градиентов. Но для нашей задачи они не имеют решающего значения, так как все эти подзадачи можно свести просто к вызову setPixelColor.

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

  1. Рабочий стол
  2. Ярлыки
  3. Панель задач
  4. Окна запущенных приложений

Всё это элементы интерфейса. Где они находятся? Изначально на окне лежит рабочий стол, на нём — все остальные элементы, по нему же «плавают» окна, на окнах расположены их собственные элементы. Теперь надо понять, как части интерфейса подчинены друг другу. Для этого следует повзаимодействовать с ними, перетаскивать их, увидеть, какие элементы получают фокус, передают подчинённым частям события мыши и клавиатуры. Сделайте это прямо сейчас — и вы заметите, что весь интерфейс основан на отношениях подчинения. Одни элементы («дети») лежат на других («родители»), а вместе они образуют древовидную структуру:

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

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

  1. Прямоугольную область, в которой он находится
  2. Правила рисования в этой области
  3. Список «детей», лежащих на нём
  4. Правила взаимодействия с пользователем

Это всё, что требуется для реализации простейшего GUI. Сейчас надо сложить всё вместе и понять, как правила, которые мы вывели, связаны с придуманной ранее функцией setPixelColor.

Снова смотрим на рабочий стол — родительский элемент лежит ниже всего, поверх его рисуются его дети — окна, ярлыки и другие элементы. Когда окно лежит на рабочем столе, мы видим всех его детей, когда оно свёрнуто — вместе с ним сворачиваются все дети. Можно сказать, что родитель рисует своих детей на себе.

Значит, нужен класс, описывающий элемент интерфейса, который может содержать на себе несколько элементов интерфейса. Какие функции он должен реализовывать? Собственно функции для создания этой иерархии — добавление и удаление подчинённого элемента.

Какие могут быть подводные камни? Вызывайте в голове эти функции и находите их.

  • Что, если добавить элемент сам на себя?
  • Что, если добавить/удалить null вместо элемента?
  • Что, если добавить элемент на свой же родитель?
  • Что, если добавить элемент, уже имеющий родителя,  на другой элемент?

Все эти случаи необходимо обработать, так как иначе это может приводить к ошибкам (а ошибки в построении иерархии между объектами всегда очень сложно обрабатывать).

Попробуем написать код

Корневой элемент «рабочий стол» сначала рисует картинку заднего фона, потом ярлыки, а потом все окна. При этом активное окно рисуется последним и «зарисосывает» всё, что лежит под ним. Кажется, мы только что нашли алгоритм. Выходит,  для того, чтобы нарисовать весь интерфейс, GUI должен лишь нарисовать корневой элемент. Он сначала нарисует себя, потом скажет делать это своим детям.

Область с элементом (frame) — это прямоугольник, для которого нам тоже надо написать свой класс. Также следует понимать, что ребёнок рисуется не в координатах окна, а в координатах родителя, поэтому для того, чтобы узнать положение какого-то пикселя на экране, надо прибавить к нему отступы всех его родителей.

Простейший способ это сделать — пробежать по всем родителям и найти сумму отступов.

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

Получается, для того, чтобы нарисовать пиксель в локальной системе координат, надо вызывать функцию setPixelColor для результата getScreenPosition. Однако, вызывать её для каждого пикселя накладно и неудобно. А как удобно? Удобно вызывать функции рисования, не думая о том, где мы находимся, и работать исключительно в локальной системе координат. Именно поэтому в большинстве GUI имеется концепция контекста рисования — посредника между видеодрайвером и программистом, который хранит в себе текущие значения цвета, отступа родителя и т.д.

Напишем его, а заодно определим структуру для хранения цвета и метод рисования элемента.

Что происходит в методе draw

Во-первых, в начале и в конце метода вызывается метод translate, сначала с положительным, а потом — с отрицательным значениями верхнего левого угла фрейма элемента. Это «смещает» графический контекст, то есть изменяет константу, которая будет добавлена к координате точки при любом вызове setPixelColor. Таким образом, пользователю не надо добавлять текущую позицию элемента и позиции его родителей к положению пикселя в локальной системе координат при отрисовке. Это будет сделано автоматически контекстом рисования. Повторный вызов translate вернёт контекст в состояние, в котором он находился до начала рисования этого элемента. Само рисование состоит из вызова drawMe — прорисовки непосредственно элемента и draw для всех его детей, которые опять же сместят контекст «под себя», нарисуют себя, всех своих детей и вернут контекст в исходное состояние.

Внимательно посмотрите на код метода draw и назовите его тонкое место. Метод translate работает, на первый взгляд, валидно и оба раза вызывается с одним и тем же значением frame, однако, если в процессе рисования это поле поменяет значение,  второй вызов translate может выставить контекст в неопределённое состояние. Это место указывает нам на то, как правильно писать UI код.  

Действительно, все GUI-фреймворки выдвигают конкретные требования к коду. Зачастую все вызовы методов UI должны происходить из UI-потока. Переменные, определяющие правила рисования элементов, не должны быть модифицированы в процессе рисования, запрещается добавление детей в процессе расчёта положения элементов в контейнере. Жизненный цикл GUI-фреймворка разделён на этапы, в каждом из которых происходят какие-то операции с элементами.

Какие это этапы?

На этот вопрос мы ответим в одной из следующих статей, где доведём наброски кода до реально работающего прототипа, оценим скорость работы попиксельного рисования, рассмотрим более сложные алгоритмы, лежащие в основе GUI-фреймворков.

Зачем всё это нужно

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

Но целью не было написание готового, мощного, полноценного GUI: я хотел мотивировать вас на поиск интересных задач, а также показать, что любая задача, которая изначально может казаться сложной, всегда состоит из простейших вещей. Если их найти, то задача по написанию такой сложной вещи, как графический интерфейс, сведётся к двум-трём часам реальной работы. Конечно, эти два-три часа являются лишь основой для огромной работы, которую нужно проделать для того, чтобы GUI стал полноценным и мощным инструментом. Но именно эти часы заложат фундамент, и от его качества будет зависеть успех всего проекта.

Чтобы потренироваться, предлагаю написать простейший рендер рабочего стола, окна с заголовком, попробовать сделать кнопку. Вместо монитора можно использовать картинку Bitmap или окно приложения javafx. Если же вы не знаете java, но знаете другой язык, ещё веселее — изучите java параллельно с написанием (это очень простой и доступный язык!) своего GUI.

Ведь язык программирования — лишь инструмент в руках разработчика, а реальная разработка происходит в голове.

Источник: dev.by
Нашли в тексте ошибку — выделите её и нажмите Ctrl+Enter.
Вакансии

Обсуждение

Missing
+2

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

Missing
+2

С нетерпением буду ждать статьи "Давайте сделаем свой Интернет", где будет описано создание странички "Hello world" :)

Missing

Интернет действительно собирается из нескольких "Hello world", они называются сетевой моделю OSI. Понимание функций каждого из этих уровней важно для создания архитектур сложных распределённых приложений

Missing
+3

переписал код со скриншетов, почему не компилируется? может нужно было вместе с комментариями?

1cdb342ea3d8a253e6a7bff7c1f3c68b?1532218851
+2

А я скомпилил и уже заюзал на проде

023060881b7c111beefa69fbb517539b?1532218885
+2

юношеский максимализм

Missing
+2

Я нашёл решение: в комментариях "редёнок" исправил на "ребёнок" — и моя Windows 11 скомпилировалась!

Missing-male

Окно-> Рабочий стол-> опять Окно ...

да уж... попытка декомпозиции сложной системы как то явно сразу не задалась...


Авторизуйтесь, чтобы оставлять комментарии

Использование материалов, размещенных на сайте, разрешается при условии прямой гиперссылки на dev.by. Ссылка должна быть размещена в подзаголовке или в первом абзаце публикации.
datahata — хостинг в Беларуси