Skip to main content

06. Область рисования

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

w

Пример готового проекта лежит здесь. Для выполнения текущего задания Fork от него делать запрещается.

В ходе работы вам будет нужно сделать несколько коммитов с промежуточными состояниями проекта, поэтому в прямом копировании нет смысла. Готовый проект даётся для того, чтобы было проще разобраться, как выполнить поставленное задание.

Если у вас не выполнено предыдущее задание или выполнено не полностью, то сделайте Fork готового проекта из предыдущей главы.

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

Заголовок окна

В первую очередь зададим заголовок окну. Для этого в конструкторе класса приложения Application добавим команду

    window.setTitle("Java 2D");

Метод setTitle() принимает в качестве аргумента строку. Какую вы укажете, таким и будет заголовок окна.

w

В этом блоке все дальнейшие команды мы будем вводить в теле конструктора приложения между командами window.setEventListener(this); и window.setVisible(true);

Система координат окна

Положение окна задаётся целочисленными координатами. В математике мы привыкли, что ось Y направлена вверх, т.е. положение по высоте определяется снизу вверх. В компьютерных системах почему-то традиционно сложилась традиция направлять ось вниз. Левая верхняя точка экрана имеет координаты [0,0][0,0], а правая нижняя [w,h][w, h], где ww - ширина окна, а hh - высота.

Зададим теперь размеры окна и его положение

// задаём размер окна
window.setWindowSize(900, 900);
// задаём его положение
window.setWindowPosition(100, 100);

Создайте новый коммит window title ready и отправьте его на сервер.

Перечисления*

Теоретический блок

Вам нужно просто прочитать его. Команды выполнять не требуется.

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

import java.util.Scanner;

public class Main {

public static void main(String[] args) {
// создаём сканер
Scanner sc = new Scanner(System.in);
// получаем номер выбора
int select = sc.nextInt();
// в зависимости от числа выбираем, что делать
switch (select) {
case 0 -> load();
case 1 -> save();
case 2 -> solve();
}
}

public static void load() {
// загружаем данные
}

public static void save() {
// сохраняем данные
}

public static void solve() {
// решаем задачу
}
}

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

Магическими константами (magic constants) иронично называют литералы в коде, которые непонятно откуда взялись и непонятно зачем нужны.

Да, например умножать на два напрямую, используя конструкцию a*=2, вполне уместно, но если множитель теоретически может поменяться причём в нескольких местах, то лучше выделить его в константу.

Поэтому раньше для каждого значения вводили отдельную константу:

import java.util.Scanner;

public class Main {
// загрузить
public static final int LOAD = 0;
// сохранить
public static final int SAVE = 1;
// решить
public static final int SOLVE = 2;

public static void main(String[] args) {
// создаём сканер
Scanner sc = new Scanner(System.in);
// получаем номер выбора
int select = sc.nextInt();
// в зависимости от числа выбираем, что делать
switch (select) {
case LOAD -> load();
case SAVE -> save();
case SOLVE -> solve();
}
}

public static void load() {
// загружаем данные
}

public static void save() {
// сохраняем данные
}

public static void solve() {
// решаем задачу
}
}

Любая константа задаётся для всех объектов класса сразу, поэтому я сделал поле ещё и статическим. Названия констант принято писать в верхнем регистре, а пробел заменять символом нижнего подчёркивания. Например:

