Aplikacja w Swing Application Framework (prosta, ale nie za prosta)

05.13.2008

Naszym celem jest utworzenie prostej aplikacji szukającej plików na dysku, gotową aplikację można uruchomić przez Java WebStart, dostępny jest oczywiście kod źródłowy, powiedzmy, że na licencji BSD ;).

Wstęp
Swing Application Framework jest szkieletem aplikacyjnym, który ma uprościć tworzenie aplikacji desktopowych w Java Swing. SAF z założenia ma być rozwiązaniem prostym, rozwiązującym podstawowe problemy, na jakie napotykamy się tworząc programy z interfejsem użytkownika w Javie.

Czym więc zajmuje się SAF?

  • Cyklem życia aplikacji od jej uruchomienia po zamknięcie
  • Zarządzaniem zasobami: łańcuchami znaków, kolorami, ikonami, czcionkami itp. rzeczami, które występują w typowej aplikacji. Oczywiście wszystkie zasoby mogą być internacjonalizowane/lokalizowane.
  • Obsługą zdarzeń (co ma się stać, gdy użytkownik naciśnie przycisk X). W szczególności SAF upraszcza znacząco obsługę długotrwałych zdarzeń, które powinny wykonywać się w wątkach roboczych, a nie głównym
  • Pamiętaniem stanu aplikacji. Po zamknięciu aplikacji pamięta ona jaki był stan interfejsu użytkownika (rozmiar okien, ich położenie) przed zamknięciem.

Żeby nie przedłużać wstępu przejdźmy do rzeczy, czyli przyjrzyjmy się przykładowej aplikacji Szukacz. Szukacz jest kulawą i ubogą namiastką grep-a napisaną w Javie, ma za to graficzny interfejs użytkownika.

Aplikacja jest skonstruowana inaczej niż w większości tutoriali Swing Application Framework, które można znaleźć w Internecie. Problem z umieszczonymi tam przykładami jest taki, że w rzeczywistej sytuacji są one mało użyteczne. Przykłady te dzielą się na dwie grupy:

  1. aplikacje wyklikane od początku do końca w środowisku NetBeans, korzystające z kreatorów kodu tam dostępnych
  2. aplikacje napisane całkowicie ręcznie, włącznie z elementami GUI

Tak na prawdę żadna z tych sytuacji nie jest typowa.

Ad. 1. Nie każdy musi chcieć używać NetBeansa (fakt, jest bardzo dobry jeśli chodzi o tworzenie GUI), a nawet jeżeli używamy go, to istnieje duża szansa, że mamy już napisany jakiś spory kawał kodu, który nie używa SAF i chcielibyśmy jakoś gładko istniejący kod zintegrować z tym frameworkiem.

Ad. 2. Jeśli nie jesteśmy maniakalnymi zwolennikami ręcznego dziergania kodu GUI, co zazwyczaj kończy się mniejszą lub większą katastrofą połączoną z gromami rzucanymi na Swinga i Javę w ogóle, to pewnie chcemy użyć jakiegoś wizulanego narzędzi, które pozwoli nam wyklikać strukturę GUI. Narzędzie to na bank nie słyszało jeszcze o SAF, a my chcemy sobie klikać interfejs użytkownika i jednocześnie łatwo go zintegrować z SAF.

Tak więc chcę pokazać, jak tworzyć sobie GUI takim sposobem, jak nam się podoba i móc go w każdej chwili zintegrować z SAF. Zatem do dzieła.

Logika “biznesowa” aplikacji
Zacznijmy od bardzo szybkiego spojrzenia na klasę Grep.java, która zajmuje się wyszukiwaniem podanego wyrażenie regularnego w plikach. Inicjalizujemy ją podając wyrażenie regularne, które kompilujemy ze względów wydajnościowych do obiektu java.util.regex.Pattern. Całą pracę wykonuje metoda void searchInFiles(File), która złośliwie nie zwraca wyników wyszukiwania. Możemy je otrzymać w formie miłej ludzkiemu oku metodą String getFormatedSearchResults(), a w formie miłej programistą dzięki metodzie Map<String, Map<Integer, String>> getSearchResults(). Zresztą kod mówi sam za siebie.

