GUI med Swing

EDT

Innan vi börjar skapa och visa fönster behöver vi först känna till EDT (Event Dispatch Thread). Detta är en speciell tråd som körs i Java vilket ansvarar för all grafik, t.ex. att rita upp vårt fönster, samt hantera event som musklick och knapptryckningar.

Swing komponenter är nämligen inte trådsäkra, detta menas med att de endast bör hanteras av en tråd - dvs EDT. Genom att medvetet eller omedvetet arbeta med Swing-komponenter ifrån flera trådar riskerar vi att skapa grafik-buggar eller i värsta fall programkrasch. Se gärna Swing's Threading Policy

Men, tänk på att inte lägga för tunga operationer på EDT. Att räkna ut stora primtal eller använda sig av filer/databaser/nätverkskommunikation etc. passar sig inte på EDT. Långsamma operationer leder till att EDT får mindre tid till sina egna sysslor, vilket gör vårt GUI/applikation oresponsivt. För detta passar det bättre att skapa en worker-tråd som vi kommer till senare.

Skapa och initiera ett tomt fönster

I Swing skapar vi ett fönster genom att instansera klassen JFrame och ställa in synligheten för denna via setVisible(true). Som vi konstaterade innan bör vi dock instansera detta objekt på EDT - men hur? SwingUtilities erbjuder oss två metoder invokeAndWait samt invokeLater. Båda tar ett Runnable objekt som parameter, detta exekveras sedan under EDT. Som du kanske gissat är skillnaden mellan dessa två metoder att invokeAndWait blockar tills EDT har slutfört exekveringen.

Värt att veta är att alla schemalagda körningar via invokeLater alltid körs i den ordning de hamna på kön. Dvs ett senare anrop till invokeLater kan aldrig startas innan samtliga tidigare kö-lagda events bearbetats.

Nog om teori! Här är ett praktiskt exempel på hur vi skapar upp ett tomt fönster:

// File: empty/MyGUI.java

