Последнее изменение: 23 декабря 2010г.

Внутреннее устройство GUI

– А почему я рисую, а программа не запоминает?
– А ты сказал ей «запомнить»?
– Нет. А что, надо было?

Из жизни

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

Одной из самых частых проблем при создании собственных компонент, по моим наблюдениям, в последнее время стала следующая. Приведу ее описание в обобщенном виде:

Я рисую на форме мышкой. Но когда я изменяю размеры формы (варианты – схлопываю и открываю окно, переключаюсь в другое окно и обратно) – все нарисованное пропадает. Почему это и что делать?

После четвертого или пятого объяснения я понял, что будет проще написать на эту тему отдельную статью. А заодно и рассказать о других вещах, связанных с отрисовкой компонент:

Начнем мы с экскурса в прошлое.

Когда деревья были большими...

..., а трава зеленой, ... В общем, когда-то такого фреймворка как Swing не существовало. Удивительно, правда? И тем не менее. До версии Java 1.2 интерфейс пользователя создавался на основании технологии AWT – Abstract Window Toolkit.

Принцип работы AWT следующий. Я об этом писал в статье о менеджерах компоновки (http://www.skipy.ru/technics/layouts.html), но повторюсь. Каждой компоненте ставится в соответствие компонента операционной системы, в которой исполняется приложение. Связь осуществляется через т.н. peer-объекты, которые создаются глубоко внутри реализации UI, пользователь их не видит. Большая часть вызовов в этих peer-объектах на самом деле native, соовтетственно, требуется реализация под каждую OS и, возможно, графическую оболочку (если они могут меняться, что не исключено в *NIX-системах).

Опора на native-компоненты ОС порождает ряд проблем. Первая – внешний вид приложений. Ниже приведен один и тот же диалог выбора графического файла с возможностью предпросмотра в разных ОС:

Windows Motif Apple Mac
Windows Motif Apple

Как видите – даже близко ничего похожего. Разные цветовые схемы, разный вид элементов и, что самое главное – разная компоновка окна. Все это способно крайне затруднить работу с приложением при переходе под другую ОС. Т.е. налицо противоречие главному принципу Java – WORA (Write Once, Run Anywhere). Прибавьте к этому разные «горячие» клавиши в разных системах, разную реакцию на события мыши... А ведь еще и каких-то компонент может не быть под определенной ОС!

С компоновкой в собственном приложении еще можно справиться, хотя при этом, возможно, придется нарушить привычные для данной ОС принципы построения интерфейса – сравните расположение кнопок в трех окнах. Остальные проблемы остаются. Мне приходилось разрабатывать приложения, которые работали под Windows и Motif, и даже я, их автор, испытывал определенный дискомфорт при смене ОС – настолько сильно менялся вид интерфейса.

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

Решение оказалось весьма удачным. Swing очень быстро вытеснил AWT. Имеющиеся изначально проблемы с производительностью тоже были устранены, сегодня скорость работы интерфейса, основанного на Swing, сравнима со скоростями native-приложений. В общем, в данный момент я не знаю ни единой причины для использования AWT, потому далее мы будем рассматривать в основном Swing. Хотя некоторые принципы у них с AWT одинаковые.

Архитектура Swing

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

Общие принципы построения графического интерфейса

При отрисовке пользовательского интерфейса используется подход, сформулированный еще Ильфом и Петровым в книге «12 стульев»: «Спасение утопающих – дело рук самих утопающих». Говоря проще – «отрисуй себя сам». У каждой графической компоненты есть метод paint(java.awt.Graphics), который вызывается для отрисовки. Метод этот определен в классе java.awt.Component и переопределен везде, где необходимо.

Как работает этот метод. В него передается графический контекст – java.awt.Graphics. Причем координаты у этого графического контекста установлены так, что верхний левый угол компоненты находится в точке [0,0] (на всякий случай напоминаю – начало координат в окне находится в левом верхнем углу, ось Y направлена вниз). Используя этот графический контекст, компонента рисует себя.

На самом деле, и это очень полезно знать, такая сигнатура оставлена для обратной совместимости. Реально же вот уже очень много версий, как правило, передается экземпляр наследника этого класса – java.awt.Graphics2D, возможности которого существенно выше. И на это можно было бы даже рассчитывать, если бы не один нюанс. В режиме отладки – о нем мы поговорим позднее, в разделе об системных оптимизациях, – передаваемый графический контекст унаследован от java.awt.Graphics. Это явный баг, даже зарегистрированный в базе (http://bugs.sun.com/bugdatabase/view_bug.do?bug_id=4262543), однако когда он будет исправлен – неизвестно (а учитывая приоритет «Very low»...). В любом случае, этот режим включается с помощью определенных телодвижений, а вне него – в методе paint(java.awt.Graphics) мы имеем java.awt.Graphics2D. Во всяком случае я за последние лет эдак восемь ни с чем другим не сталкивался. Некоторые продвинутые IDE вообще при использовании автоподстановки предлагают весь набор методов Graphics2D, при необходимости вставляя приведение типа «по месту».

На методе paint(java.awt.Graphics) сходство между AWT и Swing заканчивается. Дальше начинаются различия.

В AWT отрисовка устроена так, что, если не переопределять метод paint(Graphics) – в конечном итоге управление уйдет на уровень peer-объектов, и отрисовывать компоненту будет ОС. В Swing все иначе. Ниже приведен рисунок, иллюстрирующий устройство контейнера верхнего уровня:

RootPane

Этот рисунок взят из Sun Java Tutorial. Вообще, полезно почитать всю статью: http://download.oracle.com/javase/tutorial/uiswing/components/rootpane.html.

Контейнер верхнего уровня – на рисунке он обозначен как Frame, хотя на деле это может быть javax.swing.JApplet, javax.swing.JWindow, javax.swing.JDialog, javax.swing.JFrame и javax.swing.JInternalFrame – так вот, этот контейнер содержит всего одну корневую панель – экземпляр javax.swing.JRootPane. Вот тут можно посмотреть на ее API, там же приведены и иллюстрации с подробными описаниями, как она устроена и где используется: http://download.oracle.com/javase/6/docs/api/javax/swing/JRootPane.html.

Упомянутые JApplet, JWindow, JDialog и JFrame – единственные компоненты Swing, отрисовка которых проходит на уровне peer-объектов. Все остальные компоненты унаследованы от javax.swing.JComponent. А его метод paint(Graphics) переопределен так, что никуда в дебри native-кода управление не уходит. В этом и заключается основное отличие от AWT – в Swing отрисовка компонент происходит исключительно в java-коде. Каждая компонента отрисовывает себя сама.

Хочу обратить особое внимание на этот момент. Я намеренно использую эту формулировку – каждая компонента отрисовывает себя сама. На текущем уровне абстракции важно только это. Как именно устроена отрисовка – мы будет в деталях разбирать далее. И начнем мы с метода paint(Graphics), который в Swing претерпел логические изменения по сравнению с AWT.

Метод javax.swing.JComponent.paint(java.awt.Graphics)

Если не вдаваться в тонкости реализации – а нам сейчас это и не нужно – метод JComponent.paint(Graphics) устроен достаточно просто. Он делегирует отрисовку трем protected-методам – paintComponent(Graphics), paintBorder(Graphics) и paintChildren(Graphics). Что представляет собой каждый из методов:

  • paintComponent – этот метод позволяет отрисовать саму компоненту. Т.е. делает то, что в AWT делал paint(Graphics). Именно этот метод необходимо переопределять для того, чтобы отрисовать что-то на компоненте. Можно, конечно, переопределить и paint(Graphics) – а те, кто начинал с AWT, часто этим грешат! – но тогда при использовании рамок (border) начнутся сложности.
  • paintBorder – отрисовывает рамку компоненты. Это нововведение Swing – в AWT рамок у компоненты не было. Далее мы подробно рассмотрим, как устроена система работы с рамками и даже создадим свою. Кстати, переопределения метода это не потребует. Вообще, этот метод трогать можно только тогда, когда вы точно знаете, что делаете.
  • paintChildren – отрисовывает дочерние элементы. Это еще одно изменение поведения paint(Graphics) – в AWT он дочерние элементы не отрисовывал. Поскольку любая компонента в Swing унаследована от JComponent, а у нее в иерархии родителей есть java.awt.Container – в любую вашу компоненту можно будет добавить дочерние элементы. Если вы хотите этого избежать – это повод переопределить paintChildren и оставить его пустым. Впрочем, это всего лишь один из поводов.

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

Рисуем свою компоненту

Вот мы и дошли до практики, ради чего все и затевалось.

Сразу хочу сказать следующее. Обычно исходники примеров я публикую в самом конце статьи. Однако тут примеров будет довольно много и, более того, на них желательно смотреть параллельно с чтением. Чтобы видеть результат. Потому – все примеры вы можете взять прямо сейчас вот тут: ui_internals.zip. Как обычно, распаковываете и используете ant. В статье приведены фрагменты исходников, примеры же написаны так, что для получения результата ничего подкручивать в исходном коде не придется. Необходимо будет просто запустить соответствующую задачу с определенными параметрами. В каждом случае я буду приводить необходимую команду. Имена соответствующих классов я указывать не буду, ибо в ant-скрипте по команде можно найти главный класс, а дальше все прозрачно.

Простой пример

Начнем с совсем простого. Пусть у нас в компоненте будет нарисовано несколько эллипсов. Этакий стилизованный человечек. Всё, что надо сделать – унаследоваться от JPanel (например) и реализовать метод paintComponent. Вот так:

package ru.skipy.tests.ui.component;

import javax.swing.*;
import java.awt.*;

/**
 * SimpleComponent
 *
 * @author Eugene Matyushkin aka Skipy
 * @since 21.10.2010
 */
public class SimpleComponent extends JPanel {

    public SimpleComponent() {
        setOpaque(true);
    }

    @Override
    protected void paintComponent(Graphics g) {
        //super.paintComponent(g);
        Graphics2D g2d = (Graphics2D)g;
        //g2d.setClip(0,0, getWidth(), getHeight() * 2);
        //g2d.setClip(getWidth() / 4, getHeight() / 4, getWidth() / 2, getHeight() / 2);
        g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
        g2d.setColor(Color.blue);
        g2d.fillOval(10, 10, getWidth() - 20, getHeight() * 2 - 20);
        g2d.setColor(Color.red);
        g2d.fillOval(20, 20, getWidth() - 40, getHeight() - 40);
        g2d.setColor(Color.yellow);
        g2d.fillOval(30, 30, getWidth() - 60, getHeight() - 60);
        g2d.setColor(Color.black);
        g2d.fillOval(getWidth()/4 - getWidth()/16, getHeight()/2-getHeight()/8, getWidth()/8, getHeight()/8);
        g2d.fillOval(getWidth()*3/4 - getWidth()/16, getHeight()/2-getHeight()/8, getWidth()/8, getHeight()/8);
        g2d.setStroke(new BasicStroke(10, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND));
        g2d.drawArc(getWidth()/4, getHeight()/4, getWidth()/2, getHeight()/2, 225, 90);
    }
}

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

Ну и тестовый код, тоже совсем простой:

package ru.skipy.tests.ui;

import ru.skipy.tests.ui.component.SimpleComponent;

import javax.swing.*;
import java.awt.*;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;

/**
 * SimpleComponentTest
 *
 * @author Eugene Matyushkin aka Skipy
 * @since 21.10.2010
 */
public class SimpleComponentTest extends JFrame {

    public SimpleComponentTest() {
        super("Simple component test");
        JPanel cp = new JPanel(new BorderLayout());
        cp.setBorder(BorderFactory.createCompoundBorder(BorderFactory.createEmptyBorder(5, 5, 5, 5),
                BorderFactory.createLineBorder(Color.black)));
        setContentPane(cp);
        cp.add(new SimpleComponent(), BorderLayout.CENTER);
        JButton btn = new JButton("Close");
        btn.addActionListener(new ActionListener(){
            public void actionPerformed(ActionEvent e) {
                System.exit(0);
            }
        });
        cp.add(btn, BorderLayout.SOUTH);
        cp.setBackground(Color.green);
        setSize(500, 400);
        setLocationRelativeTo(null);
        setDefaultCloseOperation(EXIT_ON_CLOSE);
    }

    public static void main(String[] args) {
        new SimpleComponentTest().setVisible(true);
    }
}

Запускается пример командой ant run-sct.

SimpleComponent test

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

Как видите, компонента прозрачная. В смысле, там, где мы не рисовали, виден исходный зеленый фон. Теперь время раскомментировать первую строчку, в которой происходит вызов родительского метода paintComponent (в примерах команда ant -DcallSuper=true run-sct). И в этом случае мы уже имеем заполненный фон! Вот снимок полученного окна, в текст не буду вставлять (в любом случае вы это можете увидеть сами).

Если же теперь в конструкторе поменять аргумент метода setOpaque с true на false – фон опять станет прозрачным (ant -DcallSuper=true -DisOpaque=false run-sct). Таким образом, первый вывод: если вы хотите отрисовывать фон компоненты, то либо вы делаете это самостоятельно, либо делегируете родительскому коду, тогда необходимо выставить свойство opaque в true. О свойстве opaque мы еще поговорим, когда будем обсуждать системные оптимизации.

Области отсечения

Перейдем к следующему вопросу. А именно – к т.н. областям отсечения.

Что это такое. Иногда бывает так, что всю компоненту перерисовывать долго. Поэтому, в целях оптимизации определяется область, которая точно должна быть перерисована. Эта область выставляется графическому контексту и называется областью отсечения – все рисование за пределами этой области будет отсечено. Обычно для компоненты область отсечения устанавливается прямоугольная, равная ее размерам. Однако при вызове, например, метода компоненты repaint(x,y,width,height) (он унаследован от java.awt.Component) размер прямоугольника отсечения будет установлен таким, как указано в аргументах.

В нашем примере прямоугольник отсечения играет немаловажную роль. Если вы посмотрите на код внимательно, то увидите, что первый эллипс (синий) ориентируется на двойную высоту компоненты. Однако за пределами компоненты его не видно. А давайте посмотрим, что будет, если компоненте, что называется, дать волю – позволить отрисоваться во весь рост. Раскомментируем для этого первую строчку setClip(...) (в примерах это делается командой ant -DclipMode=big run-sct). И вот что получилось (снимок в текст опять-таки не вставляю). Обратите внимание на синюю область под кнопкой. Раньше там был зеленый фон окна, а теперь видно продолжение эллипса. Более того, если изменить размер окна, картинка станет еще забавнее – эллипс перетирает кнопку! Хотя забавного тут, честно сказать, мало. Кстати, если навести на кнопку мышь, то она прорисовывается обратно.

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

Если раскомментировать следующую строчку setClip(...) (ant -DclipMode=small run-sct) – мы получим вот такой желтый прямоугольник с глазками. Это видна центральная часть компоненты, все остальное наше художество осталось за пределами области отсечения и потому не видно. Если сильно уменьшить размер по вертикали или горизонтали – в эту область попадут и части красного с синим.

Хочу сделать одно уточнение. Область отсечения совсем не обязательно должна быть прямоугольной. На самом деле у Graphics есть метод setClip(java.awt.Shape), в который можно передать любой экземпляр интерфейса java.awt.Shape, в том числе и java.awt.geom.GeneralPath. А с его помощью можно вытворить что угодно. Например, добавим вместо уже имеющегося вызова setClip вот такой код:

GeneralPath gp = new GeneralPath();
double dx = getWidth() / 8.0;
double dy = getHeight() / 8.0;
gp.moveTo(dx, dy);
gp.lineTo(dx * 2, dy);
gp.quadTo(dx, dy * 4, dx * 4, dy * 4);
gp.quadTo(dx * 7, dy * 4, dx * 6, dy);
gp.lineTo(dx * 7, dy);
gp.lineTo(dx * 7, dy * 7);
gp.lineTo(dx * 6, dy * 7);
gp.lineTo(dx * 6, dy * 6);
gp.lineTo(dx * 2, dy * 6);
gp.lineTo(dx * 2, dy * 7);
gp.lineTo(dx, dy * 7);
gp.lineTo(dx, dy);
g2d.setClip(gp);

Команда следующая: ant -DclipMode=gp run-sct. Как вы можете видеть, область отсечения тут явно не прямоугольная.

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

Пожалуй, об областях отсечения сказано достаточно. Идем дальше.

Тривальный пример себя исчерпал, будем делать что-то более интересное. Например, компоненту, отрисовывающую график функции. Задается прямоугольник, – минимальные и максимальные значения x и y, – внутри которого рисуются график и оси координат, а также сама функция. Весь код тут приводить не буду, только необходимые фрагменты.

Идея отрисовки такая. Поскольку функции в общем случае имеют вещественные значения, выставлять какой-то шаг по x-координате для отрисовки нерационально. Получится ломаная. Лучше отталкиваться от естественных шагов – точек компоненты. Мы точно знаем, что функция отрисовывается на отрезке [x1, x2], таким образом, можем рассчитать, какая точка нашей компоненты соответствует какому значению x. С y-координатами аналогично.

Первый вариант, который мы рассмотрим, основывается на отрисовке компоненты по необходимости. Т.е. когда код отрисовки выполняется каждый раз при вызове paintComponent.

Вариант 1 – прямая отрисовка

Начнем мы с простейшей реализации. Вычисляем стартовую точку, дальше перебираем все точки по горизонтали, высчитываем следующую точку и проводим линию от предыдущей к следующей. Код немного отличается от того, который есть в примерах, я его упростил для лучшего понимания (в примерах присутствует переменная GAP – отступ от краев компоненты).

protected void paintComponent(Graphics g) {
    int w = getWidth();
    int h = getHeight();
    Graphics2D g2d = (Graphics2D) g;
    double stepX = (maxX - minX) / w;
    double stepY = (maxY - minY) / h;
    g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
    // drawing axis
    g2d.setPaint(Color.darkGray);
    g2d.drawLine(0, (int) (maxY / stepY), w, (int) (maxY / stepY));
    g2d.drawLine((int) (- minX / stepX), 0, (int) (- minX / stepX), h);
    // drawing finction
    g2d.setPaint(c);
    Point2D start = getGraphPoint(minX, stepX, stepY);
    int x0 = 0;
    int y0 = (int) (h - start.getY());
    for (int i = 1; i <= w; i++) {
        Point2D p = getGraphPoint(i * stepX + minX, stepX, stepY);
        int x1 = i;
        int y1 = (int) (h - p.getY());
        g2d.drawLine(x0, y0, x1, y1);
        x0 = x1;
        y0 = y1;
    }
}

В качестве примера возьмем функцию 5*e-0.1*x2 – распределение Гаусса, на участке [-10,10] по горизонтали и [-0.5, 5.5] по вертикали. Получается вот такой график. Он перерисовывается при любых изменениях размеров компоненты. Посмотреть можно, выполнив команду ant run-fgt.

Рисовать линиями, правда, может быть не всегда удобно. Тем более при наличии возможности рисовать с помощью средств Java2D – всевозможных производных от java.awt.Shape. java.awt.geom.GeneralPath я уже упоминал, когда говорил об областях отсечения. Можно реализовать отрисовку и через него. Например, так:

protected void paintComponent(Graphics g) {
    super.paintComponent(g);
    int w = getWidth();
    int h = getHeight();
    Graphics2D g2d = (Graphics2D) g;
    double stepX = (maxX - minX) / w;
    double stepY = (maxY - minY) / h;
    g2d.setPaint(Color.darkGray);
    Line2D.Double xAxis = new Line2D.Double(0, maxY / stepY, w, maxY / stepY);
    Line2D.Double yAxis = new Line2D.Double(- minX / stepX, 0, - minX / stepX, h);
    g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
    g2d.draw(xAxis);
    g2d.draw(yAxis);
    GeneralPath graph = new GeneralPath();
    Point2D start = getGraphPoint(minX, stepX, stepY);
    graph.moveTo(start.getX(), h - start.getY());
    for (int i = 1; i <= w; i++) {
        Point2D p = getGraphPoint(i * stepX + minX, stepX, stepY);
        graph.lineTo(p.getX(),h - p.getY());
    }
    g2d.setPaint(c);
    g2d.draw(graph);
}

Как видите, тут отрисовка графика происходит за один раз, в самом конце. Результат получается вот такой (команда – ant -DpanelType=gp run-fgt), отличий от предыдущего варианта не видно. Кстати, по производительности они тоже не отличаются, хотя на первый взгляд должны. Почему так – мы обсудим в разделе системные оптимизации отрисовки.

* * *

Вот, собственно, мы и научились рисовать компоненты. На этом можно закончить?

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

И первый вопрос, которым нужно задаться после только что реализованной компоненты – а зачем нам отрисовывать компоненту каждый раз? В смысле, зачем нам генерировать все точки графика? Ну, в данном случае это не представляет труда. А если вы будете отрисовывать фрактал? Очень небыстрый процесс расчета. И производить его каждый раз...

Самое интересное, что если вдуматься, то это и не нужно. Когда надо производить перерасчет? При первой отрисовке компоненты и изменении ее размеров. Всё. Остальные операции отрисовки будут идентичны первой после перерасчета. И если каким-то образом сохранить результаты первой отрисовки – можно не тратить времени на повторный расчет. Именно такой вариант мы сейчас и реализуем.

Вариант 2 – отрисовка с буферизацией

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

Во-первых, всё содержимое paintComponent из предыдущего примера мы переносим в новый метод, назовем его rebuildBuffer. Отличия его от paintComponent минимальны:

private void rebuildBuffer(){
    int w = getWidth();
    int h = getHeight();
    buffer = new BufferedImage(w, h, BufferedImage.TYPE_INT_ARGB);
    Graphics2D g2d = buffer.createGraphics();
    // ... the same painting code
}

Что мы тут делаем:

  1. Получаем размеры компоненты, под которые нужно создать буфер
  2. Создаем буфер с этими размерами – java.awt.image.BufferedImage в режиме RGB c поддержкой альфа-канала (прозрачности)
  3. Получаем его графический контекст

Дальше код идентичен тому, который был в прошлом варианте (после строки Graphics2D g2d = (Graphics2D) g;). В результате работы этого метода мы получим отображение нашей компоненты на буфере. Теперь этот метод надо использовать в paintComponent:

protected void paintComponent(Graphics g) {
    super.paintComponent(g);
    if (buffer == null) {
        rebuildBuffer();
    }
    g.drawImage(buffer, 0, 0, this);
}

Как видите, код совершенно тривиален. Если буфер не создан – создать. И отрисовать его на графическом контексте, переданном для отрисовки компоненты. Всё.

И последний штрих – отслеживание необходимости пересоздания буфера, как я уже говорил, при первой отрисовке и изменении размеров. Делается это с помощью интерфейса java.awt.event.ComponentListener, у которого есть методы componentShown(java.awt.event.ComponentEvent e) и componentResized(java.awt.event.ComponentEvent e). Они нам и нужны:

private class ComponentListenerImpl extends ComponentAdapter {

    private Dimension lastSize = null;

    @Override
    public void componentShown(ComponentEvent e) {
        if (!getSize().equals(lastSize)) {
            rebuildBuffer();
            lastSize = getSize();
        }
    }

    @Override
    public void componentResized(ComponentEvent e) {
        if (!getSize().equals(lastSize)) {
            rebuildBuffer();
            lastSize = getSize();
        }
    }
}

Этот код тоже прост до невозможности. Запоминаем последний размер, при получении события сравниваем его с текущим. Если текущий размер отличается – пересоздаем буфер.

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

addComponentListener(new ComponentListenerImpl());

Собственно, это всё. Мы реализовали буферизацию изображения компоненты. Пример запускается командой ant -DpanelType=gpdb run-fgt, отличий от прошлого варианта, разумеется, нет и быть не может – логика отрисовки графика не поменялась.

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

Системные оптимизации отрисовки

Изначально интерфейс Swing был достаточно медленным, именно ввиду того, что он весь отрисовывался в Java-коде. В связи с этим в системные механизмы Swing было внесено достаточно большое количество оптимизаций, которые оказали существенное влияние на производительность. Все их мы рассматривать не будем, коснемся только некоторых.

И первая оптимизация –

Двойная буферизация

Формально, по принципам, это то, что мы делали в предыдущем примере. Почему буферизация называется двойной – потому что существует два буфера: системный, из которого читается изображение самой ОС, и внутренний, на котором мы, собственно, отрисовываем компоненту.

Таким образом, всё несколько сложнее, чем кажется на первый взгляд. Глядя на архитектуру контейнеров верхнего уровня (я имею в виду те, куда вставляется javax.swing.JRootPane) можно было бы предположить, что для отрисовки JRootPane и, соответственно, всех ее дочерних компонент используется графический контекст самого верхнего контейнера. В реальности – создается буферное изображение, и передается уже его контекст. А оно потом отрисовывается на контейнере верхнего уровня.

Кстати, убедиться в этом совсем несложно. Простейший вариант – переопределяете paintComponent какой-нибудь компоненте, добавив туда просто вывод в консоль. Что-нибудь типа вот такого:

JButton btn = new JButton("Close") {
    @Override
    protected void paintComponent(Graphics g) {
        System.out.println("Repainting JButton");
        super.paintComponent(g);
    }
};

Запустите приложение. А теперь возьмите какое-нибудь окно и поводите им над кнопкой, приблизительно так – перекрыли половину кнопки, потом медленно открываем. И посмотрите, что при этом будет в консоли. В смысле, что там при этом не будет событий отрисовки (за исключением, возможно, нескольких в самом начале).

А теперь – добавим следующий код:

RepaintManager.currentManager(btn).setDoubleBufferingEnabled(false)

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

В примерах это есть. Запускается командой ant run-ddb. Справа внизу галка «Use DoubleBuffering». Сначала проделайте эксперимент при установленной галке, потом при снятой.

Кстати, я обнаружил, что если убрать режим двойной буферизации, а потом установить опять – реально она не восстанавливается, события отрисовки все равно продолжают сыпаться. Объяснения этому у меня пока нет, возможно, я чего-то где-то недопонимаю.

Я этот эффект для наглядности записал в качестве видео. Вот ролик: DoubleBufferingRepaint.avi. Весит он около 1.8Мб, учтите. Первые 6 секунд – в режиме буферизации. Я вожу над кнопкой окном, в консоли тихо. Дальше я отключаю режим буферизации и опять вожу над кнопкой окном. В консоли активное движение в то время, когда я открываю кнопку, т.е. когда она должна отрисоваться.

Двойная буферизация – это, безусловно, хорошо. Однако иногда хочется ровно обратного – видеть, как отрисовывается компонента. Особенно это актуально, когда на экране происходит что угодно, только не то, что должно. И вот тут в Java есть замечательный инструмент – отладочный контекст отрисовки. Называется этот класс javax.swing.DebugGraphics. К сожалению, с ним связан один баг, я об этом уже говорил раньше – он унаследован от java.awt.Graphics, а не от java.awt.Graphics2D, как должен бы. Что с этим делать – мы тоже обсудим.

Как использовать отладочный контекст. Ничего сверхъестественного. Надо всего лишь у нужной компоненты установить опции отладочного контекста методом setDebugGraphicsOptions. Этот метод определен у JComponent и, следовательно, доступен у всех компонент. Варианты опций следующие:

  • DebugGraphics.FLASH_OPTION – при установке любая операция отрисовки сопровождается миганием, по умолчанию красным цветом. Т.е. рисуете вы, например, линию, а она несколько раз мигнет красным и только потом отрисуется. Очень заметно.
  • DebugGraphics.BUFFERED_OPTION – создает отдельное окно и показывает в нем отрисовку, проводимую на буфере компоненты. Полезно при включенной буферизации. Если честно, мне не удалось включить этот режим.
  • DebugGraphics.LOG_OPTION – каждая операция отрисовки отражается в логе. Масса текста, иногда довольно сложно разобраться.
  • DebugGraphics.NONE_OPTION – выключает режим отладочного контекста.

Таким образом, все просто. Устанавливаете соответствующую опцию – мне кажется наиболее полезной DebugGraphics.FLASH_OPTION, – задаете режим отладочной отрисовки:

DebugGraphics.setFlashCount(1);         // сколько раз мигать
DebugGraphics.setFlashTime(20);         // время вспышки в миллисекундах
DebugGraphics.setFlashColor(Color.red); // цвет отладочной отрисовки

И всё. После установки опций компонента автоматически начинает отрисовываться в отладочном режиме. Это может быть медленно, а иногда – очень медленно. Но помощь часто оказывает существенную.

Кстати, все тот же отладочный режим позволяет очень хорошо увидеть работу механизма двойной буферизации, областей отсечения и т.п. Вот еще один видеоролик: Debug_and_NoDoubleBuffering.avi. Размер его – 1.5Мб. Воспроизведение замедлено в два раза относительно реальной скорости событий, для лучшего восприятия. Можете просмотреть его один раз, а потом читать дальше. Что делается: сначала я включаю отладочный режим (двойная буферизация включена), закрываю и открываю половину окна, чтобы продемонстрировать, как происходит отрисовка, котом я отключаю двойную буферизацию и опять закрываю и открываю половину окна.

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

  • 2.5. На левую «галку» наводится курсор, она перерисовывается (область, в которой ставится отметка, становится более рельефной).
  • 5.3. Область отметки становится темной, из-за клика мышкой.
  • 5.9. Вся область компоненты окрашивается красным, что в режиме отладочной отрисовки означает, что вся компонента отрисовывается.
  • 6.2. Вся компонента отрисовалась. Целиком. Фактически, отрисовался внутренний буфер, на котором компонента отрисовывалась ввиду включенной двойной буферизации.
  • 7.2. Вся область компоненты окрашивается красным. Опять полная отрисовка. Она связана с уходом курсора из области компоненты.
  • 7.6. Вся компонента отрисовалась. Обратите внимание на фон области, где стоит отметка. Он равномерно серый и совпадает с фоном панели, на которой лежит компонента. Сравните его с фоном правой «галки» – он голубого цвета и с явным градиентом.
  • 10.7. Всё окно теряет фокус, это видно по изменению цвета заголовка.
  • 11.0. Вся область левой «галки» окрашивается красным. Полная отрисовка.
  • 11.4. Компонента отрисована полностью и единовременно. Отрисовка потребовалась потому, что в режиме потери фокуса окном на компоненте не должен быть отрисован фокус (сравните с тем, что было до этой перерисовки – вокруг текста была рамка).
  • 14.2 – 15.8. Перекрываем часть окна другим.
  • 15.8 – 18.3. Медленно убираем верхнее окно. Обратите внимание на отрисовки. Они происходят блоками (в 16.1, 16.3, 16.9, 17.4, 17.9, 18.3 секунды).

Обратите также внимание на отсутствие красного цвета при отрисовках. Связано это с тем, что при открытии всего окна перерисовка начинается с самого верха – с корневой панели (JRootPane). А мы в примере отладочный режим установили на компоненте уровнем ниже (на content pane, см. устройство контейнера верхнего уровня ). И, поскольку для отрисовки всех компонент используется графический контекст, полученный от корневой панели, – отладочного мигания нет.

Третье, на что необходимо обратить внимание – разница в отрисовке фона. Слева на кнопке он остался голубым градиентным, справа стал равномерно серым и плоским. О чем это говорит? Градиент отрисовывается средствами Graphics2D, который, как я уже упоминал, в отладочном режиме отсутствует. Разработчики компоненты не понадеялись на то, что у них всегда будет контекст типа Graphics2D, за что им честь и хвала. В результате же отрисовки с использованием областей отсечения – а их применение видно явно, компонента отрисовывается небольшими блоками, – получилась ситуация, когда в буфере часть компоненты отрисована в старом режиме, а часть уже в новом. Кстати, фон на обеих «галках» теперь одинаковый – равномерно серый.

  • 22.7. Наводим мышь на кнопку.
  • 22.9. Область кнопки окрашивается красным. Отрисовка вызвана необходимостью перерисовать кнопку под курсором – она должна стать более рельефной.
  • 23.1. Кнопка отрисовывается целиком и единовременно.
  • 25.3. Мышь уходит с кнопки, кнопка опять перерисовывается полностью (убирается рельеф).
  • 25.6. Мышь заходит на правую «галку»
  • 26.0 – 26.5. Правая галка отрисовывается полностью и единовременно.
  • 28.3. В результате клика мышью вся форма получает фокус – заголовок становится ярким. Клик произошел там, где стоял курсор, т.е. на правой «галке». Это вызывает целую цепь событий:
  • 28.8 – 29.3. Полностью перерисовывается правая «галка». Она отрисовывается в нажатом состоянии (темный фон области отметки).

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

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

  • 29.6. Вся область левой «галки» окрашивается красным. Начинается перерисовка.
  • 29.6 – 32.4. Компонента прорисовывается уже по частям. Сначала область отметки, потом сама отметка, потом надпись.
  • 32.4 – 34.7. Точно так же по частям прорисовывается и правая «галка». Она отрисована с видимым рельефом в области отметки, т.е. так, как должна выглядеть под курсором. Однако за это время курсор с нее уже убрали (это было видно в процессе отрисовки). Потому...
  • 34.7 – 36.3. ... компонента отрисовывается вторично, уже в режиме, когда курсор с нее ушел. И опять-таки отрисовывается она по частям.
  • 36.4 – 36.7. Перерисовывается кнопка. Это потому, что при движении курсор ее задел (в кадрах этого не видно, но если проследить траекторию мыши, становится понятно).

Кстати, любопытный момент. Почему отрисовалась кнопка? Потому что под курсором она выглядит выпуклой. Логично? Да. Тогда вопрос. Почему она отрисовалась только один раз? Логично было бы два – сначала выпуклой, потом снова нормальной.

Это еще одна оптимизация. Дело в том, что события отрисовки могут подвергаться объединению в очереди событий. В самом деле, если есть несколько последовательных запросов на отрисовку, то выполнить их можно за один раз. Надо только соответствующим образом вычислить область отрисовки. Именно это и делается – в очереди оставляется только одно событие, но его параметры корректируются. Это касается, разумеется, только событий, которые еще не обработаны. Если одно событие уже обрабатывается, то второе к нему никак не присоединить. Поэтому правая галка с 32.4 по 36.3 отрисовывается два раза, а не один.

  • 38.8. Окно теряет фокус. Соответственно, начинается перерисовка «галки», на которой был фокус.
  • 38.8 – 40.0. Правая «галка» перерисовывается
  • 43.2 – 45.4. Перекрываем окно другим окном.
  • 45.4 – 48.0. Медленно открываем окно. Видны многочисленные перерисовки компонент, сопровождающиеся интенсивным отладочным миганием. Говорит это о том, что компоненты отрисовываются напрямую, без использования внутреннего буфера.

Последнее действие – отрисовка окна при открытии – записано еще в одном ролике. При этом воспроизведение замедлено уже в восемь раз, а количество отладочных миганий увеличено до двух. Вот этот файл: Debug_and_NoDoubleBuffering_slow.avi. Он небольшой, 210Кб. На нем хорошо видно как происходит отрисовка разных компонент и как при этом работают области отсечения.

Теперь вы имеете представление о том, как работает двойная буферизация. Причем именно «имеете представление», ибо на самом деле там всё ещё сложнее. Мы для буферизации использовали класс java.awt.image.BufferedImage. Компоненты Swing используют java.awt.image.VolatileImage. Этот класс отличается тем, что может использовать аппаратное ускорение отрисовки, что в некоторых случаях способно дать еще больший прирост производительности, чем наш вариант. Эта тема уже явно выходит за рамки этой статьи, желающие могут почитать API java.awt.image.VolatileImage самостоятельно.

Еще один момент, чтобы уже закрыть вопрос с отладочной отрисовкой. А именно – обход ошибки с наследованием javax.swing.DebugGraphics. Как вы уже поняли, просто приводить тип опасно – в режиме отладочной отрисовки будут проблемы. Можно поступить как разработчики Swing – проверять тип и отрисовывать либо нет части компоненты, фон и т.п. Код будет навороченным и оставит возможность для эффектов половинчатой отрисовки, которые мы видели.

Наилучшим выходом из ситуации, на мой взгляд, является использование варианта собственной реализации буферизации. Дело в том, что какой бы тип буфера вы ни использовали – java.awt.image.BufferedImage или java.awt.image.VolatileImage – метод getGraphics() вернет тип Graphics2D. И это уже без вариантов. Вы можете отрисовывать на этом изображении все, что угодно, используя весь арсенал из Graphics2D, а само изображение успешно отрисовывается с помощью обычного Graphics. Т.е. буферизация мало того, что может дать преимущество в скорости, – она способна дать еще и большие возможности отрисовки.

Перейдем теперь к следующему вопросу, а именно –

Прозрачность компонент и смысл свойства opaque

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

Рассмотрим следующий тест. Совсем простой. На панели – кнопка и «галка». По умолчанию непрозрачные, что хорошо видно по «галке» – у нее серый фон, в то время как у панели установлен сиреневый. Свойство opaque у компонент выставляется по состоянию «галки» – они непрозрачные при установленной отметке и прозрачные при снятой. Методы paintComponent у компонент и панели модифицированы так, что они выводят на консоль положение компоненты, размер, а также параметры области отсечения, переданные при отрисовке. Тест запускается командой ant run-opaque.

Как мы уже видели в прошлом примере, в стандартном пользовательском интерфейсе кнопка и «галка» перерисовываются при входе на них курсора мыши. Именно это нам и нужно. Запустите тест и поводите мышью над компонентами. При каждом входе/выходе вы будете видеть в консоли событие отрисовки.

А теперь – снимите отметку. Сделайте компоненты прозрачными. Это сразу станет видно, ибо исчезнет серый фон у «галки». И опять поводите над компонентами. Разницу с прошлым разом в консоли видите?

Разница есть, и кардинальная. Вместе с каждым событием отрисовки кнопки или «галки» – а точнее, перед этими событиями – проходит еще и отрисовка панели. Причем если вы посмотрите на прямоугольник отсечения при отрисовке панели, то увидите, что он точно совпадает с размерами и положением компоненты, которая отрисовывается:

Repainting ContentPane at 21:40:46.801
  Component location: java.awt.Point[x=0,y=0]
  Component size: java.awt.Dimension[width=292,height=166]
  Clip rectangle: java.awt.Rectangle[x=10,y=10,width=272,height=112]

Repainting JButton at 21:40:46.801
  Component location: java.awt.Point[x=10,y=10]
  Component size: java.awt.Dimension[width=272,height=112]
  Clip rectangle: java.awt.Rectangle[x=0,y=0,width=272,height=112]

Установите обратно свойство opaque – и события отрисовки панели исчезнут.

Как вы видите, непрозрачность компоненты – ее свойство opaque – играет важную роль в отрисовке. Если компонента непрозрачна, то совершенно логично лежащие под ней компоненты можно не отрисовывать. В противном случае необходимо отрисовать все компоненты, лежащий под исходной, вплоть до первой непрозрачной. А их может быть немало. И это – еще одна существенная оптимизация, о которой необходимо знать.

Таким образом выставленное в true свойство opaque компоненты говорит о том, что компонента самостоятельно отвечает за отрисовку всех своих точек, т.е. низлежащие компоненты можно не отрисовывать. Запомните это как «Отче наш». Ну и маленький совет – делайте компоненту прозрачной только тогда, когда это действительно необходимо. В остальных случаях – устанавливайте opaque в true и отрисовывайте фон самостоятельно. Это поможет хоть немного но ускорить интерфейс.

* * *

Вот теперь мы научились рисовать компоненты куда лучше. Можно на этом пока остановиться. И от рисования заранее определенного вида компонент мы переходим к вольным упражнениям.

Рисуем мышью

Рисовать мышью приходится довольно часто. Собственно, она предназначена в том числе и для этого. Ну и, естественно, при этом возникает некий набор стандартных задач. Я бы выделил два класса. Первый из них – рисование свободное. Как мышь идет, так и рисуем. Второй вариант – всевозможные контуры, выделения рамками и т.п. В каждом из этих классов задач есть свои тонкости, которые мы и будем рассматривать. И начнем с простого рисования.

Свободный стиль – сохранение нарисованного

Представим себе обычного разработчика, который хочет рисовать мышью. Что он делает:

  • Отслеживает движение мыши. И это правильно.
  • На каждое событие он:
    • Получает графический контекст у компоненты, на которой рисует.
    • С помощью этого графического контекста отрисовывает то, что он рисует (линию, контур, отметку).
    И вот это уже неправильно.

Почему неправильно – думаю, прочитав первую часть этой статьи, вы прекрасно понимаете сами. Первая же перерисовка компоненты приведет к тому, что все художества безвозвратно исчезнут. Что, собственно, и происходит. И неизменно вызывает удивление.

Возникает вопрос – что делать? Впрочем, теперь у вас ответ на него не должен вызвать проблем. Разумеется, использовать буфер. Рисовать на нем, а его – на компоненте.

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

Пример запускается командой ant run-fmpt. В каждой точке, где получено событие перемещения мыши, отрисовывается пустой квадрат, в начале движения – заполненный красный, в конце – заполненный зеленый. При уменьшении размеров окна уже нарисованные квадраты не теряются, при увеличении появляется новая область (результат рисования на которой также сохраняется). В общем, ничего сложного.

В принципе, есть еще вариант сохранять все события и отрисовывать по ним картинку в paintComponent. При большом количестве событий – типа движения мыши – это будет затратно по ресурсам и медленно. Если же вы рисуете, например, какие-то геометрические фигуры (вписанные в прямоугольник с диагональю по начальной и конечной точкам движения мыши) – сохранение таких событий как «отрисовка фигуры» позволит реализовать, например, операцию отмены. Это бывает удобно.

Переходим к следующей части. Тут у нас...

Выделения, контуры и прочие радости жизни

Painting based on all events Painting based on first and last events

При контурном рисовании мышью есть одно небольшое, но важное отличие от свободного рисования. Если при свободном рисовании нам важны все точки, то в контурном – только начальная и конечная. Более того, все промежуточные результаты рисования нам категорически не нужны, иначе мы получим то, что видно на левом рисунке вместо правого.

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

Такой вариант есть. Рисование в режиме XOR1. Осуществляется просто, затрагивает только нужную область и, самое главное, обратимо – повторная операция рисования даст исходное изображение. Рисование в режиме XOR достаточно активно применяется в разных приложениях, его особенности мы расмотрим дальше.

Рисование в режиме XOR

Реализуется этот метод достаточно просто. Мы отслеживаем мышь – нажатие и отпускание кнопки, а также перетаскивание (drag, перемещение с нажатой кнопкой мыши). При нажатии мы фиксируем координаты начальной и конечной точек и отрисовываем начальную фигуру. Далее, при движении мыши мы опять отрисовываем ту же фигуру (по зафиксированным координатам), фактически, стирая ее, после чего меняем координаты конечной точки и опять отрисовываем. И так до конца движения. При отпускании мыши мы окончательно фиксируем координаты и отрисовываем фигуру. Код приблизительно такой:

class MouseHandler extends MouseAdapter {

    private int x1;
    private int y1;
    private int x2;
    private int y2;

    @Override
    public void mousePressed(MouseEvent e) {
        x1 = e.getX();
        y1 = e.getY();
        x2 = x1;
        y2 = y1;
        Graphics2D g2d = (Graphics2D) getGraphics();
        Paint p = g2d.getPaint();
        g2d.setXORMode(xorColor);
        g2d.drawOval(x1, y1, 0, 0);
        g2d.setPaintMode();
        g2d.setPaint(p);
    }

    @Override
    public void mouseReleased(MouseEvent e) {
        int ltX = Math.min(x1, x2);
        int ltY = Math.min(y1, y2);
        int rbX = Math.max(x1, x2);
        int rbY = Math.max(y1, y2);
        rects.add(new Rectangle(ltX, ltY, rbX - ltX, rbY - ltY));
        repaint(0);
    }

    @Override
    public void mouseDragged(MouseEvent e) {
        int ltX = Math.min(x1, x2);
        int ltY = Math.min(y1, y2);
        int rbX = Math.max(x1, x2);
        int rbY = Math.max(y1, y2);
        Graphics2D g2d = (Graphics2D) getGraphics();
        Paint p = g2d.getPaint();
        g2d.setXORMode(xorColor);
        g2d.drawOval(ltX, ltY, rbX - ltX, rbY - ltY);
        x2 = e.getX();
        y2 = e.getY();
        ltX = Math.min(x1, x2);
        ltY = Math.min(y1, y2);
        rbX = Math.max(x1, x2);
        rbY = Math.max(y1, y2);
        g2d.drawOval(ltX, ltY, rbX - ltX, rbY - ltY);
        g2d.setPaintMode();
        g2d.setPaint(p);
    }
}

Приложение, иллюстрирующее такую технику, запускается командой ant run-xpt. Оно отрисовывает овалы, сохраняя всё, что мы уже отрисовали (описывающий прямоугольник кладется в коллекцию rects при отпускании кнопки мыши). Запустите, посмотрите, как оно работает. Вроде все хорошо. Так?

Не всегда. Как вы могли заметить, мы рисуем самостоятельно. Т.е. компонента – а точнее, ее метод paintComponent – ничего о наших художествах не знает. И если она перерисуется – а отрисовка может быть инициирована операционной системой, и мы ничего с этим поделать не можем – наша отрисовка естественным образом исчезнет. И тогда, при следующем движении мыши, первая перерисовка вместо компенсирующей становится основной. Мы не убираем предыдущий контур, а наоборот – отрисовываем его заново. А после него еще и новый.

В примере есть галка «Emulate asynchronous redrawing». Если ее включить, запустится поток, который начнет перерисовывать компоненту с произвольными интервалами от полусекунды до секунды, имитируя внешнюю перерисовку. И в этом случае картина, как вы сами можете заметить, становится существенно менее радужной. На компоненте постоянно присутствует «мусор».

Кроме этого эффекта с рисованием в режиме XOR связана еще одна ошибка в JDK: http://bugs.sun.com/bugdatabase/view_bug.do?bug_id=6635462 – под Windows XP в некоторых случаях возможно катастрофическое падение производительности. В принципе, эта ошибка на 23.12.2010 находится в состоянии «Fix Available», но когда это исправление будет доступно, в какой версии, и насколько распространены версии, в которых она присутствует – неизвестно. В любом случае это всего лишь информация к размышлению, учитывать ли наличие этой ошибки – это надо решать в каждом случае отдельно.

Собственно, про рисование в режиме XOR больше сказать особо нечего. Повторюсь – он может быть весьма удобен в определенных ситуациях.

Самый большой визуальный недостаток рисования в режиме XOR – изменения цветов. В примере я рисую цветом Color.cyan (0x00ffff) – ядовито-голубым. А виден красный. Да и зачастую полная перерисовка компоненты не настолько трудоемкая операция, чтобы избавляться от нее любой ценой. А потому – вполне можно рисовать и в обычном режиме.

Рисование в обычном режиме

Этот вариант подразумевает, что paintComponent знает о том, что мы рисуем. Соответственно, при изменении координат мыши мы меняем конечную точку, вызываем repaint – и получаем отрисованную компоненту вместе с контуром, который мы рисуем. После предыдущего примера это тривиально и даже не заслуживает отдельного приложения.

Интереснее реализация, когда мы вообще не занимаемся перерисовкой компоненты. Да-да, и такое тоже возможно. Представьте себе, что нам надо нарисовать прямоугольную рамку выделения, с каким-то полупрозрачным цветом внутри – как, например, выделяются файлы мышью в «проводнике» Windows. Мы для этого создаем компоненту на основе панели, делаем ее прозрачной (вызовом setOpaque(false)), и определяем ее отрисовку – заливка полупрозрачным цветом2. Далее мы помещаем эту компоненту на нашу (на которой мы хотим отрисовать такую рамку), и согласно движениям мыши корректируем размеры и положение этой компоненты-выделения.

class SelectionComponent extends JPanel {

    private Color selectionColor;
    private Color borderColor;

    private int startX = 0;
    private int startY = 0;

    SelectionComponent(Color color, int alpha) {
        setOpaque(false);
        this.selectionColor = new Color(color.getRed(), 
                                        color.getGreen(),
                                        color.getBlue(),
                                        alpha);
        this.borderColor = color;
    }

    @Override
    protected void paintComponent(Graphics g) {
        super.paintComponent(g);
        g.setColor(selectionColor);
        g.fillRect(0, 0, getWidth(), getHeight());
    }

    private void setStart(int x, int y) {
        startX = x;
        startY = y;
        setBounds(x, y, 0, 0);
        setBorder(null);
    }

    private void setEnd(int x, int y) {
        int tlX = Math.min(x, startX);
        int tlY = Math.min(y, startY);
        int brX = Math.max(x, startX);
        int brY = Math.max(y, startY);
        setBounds(tlX, tlY, brX - tlX, brY - tlY);
    }

    private void selected() {
        setBorder(BorderFactory.createLineBorder(borderColor));
    }
}

И обработка мыши:

class MouseHandler extends MouseAdapter {
    @Override
    public void mousePressed(MouseEvent e) {
        selectionComponent.setStart(e.getX(), e.getY());
    }

    @Override
    public void mouseReleased(MouseEvent e) {
        selectionComponent.selected();
    }

    @Override
    public void mouseDragged(MouseEvent e) {
        selectionComponent.setEnd(e.getX(), e.getY());
    }
}

selectionComponent в данном случае – экземпляр класса SelectionComponent, добавленный на панель (напомню, для произвольного расположения компоненты необходимо layout выставить в null). Пример этот запускается командой ant run-spt.

Paint delegation sample

Чем интересен этот пример? Самим принципом. Пример-то на самом деле примитивен – мы всего лишь заливаем полупрозрачным цветом панель. А представьте, что вместо этого мы создаем какую-нибудь компоненту, например, кнопку. И в paintComponent нашего класса вместо простой заливки мы делегируем рисование этой кнопке. В результате у нас отрисуется кнопка. И текстовое поле. И «галка». И что угодно еще.

Добавим сюда возможность перемещать наши компоненты в их контейнере – и вот мы уже имеем прототип визуального редактора. Запустите пример командой ant run-pdt. Я не буду его сейчас комментировать, там всё более чем прозрачно. Выбираете компоненту, кликаете на панели, перемещением мыши задаете размер созданной компоненты. Все компоненты можно выделять и перемещать. Менять размеры нельзя – просто не реализовано, но делается тривиально, активные зоны отрисовываются, попадание в них рассчитать легко. Весь пример написан за час с небольшим. Снимок окна – слева.

Собственно, о рисовании компоненты, наверное, всё. Делегирования отрисовки как принципа мы еще коснемся дальше. А пока переходим к следующей теме.

Рамки – что это и для чего нужно

Как уже было сказано, рамка у компоненты появилась в Swing. По сути своей это некоторая область вокруг компоненты, служащая для ее визуального выделения. Рамка компоненте устанавливается через метод setBorder(Border). Отрисовка происходит в методе paintBorder, который вполне можно (и даже нужно!) не трогать.

В Java SE реализовано некоторое количество рамок. Создаются они с помощью вспомогательного класса javax.swing.BorderFactory, который содержит большое количество методов createXXX. Эти методы помогают создавать как обычные рамки, так и составные, compound-border, с указанием внутренней и внешней рамок. Советую посмотреть API этого класса. Таким образом можно смоделировать рамки практически на все случаи жизни. А если этого не хватит – создадим новую, благо это несложно.

Создаем собственную рамку

Для начала создадим простую рамку.

Для создания рамки необходимо реализовать интерфейс javax.swing.border.Border. У него есть три метода:

  • void paintBorder(Component c, Graphics g, int x, int y, int width, int height) – собственно, отрисовка рамки. Рисуем на графическом контексте g, при необходимости получения данных о компоненте используем ссылку на нее с, позиция верхнего левого угла рамки – [x,y], ширина и высота охватывающего прямоугольника – width и height соответственно. Последние четыре параметра важны для отрисовки вложенной рамки, когда она идет не по краю компоненты.
  • Insets getBorderInsets(Component c) – для переданной компоненты этот метод возвращает толщину рамки по всем четырем сторонам.
  • boolean isBorderOpaque() – является ли рамка непрозрачной. Это свойство мы уже рассматривали, выставленное в true оно означает, что рамка отвечает за отрисовку всех своих точек.

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

Ну и основной метод – отрисовка (весь код тут приводить не буду, он есть в примерах):

public void paintBorder(Component c, Graphics g, int x, int y, int width, int height) {
    if (!(g instanceof Graphics2D)) {
        return;
    }
    Area border = new Area(new Rectangle2D.Float(x, y, width, height));
    border.subtract(
            new Area(new Rectangle2D.Float(x + thickness, y + thickness,
                    width - 2 * thickness, height - 2 * thickness)));
    Paint gradient = new GradientPaint(x, y, colorTL, x + width, y + height, colorBR);
    Graphics2D g2d = (Graphics2D) g;
    Paint p = g2d.getPaint();
    g2d.setPaint(gradient);
    g2d.fill(border);
    g2d.setPaint(p);
}

Устанавливается наша рамка просто:

panel.setBorder(new GradientBorder(10, Color.red, Color.blue));

Это всё. Запускается пример командой ant run-gbt. Как видите, всё более чем прозрачно.

На самом деле рамка может быть куда более сложной. Для примера – путь вокруг компоненты идут следы от босых ног. Для этого надо загрузить изображения ног, создать их развернутые копии, а при каждой отрисовке, в зависимости от размера компоненты, рассчитывать расстояние между следами, чтобы их поместилось целое количество. Код получается весьма громоздкий, потому тут его приводить не буду. Желающие могут посмотреть на класс ru.skipy.tests.ui.border.FootBorder в примерах. Запускается этот пример так: ant run-fbt.

Transparent border sample

Собственно, про отрисовку самой рамки сказать больше нечего. Есть, однако, момент, который необходимо понимать. Вернемся немного назад, туда, где мы рисовали мышью. Запустите еще раз этот пример, но уже с параметром, командой ant -DborderType=colored run-fmpt. Две синие линии по краям и окрашенное пространство между ними – это рамка. И если вы проведете мышью в области рамки, то там тоже останутся следы.

Слева приведен снимок окна, на котором виден этот эффект. Для наглядности часть окна увеличена.

О чем это говорит? Вспомним, что мы отрисовываем следы на внутреннем буфере, а сам буфер – на компоненте, в точке [0,0]. Таким образом, мы видим, что рамка компоненты располагается внутри самой компоненты. И если мы это не будем учитывать при отрисовке, то можем попасть в ситуацию, когда часть компоненты перекрыта рамкой.

Если вы хотите дать пользователям вашей компоненты использовать с ней рамки – этот момент надо учесть. На самом деле это достаточно просто. При отрисовке компоненты необходимо учитывать размеры рамки. Если она не установлена – getBorder() возвращает null – то считаем размеры равными нулю. Если рамка есть – вызываем getBorderInsets, передавая туда нашу компоненту, и получаем ширину рамки со всех сторон. Ну и, соответственно, корректируем отрисовку. Кстати, при этом надо корректировать и координаты мыши – для рисования на буфере их тоже надо смещать на ширину верхней и левой частей рамки.

Указанное поведение включается при добавлении еще одного параметра в команду запуска примера: ant -DborderType=colored -DcheckBorder=true run-fmpt. На левой и верхней частях рамки теперь рисовать уже нельзя. На правой и нижней – иногда можно. Например, если увеличить окно, а потом уменьшить его. Связано это только с нашей реализацией, конкретно, с тем, что мы не подгоняем оконный буфер под размеры компоненты, а только увеличиваем его. События же MOUSE_DRAGGED приходят и тогда, когда мышь находится за пределами окна. И если она останется в пределах буфера – ее след будет зафиксирован. Однако видно этого рисования в процессе все равно не будет – при отрисовке буфера устанавливается область отсечения, в точности соответствующая видимой части компоненты.

Собственно, о рамках на этом всё. Как видите, с ними можно сделать очень много, даже не трогая метода paintBorder.

Ну и последняя тема, касающаяся отрисовки компонент –

Кто в самом деле рисует системные компоненты Swing – немного о LookAndFeel

До сих пор мы говорили о компонентах наших, собственных. Пора поговорить и о системных, тем более, что тут тоже есть интересные моменты, связанные с отрисовкой. И прежде всего то, что сделанное мною заявление "компонента отрисовывает себя сама" в данном случае не совсем верно. В чём именно – сейчас расскажу.

Как я уже неоднократно упоминал (например, в статье про менеджеры раскладки), в Java пользовательский интерфейс является в большей степени логическим, нежели естественным. Связано это с тем, что он должен быть переносимым. Соответственно, мы располагаем компоненты логически, по отношению друг к другу. Менеджер раскладки рассчитывает их позиции и размеры. А вот кто их отрисовывает? Явно же не сами компоненты содержат весь код рисования?

Действительно, компоненты его не содержат. Тут применен точно такой же подход, как мы рассматривали выше – делегирование отрисовки. Вместо того, чтобы рисовать себя самостоятельно, компонента использует так называемого UI-делегата. Этот делегат создается фабрикой, установленной для приложения.

Этот механизм называется Pluggable Look And Feel – PLAF (используются также сокращения LnF, L&F). Для всех имеющихся в Java SE компонент в нем определены UI-делегаты, а также интерфейс фабрики. Реализуя собственную фабрику, а также всех делегатов, можно создать собственный вид приложения. В Java SE такой подход применяется для эмуляции системных интерфейсов Windows, Motif и Apple, также существует несколько вариантов LnF, разработанных для Java-приложений – Metal, Synth, Nimbus. Ну и кроме них – очень много разных LnF реализовано сторонними разработчиками.

Огромное преимущество LnF в том, что он позволяет отделить логическую функциональность компоненты от ее внешнего вида. Фактически, это шаблон MVC, где компонента является моделью, а UI-делегат – видом и контроллером. Да, и контроллером тоже. Ибо помимо внешнего вида UI-делегат может настраивать и поведение компоненты. Например, реакцию на события.

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

Модифицируем LookAndFeel для текстового поля

Итак, чтобы не реализовывать всё с нуля, унаследуемся от существующего UI-делегата. Стандартным LnF для Java является Metal, потому мы возьмем за основу javax.swing.plaf.metal.MetalTextFieldUI.

Первое, что нам надо сделать – определить обработчик реакции на события фокуса и мыши:

public static class Highlighter extends MouseAdapter implements FocusListener{

    public static final String KEY_UNFOCUSED_BORDER = "unfocused-border";

    public static final String KEY_UNHIGHLIGHTED_BG = "unhighlighted-bg";

    public static final Border HIGHLIGHTED_BORDER =
            BorderFactory.createLineBorder(new Color(255, 96, 0), 1);

    public static final Color HIGHLIGHTED_BG = new Color(176, 208, 255);

    @Override
    public void focusGained(FocusEvent e) {
        if (!(e.getComponent() instanceof JTextField))
            return;
        JTextField tf = (JTextField)e.getComponent();
        Border currentBorder = tf.getBorder();
        tf.putClientProperty(KEY_UNFOCUSED_BORDER, currentBorder);
        tf.setBorder(HIGHLIGHTED_BORDER);
    }

    @Override
    public void focusLost(FocusEvent e) {
        if (!(e.getComponent() instanceof JTextField))
            return;
        JTextField tf = (JTextField)e.getComponent();
        Object property = tf.getClientProperty(KEY_UNFOCUSED_BORDER);
        if (property instanceof Border){
            tf.setBorder((Border)property);
        } else {
            tf.setBorder(UIManager.getBorder("TextField.border"));
        }
    }

    @Override
    public void mouseEntered(MouseEvent e) {
        if (!(e.getComponent() instanceof JTextField))
            return;
        JTextField tf = (JTextField)e.getComponent();
        Color currentBG = tf.getBackground();
        tf.putClientProperty(KEY_UNHIGHLIGHTED_BG, currentBG);
        tf.setBackground(HIGHLIGHTED_BG);
    }

    @Override
    public void mouseExited(MouseEvent e) {
        if (!(e.getComponent() instanceof JTextField))
            return;
        JTextField tf = (JTextField)e.getComponent();
        Object property = tf.getClientProperty(KEY_UNHIGHLIGHTED_BG);
        if (property instanceof Color){
            tf.setBackground((Color)property);
        } else {
            tf.setBackground(UIManager.getColor("TextField.background"));
        }
    }
}

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

Далее. В самой реализации нашего UI-делегата необходимо переопределить методы установки и удаления UI-делегата:

@Override
public void installUI(JComponent c) {
    super.installUI(c);
    if (c instanceof JTextField){
        c.addFocusListener(highlighter);
        c.addMouseListener(highlighter);
    }
}

@Override
public void uninstallUI(JComponent c) {
    super.uninstallUI(c);
    if (c instanceof JTextField){
        c.removeFocusListener(highlighter);
        c.removeMouseListener(highlighter);
    }
}

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

И последний шаг, неочевидный – реализация статического метода, который, собственно, и создает экземпляр делегата:

public static ComponentUI createUI(JComponent c) {
    return new HighlightedTextFieldUI();
}

Этот метод используется UI-менеджером.

Вот, собственно, и всё. Далее мы просто устанавливаем UI-менеджеру имя класса UI-делегата для текстового поля:

UIManager.put("TextFieldUI", HighlightedTextFieldUI.class.getName());

Это надо делать до создания интерфейса, ибо наш класс понадобится в процессе. В принципе, можно и после, но тогда надо будет вызвать javax.swing.SwingUtilities.updateComponentTreeUI(java.awt.Component), передав туда корневую компоненту. Таким образом, кстати, можно поменять LnF и для части интерфейса, но тут могут возникнуть различные тонкости, явно выходящие за рамки этой статьи.

Установка UI-менеджеру нового делегата повлияет на все создаваемые после этого момента компоненты. И в этом еще одно преимущество PLAF – одним движением можно изменить внешний вид и поведение приложения, не меняя ни строчки кода. И, что более полезно, это можно сделать даже извне, путем установки системного свойства swing.defaultlaf.

Пример запускается командой ant run-lnft. Обратите внимание, что у второго поля установлена своя рамка, а у третьего – свой цвет фона. И они остаются неизменными после потери фокуса или ухода мыши. Как вы видите, и это тоже просто и эффективно.

Ну, о PLAF пока что достаточно. Тема эта очень обширная, и уже, на мой взгляд, за рамками этого материала.

* * *

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

На самом деле, конечно, тема работы с графикой в Java гораздо более обширна. Мы, например, практически не затронули набор технологий Java2D (желающие могут ознакомиться с ним вот тут: http://download.oracle.com/javase/6/docs/technotes/guides/2d/spec/j2d-bookTOC.html, ну а вообще это руководство идет в составе стандартной документации по Java SE). Но я честно сказать, и не ставил цели дать полную и исчерпывающую информацию по данной теме. Одной статьей тут не обойдешься, это можно написать книгу, и не одну.

А для стартового материала уже достаточно. Теперь, по крайней мере, вы будете знать, в какую сторону копать для получения дополнительной информации. Кстати, советую почитать вот эту статью: http://java.sun.com/products/jfc/tsc/articles/painting/index.html. Она во многом пересекается с тем, что я рассказывал, и для понимания несложна.

На этом всё. Всем спасибо! Отдыхайте и обдумывайте.


P.S. Для комментариев и обсуждения, как всегда, заведена тема в блоге: http://skipy-ru.livejournal.com/4978.html.


1.В булевской математике есть операция XOR. В принципе, она выражается и через NOT, OR и AND, но уж очень мудрено: a XOR b = (a OR b) AND NOT(a AND b). Проще объяснить на пальцах. Если a == b, то a XOR b = false, в противном случае a XOR b = true. Фактически, a XOR b по результату эквивалентна a != b. То же самое применимо и к числовым величинам, если false заменить на 0, а true – на 1. Операция XOR определена и для целых чисел, в этом случае она осуществляется побитово.

Особенность операции XOR в том, что она обратима. Иначе говоря, для любых a и b верно ((a XOR b) XOR b) == a. Что из этого можно извлечь в режииме рисования: пусть у нас есть точка, для определенности красного цвета (0xff0000). И мы на эту точку накладываем синий (0x0000ff) в режиме XOR. Получаем в результате цвет 0xff00ff. Еще раз накладываем всё тот же синий – и получаем 0xff0000. То есть после двух операций рисования одним и тем же цветом в режиме XOR мы получим исходный цвет. И это верно для любого цвета исходного изображения и для любого цвета рисования.

2.Напомню, у класса java.awt.Color есть конструкторы, принимающие параметр alpha, то есть прозрачность. Значение 0 означает полностью прозрачный цвет, значение 256 (в случае целочисленных значений) или 1.0 (в случае значений с плавающей точкой) – полностью непрозрачный. В промежутке цвет будет полупрозрачным, степень прозрачности зависит от значения.