SAF – cykl życia aplikacji
Zajmiemy się teraz podstawowym kawałkiem SAF, czyli główną klasą aplikacji. Musi ona dziedziczyć po klasie Application lub SingleFrameApplication frameworka. Klasa SingleFrameApplication daje nam kilka dodatkowych usług oraz gotową instancję klasy reprezentującej okno w Swingu, czyli JFrame. Z powodów opisanych powyżej będziemy jednak samodzielnie tworzyć okno aplikacji – chcemy używać SAF, ale nie chcemy, żeby nam się on w aplikacji za bardzo panoszył. Klasa Application jest abstrakcyjna, dziedzicząc po niej musimy zaimplementować samodzielnie metodę void startup(), za chwilę się nią zajmiemy.

Popatrzmy na listing klasy MainApp.java.

package pl.xoft.saf.finder.ui;

import java.awt.Component;
import java.util.EventObject;
import javax.swing.JOptionPane;
import org.jdesktop.application.*;

public class MainApp extends SingleFrameApplication{
  ResourceMap resource;
  ApplicationContext ctxt;

  @Override
  protected void startup() {
    FrameView view = new MainViewFrame(this);
    view.setFrame(new MainFrame());
    show(view);

    addExitListener(new ExitListener() {
      public boolean canExit(EventObject e) {
        Object[] options = {resource.getString("label.yes"),
            resource.getString("label.no")};

        Object source = (e != null) ? e.getSource() : null;
        Component owner =
            (source instanceof Component) ?
                (Component)source : null;

        boolean mayExit = JOptionPane.showOptionDialog(
            owner,
            resource.getString("label.exit"),
            resource.getString("Application.name"),
            JOptionPane.YES_NO_OPTION,
            JOptionPane.QUESTION_MESSAGE,
            null,
            options,
            options[1]) == JOptionPane.YES_OPTION;

        return mayExit;
      }
      public void willExit(EventObject event) {
        //do nothing
      }
    });
  }

  @Override
  protected void initialize(String[] args) {
    System.out.println("Inicjalizacja... ");
    this.ctxt = getContext();
    ResourceManager mgr = ctxt.getResourceManager();
    resource = mgr.getResourceMap(MainApp.class);
  }

  @Override
  protected void shutdown() {
    System.out.println("Koniec pracy!! Czyścimy");
  }

  public static void main(String[] args) {
    Application.launch(MainApp.class, args);
  }
}

Żeby było inaczej niż zwykle, zacznijmy od końca, czyli metody main(String[]), przy pomocy metody Application.launch(Application) uruchamiamy całą aplikację. Metoda ta jest bardzo wygodna dla nas, bo nie musimy pamiętać o inicjalizacji GUI z poziomu odpowiedniego wątku, Event dispatching thread (EDT). Zróbmy w tym miejscu mały skok w bok, żeby wyjaśnić sobie terminologię no i żeby osoby mniej obyte z tematem wiedziały w czym rzecz.

Trzeba pamiętać, że typowa aplikacja Swingowa potrzebuje co najmniej trzech wątków:

  1. głównego, który powinien tylko uruchomić aplikację,
  2. Event dispatching thread, w którym, i tylko w którym można tworzyć i modyfikować komponenty interfejsu użytkownika,
  3. wątku lub wątków roboczych, które wykonują wszystkie dłużej trwające zadania – nie chcemy ich wykonywać w EDT, żeby nie blokować interfejsu użytkownika.

Pierwszym często popełnianym błędem jest tworzenie GUI z poziomu wątku głównego, a nie EDT, często to nie powoduje problemów, ale zdarza się, że pojawiają się przykre i trudne do wykrycia błędy.

