Последнее изменение: 25 августа 2010г.

Синхронизация пользовательского интерфейса

Старый анекдот.

Приходит к Биллу Гейтcу сын и спрашивает:

– Папа, а Windows – многозадачная система?
– Да, сынок, – отвечает папа.
– Папа, а что такое многозадачная система?
– Сейчас дискетку отформатирую – и покажу.

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

Однако в последнее время сильно участилось появление во всяких форумах вопросов типа "Я хочу показывать прогресс загрузки, а он не показывается", "Почему окно не отрисовывается, пока я не загружу файл" и т.п. И, судя по всему, того, что я уже написал, не хватает. Плюс с версии 6 в Java появился класс javax.swing.SwingWorker, который вроде как призван облегчить данную задачу.

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

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

План нашего разговора таков:

Итак, приступим.

Взаимодействие фонового потока и GUI – теория

Начнем с теоретических основ.

Наверняка многие из вас сталкивались с ситуацией, когда нажатие кнопки, запуск команды или еще какое действие вызывает зависание всего интерфейса пользователя. Причем не просто зависание – интерфейс даже отрисовываться перестает. Не говоря уж о реакции на кнопки или клавиатуру. Так себя ведет, например, PL/SQL Developer при запуске запроса на выполнение, особенно если сервер не очень быстрый. Меня лично это раздражает до изжоги, тем более что такое поведение является очевидной ошибкой, которой можно элементарно избежать.

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

Так вот, интерфейс основан на событиях. Нажатие клавиши, движение мыши, необходимость отрисовки и много чего еще – всё это ни что иное как события. Они помещаются в одну очередь и обрабатываются одним потоком – Event Dispatching Thread, сокращенно EDT. В Java у этого потока имя AWT-EventQueue.

Для удобства обработки события от источников ввода преобразуются в события компонентные. Это действительно облегчает жизнь. Скажем, клик мышки на кнопке. Нажата кнопка или нет? А всё зависит от того, где мышку отпустили. Если в той же точке – или хотя бы в пределах кнопки – кнопка считается нажатой. Если нажали и увели за пределы кнопки – она не считается нажатой. Вот чтобы разработчику каждый раз не решать этот вопрос, введено событие ActionEvent, которое для кнопки генерируется только тогда, когда кнопка считается нажатой. Ниже приведен простой иллюстрирующий пример:

package ru.skipy.tests.ui;

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

/**
 * MouseTest
 *
 * @author Eugene Matyushkin aka Skipy
 * @since 10.08.2010
 */
public class MouseTest extends JFrame {

    public MouseTest(){
        super("Mouse events test");
        JButton btn = new JButton("Press me");
        btn.addMouseListener(new MouseListenerImpl());
        btn.addActionListener(new ActionListener(){
            @Override
            public void actionPerformed(ActionEvent e) {
                System.out.println("Action performed");
            }
        });
        getContentPane().add(btn, BorderLayout.SOUTH);
        setSize(300,200);
        setLocationRelativeTo(null);
        setDefaultCloseOperation(EXIT_ON_CLOSE);
    }

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

    private static class MouseListenerImpl extends MouseAdapter {

        @Override
        public void mouseClicked(MouseEvent e) {
            System.out.println("Mouse Clicked");
        }

        @Override
        public void mousePressed(MouseEvent e) {
            System.out.println("Mouse Pressed");
        }

        @Override
        public void mouseReleased(MouseEvent e) {
            System.out.println("Mouse Released");
        }
    }
}

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

ActionEvent, разумеется, не единственное такое дополнительное событие. Пакеты java.awt.event и javax.swing.event содержат более тридцати классов, описывающих различные события пользовательского интерфейса. И все эти события обрабатываются одним потоком – EDT. Этот факт является ключевым в понимании происходящего – до тех пор, пока не завершится текущий метод обработчика события, EDT не сможет обработать ни одно другое событие!

А вот теперь, когда вы это знаете, представьте, что будет, когда по нажатию кнопки запускается, например, запрос к БД, который длится несколько секунд. В течение этого времени поток, в котором обрабатывалось нажатие кнопки, будет просто блокирован. А в этом же потоке обрабатываются и события мыши – то есть нажать кнопку "Отмена" и прервать выполнение запроса не получится: сгенерированные события встанут в очередь и будут обработаны только после того, как EDT будет разблокирован. То есть тогда, когда, откровенно говоря, будет уже поздно.

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

