Swing. Приручаем потоки и события

Делаем GUI отзывчивее на действия пользователя
Обычно на форумах веб-сайта Java Developer Network сайт наиболее частые вопросы и проблемы возникают вокруг Swing и того, как он работает в многопоточной среде. Эта статья раскроет многие проблемы, с которыми сталкиваются разработчики, использующие Swing API в своих проектах, а также поможет избежать некоторых подводных камней и обратить внимание на потенциальные ошибки, которые будет сложно убрать, когда приложение закончено.

Как работает Swing
В отличие от других графических программных интерфейсов (далее API), Swing работает по однопоточной модели. Этот единственный поток отвечает как за непосредственно прорисовку GUI (графический пользовательский интерфейс), так и за обработку всех событий, полученных от пользователя. Это значит, что разработчик должен уделять особое внимание обработке всего, что хоть как-то касается GUI, а также на то, насколько много времени займет процесс обработки того или иного события. Если этим пренебрегать, в результате может получиться, что приложение будет медленнее реагировать на действия пользователя или вообще окажется неработоспособным. Хуже того: приложение может выбросить аварийное исключение в потоке событий, что приведет к непредсказуемому поведению всего приложения.

Одиночный поток
Swing API целиком спроектирован для работы в одном потоке. Это значит, что, когда этот одиночный поток блокируется или замедляется, весь GUI будет работать медленнее или вообще перестанет отвечать на действия пользователя. Для того чтобы этого избежать, приложение должно выполнять большинство своей работы в отдельном потоке и взаимодействовать только с потоком событий, когда тот занимается обработкой какого-либо события или когда есть необходимость обновить (или перерисовать) GUI. К счастью, большая часть Swing API уже спроектирована для такого многопоточного подхода, но это если приложение соответствует основам дизайна этого API. Подводных камней избежать не сложно. Сложности возникают при работе с событиями.
Когда Swing-приложение получает какое-нибудь событие, его получают все объекты, которые зарегистрировали себя как слушатели этого события. Например, когда нажата кнопка JButton, она создает событие ActionEvent, которое передается каждому слушателю, подписанному на это событие. Далее последовательно вызывается метод actionPerformed() каждого зарегистрированного слушателя этого события. Нет никакой гарантии, что вызов этого метода у слушателей будет вызываться в каком-либо порядке. Гарантировать можно лишь то, что ни один из слушателей не останется без внимания, и каждый из них будет вызван. Эта ситуация изображена на диаграмме 1.


Диаграмма 1

Согласно диаграмме, JButton передает событие действия (ActionEvent) каждому из своих слушателей. Listener1 получит это событие и полностью его обработает до того, как Listener2 или 3 вообще узнают, что это событие произошло. Соответственно Listener2 получит его следующим и начнет обрабатывать до того, как передать его в руки Listener3.
Один из вариантов возможных проблем – это когда слушатель ведет себя неуважительно по отношению к другим и заканчивает свою работу через слишком долгое время — например, если делает обращение, которое занимает много времени (возможно, загрузка файла с диска, обращение к базе данных и т.д., и этот код находится либо прямо, либо косвенно в потоке событий). Например, Листинг 1 содержит метод actionPerformed, который долго не завершает свою работу. Если мы запустим этот пример, при нажатии на JButton GUI не будет отвечать на действия пользователя, пока метод wait не завершит свою работу и не даст завершиться методу actionPerformed. Это происходит из-за того, что наш пример работает внутри потока событий, основного потока Swing API, и приложение вынуждено ждать, пока код этого метода не завершится.

Небезопасные операции в потоках
Другая общая ситуация – попытка обновления GUI не из потока событий. Эта ситуация прямо противоположна рассмотренной выше. Листинг 2 демонстрирует пример, где мы имеем более одного потока в приложении, и сторонний поток пытается обновить GUI. В то время как этот код может выглядеть абсолютно безопасным (и фактически в простой ситуации, подобной этой, возможно, он и безопасный), это рецепт того, как получить множество никому не нужных проблем. Поскольку Swing проектируется по однопоточной модели, его API не синхронизирован и, следовательно, не защищен от попыток изменения другими потоками своих данных. Это значит, что мы может получить опасное столкновение в GUI, если попытаемся обновить элементы из любого другого потока, а не напрямую из потока событий. Такие действия могут привести к повреждению данных, выбросу исключений и массе других опасных проблем, которые чертовски трудно обнаружить и отладить. Чтобы избежать этой проблемы, всегда следует обновлять GUI из потока событий.