Metoda lanuch() powoduje rozpoczęcie wywołań kolejnych metod odpowiedzialnych za obsługę cyklu życia aplikacji, możemy przesłonić tylko te, które chcemy za wyjątkiem void startup().

  • Metoda void initialize(String[]). Możemy w niej wykonać różnego rodzaju czynności inicjalizacyjne, które są potrzebne, w szczególności, jeśli trzeba je wykonać przed konstrukcją interfejsu użytkownika
  • Metodę void startup() musimy przesłonić obowiązkowo: w tej metodzie inicjalizujemy tworzenie GUI. W metodzie tej tworzymy instancję klasy FrameView, która jest mostem pomiędzy SAF, a oknem aplikacji MainFrame. Potencjalnie MainFrame nie musi nic wiedzieć o SAF, dzięki czemu mamy dużą swobodę pracy z nią – nie jesteśmy uzależnieni od SAF, w każdej chwili możemy używać tych jego elementów, które chcemy.
  • Metody void ready() nie ma na listingu, przesłaniamy ją wtedy, gdy potrzebne są jakieś czynności inicjalizacyjne po utworzeniu GUI – zazwyczaj chcemy jak najszybciej pokazać użytkownikowi interfejs aplikacji, a gdy on się jemu z podziwem przygląda możemy spokojnie dokończyć inicjalizację.
  • Metoda void exit() kończy działanie aplikacji. Odbywa się to w ten sposób, że jeśli z poziomu aplikacji wywołane jest zdarzenie żądające zakończenia pracy (np. klikniemy przycisk “z krzyżykiem” okienka), to przejmowane jest ono przez klasę ExitListener-a, która sprawdza, czy wolno zakończyć działanie aplikacji. W naszym przypadku w metodzie startup() dodajemy ExitListener-a, który wyświetla okno dialogowe z pytaniem, czy możemy kończyć pracę.
  • Metoda void shutdown(). Gdy zapadnie decyzja, że możemy kończyć ostatnią rzeczą jaka jest robiona jest wywołanie tej metody; możemy tam posprzątać po sobie lub zrobić cokolwiek, co ma być ostatnią rzeczą robioną przez nasz program.

Widzimy więc, że SAF automatyzuje i standaryzuje typowe elementy, które aplikacja powinna mieć: prawidłową inicjalizację i zakończenie działania.

Zarządzanie zasobami
Kolejnym elementem, który daje nam SAF jest zarządzanie zasobami. Dotyczy to wszystkich zasobów: napisów na etykietach, przyciskach, itp., ikon, używanych czcionek. W jaki sposób to się odbywa? Zasoby dzielą się na globalne i lokalne.

Zasoby globalne są umieszczone w pliku GlownaKlasaAplikacji.properties, który musi się znajdować w pakiecie nazwa.pakietu.resources, przy założeniu, że klasa GlownaKlasaAplikacji znajduje się w pakiecie nazwa.pakietu. W naszym przypadku zasoby globalne znajdziemy w pliku MainApp.properties i dla języka angielskiego w MainApp_en_US.properties. Oba pliki są w pakiecie pl.xoft.saf.finder.ui.resources.

Zasoby lokalne są przechowywane z tą samą konwencją co powyżej – każda klasa ma swój plik z zasobami umieszczony w pliku properties o nazwie takiej samej jak klasa.

Do zasobów dostajemy się tak jak to demonstruje listing klasy MainApp. W metodzie initialize(String[]) tworzymy uchwyt do bardzo użytecznej klasy, ApplicationContext (linia 49.), reprezentującej środowisko działania aplikacji. Następnie z kontekstu aplikacji wyciągamy klasę zarządzającą zasobami: ResourceManager. W klasie MainApp zasobów używamy przy tworzeniu okna dialogowego, które pojawia się, gdy ktoś chce zamknąć aplikację (linie 30 i 31).

Bardzo użyteczną cechą SAF jest automatyczna konwersja niektórych zasobów na klasy Java. Obejrzymy sobie fragment klasy MainFrame.html, w którym do aplikacji dodajemy element menu pozwalający zwiększyć wielkość czcionki.

        jMenuItem3.setAction(actionMap.get("makeLarger"));
        jMenuItem3.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_PAGE_UP, InputEvent.ALT_MASK | InputEvent.CTRL_MASK));
        jMenuItem3.setIcon(resourceMap.getIcon("jMenuItem3.icon"));
        jMenuItem3.setText(resourceMap.getString("jMenuItem3.text"));
        jMenuItem3.setName("jMenuItem3");
        jMenu2.add(jMenuItem3);

