Pohybování s obrázky v Javě

v rámci JavaTM 2 Platform, Standard Edition

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:

 

1. Úvod - nastínění problému

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.

 

2. Návrh řešení

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.

 

3. Kreslení v AWT a Swingu

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.

 

4. Implementace

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ží.
Metody 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řídy
		
U 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().
Musíme též sami naprogramovat metody 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
na hodnotu 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řídy
	
V 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
	

V přístupu 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řídy
V 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.
K vůli tomu, že se jedná o applet, jsem provedl jen malou změnu v tom, že místo 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
 

5. Testování

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:

Na těchto stránkách je též on-line dokumentace s popsanými třídami včetně zdrojových kódů.

Můžete si stáhnout:

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

Měl jsem na obrazovce vždy po sedmi obrázcích a z toho jsem pustil jen určitý počet. Což je přesně ten případ, který jsem zmiňoval výše. Počet snímků je u obou přístupů stejný. Samy si už vyzkoušejte, že například při jednom snímku na obrazovce se budou počty lišit až o 20%.

Poznámka:
Postupem času zde přibudou další výsledky z výzkumů v této oblasti.