import javax.swing.JFrame; import javax.swing.SwingUtilities; public class MyGUI { private final JFrame window; public MyGUI(String[] args){ // Initiera vår fönster window = new JFrame("My First GUI Window"); // Avsluta vår applikation om vi stänger fönstret window.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); // Ställ in dimension för vårt fönster window.setSize(400, 200); } public void show(){ // Placera fönstret i mitten på skärmen window.setLocationRelativeTo(null); // Gör vårt fönster synligt window.setVisible(true); } public static void main(final String[] args) { // Detta är våran main-tråd och startpunkt. Be EDT att // skapa och visa vårt fönster. SwingUtilities.invokeLater(new Runnable() { public void run(){ // Detta kommer exekveras under EDT. Från och med // detta kan vi fritt använda oss av Swing! MyGUI gui = new MyGUI(args); gui.show(); } }); } }

Många väljer att låta huvudklassen för applikationen ärva ifrån JFrame. Jag anser dock det vara bättre praxis att endast ärva ifrån klasser när man måste ändra dess funktionalitet/överlagra metoder. Eftersom vi inte vill ändra hur JFrame fungerar deklarerar jag där med hellre denna som en final-instansvariabel (dvs komposition i stället för arv), men detta är en smaksak - båda alternativen fungerar lika bra i praktiken!

Det vi gör ovan är att skapa en anonym Runnable-klass vars enda uppgift är att skapa och visa MyGUI. Detta lägger vi sedan på EDT's "att göra lista" via metoden invokeLater. Vi vet alltså inte när MyGUI kommer att existera ifrån vår main-tråd, bara att EDT kommer utföra det. invokeLater returnerar direkt och vår main-tråd dör, men detta hindrar inte vår applikation att fortsätta köras. När vi anropar invokeLater eller när vi ställer in en JFrame att vara synlig startas EDT automatiskt, och så länge EDT körs kommer vår applikation att köras.

Men vänta nu! Om EDT startas när vi visar en JFrame, varför kan vi inte instansera alla Swing komponenter i vår main-tråd och bara anropa setVisible sist? Det är korrekt, vi skulle kunna göra detta för simpla GUI applikationer - men det är inget som rekommenderas, och anses vara dålig praxis. Bland annat blir det lättare att råka använda Swing komponenter ifrån fel tråd, eller anropa setVisible innan vi initierat alla komponenter.

Vi ser till att vår applikation/EDT avslutas direkt när vi stänger vår JFrame genom att sätta setDefaultCloseOperation till EXIT_ON_CLOSE, skulle vi missa göra detta kommer applikationen fortsättas köras i bakgrunden. Ett alternativ till detta skulle vara DISPOSE_ON_CLOSE vilket ser till att applikationen avslutas endast om det inte finns fler initierade fönster.

Tunga operationer utanför EDT

Låt oss bygga vidare på MyGUI exemplet ovan - säg att vi vill göra en applikation som hämtar och visar ett slumpat citat via sidan www.iheartquotes.com. Vi går inte in på detalj hur själva nerladdningen går till i vår URLFetcher klass, utan vikten för detta exempel är just att själva nerladdningen inte ska ske på EDT tråden. Vi börjar med ett exempel där vi lägger operationen på vår main-tråd:

// File: quote-main/MyGUI.java

import javax.swing.JFrame; import javax.swing.SwingUtilities; import javax.swing.JTextArea; public class MyGUI { private JFrame window; private JTextArea textarea; public void init(String[] args){ // Initiera vårt fönster window = new JFrame("My First GUI Window"); // Avsluta vår applikation om vi stänger fönstret window.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); // Skapa en textarea, och be denna bryta långa rader per ord. textarea = new JTextArea(); textarea.setLineWrap(true); textarea.setWrapStyleWord(true); textarea.setEditable(false); textarea.setText("Laddar citat..."); // Lägg till vår textarea i mitten av vår JFrame window.add( textarea ); // Ställ in standard dimension för vårt fönster window.setSize(500,200); } public void show(){ // Placera fönstret i mitten på skärmen window.setLocationRelativeTo(null); // Gör vårt fönster synligt window.setVisible(true); } public static void main(final String[] args) { // Detta är vår main-tråd och startpunkt. Be EDT att // initiera och visa vårt fönster. final MyGUI gui = new MyGUI(); SwingUtilities.invokeLater(new Runnable() { public void run(){ // Detta kommer exekveras under EDT. Från och med // detta kan vi fritt använda oss av Swing! gui.init(args); gui.show(); } }); // Hämta innehållet ifrån hemsidan och spara som en sträng. URLFetcher urlf = new URLFetcher(); final String content = urlf.get("http://www.iheartquotes.com/api/v1/random"); // Visa innehållet i GUI-fönstret SwingUtilities.invokeLater(new Runnable() { public void run(){ // Be EDT att ställa in texten och anpassa fönstrets storlek gui.textarea.setText(content); gui.window.pack(); } }); } }

Det bökiga blir att flytta ut MyGUI referensen till själva main-metoden. Vi måste skapa ett MyGUI objekt först, men INTE initiera Swing-komponenterna. Vi skapar en ny metod init() för detta ändamål. Eftersom konstruktorn inte längre körs ifrån EDT kan vi inte deklarera final-instansvariabler, detta på grund av att vi måste skjuta upp själva initieringen till senare. Vi måste även vara försiktiga med att inte arbeta mot MyGUI objektet direkt ifrån main-tråden, endast ifrån EDT. En smidigare lösning hade varit att starta upp en ny tråd direkt ifrån EDT som utför det tunga arbetet. Det tar oss vidare till SwingWorker klassen!

SwingWorker - Citat nerladdare

För att förhindra att låsa upp EDT mer än nödvändigt kan vi skapa och köra en ny tråd lätt via SwingWorker. Man kan se detta mer som klassen Runnable, när vi "startar" en SwingWorker schemalägger vi egentligen denna på någon av de befintliga worker-trådarna. Är alla dessa trådar upptagna läggs denna på kö.

Låt oss börja simpelt med att implementera en arbetare som enbart överlagrar två SwingWorker-metoder. Vi ärver ifrån template-klassen SwingWorker<T,V> där T är den datatyp som vi vill att vår tråd ska returnera efter körning, i detta fall passar String bäst eftersom det är just en sträng vi hämtar ifrån Internet. V är den datatyp vi använder för status uppdateringar, något vi kommer till sedan. Vi låter detta bara vara datatypen Void så länge.

Följande SwingWorker metoder kommer användas:

// File: quote-swing/MyGUI.java

import javax.swing.JFrame; import javax.swing.SwingUtilities; import java.net.URL; import java.util.Scanner; import java.io.IOException; import java.io.InputStream; import javax.swing.JTextArea; import javax.swing.SwingWorker; public class MyGUI { private final JFrame window; private final JTextArea textarea; public MyGUI(String[] args){ // Initiera vårt fönster window = new JFrame("My First GUI Window"); // Avsluta vår applikation om vi stänger fönstret window.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); // Skapa en textarea, och be denna bryta långa rader per ord. textarea = new JTextArea(); textarea.setLineWrap(true); textarea.setWrapStyleWord(true); textarea.setEditable(false); textarea.setText("Laddar citat..."); // Lägg till vår textarea i mitten av vår JFrame window.add( textarea ); // Ställ in standard dimension för vårt fönster window.setSize(500,200); } public void show(){ // Placera fönstret i mitten på skärmen window.setLocationRelativeTo(null); // Gör vårt fönster synligt window.setVisible(true); // Skapa en arbetare och schemalägg denna för exekvering QuoteWorker qw = new QuoteWorker(); qw.execute(); } public class QuoteWorker extends SwingWorker<String,Void>{ @Override protected String doInBackground() { // Detta utförs i en ny tråd, detta är INTE EDT! // Hämta innehållet ifrån hemsidan och returnera som en sträng. URLFetcher urlf = new URLFetcher(); return urlf.get("http://www.iheartquotes.com/api/v1/random"); } @Override protected void done() { // Detta körs på EDT, arbeta med Swing-komponenter här. // Kom ihåg att inte göra allt för långa operationer här! // Hämta texten ifrån doInBackground, eller ett felmeddelande String content = ""; try{ content = get(); }catch( Exception e ){ content = "Error: "+e.toString(); } // Visa texten i vår GUI och anpassa fönstret till texten textarea.setText( content ); window.pack(); } } public static void main(final String[] args) { // Detta är vår main-tråd och startpunkt. Be EDT att // initiera och visa vårt fönster. SwingUtilities.invokeLater(new Runnable() { public void run(){ // Detta kommer exekveras under EDT. Från och med // detta kan vi fritt använda oss av Swing! final MyGUI gui = new MyGUI(args); gui.show(); } }); } }

Detta exempel som ni ser fungerar på samma sätt som det tomma JFrame exemplet. Konstruktorn körs under EDT vilket gör att vi kan deklarera final-instansvariabler. Den stora skillnaden är att en SwingWorker skapas och schemaläggs ifrån show-metoden. Vi kallar vår arbetare för QuoteWorker, vilket deklareras som en nästlad klass. Detta gör att vi får tillgång till huvudklassens instansvariabler, som window och textarea referenserna. Programmet körs i dessa steg:

SwingWorker - IRC Client

I detta exempel implementerar vi en mycket simpel IRC client. För er som inte känner till IRC står det för "Internet Relay Chat", dvs ett chatt protokoll. Programmet kopplar upp sig mot port "6667" på serven "athena.hevox.net" och hoppar därefter in chatt kanalen "public". Om serven är igång och fler av er testar att köra detta exempel samtidigt kommer ni att kunna skriva till varandra!

Till skillnad ifrån innan kommer vår anslutning till serven vara öppen ända tills antingen serven, användaren eller ett fel stänger anslutningen. Så det räcker inte att bara returnera något när vår SwingWorker avslutat - det skulle menas med att man ser vad folk skrivit först efter man kopplat ned! Vi behöver ett sätt att ifrån en SwingWorker skicka information till EDT kontinuerligt under exekveringen.

Implementering

Vi kommer att behöva upprätta en anslutning till serven efter användaren valt ett smeknamn och tryckt på Connect-knappen. Detta kan vi göra genom att implementera IrcConnect. Denna SwingWorker öppnar upp en anslutning till serven och startar SwingWorker klasserna SocketReader och SocketWriter. Dessa två trådar skriver och läser parallellt till vår socket.

Implementeringer sker via följande tre SwingWorker-klasser:

IrcConnect - För att ansluta till serven. Denna fungerar på samma sätt som QuoteWorker.
SocketReader - Tar emot information ifrån serven. Varje rad som tas emot loggas i vårt GUI fönster.
SocketWriter - Skickar information till serven. Varje rad som skickas loggas på samma sätt som i SocketReader.

Vi introducerar ett par nya metoder hos SwingWorker:

Exempel

OBS! Detta är inte en snygg klient-lösning, utan ska enbart ses som en exempel på SwingWorker-trådar och dess samspel mellan varandra. Klienten skulle gott och väl klarat sig med enbart en tråd där inloggning, mottagning och skickandet av meddelanden sker sekventiellt.

Vi tittar närmare på klassen SocketReader, denna använder String som template-variablen V. Detta eftersom String passar bäst till publish() och process() metoderna för att skicka chatt-meddelanden.

// File: IRC/SocketReader.java
// Line: 7 - 17

public class SocketReader extends SwingWorker<String,String> { private BufferedReader in = null; private final Socket s; private final SocketWriter sw; private final MyGUI gui; public SocketReader(MyGUI gui, Socket s, SocketWriter sw){ this.s = s; this.sw = sw; this.gui = gui; }

En närmare titt på doInBackground() samt process() visar hur man skulle kunna ta emot meddelanden ifrån IRC och presentera det i vår textruta på ett smidigt sätt:

// File: IRC/SocketReader.java
// Line: 19 - 67

@Override protected String doInBackground() { try{ // Försök skapa en dataström in = new BufferedReader( new InputStreamReader( s.getInputStream() ) ); // Ta emot information ifrån serven om vi inte blivit avbrutna String line; while( ! isCancelled() ){ try{ // Så länge vi har en anslutning... if( (line = in.readLine()) == null ){ sw.cancel(true); return "Server closed connection"; } }catch( SocketTimeoutException ste ){ // Ingen data togs emot innan timeout, försök igen continue; } // Skicka data ifrån serven till EDT publish(">"+line); // Svara på PING meddelanden if( line.substring(0,4).equalsIgnoreCase("PING") ){ // Skicka vårt svar till SocketWriter line = "PONG"+line.substring(4); sw.writeLine(line); } } return "Gracefully cancelled"; }catch( Exception e ){ return "Error - " + e.getMessage(); }finally{ // Försök stänga vår in-ström och socket, vi ignorerar fel IrcConnect.tryClose( in ); IrcConnect.tryClose( s ); } } @Override protected void process( List<String> chunks ){ // Detta körs på EDT, arbeta med Swing-komponenter här. // Kom ihåg att inte göra allt för långa operationer här! // Ta emot linjerna vi fick ifrån serven, skriv ut dem i vår text ruta for( String line : chunks ){ gui.printText( line ); } }

Vi har en while-loop som kontrollerar att våran tråd inte blivit avbruten, i denna loop försöker vi ta emot en rad ifrån IRC-serven - misslyckas vi försöker vi igen! Om serven har stängt anslutningen returnerar vi detta som vår status, denna kan vi sedan läsa med get() efter tråden avslutat. Vi ser även till att avbryta vår SocketWriter-tråd genom att anropa cancel().

Efter vi tagit emot en sträng ifrån IRC-serven vill vi presentera denna för användaren, vi använder publish för att skicka meddelandet till EDT. Om det skulle vara ett PING meddelande ber vi våran Writer-tråd att skicka svaret för att förhindra att vi blir utloggade!

När vi signalerat EDT om det nya meddelandet anropas metoden process() - i denna kan vi fritt använda våra swing-komponenter och kan således skriva ut meddelandet direkt till vår textruta!

// File: IRC/SocketReader.java
// Line: 69 - 86

@Override protected void done() { // Detta körs på EDT. // Hämta statusen, ignorera fel. String status = "Unknown"; try{ status = get(); }catch( InterruptedException | CancellationException e ){ status = "Cancelled"; }catch( Exception e ){ status = e.toString(); } // Skriv ut information om att tråden avslutades och ställ in knapparna. gui.printText( "SocketReader disconnected: " + status ); gui.setReader( null ); gui.setConnected( gui.isConnected() ); }

Att anropa get() på en tråd vilket blivit avbruten resulterar i att InterruptedException eller CancellationException kastas, vi kan därför använda detta för att få fram trådens anledning till avslutet. OBS! Strängen "Gracefully cancelled" kommer med andra ord aldrig att returneras (den enda gången while-loopen avslutas är vid fel eller avbrutning). Skillnaden mellan de två är som sagt att CancellationException kastas om vi försöker anropa get() på en tråd som redan har blivit avbruten, medan InterruptedException kastas om tråden avbryts under get() anropet.

Referenser

SwingWorker
SwingUtilities
JFrame
Swing's Threading Policy
AWT (Wikipedia)
AWT (javadoc)