Aby menu było czytelniejsze jest tam umieszczona odpowiednia ikona. Bez SAF sami byśmy musieli z łańcuch znaków wyprodukować obiekt klasy ImageIcon, a tutaj wystarczy, że użyjemy metody ResourceMap.getIcon(String). Podobnie konwertowane są z łańcuchów znaków definicje czcionek i kolory. Niby drobne udogodnienie, ale jeśli dołożymy do tego internacjonalizację i pomnożymy wszystko przez 500 wystąpień w dużej aplikacji, to okaże się, że jednak trochę rękę oszczędziliśmy.

Akcje – obsługa zdarzeń
Wreszcie dochodzimy do najciekawszego elementy SAF, uproszczonego definiowania akcji. W Swingu jeśli chcemy, żeby jakiś komponent oprócz siedzenia w okienku mógł zrobić coś pożytecznego, musieliśmy dodać do niego nasłuchiwacza zdarzeń (ActionListener lub innego) a następnie tegoż nasłuchiwacza zaimplementować jako osobną klasę. Często robi się to wykorzystując klasę wewnętrzną.

SAF całą sprawę bardzo upraszcza, po prostu tworzymy metodę, oznaczamy ją metadaną @Action i dodajemy informację o tym, że pojawiła się obsługa zdarzenia do mapy akcji.

Popatrzmy na poniższy fragment kodu, wzięty z klasy MainPane.java:

//fragmenty klasy MainPane
public class MainPane extends javax.swing.JPanel {

  File f = null;

  public MainPane() {
    initComponents();
  }

  //w metodzie pozostawiona jest tylko inicjalizacja interesujących nas w tej chwili komponentów
  private void initComponents() {
    jTextArea1 = new javax.swing.JTextArea();
    jButton1 = new javax.swing.JButton();
    jTextField2 = new javax.swing.JTextField();

    jTextArea1.setColumns(20);
    jTextArea1.setEditable(false);
    jTextArea1.setFont(resourceMap.getFont("jTextArea1.font"));
    jTextArea1.setRows(5);
    jTextArea1.setName("jTextArea1");

    ActionMap actionMap = Application.getInstance(MainApp.class).getContext().getActionMap(MainPane.class, this);
    jButton1.setAction(actionMap.get("search"));
    jButton1.setText(resourceMap.getString("jButton1.text"));
    jButton1.setName("jButton1"); 

    jTextField1.setEditable(false);
    jTextField1.setText(resourceMap.getString("jTextField1.text"));
    jTextField1.setName("jTextField1");
  }

  @Action
  public void search() {
    jTextArea1.setText("");
    Grep g = new Grep(jTextField2.getText());
    try {
      if(f != null)
        g.searchInFiles(f);
    } catch (FileNotFoundException ex) {
      Logger.getLogger(MainPane.class.getName()).log(Level.SEVERE, null, ex);
    } catch (IOException ex) {
      Logger.getLogger(MainPane.class.getName()).log(Level.SEVERE, null, ex);
    }
    getJTextArea1().setText(g.getFormatedSearchResults());
  }
}

Interesuje nas wyłącznie obsługa przycisku “Szukaj”, dlatego cały kod służący do innych celów został usunięty z powyższego listingu. W linii 32. zaczyna się metoda search, oznaczamy ją metadaną @Action, żeby zarejestrować ją jako akcję. Pozostaje jeszcze powiązać odpowiedni przycisk (JButton1) z tę akcją. Robimy to w linii 23., wykorzystując klasę javax.swing.ActionMap.

Warto zwrócić uwagę na to, że możemy się odwoływać do akcji zdefiniowanych w innych klasach, przekazując odpowiedni obiekt do metody ApplicationContext.getActionMap(Class, Object) – jest to bardzo wygodne, gdyż raz zdefiniowaną akcję można wykorzystywać w całej aplikacji.

