суббота, 26 марта 2011 г.

Туториал по рисованию в Java

Добрый день, сегодня я вкратце расскажу вам, как же рисовать в Java. Сразу оговорюсь, что речь идет о рисовании графических примитивов в desktop приложении, например на JFrame. Разрабатывать я буду, в силу специфики нашей встречи, с помощью TDD, делая на этом основной акцент.

Итак, я хочу чтобы у пользователя при запуске приложения появлялся frame для отображения наших умопомрачительных художеств. Для этого создаем наследника класса JFrame и, проявив оригинальность, называем его, PaintFrame. Далее, создаем TestCase на этот класс (PaintFrameTest). Неплохое начало, не так ли? Опишем требования к frame’у в созданном TestCase’е. Пусть созданный фрейм будет находиться на позиции (50, 50):


@Test
public void paintFrameAt50x50Position() throws Exception {
PaintFrame frame = new PaintFrame();
Point actual = frame.getLocation();
Point expected = PaintFrame.DEFAULT_LOCATION_POINT;
assertEquals("PaintFrame must be at position (50,50) after creation",
actual, expected);
}

Запускаем тест – он валится. Прекрасно, сделаем его зеленым:
public PaintFrame() {
init();
}

private void init() {
setLocation(DEFAULT_LOCATION_POINT);
}

Теперь мне хочется, чтобы:
  • созданный frame имел размеры 400x400 и пользователь не мог бы их изменить.
  • У нашего окошка должен быть менеджер размещения BorderLayout
  • Frame должен содержать только один компонент и это должна быть панель типа JPanel.
  • При закрытии frame’а пользователем, оный должен уничтожаться.

Реализуем эту функциональность по аналогии с показанным примером. Приведу пример полученного TestCase’а:
public class PaintFrameTest {

@Test
public void paintFrameAt50x50Position() throws Exception {
PaintFrame frame = new PaintFrame();
Point actual = frame.getLocation();
Point expected = PaintFrame.DEFAULT_LOCATION_POINT;
assertEquals("PaintFrame must be at position (50,50) after creation",
actual, expected);
}

@Test
public void paintFrameHas400x400Size() throws Exception {
PaintFrame frame = new PaintFrame();
Dimension actual = frame.getSize();
Dimension expected = PaintFrame.DEFAULT_SIZE;
assertEquals("PaintFrame must have size 400x400 after creation",
expected, actual);
}

@Test
public void paintFrameNotResizable() throws Exception {
PaintFrame frame = new PaintFrame();
assertFalse("PaintFrame must be non-resizable.", frame.isResizable());
}

@Test
public void paintFrameHasBorderLayoutManager() throws Exception {
PaintFrame frame = new PaintFrame();
assertTrue("LayoutManager of PaintFrame must be a BorderLayout",
frame.getContentPane().getLayout() instanceof BorderLayout);
}

@Test
public void paintFrameContainsOnlyOnePanel() throws Exception {
PaintFrame frame = new PaintFrame();
Component [] components = frame.getContentPane().getComponents();
assertEquals("PaintFrame must contains only one component", 1, components.length);
assertTrue("PaintFrame contains only one JPanel", components[0] instanceof JPanel);
}

@Test
public void paintFrameMustExitedOnClose() throws Exception {
PaintFrame frame = new PaintFrame();
assertEquals("PaintFrame must exited on close operation.", JFrame.EXIT_ON_CLOSE,
frame.getDefaultCloseOperation());
}

}

Что-то вроде того должно получиться и у вас.

Основной каркас готов, теперь капельку теории. Отрисовка компонента в swing осуществляется в методе paintComponent(Graphics graphics). Класс Graphics предоставляет необходимые нам методы по рисованию примитивов наподобие точки, прямой, круга и других. Таким образом, как вы уже догадались, мы будем переопределять метод paintComponent у единственной панели, которую мы добавили на frame.

Очевидно нам понадобиться свой собственный тип панели. Сделаем его и назовем как-нибудь вроде Linen. Соответственно, сразу создаем соответствующий TestCase. Не забудем, что требования к фрейму изменились, отобразим это:

@Test
public void paintFrameContainsOnlyOneLinen() throws Exception {
PaintFrame frame = new PaintFrame();
Component [] components = frame.getContentPane().getComponents();
assertEquals("PaintFrame must contains only one component", 1, components.length);
assertTrue("PaintFrame contains only one JPanel", components[0] instanceof Linen);
}

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

Сосредоточимся на самом рисовании. Допустим я хочу чтоб при отрисовке панель рисовала на себе прямоугольник (10,10,92,17). Правда один прямоугольник это немного уныло, поэтому сделаем еще линию из точки (32,17) в точку (250, 378). Давайте более конкретно сформулируем требования, чтобы было удобно.

  • Полотно сконфигурировано для рисования в режиме сглаживания
  • На полотне нарисован зеленый прямоугольник с координатой левого верхнего угла (10,10) и размерами (92,17).
  • Поверх прямоугольника нарисована линия с началом в точке (32,17) и концом в точке (250, 378).
У меня получились такие тесты на все это хозяйство:
@Test
public void configureGraphicsSetAntializingAndRenderQuality() throws Exception {
Linen linen = spy(new Linen());
Graphics2D gr = mock(Graphics2D.class);
linen.configureGraphics(gr);
InOrder order = inOrder(gr);
order.verify(gr).setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
order.verify(gr).setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY);
}