public static final int MY_FIRST_CONSTANT = 152;`

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

Ещё в довольно ранних версиях java уже была поддержка перечислений Enum. Помимо самоконтроля, перечисление можно рассматривать как класс. В своей сути оно и есть класс, у него можно определять поля и даже методы. В перечислениях определён метод toString(). Он просто выводит название значения перечисления.

В своей сути объект перечисления - это переменная, которая может принимать только фиксированный набор значений.

Исключения*

Теоретический блок

Вам нужно просто прочитать его. Команды выполнять не требуется.

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

Непредсказуемость ресурса здесь условная. Под этим понимается то, что ресурс может находиться в неожиданном для нас состоянии или вообще отсутствовать. Такие ситуации называются исключительными. В более ранних языках исключения обрабатывались вручную. Разработчики Java сразу переложили логику исключительных ситуаций на логику ООП.

Чтобы обработать исключительную ситуацию (поймать), нужно код, использующий непредсказуемый ресурс обернуть конструкцией

    try {
// код, использующий непредсказуемые ресурсы
...
} catch (Exception e) {
// обработка исключительной ситуации
System.out.println("Получена ошибка: " + e);
}

В блоке try{} выполняется код, который нам нужно проверить, в блоке catch(Exception e){} - обработка исключительной ситуации. Строго говоря, в блок catch может передаваться конкретный потомок Exception, отвечающий за тот или иной вид исключений. Для простоты мы будем работать всегда с базовым классом всех исключений.

В области видимости блока catch доступен объект класса Exception. Это и есть объект исключения. У него есть встроенное преобразование к строке, поэтому мы можем просто вывести текст ошибки на экран и продолжить выполнение программы.

Если вы хотите увидеть весь стек вызовов методов, которая привела к исключительной ситуации, то вызовите команду e.printStackTrace();. Правда, она прервёт выполнение программы.

Выбор элемента*

Теоретический блок

Вам нужно просто прочитать его. Команды выполнять не требуется.

В современных программах не задают режим с помощью числа, его задают явно указывая его название, да и очень редко читают это название из консоли. Чаще всего это происходит в автоматическом режиме, поэтому и используют названия.

Мы же пишем консольную программу, поэтому придётся вручную ввести название режима. У каждого множества есть встроенный парсинг (разбор) строки Mode.valueOf(s). Если строка не соответствует одному из значений множества, то метод кидает исключение.

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

import java.util.Scanner;

public class Main {
enum Mode {
// загрузить
LOAD,
// сохранить
SAVE,
// решить
SOLVE
}

public static void main(String[] args) {
// создаём сканер
Scanner sc = new Scanner(System.in);
String s = sc.nextLine();

// получаем режим
try {
Mode mode = Mode.valueOf(s);
// в зависимости от режима выбираем, что делать
switch (mode) {
case LOAD -> load();
case SAVE -> save();
case SOLVE -> solve();
}
} catch (Exception e) {
e.printStackTrace();
}

}

public static void load() {
// загружаем данные
System.out.println("load");
}

public static void save() {
// сохраняем данные
System.out.println("save");
}

public static void solve() {
// решаем задачу
System.out.println("solve");
}
}

Если мы сейчас введём неправильное название элемента перечисления, то программа выдаст нам полный стек вызовов.

w

Введём правильную команду:

w

Обратите внимание: название элемента, как и все названия в Java чувствительны к регистру. Поэтому если мы напишем название с маленькой буквы, а задано он с большой, то Enum.valueOf() тоже сгенерирует исключительную ситуацию, т.е. бросит исключение.

w

Напишем теперь обработку исключительной ситуации:

import java.util.Scanner;

public class Main2 {
enum Mode {
// загрузить
LOAD,
// сохранить
SAVE,
// решить
SOLVE
}

public static void main(String[] args) {
// создаём сканер
Scanner sc = new Scanner(System.in);
String s = sc.nextLine();

// получаем режим
try {
Mode mode = Mode.valueOf(s);
// в зависимости от режима выбираем, что делать
switch (mode) {
case LOAD -> load();
case SAVE -> save();
case SOLVE -> solve();
}
} catch (Exception e) {
System.out.println("Команда " + s + " не определена");
}

}

public static void load() {
// загружаем данные
System.out.println("load");
}

public static void save() {
// сохраняем данные
System.out.println("save");
}

public static void solve() {
// решаем задачу
System.out.println("solve");
}
}

Теперь неправильная команда не "ломает" работу нашего приложения. Программа просто выводит сообщение об исключительной ситуации и корректно завершает свою работу.

Работа завершается потому, что после try...catch нет никаких команд.

w

public static void main(String[] args) {
// создаём сканер
Scanner sc = new Scanner(System.in);
String s = sc.nextLine();

// получаем режим
try {
Mode mode = Mode.valueOf(s);
// в зависимости от режима выбираем, что делать
switch (mode) {
case LOAD -> load();
case SAVE -> save();
case SOLVE -> solve();
}
} catch (Exception e) {
System.out.println("Команда " + s + " не определена");
}

// выполним ещё одну команду после
System.out.println("Программа завершается");
}

w

Перечисление ОС

Давайте поменяем иконку окна. Т.к. мы пишем кросс-платформенное приложение, то нам нужно учитывать, что системы на MacOS требуют иконок одного формата, а Windows - другого.

Скачать иконки, которые я использую, можно отсюда.

Я покажу, как их добавить из архива с помощью программы winRar. Сам архив имеет расширение zip, поэтому даже если у вас нет этой программы, архив всё равно откроется, просто в проводнике. Вам нужно скопировать файлы и переходить к следующему шагу.

Чтобы скопировать файлы из winRar не извлекая, выделите их и нажмите правой кнопкой мыши по выделению

w

И выберите пункт "Скопировать файлы в буфер обмена". Вместо этого также можно просто нажать сочетание клавиш Ctrl+C. Потом переходим в idea, кликаем правой кнопкой мыши по папке resources и выбираем в меню пункт Paste.

w

В появившемся окне жмём "ОК"

w

Появится новое окно, предлагающее нам выбрать, какие файлы добавить в индекс. Нам все они нужны, idea выделила их автоматически, поэтому просто жмём Add.

w

Теперь наше дерево проекта будет выглядеть так

w

Теперь в конструкторе Application можно задать иконку приложению

// задаём иконку
switch (Platform.CURRENT) {
case WINDOWS -> window.setIcon(new File("src/main/resources/windows.ico"));
case MACOS -> window.setIcon(new File("src/main/resources/macos.icns"));
}

Это switch оформленный в функциональном стиле. Platform - это перечисление, а current - текущее его значение. В зависимости от значения множества выполняется та или иная команда. В нашем случае загружается соответствующая иконка.

Посмотрим на его реализацию (это демонстрационный код, его не нужно копировать в проект):

public enum Platform {
WINDOWS,
X11,
MACOS;

public static final Platform CURRENT;
static {
String os = System.getProperty("os.name").toLowerCase();
if (os.contains("mac") || os.contains("darwin"))
CURRENT = MACOS;
else if (os.contains("windows"))
CURRENT = WINDOWS;
else if (os.contains("nux") || os.contains("nix"))
CURRENT = X11;
else
throw new RuntimeException("Unsupported platform: " + os);
}
}

У него есть три элемента, соответствующие той или иной операционной системе. В блоке статической инициализации читается название операционной системы и по нему задаётся значение константы CURRENT.

Обратите внимание: если не получилось определить операционную систему, в блоке статической инициализации кидается исключение при помощи команды throw new Exception(). Т.е. в качестве аргумента эта команда принимает объект класса исключения Exection или его потомка.

Вообще, кидать исключения в блоке статической инициализации - дурной тон. С другой стороны, если операционная система не определена, то и как создавать окно - непонятно. Ведь у каждой ОС свои поддходы, события и т.д.

Если опять посмотреть на то, как мы задаём иконку, то можно увидеть, что этот блок кода использует непредсказуемые ресурсы.

// задаём иконку
switch (Platform.CURRENT) {
case WINDOWS -> window.setIcon(new File("src/main/resources/windows.ico"));
case MACOS -> window.setIcon(new File("src/main/resources/macos.icns"));
}

Эти ресурсы - файлы. Конечно, чтение из файла нужно оборачивать блоком try...catch, но здесь обработка исключения выполнена внутри метода window.setIcon(). Если иконка недоступна, движок (так дальше будет называть JWM, чтобы не путать его с виртуальной машиной Java) просто проигнорирует эту команду и продолжит работу.

Создайте новый коммит window icon ready и отправьте его на сервер.

Canvas

Теперь нам нужно добавить область рисования на наше окно. Область рисования по-английски называется Canvas, дальше иногда я буду её называть канвасом.

Движок позволяет построить область рисование на разных слоях. Каждый слой - это самостоятельное средство рисования пикселей на экране. У JWM есть три основных слоя - это: OpenGL, растр, и DirectX.

Откровенно говоря, слой с DirectX глючит, да и на unix не поддерживается. Поэтому мы оставим два слоя. Первым попытаемся загрузить слой OpenGL, если с ним какие-то проблемы, то запустим растр. Подробнее об OpenGl можно прочитать здесь.

Если коротко, то OpenGL позволяет преобразовать двумерные вещественные координаты в координаты экрана, а также определить цвет каждого пикселя. При этом все параметры рисования задаются вещественно. Это позволяет не думать о том, как отобразится наш вещественно определённый мир на пиксельной поверхности.

В конструкторе класса Application допишем следующие строки:

        // названия слоёв, которые будем перебирать
String[] layerNames = new String[]{
"LayerGLSkija", "LayerRasterSkija"
};

// перебираем слои
for (String layerName : layerNames) {
String className = "io.github.humbleui.jwm.skija." + layerName;
try {
Layer layer = (Layer) Class.forName(className).getDeclaredConstructor().newInstance();
window.setLayer(layer);
break;
} catch (Exception e) {
System.out.println("Ошибка создания слоя " + className);
}
}

// если окну не присвоен ни один из слоёв
if (window._layer == null)
throw new RuntimeException("Нет доступных слоёв для создания");

После того как мы задали названия слоёв, мы начинаем их перебирать. Первым идёт слой OpenGL. Если слой получилось загрузить останавливаем перебор и задаём его окну.

Теперь конструктор нашего приложения будет выглядеть так:

    /**
* Конструктор окна приложения
*/
public Application() {
// создаём окно
window = App.makeWindow();
// задаём обработчиком событий текущий объект
window.setEventListener(this);
// задаём заголовок
window.setTitle("Java 2D");
// задаём размер окна
window.setWindowSize(900, 900);
// задаём его положение
window.setWindowPosition(100, 100);
// задаём иконку

switch (Platform.CURRENT) {
case WINDOWS -> window.setIcon(new File("src/main/resources/windows.ico"));
case MACOS -> window.setIcon(new File("src/main/resources/macos.icns"));
}

// названия слоёв, которые будем перебирать
String[] layerNames = new String[]{
"LayerGLSkija", "LayerRasterSkija"
};

// перебираем слои
for (String layerName : layerNames) {
String className = "io.github.humbleui.jwm.skija." + layerName;
try {
Layer layer = (Layer) Class.forName(className).getDeclaredConstructor().newInstance();
window.setLayer(layer);
break;
} catch (Exception e) {
System.out.println("Ошибка создания слоя " + className);
}
}

// если окну не присвоен ни один из слоёв
if (window._layer == null)
throw new RuntimeException("Нет доступных слоёв для создания");

// делаем окно видимым
window.setVisible(true);
}

Запустим программу. Теперь окно стало чёрным, но зато с иконкой и правильных размеров.

w

Чтобы что-то нарисовать, нам нужно обработать событие рисования. Для этого в методе accept(Event e) добавим строку обработки событий Skija.

        else if (e instanceof EventFrameSkija ee) {
// получаем поверхность рисования
Surface s = ee.getSurface();
// очищаем её канвас заданным цветом
s.getCanvas().clear(0xFF264653);
}

Область рисования окрасилась в указанный нами цвет

w

Весь код обработки событий теперь будет таким:

    /**
* Обработчик событий
*
* @param e событие
*/
@Override
public void accept(Event e) {
// если событие - это закрытие окна
if (e instanceof EventWindowClose) {
// завершаем работу приложения
App.terminate();
} else if (e instanceof EventWindowCloseRequest) {
window.close();
}else if (e instanceof EventFrameSkija ee) {
Surface s = ee.getSurface();
s.getCanvas().clear(0xFF264653);
}
}

Создайте новый коммит canvas works и отправьте его на сервер.

Цвета

Цвет задаётся целым четырёх-байтным числом. По одному байту отведено на каждую из компонент. Тогда цвет будет удобно записывать с помощью шестнадцатеричного представления. В Java все шестнадцатеричные литералы начинаются с двух символов 0x, чтобы отличать их от десятеричных.

Например, первая компонента равна 0xFF, вторая - 0x26, третья 0x46, четвёртая 0x53

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

Остальные три компоненты - это компоненты цвета. Любой цвет можно задать при помощи трёх базовых. В программировании самый распространённый набор базовых цветов - это RGB, ещё этот набор называют моделью RGB.

w

Модель RGB составлена по первым буквам названий базовых цветов: Red(красный), Green(зелёный) и Blue(синий). Чем больше значение соответствующей компоненты, тем значительнее вклад того или иного базового цвета.

Если все компоненты имеют максимальное значение, то мы получим белый цвет, а если минимальное, то чёрный.

Такой способ задания цвета довольно краток, но тяжёл для обучения. Поэтому мы напишем метод getColor(), который по компонентам, переданным по отдельности, формирует число, подходящее для движка.

Для этого создадим ещё один пакет и назовём его misc. В пакете, названном таким образом, обычно хранят вспомогательные классы. Т.е. классы, которые не сильно зависят от классов из других пакетов и решают прикладные задачи.

Нам нужно просто куда-то поместить статический метод формирования цвета по компонентам. Поэтому мы создадим класс Misc и поместим метод в него.

w

package misc;

/**
* Вспомогательная библиотека
*/
public class Misc {

/**
* Получить цвет по компонентам
*
* @param a прозрачность
* @param r красная компонента
* @param g зелёная компонента
* @param b синяя компонента
* @return целове число с цветом
*/
public static int getColor(int a, int r, int g, int b) {
return ((a * 256 + r) * 256 + g) * 256 + b;
}


/**
* Запрещаем вызов конструктора
*/
private Misc() {
throw new AssertionError("Вызов этого конструктора запрещён");
}

}
Обратите внимание

У класса Misc я добавил закрытый конструктор. Внутри него генерируется исключение, что его запускать нельзя.

Это сделано специально для защиты от взломов. Да, ваше приложение навряд ли кто-то будет взламывать. Но лучше привыкать сразу писать безопасный (Enterprise) код.

Если мы не запретим вызов конструктора в классе, который и не предполагает создание экземпляра, то злоумышленник может инстанцировать его (т.е. создать объект). При этом новый класс может быть построен на основе вашего, но поведение будет изменено.

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

Но за счёт рефлексии модификаторы доступа можно изменить прямо в ходе исполнения программы. Поэтому для защиты вашего класса нужно ещё и в теле закрытого конструктора кинуть исключение с соответствующим сообщением.

Создадим ещё второй класс Colors, но на этот раз в пакете app:

w

Исходный код Colors:

package app;

import misc.Misc;

/**
* Класс цветов
*/
public class Colors {
/**
* цвет фона
*/
public static final int APP_BACKGROUND_COLOR = Misc.getColor(255, 38, 70, 83);

/**
* Запрещённый конструктор
*/
private Colors() {
throw new AssertionError("Вызов этого конструктора запрещён");
}
}

Цвет я задаю при помощи константы, но чтобы её использовать в других классах, нам необходимо импортировать её.

    /**
* Обработчик событий
*
* @param e событие
*/
@Override
public void accept(Event e) {
// если событие - это закрытие окна
if (e instanceof EventWindowClose) {
// завершаем работу приложения
App.terminate();
} else if (e instanceof EventWindowCloseRequest) {
window.close();
}else if (e instanceof EventFrameSkija ee) {
// получаем поверхность рисования
Surface s = ee.getSurface();
// очищаем её канвас заданным цветом
s.getCanvas().clear(Colors.APP_BACKGROUND_COLOR);
}
}

Я заменил прямое указание цвета на обращение к константе, но она горит красным:

w

Жмём Alt+Enter, в появившемся окне выбираем первый пункт.

w

Снова запустим программу и убедимся, что цвет не изменился

w

Создайте новый коммит add setColor() и отправьте его на сервер.

Задание

Пример готового проекта лежит здесь. Для выполнения первого задания Fork от него делать запрещается.

В ходе работы вам будет нужно сделать несколько коммитов с промежуточными состояниями проекта, поэтому в прямом копировании нет смысла. Готовый проект даётся для того, чтобы было проще разобраться, как выполнить поставленное задание.

Если у вас не выполнено предыдущее задание или выполнено не полностью, то сделайте Fork готового проекта из предыдущей главы.

Вам необходимо создать свой репозиторий, в котором добавлены следующие коммиты (для каждого сначала указывается название, потом требования):

  1. window title ready - проект с оконным приложением, которое можно закрыть, и у которого в заголовке написано: Java2D
  2. window icon ready - у приложения должна появиться иконка, как в примере, причём иконка должна корректно отображаться и на Windows и на MacOS.
  3. canvas works - у приложения должен поменяться фон на серо-синий.
  4. add setColor() - перепишите логику приложения так, чтобы цвет фона был константой, значение которой вычислялось бы через метод getColor().

w

Ссылку на github репозиторий необходимо отправить в поле ввода задания на сайте mdl.

Ссылка на контест