Pewnie najwygodniejszy byłby tutaj mechanizm wstrzykiwania zależności (ang. dependency injection), ale SAF ma być prosty, więc przynajmniej w obecnej wersji robimy to wyszukując odpowiednią klasę i metodę z kontekstu aplikacji.

Opisana implementacja funkcjonalności wyszukiwania jest bardzo prosta w implementacji, ma jednakże jedną dosyć zasadniczą wadę: jeżeli wyszukiwanie trwa dłużej, to blokuje ono interfejs użytkownika, w szczególności nie ma możliwości przerwania wyszukiwania. Dlaczego tak się dzieje jest jasne: długotrwałe zadanie wykonujemy w wątku EDT odpowiedzialnym za rysowanie interfesju użytkownika.

Musimy zatem uruchomić wyszukiwanie w osobnym wątku. Brzmi to dość prosto, ale w praktyce jest uciążliwe, gdyż trzeba zsynchronizować działanie wątku szukającego z wątkiem EDT. Dotychczas najwygodniejszym rozwiązaniem było wykorzystanie klasy narzędziowej SwingWorker [javadoc, przykład wykorzystania]. Podobne podejście stosuje SAF, tyle, że użycie analogicznego mechanizmu jest prostsze niż w przypadku SwingWorker-a.

Przyjrzymy się jeszcze raz klasie MainPane.java, tyle, że innemu jej fragmentowi

//fragmenty klasy MainPane
public class MainPane extends javax.swing.JPanel {

  File f = null;

  public MainPane() {
    initComponents();
  }
   //w metodzie pozostawiona jest tylko inicjalizacja interesujących nas w tej chwili komponentów
  private void initComponents() {
    ActionMap actionMap = Application.getInstance(MainApp.class).getContext().getActionMap(MainPane.class, this);

    jButton2.setAction(actionMap.get("searchNoBlocking"));
    jButton2.setText(resourceMap.getString("jButton2.text"));
    jButton2.setName("jButton2");

    jTextField2.setText(resourceMap.getString("jTextField2.text"));
    jTextField2.setName("jTextField2");

    jButton4.setAction(actionMap.get("cancel"));
    jButton4.setText(resourceMap.getString("jButton4.text"));
    jButton4.setName("jButton4");
  }

  @Action(block=Task.BlockingScope.COMPONENT)
  public Task searchNoBlocking() {
    searchTask = new SearchNoBlockingTask(Application.getInstance(MainApp.class));
    return searchTask;
  }

  private class SearchNoBlockingTask extends Task<String, Void> {
    StringBuilder str = null;
    Grep g = null;

    SearchNoBlockingTask(Application app) {
      super(app);
      jTextArea1.setText("");
      g = new Grep((jTextField2.getText()));
    }

    protected String doInBackground()  {
      try {
        if(f != null )
           g.searchInFiles(f);
      } catch (FileNotFoundException ex) {
        Logger.getLogger(MainPane.class.getName()).log(Level.SEVERE, null, ex);
      } catch (IOException ex) {
        Logger.getLogger(MainPane.class.getName()).log(Level.SEVERE, null, ex);
      }
      return g.getFormatedSearchResults();
    }
    protected void succeeded(String result) {
      getJTextArea1().setText(result);
    }
  }

  @Action
  public void cancel() {
    if(searchTask != null)
      searchTask.cancel(true);
  }
}

Tak jak wcześnie, do komponentu, w tym przypadku przycisku przypisujemy akcję, która tym razem nazywa się searchNoBlocking (linia 13.), inaczej wygląda za to implementacja wyszukiwania. Metoda obsługująca wyszukiwanie zwraca tym razem obiekt typu Task, którego instancję musimy utworzyć w metodzie (linia 25.).