@Test
public void drawObjectsDrawsGreenRectangleAndBlueLine() throws Exception {
Linen linen = spy(new Linen());
Graphics2D gr = mock(Graphics2D.class);
linen.drawObjects(gr);
InOrder order = inOrder(gr);
order.verify(gr).setColor(new Color(0, 255, 0));
order.verify(gr).fillRect(10, 10, 92, 17);
order.verify(gr).setColor(new Color(0, 0, 255));
order.verify(gr).drawLine(32, 17, 250, 320);
}

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

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

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

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

Создадим абстрактный класс AbstractLayer и добавим в его состояние информацию о весе.

public abstract class AbstractLayer implements Comparable<AbstractLayer> {

private int weight;

public int compareTo(AbstractLayer o) {
return weight - o.getWeight();
}

protected abstract void draw();

/**
* @param weight
* the weight to set
*/
public void setWeight(int weight) {
this.weight = weight;
}

/**
* @return the weight
*/
public int getWeight() {
return weight;
}

}

Вроде все замечательно, теперь добавим в наш класс Linen поддержку слоев. Во-первых дополним состояние объекта Linen параметром «список слоев».
private SortedSet<AbstractLayer> layers = new TreeSet<AbstractLayer>();


/**
* @param layers the layers to set
*/
void setLayers(SortedSet<AbstractLayer> layers) {
this.layers = layers;
}

/**
* @return the layers
*/
SortedSet<AbstractLayer> getLayers() {
return layers;
}
Я поступил как параноик- само множество слоев будет недоступно для стороннего пользователя. Добавление и удаление слоев будет осуществляться методами, соответствующими следующей спецификации:
@Test
public void addLayerAddsGivenParamIntoInnerLayersField() throws Exception {
Linen linen = new Linen();
AbstractLayer layer = new StubLayer(1);
linen.addLayer(layer);
assertTrue("addLayer method must adds layer to inner field of linen.",
linen.getLayers().contains(layer));
}

@Test
public void addLayerThatAlreadyExistIgnored() throws Exception {
Linen linen = new Linen();
AbstractLayer layer1 = new StubLayer(1);
AbstractLayer layer2 = new StubLayer(2);
linen.addLayer(layer1);
linen.addLayer(layer2);
int oldSize = linen.getLayers().size();
linen.addLayer(layer1);
assertEquals("addLayer method must ignored when layer already added.",
oldSize, linen.getLayers().size());
}

@Test
public void removeLayerRemovesGivenParamFromInnerLayersField()
throws Exception {
Linen linen = new Linen();
AbstractLayer layer = new StubLayer(1);
linen.getLayers().add(layer);
linen.removeLayer(layer);
assertFalse("removeLayer method must removes layer from inner "
+ "field of linen.", linen.getLayers().contains(layer));
}

@Test
public void removingNonExistLayerIgnored() throws Exception {
Linen linen = new Linen();
AbstractLayer layer = new StubLayer(1);
AbstractLayer layer2 = new StubLayer(2);
linen.getLayers().add(layer);
int oldSize = linen.getLayers().size();
linen.removeLayer(layer2);
assertEquals("removeLayer method must ignored if given layer "
+ "not exist in inner set.", oldSize, linen.getLayers().size());
}

@Test
public void innerSetSortedByLayerWeight() throws Exception {
Linen linen = new Linen();
AbstractLayer layer1 = new StubLayer(1);
AbstractLayer layer2 = new StubLayer(2);
AbstractLayer layer3 = new StubLayer(3);
linen.addLayer(layer2);
linen.addLayer(layer3);
linen.addLayer(layer1);
int old = 4;
for (AbstractLayer layer : linen.getLayers()) {
if (old < layer.getWeight()) {
fail("inner set of layers must be sorted");
}
old = layer.getWeight();
}
}

Изменим требования к методу drawObjects(Graphics2D gr). Теперь его работа будет заключаться в последовательном вызове методов отрисовки у всех своих внутренних слоев.
@Test
public void drawObjectsDrawsAllLayersSortedByWeight() throws Exception {
Linen linen = new Linen();
Graphics2D gr = mock(Graphics2D.class);
AbstractLayer layer1 = spy(new StubLayer(1));
AbstractLayer layer2 = spy(new StubLayer(2));
AbstractLayer layer3 = spy(new StubLayer(3));
linen.addLayer(layer2);
linen.addLayer(layer3);
linen.addLayer(layer1);
linen.drawObjects(gr);
InOrder inOrder = inOrder(layer1, layer2, layer3);
inOrder.verify(layer3).draw(gr);
inOrder.verify(layer2).draw(gr);
inOrder.verify(layer1).draw(gr);
}

Вот, собственно все и готово. Теперь можно использовать для рисования слои, абстрагируясь от положения элементов друг на другом.

Теперь можно побаловаться и сделать что-нибудь вроде перемещающегося квадрата по панельке по некоторой траектории. Чтобы не утомлять вас длинными рассуждениями на тему того, как же это должно быть реализовано, оставлю решение за вами, вы вольны придумать такую реализацию, которая вам больше нравится. Я добавил на слои поддержку событий изменения состояния слоя и при изменении состояния обновлял компонент. Мой вариант реализации можно найти в исходных кодах XP Party. Исходный код можно найти на нашем SVN.