|
Java Tutorial - Parte 2 0.1
Un tutorial per esempi
|
Benchè JavaSwing metta a disposizione molti componenti grafici predefiniti (bottoni, caselle, combo-box, etc) in questo capitolo impareremo a scrivere un componente custom (=personalizzato). Si tratta di un tachimetro analogico (sembra analogico) simile a quello che si vedono sulle automobili.
Ovviamente, può essere usato per visualizzato un qualsiasi valore istantaneo all'interno di due limiti che vengono visualizzati in una scala graduata. Qui sotto potete vedere una immagine del tachimetro che scriveremo. Può essere disegnato con tre stili diversi:
| ROUNDED | SQUARED | RECTANGULAR |
|---|---|---|
|
|
|
Nella cartella javatutor2/projects/customcomp trovate i files sorgente che saranno commentati in questo capitolo:
| nome file | package | descrizione |
|---|---|---|
| Speedometer.java | customcomp | un tachimetro digitale |
| TestSpeedo.java | customcomp | il test del componente |
La classe che disegna il tachimetro si chiama Speedometer e deriva da JPanel, la classe generica di un qualsiasi componente grafico. Analizziamo i suoi membri dati principali:
style: lo stile del tachimetro: ROUNDED, SQUARED, RECTANGULAR speed: la velocità corrente, quella puntata dall'ago minSpeed: la velocità minima maxSpeed: la velocità massima needleColor: il colore dell'agoCi sono molti altri membri dati, alcuni statici ma sono usati per il disegno. In questo capitolo non analizzerò le tecniche geometriche usate per il disegno; questo tutorial insegna la programmazione GUI e quindi ci concentreremo su questo aspetto della programmazione.
Vi sono due costruttori; entrambi accettano come argomenti il valore della velocità minima e massima. Questi valori sono necessari per disegnare i limiti dello strumento, i cosidetti fondoscala. Su questo aspetto ci sono due considerazioni da fare:
minSpeed è sempre ZERO dal momento che non si può viaggiare a velocità negative e non esiste una velocità minima poichè un veicolo può essere fermo; tuttavia, questo componente può essere usato per misurare altre quantità oltre alla velocitàNel secondo costruttore è possibile specificare anche lo stile del componente: lo stile ha effetto sugli angoli di partenza e arrivo della scala graduata dello strumento:
| stile | start-angle | end-angle |
|---|---|---|
| ROUNDED | 225 | -45 |
| SQUARED | 180 | 90 |
| RECTANGULAR | 180 | 0 |
I valori degli angoli vengono calcolati nel costruttore.
Il componente possiede pochi metodi il cui nome e praticamente self-explanatory (=si commenta da solo):
setNeedleColor: imposta il colore dell'ago createLine: disegna la linea della scala graduata setSpeed e getSpeed: no comment paintComponent: questo è il metodo interessante e che commenterò in dettaglioCome accennato in Introduzione alla GUI l'ambiente grafico viene pilotato dagli eventi che scaturiscono dalla interazione tra l'user e l'interfaccia grafica (ma non solo, un evento può scaturire anche dai dati disponibili in un socket oppure perchè un timer è scaduto).
Prendiamo ad esempio il caso in cui l'user aveva ridotto a icona una applicazione ed ora la riporta alle dimensioni normali; oppure l'user ha sovrapposto una altra applicazione alla finestra di Mastermind ed ora vuole riportare Mastermind in primo piano: in tutti e due i casi l'ambiente grafico informa la applicazione che è necessario ridisegnare il contenuto della finestra da visualizzare e questo avviene inserendo nella coda degli eventi un evento di tipo PaintEvent.
Tutti i componenti della libreria JavaSwing vengono disegnati dalla libreria stessa (i bottoni, le etichette, le caselle di testo, etc) ma questo è un componente personalizzato e quindi dobbiamo disegnarlo noi.
L'evento PaintEvent è un evento molto importante: non è necessario aggiungere un listener degli eventi per intercettarlo. Esso viene intercettato automaticamente dalla classe JComponent, da cui JPanel deriva. Quando viene intercettato l'evento paint, la classe del componente richiama il metodo:
al quale viene passato come argomento un graphic context (=contesto grafico) su cui si può disegnare usando le primitive grafiche come drawLine, drawImage, drawEllipse, etc... Abbiamo già incontrato le primitive grafiche ed il loro contesto in Java Tutorial - parte prima: il disegno dei poligoni a cui rimando per i dettagli.
Quello che ci interessa in questo momento è: cosa fà il metodo paint della classe JComponent? Ebbene, esso richiama, in sequenza, altri tre metodi:
protected void paintComponent(Graphics g): disegna il componente protected void paintBorder(Graphics g): disegna il bordo del componente protected void paintChildren(Graphics g): disegna i "figli" del componentePer disegnare il componente personalizzato è quindi sufficente (e necessario) sovrascrivere il metodo paintComponent:
paint: potete farlo, ma se lo fate assicuratevi di richiamare anche i due metodi successivi altrimenti rischiate di non lasciare la possibilità alla API di disegnare un eventuale bordo e gli eventuali sotto-componenti del vostro componente personalizzato. Il modo migliore per non sbagliare è quello di richiamare la versione della classe base del metodo paintComponent. Il metodo di disegno è piuttosto complesso perchè deve calcolare molte coordinate per il disegno della scala graduata e dell'ago. Non mi dilungherò su questi dettagli tecnici anche perchè non sono stato io a scrivere quei calcoli. Il componente che stiamo implementando è il risultato delle modifiche che io ho apportato al codice scritto nel 2009 da Clemens Krainer e che fà parte del progetto JAviator, pubblicato su javiator.cs.uni-salzburg.at.
Ci concentreremo invece su una piccola porzione di codice che appare all'inizio del metodo, subito dopo il richiamo del metodo della classe base e che probabilmente non avrete compreso del tutto:
Il metodo componentResized è il metodo richiamato da ComponentListener che è il gestore degli eventi di ridimensionamento della finetra del componente. Analizzeremo questo aspetto in dettaglio in Il ridimensionamento del componente.
Per concludere il discorso sul disegno del componente devo darvi un ultimo avvertimento. Quando il componente è disegnato perchè la finestra è in primo piano, cosa succede se richiamo il metodo setSpeed? Questo metodo imposta una velocità nel tachimetro e quindi l'ago deve essere ridisegnato. Dopo aver impostato il membro dati speed, posso quindi richiamare paintComponent dal metodo setSpeed? ASSOLUTAMENTE NO.
E non posso farlo per una semplice ragione: dovrei creare il contesto grafico da passare come argomento al metodo.
L'unico modo appropriato per cambiare il disegno dell'ago dopo aver modificato il valore della velocità corrente è quello di forzare la GUI ad inserire un evento PaintEvent nella coda degli eventi!
L'evento paint, ovviamente, ridisegna l'intero componente, cancellando completamente il tachimetro e ridisegnandolo daccapo, compresa la scala graduata, non solo l'ago.
Questo, in passato, comportava un problema: benchè il codice sia piuttosto veloce (il ridisegno completo dura una frazione di secondo) l'occhio umano è abbastanza sensibile da cogliere questa operazione e si genera un fastisioso effetto visivo: il flickering che in italiano si traduce con "sfarfallio".
In passato si potevano usare diverse tecniche per evitare lo sfarfallio: una di queste, la più usata, era nota come buffering: il nuovo disegno non veniva generato nel contesto grafico del device (lo schermo, per intenderci) ma veniva creato in memoria, in una immagine (vedi la interfaccia Image di java.awt). Una volta creata l'immagine in memoria essa veniva copiata direttamente nel contesto grafico.
Dalle prove fatte con il componente Speedometer non sembra che JavaSwing soffra di questo problema, almeno non più.
Una delle cose più fastidiose per un utente di una interfaccia grafica è quella di constatare che un componente della applicazione non si comporta come dovrebbe, o meglio, come ci si aspetterebbe. Nel caso del nostro tachimetro l'utente si aspetta che ridimensionando la finestra, il componente si ridimensiona a sua volta in modo che la sua immagine occupi tutta la finestra:
| 180x180 | 220x220 | 260x260 |
|---|---|---|
|
|
|
Ma se avete osservato il codice originale scritto da Clemens Krainer noterete che la dimensione del tachimetro è fissa ed immutabile (la keyword final la dice lunga, no?):
Questo è un comportamento inaccettabile per un componente degno di questo nome. Considerato che tutti i valori delle coordinate di disegno dipendono dal valore di size, è sufficiente determinare il valore di size e poi ricalcolare le coordinate. Poichè size dipende dalla dimensione della finestra che contiene il componente, sarà necessario e sufficiente gestire l'evento resize.
Ogni componente che deriva da JComponent riceve dalla GUI una serie di eventi riguardanti le caratteristiche grafiche del componente. Questi eventi sono dichiarati nella interfaccia ComponentListener:
componentHidden: il componente è stato reso invisibile componentMoved: il componente è stato spostato sullo schermo componentResized: il componente è stato ridimensionato; questo è l'evento che ci interessa da vicino componentShown: il componente è stato reso visibile (è il contrario di hidden)Nel metodo componentResized otteniamo la dimensione effettiva della finestra (vedi il metodo JComponent.getSize) ed in base a questa calcoliamo il valore di size; osservate che a seconda dello stile del tachimentro, il valore di size non è uguale identico alle dimensioni della finestra:
Successivamente il metodo calcola tutte le coordinate grafiche delle linee della scala graduata, dell'ago e del testo dei valori di scala. Questo è il punto giusto per calcolare queste variabili: esse non cambieranno fino al prossimo evento resize.
Per testare il componente scriviamo una piccola app GUI che visualizza il tachimetro e fa partire un worker-thread il quale, ad intervalli regolari, genera un valore di velocità da impostare nel tachimetro. Il worker-thread è controllato da tre bottoni di comando:
Di seguito uno screenshot della app di test:
Il codice è contenuto in TestSpeedo.java ed è di facile lettura; non credo servono ulteriori commenti.
Abbiamo già incontrato il concetto di worker-thread in introdurre un ritardo fittizio ed anche in Una connessione di test - i metodi di I/O.
In entrambi questi casi abbiamo usato un thread separato per introdurre un ritardo nella esecuzione di una operazione che sarebbe, in effetti, immediata. Prendiamo come esempio il caso del giocatore A.I.: esso elabora la guess in una frazione di secondo ma, per dare al giocatore umano l'impressione che la macchina ci pensa in po su abbiamo escogitato il seguente trucchetto:
Thread.sleep) per un certo periodo di tempo in modo da simulare la concentrazione della A.I. done done, eseguito nel thread principale (il EDT, vedi Event Dispatching Thread), invia la guess al server di giocoQuesto thread separato quindi ha un inizio ed una fine: quando il metodo doInBackground termina, il dato elaborato nel thread separato è pronto e può essere restituito per essere poi elaborato nel thread principale.
Voglio attirare la attenzione del lettore sul fatto che, eseguito in questo modo, il thread separato è del tipo one-shot (=spara una sola volta) nel senso che, una volta finito, il thread non può più essere ri-eseguito nemmeno se viene richiamato nuovamente il metodo execute.
Per le due operazioni viste finora questa modalità di esecuzione è perfettamente funzionale e assolutamente congrua ed efficiente: una volta inviata la guess al server, non ha più senso mantenere in esecuzione il thread separato; meglio creare un altro thread quando una guess sarà nuovamente disponibile.
Diverso è il caso in cui serve un thread separato per generare valori di velocità ad intervalli regolari come nel test del tachimetro: in questo caso, quando è disponibile un dato (la velocità) da impostare nel tachimentro, il thread non può considerarsi davvero concluso perchè servono molti altri dati.
La soluzione potrebbe essere quella di creare un altro thread separato ad intervalli regolari in modo da avere sempre dati disponibili ma questa non è una soluzione molto efficente: ricordo al lettore che la creazione di un thread ha un costo in termini di tempo e risorse che il sistema operativo deve allocare.
La classe SwingWorker è una classe parametrizzata nel senso che essa è specializzata nel restituire un tipo di dato specifico ma, come potete osservare dalla sua dichiarazione, essa accetta due parametri (T e V):
Il primo tipo (T) è il tipo di dato restituito dal metodo doInbackground quando esso termina.
Il secondo tipo (V) è il tipo di dato restituito come risultato intermedio dal metodo publish che può essere elaborato nel EDT dal metodo process.
Possiamo quindi sfruttare questa caratteristica della classe swingWorker per mantenere sempre in esecuzione il worker-thread che continuerà a fornirci i valori della velocità da impostare nel tachimetro come risultati intermedi: il worker-thread diventerà pertanto un thread infinito; cosa restituità il thread infinito quando termina? Ovviamente null! I parametri del worker thread di test saranno:
Void come primo parametro: il dato restituito dalla fine del thread Double come secondo parametro: il dato restituito come risultato intermedioUn thread infinito non termina mai? Eppure, non è possibile che ciò accada anche perchè la nostra app di test prevede che clikkando il bottone Stop la generazione delle velocità da impostare nel tachimetro deve cessare. Per ottenere questo abbiamo due possibilità:
stop che imposta un flag di sospensione delle attività: il thread viene sempre eseguito ma non genera alcun dato intermedio cancel: in questo caso viene sollevata una eccezione CancellationException che può essere intercettata nel metodo done; il thread terminaPer la questa app di test è stato scelto il secondo metodo:
Poichè questo è un thread che non finisce mai, il metodo doInBackground viene eseguito in un loop infinito; a dire il vero quasi infinito pouchè è necessario verificare che esso non sia stato cancellato:
Dopo aver ottenuto la velocità attuale, il metodo calcola la nuova velocità da impostare nel tachimetro aggiungendo ad essa lo step il quale viene controllato dai metodi gas (=accelera) e brake (=frena).
Dopo aver ottenuto la nuova velocità da impostare, il metodo in background pubblica il dato come risultato parziale richiamando il metodo publish.
Il metodo process viene richiamato nel EDT (vedi Event Dispatching Thread) e ad esso vengono passati come argomento i risultati intermedi della elaborazione in background. Nel nostro caso, i risultati intermedi sono le velocità da impostare nel tachimetro:
Vi sembrerà strano che, benchè il metodo publish pubblichi uno ed un solo dato della velocità (un double), il metodo process accetti come argomento non uno ma una lista di double.
La spiegazione è piuttosto semplice: ricordate sempre che il thread separato e il EDT vengono eseguiti in parallelo, ognuno per conto suo, e nessuno dei due è a conoscenza dello stato di avanzamento dell'altro.
Può succedere che il worker-thread pubblichi una velocità per essere poi impostata nel tachimetro ma il EDT sia in altre faccende affancendato e non possa quindi elaborare subito il dato pubblicato. Nel mentre, il worker-thread elabora un altra velocità e la pubblica: a questo punto vi sono DUE velocità da pubblicare e non una una sola.
Ecco perchè il metodo process accetta una lista di risultati intermedi: esso deve iterare sulla lista di risultati per poterli elaborare tutti.
Per allenarvi a scrivere componenti personalizzati vi consiglio di provarci da soli. Sicuramente molti di voi hanno una necessità di un componente che non trovano da nessuna parte e questa è l'occasione giusta per scriverselo da soli. Per i più pigri, ho io una proposta: visto che abbiamo il tachimetro, cosa manca per completare il quadro? Ma è ovvio, il tachigrafo! Si tratta di uno strumento che equipaggia obbligatoriamente (almeno credo) i mezzi pesanti come camion e autobus. Esso registra su un supporto non cancellabile la velocità del mezzo in ogni momento e può essere usato dalle forze dell'ordine per verificare il rispetto dei limiti di velocità anche nel passato.
Ovviamento, il nostro tachigrafo non registra in modo permanente anzi. Esso è in grado di mostrare solo un arco di tempo limitato: le velocità più vecchie scrolleranno verso sinistra nella finestra per far posto ai nuovi dati.
Di seguito una immagine di come potrebbe essere questo componente: le velocità sono mostrate sull'asse delle ordinate mentre il tempo sull'asse delle ascisse.
Per i molto, molto pigri, il codice del tachigrafo è disponibile nel listato sorgente Speedograph.java contenuto nella cartella javatutor2/projects/queues/custcomp.
La documentazione completa del progetto descritto in questo capitolo può essere visualizzata clikkando il seguente link: Componenti personalizzati