Oczywiście nikt nam nie da gotowej klasy Task, także trzeba ją samodzielnie utworzyć, przesłaniając odpowiednie metody – właśnie to jest naszym celem. Nasza implementacja Task nazywa się SearchNoBlockingTask (linia 31.) i przesłaniamy w niej trzy metody:

  • konstruktor, w którym inicjalizujemy potrzebne obiekty, warto zwrócić uwagę na to, że możemy w nim modyfikować stan GUI
  • doInBackground(), która jest odpowiedzialna za uruchomienie wyszukiwania – ta metoda działa w osobnym wątku, nie blokuje zatem interfejsu użytkownika, nie wolno w tej metodzie odwoływać się w związku z tym do elementów GUI. Metoda ta jest automatycznie uruchomiana, gdy tworzymy instancję klasy SearchNoBlockingTask.
  • succeeded, która jest wywoływana jak tylko skończy się działanie metody doInBackground() – w tej metodzie możemy zaktualizować interfejs użytkownika przy pomocy danych uzyskanych z metody doInBackground(). Metoda ta jest oczywiście uruchomiona w wątku EDT, dlatego może modyfikować GUI.

Klasa SearchNoBlockingTask ma jeszcze inne użyteczne metody, z jednej z nich korzystamy w metodzie cancel, umieszczonej na samym końcu listingu, przerywa ona wyszukiwanie po kliknięciu w odpowiedni przycisk.

Często się zdarza, że chcemy jednak zablokować jakąś część interfejsu użytkownika w czasie trwania długotrwałej operacji, możemy to zrobić ręcznie, w metodach klasy SearchNoBlockingTask, lub przekazując w parametrze metadanej @Action informację o tym, co chcemy blokować, w powyższym przykładzie blokujemy komponent, który spowodował rozpoczęcie wyszukiwania, czyli odpowiedni przycisk. Uchroni to naszą aplikację przed wielokrotnym jego naciśnięciem przez użytkownika.

Co jeszcze potrafi SAF
Jest jeszcze kilka rzeczy, o których warto wspomnieć. SAF ułatwia tworzenie bardzo często pożądanej funkcjonalności, czyli paska postępu. SAF zapamiętuje także automatycznie stan GUI – wielkość i położenie okien (akurat w moim przykładzie to nie działa, gdyż jest on skonstruowany nie do końca zgodnie z duchem SAF)

Dodatkowe informacje

Podsumowanie
Najważniejszy pytanie oczywiście brzmi, czy SAF się przyjmie. Niewątpliwie Swing potrzebuje tego typu rozwiązania, także coś tę pustkę wypełni i nie bardzo widać konkurencję na tym polu. Potrzebne jest też wsparcie narzędzi, na razie SAF wspiera tylko NetBeans i robi to całkiem dobrze, mimo kilku drobnych uciążliwości, jak na przykład notoryczne kasowanie nazwy komponentu, do którego dodajemy akcję. Zobaczymy, co się będzie działo na polu tworzenia interfejsu użytkownika, bo ewidentnie szykuje się mniejszy lub większy przełom.

Z jednej strony aplikacje “grubego klienta” przestają być wystarczające w niektórych zastosowaniach, z drugiej strony interfejs użytkownika, który można utworzyć przy pomocy HTML/CSS/JavaScript nawet jeśli wykorzystuje się AJAX jest daleki od tego, co byśmy chcieli dostać. Pytanie brzmi, jaka technologia będzie używana: może wraz z uproszczeniem instalacji Javy, która ma być wkrótce dostępna jako Java SE 6 Update 10 (zwany także 6uN) do łask wrócą applety, które z technologicznego punktu widzenia są bardzo skutecznym rozwiązaniem w wielu zastosowaniach, podobnie może stać sie z Java Web Start. Są też oczywiście twardzi konkurenci na tym polu: Adobe Flex i Adobe Air no i Silverlight od Microsoftu.

Uwaga: artykuł bazuje na dość wczesnej wersji SAF, która może się jeszcze zmieniać, także z czasem niektóre rzeczy mogą przestać działać, albo zacząć działać niepoprawnie.



One Response dla “Aplikacja w Swing Application Framework (prosta, ale nie za prosta)”

  1. Tomasz Says:

    Super, bardzo mi się spodobał SAF w tym opisie. Chciałbym by do łask wróciły applety, ale wątpię.
    Gdyby instalacja javy dla przeglądarek była tak łatwa jak instalacja playera flasha to applety miały by szansę na powrót.

Twój komentarz