Поток событий
Если мы не можем работать в потоке событий, то как наше приложение вообще будет что-то делать? Все просто. Всякий раз, имея дело с ситуацией, когда знаем, что обработка события может занять много времени, выносим его в рабочий поток. Листинг 3 – это переписанный Листинг 1, где вместо того, чтобы блокировать поток событий, пока не завершится метод wait, создается анонимный внутренний класс, который расширяет java.lang.Thread, который будет вызывать для нас метод wait. В этой версии кода событие больше не будет ждать завершения метода wait, а просто вернет управление остальному приложению (передаче собития другим слушателям) после того, как новый поток инициализировался и запустился. В результате имеем быструю реакцию на события и незаторможенную работу GUI.
Если мы имеем дело с ситуацией, когда код, вызываемый событием, может вызываться очень часто из множества различных мест программы, то нам нет необходимости переписывать анонимный класс в каждом из таких мест. Чтобы избежать повторения одного и того же кода, можно создать внутренний класс, объекты которого будут создаваться, когда это будет нужно. Как видно из Листинга 4, событие создает объект WorkerThread класса и запускает его. Оно также может создать еще одну копию этого потока из любого другого места программы, если это понадобится. Следующая типичная ситуация – когда мы имеем кусок кода, который очень долго выполняется и будет вызываеться только из рабочего потока. В этой ситуации наиболее выгодно перенести весь метод в рабочий поток, как это показано в Листинге 5. Это позволяет нам полностью отделить логику от GUI. Этот рабочий поток может быть вынесен в отдельный самостоятельный класс или же быть внутренним классом, как в нашем примере. Каждый из этих трех примеров позволяет своевременно завешать работу потока событий и выполнять необходимые действия в отдельном потоке, тем самым позволяя GUI продолжить обработку других событий и пр.

Множественные потоки
С ростом сложности приложений они неизбежно требуют более гладкой работы множества потоков. Но это прямое противоречие дизайну Swing API. Чтобы обойти это несоответствие, компания Sun определила несколько методов в AWT и Swing API, чтобы дать разработчикам возможность использовать множественные потоки в этой однопоточной модели, которая стоит в основе дизайна Swing API. Основная идея заключается в том, чтобы разместить все инструкции, касающиеся GUI, в потоке событий. Чтобы сделать это, необходимо поставить их в очередь событий. Следующие два метода доступны как статические методы классов javax.swing.SwingUtilities и java.awt.EventQueue:
. invokeLater(Runnable r) вызывает метод r.run() для выполнения событий AWT асинхронно с потоком. Этот поток будет обработка, как только он достигнет вершины очереди событий.
В Листинге 6 продемонстрирован пример использования этого метода. Важно отметить, что в случае, если внутри Runnable объекта, помещенного в очередь событий, возникнет исключение, это приведет к выходу из строя потока очереди событий, а не самого этого потока. Если существует вероятность возникновения события, оно должно быть обработано непосредственно на месте, в противном случае оно может просто-напросто "убить" поток событий.
. invokeAndWait(Runnable r) заставляет вызывать метод r.run() одновременно с потоком выполнения событий AWT.
Работу этого метода демонстрирует Листинг 7. Из этого примера видно, что он выбрасывает два типа исключений. В случае, если поток прерывается, выбрасывается исключение InterruptedException. Второй тип исключения — InvocationTargetException – это исключение-обертка, которое будет выброшено, если наш Runnable-объект выбросит какое-нибудь другое исключение. Фактически выброшенное Runnable-объектом исключение будет содержаться внутри InvocationTargetException.
Используя эти два метода, многопоточное приложение вполне может как синхронно, так и асинхронно взаимодействовать с очередью событий и таким образом твердо следовать принципу одиночного потока Swing API.

