Kruhy v Jave na Android

V jazyku Java môžeme písať aj programy na operačný systém Android, ktorý používajú mnohé telefóny a tablety. Stačí teda napísať jeden program a bude nám fungovať všade? Nie, nie je to až také jednoduché. Predsa len, mobil nie je počítač; je to trochu iné zariadenie, ktoré sa ovláda trochu iným spôsobom, a tomu zodpovedajú zmeny v písaní programu. Navyše, pri programovaní na mobily je väčší dôraz na šetrenie pamäťou, čo si vyžiadalo ďalšie zmeny.

Aby som vyskúšal podobnosti a rozdiely medzi štandardnou Javou a Javou na Android, prerobil som program z článku "Kruhy v Jave" na Android. Na rozdiel od štandardnej Javy, v Jave na Android sa zatiaľ tak dobre nevyznám, a program má pravdepodobne nedostatky, o ktorých ani neviem. Dôležité však je, že funguje, a že dáva začiatočníkovi istú predstavu o tom, čo by znamenalo prejsť z jednej Javy na druhú.

Okrem prepísania z jednej Javy do druhej došlo aj k drobným zmenám vo funkcionalite. Po prvé, program zaberá celú obrazovku, pretože na mobile nie je zvykom mať otvorených viacero okien vedľa seba. (Môžete mať viacero programov spustených naraz, ale vidíte iba jeden z nich, a môžete ich prepínať.) Po druhé, v pôvodnom programe sa ľavým tlačidlom myši kruhy ťahali, a pravým tlačidlom sa pridávali alebo odoberali. Na mobile s dotykovým displejom neviem rozlíšiť rôzne prsty, preto som ovládanie upravil na jeden prst: ťuknutím na prázdne miesto sa kruh pridá; ťuknutím na kruh sa posúva; a odoberá sa jednoducho tak, že kruh vysuniete z obrazovky (presnejšie: kruh sa odstráni, keď je jeho stred mimo obrazovky). Po tretie, kruhy sú o čosi väčšie, aby sa na ne jednoduchšie "klikalo" prstom. Okrem týchto zmien sú programy funkčne rovnaké, a ich ďalšie rozdiely sú dané prechodom na inú platformu.

Pri programovaní na Android treba zadať aj rôzne nastavenia a zdroje, ale tomu všetkému som sa v tomto príklade vyhol. Nie sú tu žiadne texty ani obrázky, správanie programu nezávisí od veľkosti a rozlíšenia obrazovky. (Aj keď, možno by dávalo zmysel prispôsobiť veľkosť kruhu vlastnostiam obrazovky.) Jednoducho si vytvorte nový projekt s hlavnou aktivitou "MainActivity", a potom jej kód nahraďte kódom z tejto stránky; zvyšok zatiaľ ignorujte.

package sk.bur.blog.circles;

import java.util.*;
import android.app.Activity;
import android.content.Context;
import android.content.pm.ActivityInfo;
import android.graphics.*;
import android.os.Bundle;
import android.view.*;

public class MainActivity extends Activity {