Почему не отрисовывается прогресс операции – объяснять? Думаю, уже не стоит.

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

И вот тут... Тут нас подстерегает другая проблема. Дело в том, что практически все компоненты Swing – НЕ thread-safe. Во всяком случае документация рекомендует их считать таковыми. Что означает, что методы UI-компонент Swing должны вызываться в EDT, в противном случае корректная работа не гарантируется. Исключение составляет метод repaint(), который можно вызывать откуда угодно.

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

Взаимодействие фонового потока и GUI – практика

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

Общие соображения

Начнем мы, как всякие приличные люди, с... определения интерфейсов. А именно – что GUI хочет от фонового потока, и что фоновый поток хочет от GUI.

Ну, первое относительно просто. Нам нужно иметь возможность запустить фоновый поток и остановить его. Больше ничего. Соответственно, интерфейс – назовем его Loader – выглядит так:

package ru.skipy.tests.ui;

/**
 * Geberal background loader interface with two operations - {@link #execute()} and {@link #cancel()}
 *
 * @author Eugene Matyushkin aka Skipy
 *
 * @since 09.07.2010
 */
public interface Loader {

    /**
     * Performs loading operation in new thread
     */
    void execute();

    /**
     * Cancels current operation
     */
    void cancel();
}

Теперь в обратную сторону – что фоновому потоку нужно от GUI. Тут операций значительно больше. Во-первых, отобразить начало/окончание загрузки. Во-вторых, добавить текст к уже загруженному и установить его целиком. Показать процент загрузки. Показать ошибку, если она случится. Итого – шесть операций. Соответственно, интерфейс – UICallback – выглядит так:

package ru.skipy.tests.ui;

/**
 * Callback class to use from {@link Loader} to publish data, perform UI changes etc. Thread safety
 * should be taken into account when implementing this interface
 *
 * @author Eugene Matyushkin aka Skipy
 *
 * @since 09.07.2010
 */
public interface UICallback {

    /**
     * Appends text to the main text area. This method can be called from outside EDT.
     *
     * @param text text to append
     */
    void appendText(String text);

    /**
     * Sets text to the main text area, replacing existing context. This method can be called from outside EDT.
     *
     * @param text text to set
     */
    void setText(String text);

    /**
     * Sets current progress. Values should be in the range [0,100]. This method can be called from outside EDT.
     *
     * @param progressPercent progress value to set
     */
    void setProgress(int progressPercent);

    /**
     * Performs required UI operations when loading starts. This method can be called from outside EDT.
     */
    void startLoading();

    /**
     * Performs required UI operations when loading stops. This method can be called from outside EDT.
     */
    void stopLoading();

    /**
     * Displays error message. This method can be called from outside EDT.
     *
     * @param message message to display
     */
    void showError(String message);
}

Интерфейс Loader мы реализуем два раза – вручную и с использованием SwingWorker. UICallback достаточно реализовать один раз.

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

Еще один момент. Поскольку нам интересны именно длительные операции – я искусственно замедляю чтение файлов. Для этого я реализовал InputStream с ограничением по пропускной способности. Если кого-нибудь заинтересует этот класс – могу поделиться исходниками. В данном примере он играет абсолютно утилитарную роль.

Начнем, пожалуй, с интерфейса к UI.

Реализация интерфейса к UI – UICallback