Swing Worker
Компания Sun разработала класс, который выполняет некоторые из перечисленных ранее функций. Более того: он содержит некоторые дополнительные полезные особенности. SwingWorker – это класс, который вы можете унаследовать, перенеся в него тот самый код, на выполнение которого может быть затрачено достаточно много времени. Благодаря этому классу этот код будет помещен в отдельный поток, а также вы получите больше гибкости и возможности контроля его выполнения. Хотя SwingWorker может оказаться полезным не во всех ситуациях, лучше использовать его, чем постоянно придумывать и реализовывать функции, которые он на себя берет. SwingWorker не входит в стандартный Java API. Его нужно отдельно загрузить с веб- сайта компании Sun и скомпилировать. Ссылка приведена в конце этой статьи. Листинг 8 начинается с раширения класса SwingWorker. Единственный метод, который должен быть определен – это construct(). Как видно из примера, мы создали цикл внутри метода construct(), на каждой итерации которого метод бездействует ("спит") в течение 100 миллисекунд. Наш объект класса JFrame создает кнопку btnStartMe, к которой мы добавили слушатель событий ActionListener. Когда пользователь нажимает кнопку, ActionListener создает экземпляр нашего рабочего класса и вызывает его метод start(). Метод start() выполняет метод run() родителя класса SwingWorker, который, в свою очередь, кроме прочих операций, вызывает метод construct(). Если вы внимательно посмотрите на код класса SwingWorker, то увидите, что внутри метода run() наш метод construct() полностью блокирует его завершение, пока последний не завершит свою работу. SwingWorker предоставляет нам, кроме раздельного выполнения рабочего потока и потока событий, еще и некоторые дополнительные возможности. Во-первых, метод construct() возвращает объект, который мы можем позже получить после завершения работы потока. Если мы попытаемся получить этот объект до того, как наш рабочий поток завершит свою работу, то попытка получения будет блокироваться до тех пор, пока поток не будет готов вернуть это значение. Когда SwingWorker завершает свою работу, он будет вызывать метод finished(), который по умолчанию, если мы его не расширили (перегрузили, как метод construct()), ничего не делает. Очень приятная вещь в методе finished() — это то, что он выполняется в потоке событий, в отличие от рабочего потока. Соответственно мы можем выполнять любой код, относящийся к изменению GUI внутри этого метода. Как видно из нашего примера, мы показываем окно диалога, которое дает пользователю знать, что работа завершена. Что, если мы, например, хотим прервать наш рабочий поток до того, как он выполнит свою задачу? Класс SwingWorker имеет встроенную возможность для выполнения этой операции. Когда мы хотим завершить рабочий поток, мы можем вызывать метод SwingWorker.interrupt(), который остановит работу в тот же момент. Но важно понимать, что метод finished() никогда не будет вызван в случае прерывания работы потока.

Многопоточная инициализация GUI
Тема, которую мы сейчас собираемся раскрыть, является очень неоднозначной стороной программирования Swing. Причина, за этим стоящая, проста. Можно легко создать JFC (Java Foundation Classes) приложение, основанное на многопоточной модели, которое будет вести себя изменчиво. Однако можно сконструировать JFC-приложение с использованием множества потоков на этапе инициализации приложения. Если у нас есть приложение с несколькими панелями, парой инструментальных панелей, несколькими другими более сложными "виджетами", то первоначальный запуск такого приложения иногда может занимать огромное количество времени. Идеальный пример такой ситуации – очень популярный и многими любимый редактор jEdit. Единственный значительный его недостаток – проблема комплексных GUI, сделанных на Swing: слишком много времени отводится на первоначальную загрузку. После того, как он запустится, вся работа выполняется очень быстро, и работать удобно, но ожидание загрузки может очень напрягать. То, что мы сейчас будем рассматривать, может помочь не любому приложению, однако этот метод позволяет ускорить инициализацию и, следовательно, дать пользователю быстрый и удобный интерфейс для работы. У подхода, который будет описан ниже, есть два негативных момента:

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