	@Override
	protected void onCreate(Bundle bundle) {
		super.onCreate(bundle);
		setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE);
		Obraz obraz = new Obraz(this);
		obraz.init();
		setContentView(obraz);
	}

	static class Obraz extends View {
		Collection<Kruh> kruhy = new ArrayList<Kruh>();
		Kruh tahanyKruh = null;
		float tahanyPovodnyStredX;
		float tahanyPovodnyStredY;
		float tahanyZaciatokX;
		float tahanyZaciatokY;
		Random nahoda = new Random();
		Paint kresliPozadie;
		Paint kresliKruh;

		Obraz(Context context) {
			super(context);
		}

		Kruh najdiKruh(float x, float y) {
			Kruh najdeny = null;
			for (Kruh kruh : kruhy) {
				if (kruh.contains(x, y)) {
					najdeny = kruh;
				}
			}
			return najdeny;
		}

		void init() {
			kresliPozadie = new Paint();
			kresliPozadie.setColor(Color.WHITE);
			kresliPozadie.setStyle(Paint.Style.FILL);
			kresliKruh = new Paint();
			kresliKruh.setStyle(Paint.Style.FILL);
			setOnTouchListener(new OnTouchListener() {

				@Override
				public boolean onTouch(View v, MotionEvent event) {
					if (MotionEvent.ACTION_DOWN == event.getActionMasked()) {
						prstDole(
						  event.getX(event.getActionIndex()),
						  event.getY(event.getActionIndex())
						);
						return true;
					}
					if (MotionEvent.ACTION_MOVE == event.getActionMasked()) {
						prstPohyb(
						  event.getX(event.getActionIndex()),
						  event.getY(event.getActionIndex())
						);
						return true;
					}
					if (MotionEvent.ACTION_UP == event.getActionMasked()) {
						prstHore(
						  event.getX(event.getActionIndex()),
						  event.getY(event.getActionIndex())
						);
						return true;
					}
					return false;
				}

				private void prstDole(float fx, float fy) {
					Kruh dotknutyKruh = najdiKruh(fx, fy);
					if (null != dotknutyKruh) {
						// zacni tahat
						tahanyKruh = dotknutyKruh;
						tahanyPovodnyStredX = tahanyKruh.stredX;
						tahanyPovodnyStredY = tahanyKruh.stredY;
						tahanyZaciatokX = fx;
						tahanyZaciatokY = fy;
					} else {
						// pridaj kruh
						Kruh kruh = new Kruh();
						kruh.stredX = fx;
						kruh.stredY = fy;
						kruh.polomer = 50 + 30 * nahoda.nextFloat();
						// nejake pekne farby
						kruh.farbaVyplne = Color.HSVToColor(new float[] {
						  360 * nahoda.nextFloat(),
						  (1 + nahoda.nextFloat()) / 2,
						  (1 + nahoda.nextFloat()) / 2
						});
						kruhy.add(kruh);
					}
					invalidate();
				}

				private void prstPohyb(float fx, float fy) {
					// tahanie
					if (null != tahanyKruh) {
						tahanyKruh.stredX =
						  tahanyPovodnyStredX - tahanyZaciatokX + fx;
						tahanyKruh.stredY =
						  tahanyPovodnyStredY - tahanyZaciatokY + fy;
						invalidate();
					}
				}

				private void prstHore(float fx, float fy) {
					if (null != tahanyKruh) {
						if (!tahanyKruh.vObraze(getWidth(), getHeight())) {
							// odober kruh
							kruhy.remove(tahanyKruh);
						}
						// prestan tahat
						tahanyKruh = null;
						invalidate();
					}
				}

			});
		}

		@Override
		protected void onDraw(Canvas canvas) {
			canvas.drawRect(0, 0, getWidth(), getHeight(), kresliPozadie);
			for (Kruh kruh : kruhy) {
				kresliKruh.setColor(kruh.farbaVyplne);
				canvas.drawOval(
				  new RectF(
				    kruh.stredX - kruh.polomer,
				    kruh.stredY - kruh.polomer,
				    kruh.stredX + kruh.polomer,
				    kruh.stredY + kruh.polomer
				  ), kresliKruh
				);
			}
		}

	}

	static class Kruh {

		public float stredX;
		public float stredY;
		public float polomer;
		public int farbaVyplne;

		float naDruhu(float i) {
			return i * i;
		}

		boolean contains(float x, float y) {
			return naDruhu(x - stredX) + naDruhu(y - stredY) <= naDruhu(polomer);
		}

		boolean vObraze(int sirka, int vyska) {
			return (0 <= stredX) && (stredX < sirka)
			  && (0 <= stredY) && (stredY < vyska);
		}

	}

}

Budem predpokladať, že ste si prečítali vysvetlenie k pôvodnej verzii programu, a že mu viacmenej rozumiete. Venujme sa teda iba rozdielom.

Začnem tým, že si na tejto stránke nemáte čo stiahnuť, lebo aplikácie na Android sa väčšinou inštalujú z webovej stránky Google Play. Vydavateľ programu tam má zaregistrované konto, a uverejnené aplikácie podpisuje svojím digitálnym podpisom. Ja zatiaľ nič také nemám. Vlastné programy si spustíte tak, že si nainštalujete Android Developer Tools, čo je plugin do vývojového prostredia Eclipse, tam si vytvoríte program, pripojíte svoj mobil alebo tablet k počítaču, napríklad cez USB kábel, a program spustíte. (Inštalácia a ovládanie Android Developer Tools, to by bolo na dlhšie vysvetľovanie, a nájdete to na stránke "developer.android.com".)

DOPLNENÉ: Doplnil som pár detailov a výsledok som uverejnil na Google Play pod názvom Relax Bubbles.

Ak raz napíšete program a uverejníte ho na Google Play, niektoré veci v ňom neskôr nemôžete zmeniť. Konkrétne: hlavný balík aplikácie (v tomto príklade "sk.bur.blog.circles") a názov hlavnej aktivity (v tomto príklade "MainActivity"). Podľa nich si totiž systém aplikáciu zaregistruje, vytvorí na ňu odkaz na ploche, atď. Preto som balík rozšíril o záverečné slovo "circles", aby nedošlo ku konfliktu s inými (hypotetickými) aplikáciami na Android na tomto blogu.

Z importov zmizli všetky triedy z balíkov "java.awt..." (a "java.applet"), lebo nič také na Androide neexistuje. Nahradia ich triedy z balíkov "android...". Pokiaľ ide o okná a ich ovládacie prvky, dôvod je jasný. V balíku AWT sú však aj iné triedy, napríklad Point, Rectangle, Color, ktoré tiež treba nahradiť ich ekvivalentmi, a tie niekedy fungujú trochu inak.

Program sa začína vytvorením hlavnej aktivity, čo je viacmenej náhrada za okno. Objekt pre aktivitu si nevytvárame; vytvorí ho za nás systém, a potom zavolá jeho metódu "onCreate". Systém sa postará aj o vypnutie aktivity, čím sa naša aplikácia ukončí. (Pri takejto jednoduchej aplikácii nemusíme ani riešiť multithreading.) Táto časť je jednoduchšia. Zatiaľ.

