Zpracoval: Vašek Mikolášek
Vedoucí projektu, korektury, závěrečná revize: Pavel Herout
Cílem tohoto článku je podat odpověď na otázku, jak pohybovat s obrázky v Javě a zda to jde udělat nějak efektivně. To vše v rámci standardních balíků API. To v důsledku znamená, že se budeme držet AWT a Swingu a čtenář nepotřebuje znát teorii počítačové grafiky ani jejích algoritmů, stačí znalost programování okenních aplikací v Javě. Dočtete se zde několik obecně dobrých rad, jak správně postupovat, když potřebujete v Javě kreslit. Článek pokrývá následující témata:
Jednou větou: Chceme v Javě pohybovat s obrázky na podkladu, který tvoří jiný obrázek.
Představme si okénkovou aplikaci, která čeká na vstupy od uživatele, ale zároveň sama něco počítá a výsledkem je grafický výstup, například pohyb míčku, autíčka apod. Naším cílem je připravit třídy, které reprezentují onen dynamický grafický výstup.
Dá se předpokládat, že výpočet probíhá ve vlastním vlákně odděleně od vlákna starajícího se
o vykreslování. Dále chceme zachovat možnost přistupovat k našemu grafickému výstupu jako ke
standardním prvkům AWT/Swing, jak z pohledu programátora tak i uživatele. Programátorovi chceme umožnit
snadné začlenění takového grafického výstupu do standardních komponent a uživateli ponechat možnost
s pohyblivými prvky komunikovat, např. kliknutím.
Nejlepší by bylo, kdybychom si připravili třídu, nazveme ji třeba Obrazovka
, která by
uměla pohybující se objekty (v důsledku obrázky) chytře spravovat a zobrazovat. Bylo by dobré, aby se bez
problémů dala začlenit spolu s jinými komponentami do našich okének.
Dále bychom si měli připravit třídu, která by reprezentovala právě ty dynamické prvky - pohybující se objekty.
Nazvěme ji třeba PohyblivýObrázek
. Vlákno výpočtu by více méně nazávisle měnilo stav
PohyblivýchObrázků
a Obrazovka
by tyto změny vzápětí vykreslovala.
Teď přistupme ke konkrétním návrhům řešení.
Ve standardních knihovnách Java 2 SDK nemáme jinou možnost jak kreslit na obrazovku, než přes objekt
třídy java.awt.Graphics
. Navíc tento objekt sami nevytváříme, ale musí nám být předán
systémem. Z toho vyplývá další požadavek na Obrazovku
: bude dědit od nějaké komponenty
z AWT/Swing (čímž zajistíme i výše zmíněnou kompatibilitu) a předaný grafický kontext bude využívat
dle své libosti, pro zajištění požadované funkčnosti - to je naprosto standardní praxe.
Od objektu třídy PohyblivýObrázek
budeme požadovat především to, aby se uměl správně nakreslit, bude
to prostě objekt, který bude mít nějakou metodu nakresliSe(Graphics g)
, kterou bude volat Obrazovka
Při úvahách nad tím, jak navrhnout Obrazovku
vyvstávají podstatné otázky. Od jaké třídy budeme dědit? Jaký přesně bude
postup při vykreslování? Jistě jsou možná různá řešení.
V následujícím textu budou popsány dva vyzkoušené přístupy.
I. Udělej to za mě
Obrazovka
bude dědit od nějakého zatím neurčeného Containeru
.
PohyblivýObrázek
bude dědit od nějaké komponenty, například od JButton
, kterému ovšem
změníme grafickou podobu. Takové "buttony" můžeme standardním způsobem nalepit na náš Container
, který
se sám, postará o jejich správné vykreslení. Pozice našich tlačítek, tj. PohyblivýchObrázků
se
bude měnit již implementovanou metodou setLocation(Point point)
.
Výhodou tohoto přístupu je, že je vše krásně jednoduché.
II. Udělám to radši sám
Tento přístup bude komplikovanější. Vytvoříme si vlastně svůj kontejner PohyblivýchObrázků
a tím převezmeme zcela zodpovědnost za jejich správné vykreslování, překrývání apod. Obrazovka
bude
dědit od nějaké Component
a v metodě, kde se sama vykresluje, bude náš kód, který
se postará i o PohyblivéObrázky
.
Bude stačit, když PohyblivéObrázky
budou jenom instance nějaké třídy, která poskytne metodu
nakresliSe()
a případně nastavPozici(Pozice poz)
a podle potřeby další metody.
Rozdíly mezi oběma přístupy jsou zřejmé. Ve druhém případě záleží jen na nás, jak budeme kreslit. Hlavní změnou v tomto přístupu je to, že PohyblivéObrázky
jsou pro automatický vykreslovací systém Swingu neexistující -- jsou to jen objekty (sice z pohledu uživatele ve skutečnosti objekty s obrázky) ale ne komponenty, o jejichž vykreslování se Swing musí postarat sám.
Oba dva zmíněné postupy jsou dále podrobně vysvětleny a implementovány. Než se ale pustíme do implementace obou postupů, podívejme se důkladně na základní principy a postupy při kreslení v AWT a Swingu. Doporučuji tuto kapitolu nevynechat. Je to ústřední téma, neboť od toho jak dobře naprogramujeme kreslení, se odvíjí celkový výkon aplikace.
Devadesát devět procent z následujících rad je převzato ze skvělého článku Painting in AWT and Swing. Vřele doporučuji si tento nepříliš dlouhý dokument přečíst. Zde vypíchnu jen to nejpodstatnější, co se týká zejména Swingu.
JComponent
, překrývat metodu
paint(Graphics g)
. Když potřebujeme změnit podobu komponenty, lze to
bezpečně a bez nějakých omezeních provést v metodě
paintComponent(Graphics g)
, která je z paint(Graphics g)
volána.
V paint(Graphics g)
jsou postupně volány tyto metody:
paintComponent(Graphics g)
paintBorder(Graphics g)
paintChildren(Graphics g)
paint()
překrývá velice často, pozor ale, děláte-li to
nad komponentou, která je potomkem třídy Container
, nezapomeňte zavolat
super.paint(g)
, jinak se komponenty vložené do tohoto Containeru nevykreslí.
Nebo se o to můžete postarat sami serií asi takových příkazů:
while(childrenIterator.hasNext()) { child = childrenIterator.next(); Graphics childGraphics = g.create(child.getLocaton().x, child.getLocaction().y, child.getPreferredSize().width, child.getPrefferedSize().height)); child.paint(childGraphics); }
Opacity - neprůhlednost
Když systém Swing zjistí, že je některou komponentu nutné překreslit, kontroluje zda je
neprůhledná. Když bude průhledná, musí potom dojít k překreslení i té komponenty, na které
leží. Rodičovská komponenta je opět testována na neprůhlednost atd., dokud se nenarazí na první
neprůhlednou komponentu. Dojde tak k prohledávání často rozsáhlého stromu komponent a následnému
časově náročnému vykreslování.
Objekty třídy JComponent
poskytují metody setOpaque(boolean)
a
isOpaque()
. Swing testuje neprůhlednost právě metodou isOpaque()
.
Nastavíte-li na své komponentě setOpaque(false)
, potom přebíráte zodpovědnost za to,
že pro každý pixel komponenty zajistíte jeho vykreslení a to včetně okrajů (borders)! .
Pozor ! setOpaque(true)
nezpůsobí, že se komponenta stane neprůhlednou. Znamená to,
že se k ní tak bude systém Swing chovat!
Překrývání komponent
Překrývání komponent je podobný problém. Pokud nemá Swing systém zaručeno, že se komponenty nebudou
překrývat, musí při každém požadavku na překreslení jedné komponenty testovat všechny "sourozence", zda
tuto komponentu nepřekrývají. V takovém případě je nutné překreslit i je. To vede, stejně jako v předchozím
případě, k prohledávání stromu komponent a je to časově náročné.
Swing redukuje zbytečné překrývání pomocí metody z JComponent
public
boolean isOptimizedDrawingEnabled()
Metoda vrací true
, jestliže je zaručeno, že
komponenty na tomto kontejneru se nebudou překrývat.
Nikde nenajdete něco jako
setOptimizedDrawingEnabled(boolean)
, protože jde o read-only property třídy JComponent
. Tím nás Swing nutí
nejprve od nějaké JComponent
zdědit a tuto metodu posléze překrýt.
RepaintManager
- Voláte-li ve svém programu repaint()
, měli byste vždy
používat jeho přetíženou verzi repaint(int x, int y, int width, int height)
, která
specifikuje oblast (tzv. dirty region), kterou je potřeba překreslit. Když budete volat verzi bez
parametrů, bude se na obrazovku zbytečně vykreslovat celý grafický kontext a aplikace se velmi zpomalí.
RepaintManager
je záležitostí Swingu a má jednu zásadní vlastnost: vyskytnou-li
se dva a více požadavků na překreslení bezprostředně (časové poměry závisejí na výkonu použitého počítače ;-) po sobě, umí je sloučit v jeden a urychlit tak
celý proces.
g.getClipBounds()
update(Graphics g)
, známá z AWT, se v systému Swing vůbec nevolá, není nutné
ji překrývat.
Teď je na řadě implementace. Nejprve si připravíme třídy Obrazovka
a PohyblivýObrázek
.
Vlákno, které bude pohybovat s obrázky je teď pro nás celkem nezajímavé. Až bude vše připravené, dojde i na něj.
V následujících ukázkách kódu programu vynechám vše, co se netýká přímo problému pohybování s obrázky. Třídy by měly být
plně funknční, ale pro účely pozdějšího testování, zachování jednotného přístupu atd. jsou přidány další metody a atributy
-- viz Testování.
Budeme postupovat zezdola nahoru a připravíme si nejprve třídu PohyblivýObrázek
.
V přístupu I. - udělej to za mě
ji pojmenujeme PictureButton
- podědíme
od třídy JButton
.
V druhém přístupu "Udělám to radši sám
" ji nazveme MoveablePicture
, protože je to
především zapouzdřený obrázek.
PictureButton:
Nejprve deklarace třídy a konstruktor, který umožní nastavit obrázek:
public class PictureButton extends JButton { private BufferedImage img = null; public PictureButton(BufferedImage img) { this.img = img; setBorder(null); //aby nemel obrazek oramovani setOpaque(false); //DULEZITE - aby nevznikal za PictureButtonem "neporadek" setPreferredSize(new Dimension(img.getWidth(),img.getHeight())); } ...Podstatné je nastavení
setOpaque(false)
. Implicitně je totiž JButton
neprůhledný,
ale protože dopředu nevíme, zda obrázek nebude náhodou (a on bude :-) místy transparentní, musíme to změnit.
Kdybychom to neudělali, bylo by za obrázkem vidět pozadí, které by ale mělo náhodný obsah - vznikal by za obrázkem
nepořádek. Takhle řekneme systému Swing, že spolu i s touto komponentou je potřeba překreslit i to na čem leží.
setLocation
a getLocation
jsou již v rodičovské třídě implementovány, a tak
nám zbývá jen překrýt metodu paintComponent(Graphics g)
tak,
že bude kreslit náš obrázek. (Proč zrovna tuto metodu je vysvětleno výše):
... public void paintComponent(Graphics g) { g.drawImage(img,0,0,img.getWidth(),img.getHeight(),null); } } // konec deklarace třídyU
MovablePicture
bude konstruktor vypadat skoro stejně, s tím rozdílem, že mezi parametry přibude odkaz
na "rodiče" ve smyslu komponenty "nositelky" obrázku, protože potřebujeme vědět, nad kým volat metodu repaint()
.
setLocation()
a getLocation()
. V metodě setLocation()
se provede
samozřejmě nastavení nové polohy, ale také zavolání repaint()
s parametry ( ! ) a nastavení privátní proměnné needUpdate
true
. Příznaková proměnná needUpdate
nás informuje, že s objektem bylo hýbáno a tedy potřebuje
překreslit. Hodnotu této proměnné využívá metoda paint()
.
Rodičovská komponenta bude totiž volat metodu paint()
nad každým svým objektem typu MoveablePicture
a ten si
sám rozhodne, zda potřebuje překreslit nebo ne. A jak se vlastně rozhoduje? V zásadě jsou dvě možnosti. Objekt má nastaveno needUpdate
na true
, potom je situace přímočará - objekt bude vykreslen. V opačném případě sice víme, že se s objektem nehýbalo, ale nevíme,
zda jej jiný objekt "nepřejel", potom by bylo překreslení nutné. Tuto informaci získáme z "clipping area" viz Kresleni v AWT a Swing.
MoveablePicture:
public class MoveablePicture { private Component owner; private BufferedImage img; public boolean needUpdate = true; private int x = 0; // location private int y = 0; // location private int imgWidth = 0; private int imgHeight = 0; /** * Konstruktor vyžaduje jako parametr grafického vlastníka (rodiče) objektu, * tedy nějakou Componentu na které spočívá. */ public MoveablePicture(Component owner,BufferedImage img) { this.owner = owner; this.img = img; imgWidth = img.getWidth(); imgHeight = img.getHeight(); } public void paint(Graphics g) { if (!needUpdate) { Rectangle toDraw = g.getClipBounds().intersection(new Rectangle(x,y,imgWidth,imgHeight)); if (!toDraw.isEmpty()) { g.drawImage(img,x,y,imgWidth,imgHeight,null); } } else { g.drawImage(img,x,y,imgWidth,imgHeight,null); needUpdate = false; } } public synchronized void setLocation(int x,int y) { int left = (this.x < x ? this.x : x) - 1; int up = (this.y < y ? this.y : y) - 1; int dx = Math.abs(this.x - x) + 2; int dy = Math.abs(this.y - y) + 2; this.x = x; this.y = y; needUpdate = true; owner.repaint(left,up,imgWidth+dx,getHeight+dy); } public Point getLocation(){ return new Point(x,y); } } // konec deklarace třídyV metodě
setLocation()
si všimněte, že je repaint()
opravdu voláno s parametry. Oblast, kterou je nutné překreslit,
určíme tak, že ze staré a nové pozice vypočítáme obdélník, který je obaluje a jeho rozměry pak předáme jako parametr volání
metody owner.repaint()
Obrazovka.
Implementace této třídy je velmi jednoduchá v obou případech. V I. přístupu jsem zvolil za rodiče naší třídy
JLayeredPane
. Tento kontejner již ve své implementaci metody isOptimizedDrawingEnabled()
vrací hodnotu false
a umožňuje umísťovat komponenty do sedmi různých vrstev a určovat tak, která
komponenta leží nad kterou. Toho sice v našem programu nevyužijeme, ale dokážu si představit, že to může být užitečné.
V obou případech požadujeme možnost umístit na pozadí nějaký statický
obrázek.
Naši třídu jsem nazval PicturePanel
.
public class PicturePanel extends JLayeredPane { private BufferedImage background; /** * Vytvoří PicturePanel s obrázkem na pozadí * @param background obrázek, který bude na pozadí PicturePanelu */ public PicturePanel(BufferedImage background) { this.background = background; setLayout(new FlowLayout()); setOpaque(true); } /** * Nedělá nic jiného, než že nakreslí obrázek na pozadí */ public void paintComponent(Graphics g) { g.drawImage(background,0,0,null); } } // konec deklarace třídy
II.
máme opět o něco víc práce. Jako rodičovskou třídu pro naši
komponentu jsem zvolil JLabel
a nazval ji PictureLabel
.
Vytváříme vlastní kontejner objektů MoveablePicture
a proto je nutné přidat nějaký seznam
těchto obrázků a ještě metodu, kterou je budeme přidávat. Metoda paintComponent
bude opět překryta
tak, že vykreslí statický obrázek na pozadí a zavolá paint(g)
nad všemi obrázky v seznamu.
public class PictureLabel extends JLabel { private BufferedImage background; private ArrayList mPictures = new ArrayList(); // MoveablePicture List private Iterator it; public PictureLabel(BufferedImage background) { this.background = background; setOpaque(true); // DULEZITE ! } public void addMoveablePicture(MoveablePicture mp){ mPictures.add(mp); } public void paintComponent(Graphics g) { g.drawImage(background,0,0,null); it = mPictures.iterator(); while(it.hasNext()) { MoveablePicture mp = (MoveablePicture) it.next(); mp.paint(g); } } } // konec deklarace třídyV tuto chvíli máme funkční třídy. Můžete se podívat na malé demo, kde jsou použity třídy z
II. přístupu
.
BufferedImage
používám
pouze Image
. Jinak jsou kódy zkopírovány z tohoto článku.
Pokud si vzpomínáte, v úvodu jsme požadovali, aby uživatel mohl s pohybujícími se
obrázky komunikovat, například kliknutím. Jak to provedeme?
V I. přístupu - Udělej to za mě
nemáme kupodivu žádnou práci, protože pohyblivý obrázek
je již tlačítko a umísťujeme ho na JLayeredPane
. V programu pak, jak jsme zvyklí, zaregistrujeme běžným postupem tlačítku jeho posluchače:
pictureButton1.addActionListener(new MyActionListener());
(a samozřejmě nezapomeneme napsat kód tohoto posluchače ;-)
V II. přístupu - Udělám to radši sám
máme mnohem více práce. Pro zachování jednotného přístupu
budeme požadovat, abychom mohli též registrovat objekt implementující rozhraní ActionListener
a to tak, že jej nebudeme muset
speciálně programovat, ale bude vypadat stejně jako kterýkoliv jiný ActionListener
. MoveablePicture
si bude
umět zaregistrovat své posluchače a PictureLabel
se zase bude starat o to, aby událost "kliknutí" distribuoval na své objekty.
Proto do třídy MoveablePicture
přibude metoda addActionListener
, která bude vypadat takto:
private ActionListener actionListener = null; ... public void addActionListener(ActionListener newActionListener) { actionListener = AWTEventMulticaster.add(actionListener, newActionListener); } ...Dále implementuje v
MoveablePicture
metodu, pomocí které bude PictureLabel
distribuovat události kliknutí.
... public void processEvent(AWTEvent e) { if (actionListener != null) { actionListener.actionPerformed(new ActionEvent(this,0,"")); } }...A ještě by se nám hodila metoda, která by řekla, zda
MoveablePicture
obsahuje daný bod:
... public boolean contains(Point point) { return (new Rectangle(x,y,imgWidth,imgHeight)).contains(point); }Objekt třídy
PictureLabel
pak už musí jen poslouchat kliknutí na sebe sama a rozhodnout, kterému
MoveablePicture
toto kliknutí patří, a nad ním pak zavolat metodu processEvent
.
To se dá jednoduše zařídit pomocí vnitřní třídy implementující rozhraní ActionListener
. Na výsledek
a zdrojové kódy se můžete podívat v demu 2
Teď, když už máme vše připravené, je čas otestovat, který ze dvou uvedených přístupů je lepší.
První test - složitost implementace je z předešlého textu zřejmý. Přístup Udělej to za mě
vyhrává. Ale jak je to s rychlostí?
Pro účely testování bylo nutné mnoho tříd přidat. Zejména z důvodů zachování jednotného přístupu a objektivity
testování. Chceme měřit FPS
(Frames Per Second, což zde bude představovat počet překreslených obrazovek za sekundu), což jde jednoduše udělat, tak, že pohneme s objektem a dokud není vykreslen,
tak s ním dál nehýbeme. To sice trochu nabourává představu o tom, že o pohyb se stará nezávislé vlákno a bude nutné
stálá synchronizace s vláknem vykreslování, ale pro účely testování se nám to hodí.
Popis programu je následující:
Inicializujeme potřebné třídy, vytvoříme vlákna pro pohyblivé obrázky, spustíme je. Dále už poběží tak, že vlákno pohybu -
Driver
pohne s objektem, který je mu svěřen a zavolá na námi naprogramovaném monitoru PaintControl
objectMoved()
a čeká, dokud vlákno vykreslování nezavolá nad stejným monitorem metodu
objectPainted()
. (Tu voláme my na konci metody paint()
).
Tím se probudí vlákno Driver
a tak dále.
Vše podstatné už bylo řečeno a tak jen krátce o nových třídách:
Moveable
slouží k identifikaci objektů, které jsou určeny k pohybování.
Zapouzdřuje metody getLocation()
a setLocation()
Driver
je vlákno, které nějakým způsobem pohybuje s objekty implementujíci rozhraní
Moveable
PaintControl
objekt této třídy je monitor, který má za úkol střídat vlakna kreslení
a pohybu. Také měří FPS a počítá snímky.
PaintControled
implementuje každý objekt, od kterého chceme získat informaci o
FPS. Zapouzdřuje metody setPaintControl
a getPaintControl
.
ImageSource
poskytuje našemu testu obrázky, takže je zaručeno, že jsou v obou případech
použity stejné.
TestLabel
a TestPanel
, které celý test rozeběhnou.
Můžete si stáhnout:
.zip soubor
. Nejdříve je nutný překlad (soubor compile.bat
) a pak již můžete spustit testUdelejSam.bat
nebo testUdelejToZaMe.bat
Výsledky a shrnutí
Doporučuji, abyste si provedli test sami, ale pokud jste zvědaví, řeknu vám, jak to dopadlo na mém počítači. Bylo zajímavé pozorovat, jak se oba dva přístupy vyrovnávaly s různým počtem pohyblivých obrázků.
Přístup II. Udělám to radši sám
se ukázal jako o něco málo rychlejší, zhruba o 3%.
Ale je třeba zvážit, zda se vyplatí usilí vložené do implementace, protože 3% není mnoho.
Testování ale přineslo i o něco zajímavější
výsledek -- v přístupu I.
je totiž nositelem obrázku komponenta, konkrétně JButton
, která
je pro systém Swing přístupná a když porovnáte počet posunutí s počtem vykreslených obrazovek (frames), zjistíte, že Swing
sám volá repaint()
. Ve druhém přístupu je MoveablePicture
pro systém Swingu nepřístupný, a tak nedochází
k nadbytečným voláním repaint()
.
Výsledkem je, že FPS
jsou téměř stejné, ale v případě PictureButtonu
trvá pohyb déle, protože
došlo k překreslení více obrazovek. Nutno ale ještě říct, že pokud máte pohyblivých obrázků více a je jedno kolika najednou
pohybujete, pak k tomuto jevu nedochází. Nevím proč tomu tak je.
Důležité: Při testování záleží, ze které pozice obrázek odstartoval. Jiná pozice = jiný výsledek. Na konec ještě připojím tabulku s některými změřenými údaji. Od shora dolů roste počet obrázků, které běžely všechny najednou, vpravo jsou výsledky u obou přístupů:
Obrázků |
I. FPS |
I. počet snímků |
II. FPS |
II. počet snímků |
---|---|---|---|---|
1 |
364 |
5000 |
382 |
5000 |
2 |
231 |
5000 |
236 |
5000 |
3 |
173 |
5000 |
176 |
5000 |
Poznámka:
Postupem času zde přibudou další výsledky z výzkumů v této oblasti.