Этот интерфейс проще всего реализовывать как внутренний класс основного окна – таким образом мы получаем естественный доступ ко всем нужным полям. Есть, однако, большое "НО". Поскольку методы этого интерфейса могут вызываться как в EDT, так и извне него – необходимо позаботиться о том, чтобы работа с компонентами пользовательского интерфейса шла только в EDT. Иначе говоря, в каждый метод необходимо поместить конструкцию такого типа (вот тут я об этом писал – Синхронизация GUI (http://www.skipy.ru/technics/synchronization.html#gui_sync)):

if (SwingUtilities.isEventDispatchThread()){
    dataField.setText(content.toString());
} else {
    SwingUtilities.invokeLater(new Runnable(){
        public void run(){
            dataField.setText(content.toString());
        }
    });
}

Подобная конструкция в каждом из шести методов означает, что за деревьями мы не увидим леса. Громоздко и сложно для восприятия. А потому – мы пойдем другим путем. :) И воспользуемся мы такой прекрасной возможностью, как создание динамического прокси. Этот способ позволяет динамически, в процессе исполнения кода, создать объект, который будет реализовывать определенный интерфейс (или интерфейсы, их вполне может быть несколько). И все вызовы методов этого интерфейса будут перенаправлены обработчику, который мы напишем. А уж обработчик может сделать с ним что угодно.

Именно так мы и поступим. Более того. Мы создадим для методов аннотацию, которая будет показывать, нужно ли этот метод вызывать именно в EDT. И если да – в синхронном (через javax.swing.SwingUtilities.invokeAndWait(java.lang.Runnable)) или же асинхронном (через javax.swing.SwingUtilities.invokeLater(java.lang.Runnable)) режимах. На этой аннотации будут основываться действия нашего обработчика.

Итак. Аннотация и режим вызова:

package ru.skipy.tests.ui;

/**
 * EDT execution policy. Code can be executed in EDT synchronously - through
 * <code>javax.swing.SwingUtilities.invokeAndWait(Runnable)</code>, - or asynchronously - through
 * <code>javax.swing.SwingUtilities.invokeLater(Runnable)</code>.
 *
 * @author Eugene Matyushkin aka Skipy
 *
 * @since 13.08.2010
 */
public enum RequiresEDTPolicy {

    /**
     * Asynchronous execution
     */
    ASYNC,

    /**
     * Synchronous execution
     */
    SYNC
}
package ru.skipy.tests.ui;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

/**
 * Annotation to mark methods that require execution in EDT
 *
 * @author Eugene Matyushkin aka Skipy
 *   
 * @since 13.08.2010
 *
 * @see ru.skipy.tests.ui.RequiresEDTPolicy
 */
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RequiresEDT {

    /**
     * Execution policy
     *
     * @return execution policy
     */
    RequiresEDTPolicy value() default RequiresEDTPolicy.ASYNC;
}

Ну и основное – обработчик:

package ru.skipy.tests.ui;

import javax.swing.*;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;

/**
 * EDT invocation handler. Intercepts calls to the interface and invokes methods in EDT
 * according to the {@link RequiresEDTPolicy}
 *
 * @author Eugene Matyushkin aka Skipy
 *   
 * @since 13.08.2010
 */
public class EDTInvocationHandler implements InvocationHandler {

    /**
     * Method invocation result
     */
    private Object invocationResult = null;

    /**
     * Target object to translate method's call
     */
    private UICallback ui;

    /**
     * Creates invocation handler
     *
     * @param ui target object
     */
    public EDTInvocationHandler(UICallback ui) {
        this.ui = ui;
    }

    /**
     * Invokes method on target object. If {@link RequiresEDT} annotation present,
     * method is invoked in the EDT thread, otherwise - in current thread.
     *
     * @param proxy  proxy object
     * @param method method to invoke
     * @param args   method arguments
     * @return invocation result
     * @throws Throwable if error occures while calling method
     */
    @Override
    public Object invoke(Object proxy, final Method method, final Object[] args) throws Throwable {
        RequiresEDT mark = method.getAnnotation(RequiresEDT.class);
        if (mark != null) {
            if (SwingUtilities.isEventDispatchThread()) {
                invocationResult = method.invoke(ui, args);
            } else {
                Runnable shell = new Runnable() {
                    @Override
                    public void run() {
                        try {
                            invocationResult = method.invoke(ui, args);
                        } catch (Exception ex) {
                            throw new RuntimeException(ex);
                        }
                    }
                };
                if (RequiresEDTPolicy.ASYNC.equals(mark.value())) {
                    SwingUtilities.invokeLater(shell);
                } else {
                    SwingUtilities.invokeAndWait(shell);
                }
            }
        } else {
            invocationResult = method.invoke(ui, args);
        }
        return invocationResult;
    }
}

То есть что мы делаем? Проверяем у метода, если ли аннотация RequiresEDT. Если есть – смотрим, в каком мы потоке. Если уже в EDT – просто вызываем метод. Если нет – создаем Runnable-объект для вызова метода и затем, в зависимости от режима вызова, выполняем этот Runnable-объект синхронно или асинхронно. Если же аннотации RequiresEDT нет – просто вызываем метод в текущем потоке.

Для того, чтобы это заработало – необходимо пометить аннотацией все методы интерфейса UICallback. Т.е. на самом деле он будет выглядеть так (комментарии для краткости я опустил):

public interface UICallback {

    @RequiresEDT
    void appendText(String text);

    @RequiresEDT
    void setText(String text);

    @RequiresEDT
    void setProgress(int progressPercent);

    @RequiresEDT
    void startLoading();

    @RequiresEDT
    void stopLoading();
            
    @RequiresEDT(RequiresEDTPolicy.SYNC)
    void showError(String message);
}

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

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

UICallback ui = (UICallback) java.lang.reflect.Proxy.newProxyInstance(getClass().getClassLoader(),
                new Class[]{UICallback.class}, new EDTInvocationHandler(new UICallbackImpl()));

UICallbackImpl – это реализация нашего интерфейса UICallback. Теперь она не содержит в каждом методе обертку для вызова в EDT – всю эту обработку выполняет EDTInvocationHandler. Соответственно, код прозрачен и легко читается (часть методов опущена):

private class UICallbackImpl implements UICallback {

    @Override
    public void appendText(final String text) {
        taText.append(text);
    }

    @Override
    public void setText(final String text) {
        taText.setText(text);
    }

    @Override
    public void setProgress(final int progressPercent) {
        pb.setValue(progressPercent);
    }

    // ... other methods should be here
}

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

Самостоятельная реализация фонового потока

Собственно, большая часть работы уже сделана. Осталось только написать код, который будет читать данные из файла и складывать их в текстовое поле. Да, разумеется этот класс реализует интерфейс Loader. И вот что получается (часть комментариев опущена):

package ru.skipy.tests.ui;

import java.io.IOException;

public class CustomLoader implements Runnable, Loader {

    /** Execution flag */
    private boolean executed = false;

    /** UI callback */
    protected UICallback ui;

    /** Data source */
    private ResourceReader reader;

    /** Loader cancelation flag */
    private boolean canceled = false;

    public CustomLoader(UICallback ui, ResourceReader reader) {
        this.ui = ui;
        this.reader = reader;
    }

    /**
     * Starts this loader execution in separate thread
     */
    @Override
    public synchronized void execute() {
        if (executed) {
            throw new IllegalStateException("Loader is already executed");
        }
        executed = true;
        Thread t = new Thread(this, "Custom loader thread");
        t.start();
    }

    /**
     * Loader main cycle
     */
    @Override
    public void run() {
        ui.startLoading();
        try {
            StringBuilder sb = new StringBuilder();
            while (!isCanceled()) {
                String str = reader.getNextLine();
                if (str == null) {
                    break;
                }
                sb.append(str).append("\n");
                ui.setProgress(reader.getProgressPercent());
                ui.appendText(str + "\n");
            }
            ui.setText(sb.toString());
        } catch (IOException ex) {
            ui.showError("Error while reading data: " + ex.getMessage());
        } finally {
            try {
                reader.close();
            } catch (IOException ex) {
                ui.showError("Error while reading data: " + ex.getMessage());
            }
            ui.stopLoading();
        }
    }

    /**
     * Cancels current loading
     */
    public synchronized void cancel() {
        canceled = true;
    }

    private synchronized boolean isCanceled() {
        return canceled;
    }
}

Наибольший интерес для нас представляет реализация цикла загрузки, ибо реализация интерфейса Loader – методы execute и cancel, – тривиальна. В первом запускается новый поток, во втором – выставляется флаг для его остановки. Тут применяется стандартная синхронизация методов.

Логика метода run, в котором происходит загрузка, тоже достаточно прозрачна. Сначала мы вызываем у UICallback метод startLoading, уведомляя UI, что начата загрузка, потом, пока не остановлена загрузка, читаем данные и скармливаем их пользовательскому интерфейсу, попутно выставляя процент загрузки. Если данные закончились – цикл прерывается, все загруженные данные отдаются в UI единым куском. В случае ошибки мы уведомляем пользователя, в конце работы мы уведомляем пользовательский интерфейс, что загрузка окончена. Весь метод уместился в 25 строк и читается одним взглядом.

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

Приступаем во второму способу –

Реализация фонового потока с использованием javax.swing.SwingWorker

И опять немного теории. Вернее, это не столько даже теория, сколько исследование того, на что нацелен класс javax.swing.SwingWorker.

javax.swing.SwingWorker предназначен для выполнения определенных фоновых операций, требующих синхронизации с пользовательским интерфейсом. Он предоставляет пользователю несколько методов, которые вызываются на разных стадиях работы в разных потоках. Только один из этих методов является абстрактным и требует реализации – doInBackground. Остальные имеют модификатор protected и могут быть переопределены по необходимости. Для удобства мы рассмотрим методы SwingWorker на нашем модельном примере.

Сначала идут методы интерфейса Loaderexecute и cancel. Первый из них есть в классе SwingWorker в том же виде, что в интерфейсе Loader. Метод execute может быть вызван из любого потока и вызывает запуск исполнения экземпляра SwingWorker в рабочем потоке. Вот на этом я хочу заострить особое внимание. У SwingWorker-а есть некоторое количество рабочих потоков, в которых выполняются все его экземпляры. Конкретно в версии Java 6 таких потоков десять. Это означает, что с помощью класса SwingWorker параллельно могут выполняться только десять фоновых задач. Все остальные будут помещены в очередь. Это первое серьезное отличие от ручной реализации, в которой количество потоков не ограничено ничем.

Метод SwingWorkercancel имеет параметр типа boolean. Это параметр указывает, вызывать ли для рабочего потока метод interrupt (как я уже писал в первой статье о синхронизации потоков, этот метод способен вывести поток из состояния ожидания). В любом случае рабочий поток может полагаться на результат вызова isCancelled.

Теперь рассмотрим методы интерфейса UICallback. Что есть у SwingWorker-а для их поддержки?

Метод startLoading аналога не имеет. В принципе, это понятно – необходимые действия можно выполнить до запуска SwingWorker-а на выполнение. Кроме того, есть механизм свойств, позволяющий реализовать и такое сообщение, о нем мы поговорим ниже.

Метод stopLoading, как вы помните, содержит действия, которые должн быть выполнены по окончании работы фонового потока. У SwingWorker-а для этой цели служит метод done. Он вызывается в EDT после окончания работы метода doInBackground. Это происходит автоматически – нам никаких усилий прилагать для этого не надо.

Методы appendText и setText служат для передачи данных из фонового потока в EDT. У SwingWorker-а для этого есть два метода – publish и process. Первый из них имеет аргумент переменной длины, фактически массив. Он вызывается в рабочем потоке для передачи данных в EDT. Второй имеет параметр типа lava.util.List и вызывается в EDT. Обратите внимание, что process вызывается асинхронно – т.е. вызов publish при этом не блокируется.

Тут я хочу немного отвлечься на сам класс SwingWorker. Точная сигнатура этого класса – SwingWorker<T,V>, т.е. класс на самом деле параметризован двумя типами. Необходимо уточнить, что это за типы.

Первый из двух типов – T. Он является типом возвращаемого значения в методе doInBackground и двух методах get (с таймаутом и без). Иначе говоря, T – тип результата работы фонового потока.

Второй из двух типов – V – является типом параметров в методах publish и process. Их точные сигнатуры – publish(V... chunks) и process(java.util.List<V> chunks) соответственно. Иначе говоря, V – это тип данных, которые фоновый поток передает в EDT в процессе исполнения. Важно то, что V никак не привязан к T, что полностью развязывает руки пользователю класса. Конечным результатом, например, может быть полный объем всех файлов в дереве на диске (и, соответственно, T = java.lang.Long), в то время как в процессе я могу отображать имя сканируемого файла и, соответственно, передавать в GUI для отображения строку (V = java.lang.String), а то и вообще файл (V = java.io.File).

Метод setProgress, который мы определили в UICallback, является абсолютно логичным для ситуации длительной работы, потому он присутствует и в SwingWorker-е. Вернее, там есть пара методов – setProgress и getProgress. Интересный момент – эти методы не синхронизированы. В принципе, даже это было бы некритично, хотя они, как правило, зовутся в разных потоках – setProgress в рабочем, getProgress в EDT. Однако, как указал один из читателей, оставшийся неизвестным, тут все в порядке. Дело в том, что переменная progress в классе SwingWorker объявлена как volatile. Учитывая, что ее тип – int, этого достаточно для устранения любых проблем с синхронизацией. То же касается и индикатора состояния – переменной state. Она имеет тип перечисления, а его объекты существуют в единственном экземпляре и могут сравниваться по ссылкам.

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

Краткий экскурс в JavaBeans™

С версии 1.1 в Java присутствует такая технология как JavaBeans™. Она предназначена для создания компонент. Вот так вот, абстрактно, – компонент. Об их природе ничего не говорится. Такая абстракция полезна для создания унифицированных инструментов по работе с компонентами, о которых ничего заранее не известно.

У компоненты могут быть свойства. Эти свойства могут принимать любые значения, а могут только строго определенные. Например, свойство «процент загрузки» ограничено значениями от 0 до 100. Соответственно, на уровне технологии должны быть средства по мониторингу значений свойств и ограничению этих значений в случае необходимости. И такие средства, разумеется, есть. Соответствующие классы расположены в пакете java.beans.

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

Мониторинг значений свойств построен на шаблоне Listener – слушатель. Мы реализуем и где-то регистрируем слушателя, после чего его уведомляют об изменениях. В JavaBeans™ слушателем является интерфейс java.beans.PropertyChangeListener с единственным методом void propertyChange(java.beans.PropertyChangeEvent). Этот метод вызывается тогда, когда изменяется значение свойства. Имя свойства, старое и новое значения, а также источник события указаны в переданном экземпляре java.beans.PropertyChangeEvent.

Для удобства использования данного механизма реализован класс java.beans.PropertyChangeSupport. Этот класс позволяет регистрировать слушателя как для всех свойств, так и для конкретных. Кроме того, он содержит несколько методов, позволяющих легко инициировать оповещение об изменении свойства. Имена этих методов – firePropertyChange и fireIndexedPropertyChange с различными параметрами.

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

Если вы посмотрите на сигнатуру класса SwingWorker, то увидите там методы addPropertyChangeListener/removePropertyChangeListener, firePropertyChange и getPropertyChangeSupport. Теперь их назначение должно быть понятным. У SwingWorker-а есть два свойства – state и progress. Так что когда вы вызываете метод setProgresss, на самом деле происходит выставление свойства. И если есть слушатели – они уведомляются о произошедшем изменении. Важный момент – уведомление происходит в EDT.

Таким образом, например, можно реализовать метод startLoading (выше я говорил об этом). Создается слушатель на свойство started, а в начале doInBackground вызывается firePropertyChange("started", false, true). В принципе, на свойствах же можно организовать и оповещение пользователя об ошибках, но это уже сложнее, гораздо больше усилий по синхронизации. Потребуется останавливать фоновый поток в случаях, когда требуется реакция пользователя, а учитывая, что их число ограничено... Одна ошибка в логике – и через 10 нештатных ситуаций при загрузке все фоновые потоки будут заблокированы.

Вот, собственно, мы и рассмотрели класс SwingWorker в подробностях. Остались два метода, которые я упоминал вскользь – get() и get(long, TimeUnit) получение результата работы без таймаута и с таймаутом. В обоих случаях вызов этого метода блокирует текущий поток. Соответственно, если вы вызовете его в EDT – можете считать, что все усилия по реализации вашего SwingWorker-а пропали впустую. Эффект будет ровно такой же, как если бы вы всё делали в EDT.

Ну и, наконец – doInBackground. Что делает этот метод – решать вам. Он выполняет работу в фоновом потоке. Он может – но не обязан – использовать publish для передачи какой-то текущей информации в EDT, где она будет обработана в process, который нужно переопределить для этой цели.

Теория закончена, перейдем к практике.

Собственно, мы уже всё обсудили. Что какой метод делает – знаем. Соответственно, код получается такой (несущественная часть комментариев опущена):

package ru.skipy.tests.ui;

import javax.swing.*;
import java.io.IOException;
import java.util.List;

public class SwingWorkerLoader extends SwingWorker<String, String> implements Loader {

    /**
     * Buffer for data loading. This field is used in both background and ED threads,
     * that's why StringBuffer is used
     */
    private StringBuffer buffer = new StringBuffer();

    /** UI callback */
    private UICallback ui;

    /** Data source */
    private ResourceReader reader;

    public SwingWorkerLoader(UICallback ui, ResourceReader reader) {
        this.ui = ui;
        this.reader = reader;
        // this operation is safe because
        // 1. SwingWorkerLoader is created in EDT
        // 2. Anyway - UICallback is proxied by EDTInvocationHandler  
        this.ui.startLoading();
    }

    /**
     * Background part of loader. This method is called in background thread. It reads data from data source and
     * places it to UI  by calling {@link javax.swing.SwingWorker#publish(Object[])}
     *
     * @return background execution result - all data loaded
     * @throws Exception if any error occures
     */
    @Override
    protected String doInBackground() throws Exception {
        try {
            while (!isCancelled()) {
                String str = reader.getNextLine();
                if (str == null) {
                    break;
                }
                buffer.append(str).append("\n");
                setProgress(reader.getProgressPercent());
                publish(str);
            }
        } catch (IOException ex) {
            ui.showError("Error while reading data: " + ex.getMessage());
        } finally {
            reader.close();
        }
        setProgress(100);
        return buffer.toString();
    }

    /**
     * EDT part of loader. This method is called in EDT
     *
     * @param chunks data, that was passed to UI in {@link #doInBackground()} by calling
     *               {@link javax.swing.SwingWorker#publish(Object[])}
     */
    @Override
    protected void process(List<String> chunks) {
        for (String line : chunks) {
            ui.appendText(line + "\n");
        }
        ui.setProgress(getProgress());
    }

    /**
     * Cancels execution
     */
    @Override
    public void cancel() {
        cancel(true);
    }

    /**
     * This method is called in EDT after {@link #doInBackground()} is finished.
     */
    @Override
    protected void done() {
        ui.stopLoading();
        ui.setText(buffer.toString());
    }
}

Фактически, наша работа состояла в следующем:

  • Реализовали метод doInBackground – основной метод SwingWorker-а. Читаем в цикле строки, каждую приписываем к буферу прочитанных данных, обновляем прогресс и вызываем обновление UI при помощи метода publish.
  • Переопределили метод process – для отображения загружаемой информации в процессе работы.
  • Переопределили метод done – для установки всего загруженного текста и уведомления UI об окончании загрузки.
  • Реализовали метод cancel из нашего интерфейса Loader.

Уведомление о начале загрузки реализовано прямо в конструкторе, по причине того, что он вызывается в EDT, во-первых, и мы используем прокси над UICallback, во-вторых. Хотя в случае использования SwingWorker-а прокси нам и не нужен на самом деле – все методы, кроме showError, вызываются в EDT, в этом и есть суть использования SwingWorker-а. Ну а с уведомлением об ошибках тоже можно было бы что-нибудь придумать, хотя бы на базе свойств.

Об иллюстративном примере

Всё вышеперечисленное – интерфейсы, аннотации, обработчик вызовов в EDT, две реализации загрузчиков, а в дополнение к этому и пользовательский интерфейс – всё это собрано воедино в иллюстративном примере. Как и всегда пример содержит ant-скрипт для сборки и запуска. Естественным образом требуется использовать Java 6 – ибо SwingWorker появился только в этой версии.

Пример представляет собой приложение, которое загружает текстовый файл в текстовое поле. Ничего другого оно не делает. Приложение позволяет выбрать загрузчик – свой или основанный на SwingWorker-е, – скорость загрузки, кодировку файла и, наконец, сам файл. Список файлов строится автоматически, в него включаются все файлы с расширением .txt, которые находятся в директории запуска приложения. В состав примера включены два тестовых файла. Они не несут абсолютно никакой смысловой нагрузки и являются на самом деле побочным продуктом каких-то моих бесчеловечных экспериментов. Рассматривайте их как текстовые файлы определенного объема.

Архив с кодом можно скачать тут: gui_sync.zip. Скачиваете, распаковываете. Запускается приложение командой ant run или просто ant, т.к. run является целью по умочанию.

Заключение

Итак, мы рассмотрели два варианта реализации взамодействия между фоновым потоком и пользовательским интерфейсом – полностью вручную и с использованием javax.swing.SwingWorker. Оба варианта рабоают успешно, правда, ничего другого и не ожидалось.

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

Вариант использования SwingWorker-а, с другой стороны, требует меньшей работы – уберите аннотацию и все, что с ней связано, интерфейс Loader, EDTInvocationHandler. Однако, на мой взгляд, есть у этого метода существенные недостатки.

Первый, конструктивный, – ограничение количества фоновых потоков. Если в какой-то ситуации поток будет блокироваться, а вы этого не отследите, – пул исчерпается. И тогда вся фоновая загрузка остановится.

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

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

На этом всё. Всем спасибо за внимание!


P.S. Комментарии? Дополнения? Возражения? Добро пожаловать в блог: http://skipy-ru.livejournal.com/4185.html.