Príkazom "setRequestedOrientation" zabránime zmene orientácie okna pri naklonení tabletu. Keby tam tento riadok nebol, po naklonení tabletu by sa okno otočilo o 90 stupňov, reštartovalo, zmenila by sa jeho veľkosť, a jeho dáta by sa stratili. Strate dát sa dá zabrániť, ale to už by bola ďalšia funkcionalita. V pôvodnej aplikácii sa tiež nedala meniť veľkosť okna.

Plocha, do ktorej možno kresliť, sa v Androide nazýva "View". (V istom zmysle je to ekvivalent tried "Component" a "Canvas" z AWT.) Potrebujeme jej predefinovať iba jednu metódu, "onDraw", a nemusíme sa starať o blikanie obrazu. Táto časť je opäť jednoduchšia. Názvom "Canvas" sa tu pre zmenu označuje objekt, ktorý kreslí. (V AWT mu zodpovedá trieda "Graphics".) Metódy na kreslenie tiež fungujú trochu inak: zatiaľčo v štandardnej Jave sme jedným príkazom nastavili farbu a druhým príkazom kreslili, na Androide máme pomocný objekt typu "Paint", ktorý obsahuje informáciu o farbe, a ten dávame ako posledný parameter v príkazoch na kreslenie. Ak používame stále rovnaký "Paint", napríklad ak má mať pozadie obrázku stále rovnakú farbu, môžeme si príslušný objekt pripraviť vopred, a nemusíme ho vytvárať znova pri každom volaní "onDraw".

Ďalší rozdiel je v tom, že v štandardnej Jave sa súradnice na obrazovke zadávajú ako "int", čiže celé číslo, kým na Androide je to "float", čiže desatinné číslo. (Asi je to kvôli lepšej podpore zväčšovania a zmenšovania obrazu; neviem naisto.) Aby sme dodržali túto konvenciu, aj v objekte "Kruh" sú súradnice stredu a polomer zadané ako "float".

Ani farba sa nezadáva ako objekt typu "Color", ale ako "int". Existuje tu trieda "Color", ale má iba konštanty a statické metódy. Pozor, tieto metódy môžu očakávať iné parametre. Napríklad pri vytvorení farby pomocou HSV, trieda "Color" zo štandardnej Javy očakáva tri čísla v rozsahu od 0 do 1, trieda "Color" z Androidu očakáva ako prvý parameter (odtieň) číslo od 0 do 360, a ako druhé dva parametre (sýtosť, intenzita) čísla od 0 do 1. -- Na takéto zákernosti si treba dávať pozor. Podobne aj triedy "Rect" a "RectF" (ekvivalenty "Rectangle" a "Rectangle2D.Float" zo štandardnej Javy) sú dané štyrmi údajmi, ktoré však neznamenajú "ľavý okraj, horný okraj, šírka, výška" ale "ľavý okraj, horný okraj, pravý okraj, dolný okraj".

Ovládanie prstom robíme cez objekt "OnTouchListener". Ten má iba jednu metódu, "onTouch", ktorá sa volá pri rôznych udalostiach. Aj tieto udalosti sú o čosi zložitejšie, lebo dotykový displej môže naraz registrovať viacero prstov. Metóda "event.getActionIndex" vráti číslo prstu, ktorého sa daná udalosť (položenie, pohyb, zdvihnutie) týka, a toto číslo dáme ako parameter do metód "event.getX" a "event.getY". Ďalej je to už podobné, akurát že súradnice sú "float".

Prekreslenie obrazovky dosiahneme príkazom "invalidate". (Nie som si celkom istý, či je toto správny postup, ale funguje to.)

Na určenie, kedy je kruh ešte v obraze a kedy ho už treba odstrániť, máme v triede "Kruh" pomocnú metódu "vObraze".

Záver: Java na Android sa podobá na Javu SE: syntax je rovnaká; objekty trochu iné, ale podobné; celková dĺžka a štruktúra programu je viacmenej rovnaká. Napriek tomu obsahuje mnoho drobných detailov, na ktorých sa začiatočník môže nachytať. Predpokladám, že pri zložitejšej hre, ktorá by obsahovala načítanie obrazu a zvuku, uloženie a načítanie hry, a podobné veci, by bola situácia zhruba podobná: podobné princípy a kopec nových detailov, na ktoré si treba dať pozor.

Ak viete urobiť program v Jave SE, máte dobrú štartovaciu pozíciu na robenie mobilných hier. Počítajte však s tým, že aj keby ste hru v Jave SE mali hotovú, samotná konverzia vám môže zabrať pár dní; najmä ak to robíte prvýkrát. Samozrejme, nie je povinné mať dve verzie jednej hry, môžete vyvíjať priamo na Android; mne však taký vývoj pripadá o dosť pomalší (možno iba nie som zvyknutý na tablety). Tretia možnosť by bola urobiť si nejaké všeobecné API spájajúce počítač a mobil/tablet, implementovať toto API na obe platformy, a potom vyvíjať priamo v tomto API; takáto možnosť by z dlhodobého hľadiska bola asi najlepšia.

viliam@bur.sk