Принимая во внимание все это, инициализация GUI с использованием множества потоков может значительно ускорить процесс загрузки. Листинг 9 показывает разобранный пример многопоточной инициализации GUI. В этом примере поточная обработка встречается в классах JPanel, которые будут иметь место в главном фрейме класса JFrame. Каждая из этих панелей во время создания сразу увеличивают на единицу статический счетчик нашего главного фрейма. Счетчик дает возможность фрейму знать, все ли потоки завершили свою работу. После увеличения этого счетчика каждая панель JPanel создает свой поток, который является Runnable-объектом. И, наконец, конструктор запускает только что принятый поток и после этого завершает свою работу. Это позволяет нашему фрейму очень быстро инициализировать все четыре панели JPanel и, что немаловажно, пока панели будут заняты своей работой по инициализации самих себя, наш фрейм JFrame свободен выполнять свою работу (например, инициализировать слушателей событий и т.д.). Когда JFrame готов для отображения себя на экране, он проверяет и ждет, пока все ему принадлежащие панели не завершат свою инициализацию. В методе run() каждой из панелей JPanel последний метод ThreadedFrame.decCounter() вызывается для того, чтобы уменьшить счетчик на единицу. Когда все панели готовы, счетчик будет равен нулю, и таким образом JFrame будет знать, что он может собирать все воедино и выводить себя на экран.

Хотя этот пример и выглядит слишком упрощенным, он демонстрирует основные правила процесса организации многопоточной инициализации: . Убедитесь, что никакие объекты, которые вовлечены в разные потоки, не взаимодействуют друг с другом. Иначе может получиться так, что один из объектов будет пытаться получить доступ к ресурсу, который еще не инициализировался (т.е. пытаться слушать объект, который еще не был создан).
. Убедитесь, что все потоки завершили свою работу перед тем, как отображать GUI. Сюда относятся вызовы методов setVisible(true), pack() и validate(). Вызов этих методов в таком случае может повлечь за собой непредсказуемое поведение GUI.
. Запуск потоков из других потоков – не очень хорошая идея. Исключением здесь может быть только случай, если вы действительно уверены и осторожны с этими внутренними потоками. Иначе это может повлечь за собой крах всего приложения еще на этапе инициализации. Избегайте таких приемов, если только вы не уверены в корректном взаимодействии своих потоков.

Резюме
Хотя примеры, рассмотренные в этой статье, сделают наши приложения сложнее, они могут все же значительно увеличить скорость реакции GUI на действия пользователя. Все эти приемы нужно серьезно взвесить, определив, стоит ли усложнять GUI, и принесет ли это достаточный выигрыш. Если GUI вашего приложения очень прост и обладает небольшими возможностями нежелательных столкновений и взаимодействий, то беспокоиться вам особо не нужно. Однако, когда приложение становится все более сложным (что, в принципе, постоянно и происходит), использование множества потоков становится более значимым элементом. Вырабатывая привычку следить за взаимодействием потоков вашего GUI, вы можете спасти себя от переписывания кучи кода впоследствии, когда приложение перерастет себя.

Ссылки
. Статья про класс SwingWorker: сайт .
. "Threads and Swing": сайт .
. "High Performance GUIs with the JFC/Swing API": сайт .

Листинги
Листинг 1


public void actionPerformed(ActionEvent evt) {
// Вызов этого метода повлечет за собой задержку в 60 секунд (1 минуту)
waiter();
}

