Swing. Приручаем потоки и события
(Далаем GUI отзывчивие на действия пользователя)
Обычно на форумах веб-сайта Java Developer Network (http://forums.java.sun.com) наиболее частые вопросы и проблемы возникают вокруг Swing и того, как он работает в многопоточной среде. Эта статья раскроет многие проблемы, с которыми сталкиваются разработчики, которые используют Swing API в своих проектах, а также поможет избежать некоторых подводных камней и обрать внимание на потенциальные ошибки, которые сложно будет убрать, когда приложение закончено.
Как работает Swing
В отличие от других графических программных интерфейсов (далее API), Swing работает по однопоточной модели. Этот единственный поток отвечает как за непосредственно просисовку GUI (графический пользовательский интерфейс), так и за обработку всех событий, полученных от пользователя. Это значит, что разработчик должен уделять особое внимание обработке всего, что хоть как-то касается GUI, а также на то, насколько много времени займет процесс обработки того или иного события.
Пренебрегая этим, в результате может получиться, что приложение будет медленнее реагировать на действия пользователя или вообще окажется не работоспособным. Хуже того, приложение может выбросить аварийное исключение в потоке событий, что приведет к непредсказуемому поведению всего приложения.
Одиночный поток
Swing API целиком спроектирован для работы в одном потоке. Это значит, что когда этот одиночный поток блокируется или замедляется, весь GUI будет работать медленнее или вообще перестанет отвечать на действия пользователя. Для того чтобы этого избежать, приложение должно выполнять большинство своей работы в отдельном потоке и взаимодействовать только с потом событий, когда тот занимается обработкой какого-либо события или когда есть необходимость обновить (или перерисовать) GUI.
К счастью, большая часть Swing API уже спроекирована для такого многопоточного подхода, но это если приложение соответствует основам дизайна этого API. Подводных камней избажать не сложно. Сложности возникают при работе с событиями.
Когда Swing-приложение получает какое-нибудь событие, все объекты, которые зарегистрировали себя как слушатели этого события, получают его. Например, когда нажата кнопка JButton, она создает событие ActionEvent, которое передается каждому слушателю, подписанному на это событие. Далее последовательно вызывается метод actionPerformed() каждого зарегистрированного слушателя этого события. Нет никакой гарантии, что вызов этого метода у слушателей будет вызываться в каком-либо порядке. Гарантия есть лишь на то, что ни один из слушателей не останется без внимания и каждый из них будет вызван. Эта ситуация изображена на Диаграмме 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: http://java.sun.com/products/jfc/tsc/articles/threads/threads2.html
- “Threads and Swing”: http://java.sun.com/docs/books/tutorial/uiswing/overview/threads.html
- “High Performance GUIs with the JFC/Swing API” : http://developer.java.sun.com/developer/community/chat/JavaLive/2002/jl0423.html