private void waiter() {
try {
Thread.sleep(60000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}

Листинг 2

public ExampleClass extends JFrame implements Runnable {

private JTextField example1;
private JTextField example2;

public ExampleClass() {
getContentPane().setLayout(new FlowLayout());
example1 = new JTextField("Поле1");
example2 = new JTextField("Поле 2");
getContentPane().add(example1);
getContentPane().add(example2);
Thread t = new Thread(this);
t.start();
}

public void run() {
// Здесь ни в коем случае нельзя изменять GUI, но мы это делаем
example1.setText("Поле 1 изменено.");
}

}

Листинг 3

public void actionPerformed (ActionEvent evt) {
Thread t = new Thread() {
public void run() {
waiter();
}
};
t.start();
}

private void waiter() {
try {
Thread.sleep(60000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}

Листинг 4

class WorkerThread extends Thread {
public void run() {
waiter();
}
}

public void actionPerformed(ActionEvent evt) {
WorkerThread wt = new WorkerThread();
wt.start();
}

public void waiter() {
try {
Thread.sleep(60000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}

Листинг 5

class WorkerThread extends Thread {
public void run() {
try {
Thread.sleep(60000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}

public void actionPerformed(ActionEvent evt) {
WorkerThread wt = new WorkerThread();
wt.start();
}

Листинг 6

Runnable example = new Runnable() {
public void run() {
System.out.println("Пример, работающий из " + Thread.currentThread());
}
};

SwingUtilities.invokeLater(example);
System.out.println("Это должно быть выведено до запуска потока example");

Листинг 7

Runnable example = new Runnable() {
public void run() {
System.out.println("Пример, работающий из " + Thread.currentThread());
}
};
try {
SwingUtilities.invokeAndWait(doSomething);
} catch (InterruptedException e) {
e.printStackTrace();
} catch (InvocationTargetException e) {
e.printStackTrace();
// Исключение-обертка, выброшенное из нашего потока example
}

Листинг 8

public class MyWorker extends SwingWorker {
JFrame parent;

public MyWorker(JFrame p) {
parent = p;
}

public Object construct() {
for (int i = 0; i <10; i++) {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
return null;
}
public void finished() {
JOptionPane.showMessageDialog(parent, "Готово!");
}
}

public class MyFrame extends JFrame implements ActionListener {
private JButton btnStartMe;
private JButton btnStopMe;
private MyWorker myWorker;

public MyFrame() {
JPanel buttons = new JPanel(new FlowLayout());
btnStartMe = new JButton("Запустить поток ");
btnStopMe = new JButton("Остановить поток");
btnStartMe.addActionListener(this);
btnStopMe.addActionListener(this);
buttons.add(btnStartMe);
buttons.add(btnStopMe);
getContentPane().add(buttons, BorderLayout.NORTH);
}

public void actionPerformed(ActionEvent evt) {
if (evt.getSource() == btnStartMe) {
myWorker = new MyWorker();
myWorker.start();
} else {
if (myWorker != null) {
myWorker.interrupt();
}
}
}
}

Листинг 9

public class ThreadedFrame extends JFrame {
private static int initCounter = 0;
private JTabbedPane tabbedPane;

public ThreadedFrame() {
getContentPane().setLayout(new BorderLayout());
tabbedPane = new JTabbedPane();
getContentPane().add(tabbedPane, BorderLayout.CENTER);

tabbedPane.addTab("Закладка 1", new TabOne());
tabbedPane.addTab("Закладка 2", new TabTwo());
tabbedPane.addTab("Закладка 3", new TabThree());
tabbedPane.addTab("Закладка 4", new TabFour());

while (initCounter> 0) {
try {
Thread.sleep(50);
} catch (InterruptedException e) {
e.printStackTrace();
}
}

pack();
setVisible(true);
}

public synchronized static final void incCounter() {
initCounter++;
}

public synchronized static final void decCounter() {
initCounter--;
}
}

public class TabOne extends JPanel implements Runnable {
public TabOne() {
ThreadedFrame.incCounter();
Thread t = new Thread(this);
t.start();
}
public void run() {
// Здесь должен быть код инициализации этой панели
ThreadedFrame.decCounter();
}
}

public class TabTwo extends JPanel implements Runnable {
public TabTwo() {
ThreadedFrame.incCounter();
Thread t = new Thread(this);
t.start();
}
public void run() {
// Здесь должен быть код инициализации этой панели
ThreadedFrame.decCounter();
}
}

public class TabThree extends JPanel implements Runnable {
public TabThree() {
ThreadedFrame.incCounter();
Thread t = new Thread(this);
t.start();
}
public void run() {
// Здесь должен быть код инициализации этой панели
ThreadedFrame.decCounter();
}
}

public class TabFour extends JPanel implements Runnable {
public TabFour() {
ThreadedFrame.incCounter();
Thread t = new Thread(this);
t.start();
}
public void run() {
// Здесь должен быть код инициализации этой панели
ThreadedFrame.decCounter();
}
}

-----------------------
Listener3 получает запрос на вызов метода
actionPerformed(event)

Listener2 получает запрос на вызов метода
actionPerformed(event)

Listener1 получает запрос на вызов метода
actionPerformed(event)

JButton выбрасывает событие ActionEvent

По материалам Marcus S. Zarra Литвинюк Алексей, litvinuke@tut.by


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

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