O midletach dokładniej, czyli specyfika pisania programów na telefony komórkowe

Przesłanki, czyli dlaczego tak, a nie inaczej

Tworzenie aplikacji mających działać na urządzeniach przenośnych - telefonach komórkowych czy też nawet palmtopach wymaga nieco innego podejścia niż pisanie programów na pecety. Należy zdawać sobie sprawę z dwóch czynników określających specyfikę urządzeń przenośnych. Pierwszy z nich to urządzenia same w sobie. Przede wszystkim mają one inne możliwości. Procesor takiego urządzenia jest wiele wolniejszy od procesorów PC, ograniczenia na wykorzystanie pamięci też są znacznie bardziej restrykcyjne. Innym aspektem jest przenoszalność aplikacji. Java umożliwia pisanie programów całkowicie przenośnych. Interfejs programisty jest dobrze opisany i wspólny dla wszystkich urządzeń obsługujących Javę, wiele aspektów działania urządzeń przenośnych nie zostało w nim zawartych. Aspektów ważnych tak dla programisty, jak i zwykłego użytkownika takich aplikacji, jak choćby wyświetlacz. Poszczególne telefony różnią się wymiarami ekranu i mimo iż można teoretycznie napisać aplikację która będzie wyświetlana bez konieczności przesuwania obrazu na wszystkich urządzeniach to jednak program taki nie będzie wyglądał estetycznie.

Drugi z czynników jakie należy uwzględnić pisząc program na "komórkę" to użytkownik. Użytkownik programu na peceta jest tolerancyjny. Kilkanaście lat obecności tych urządzeń na rynku "wychowało" odbiorców aplikacji. Nikogo już nie dziwią zawieszenia się komputera, lub też długie pozorne przerwy w pracy, kiedy maszyna "myśli". Rzecz ma się zupełnie inaczej w przypadku użytkowników telefonów. Mogą to być osoby nie mające żadnej styczności z komputerem, mało tego, komórka w żaden sposób nie przypomina peceta, więc nie można oczekiwać od nich tolerancji dla powolnego, lub niestabilnego działania programu.

Te właśnie dwa czynniki określają podejście programisty do pisania midletów. Przede wszystkim midlet musi działać szybko. Oznacza to takie napisanie kodu, aby był jak najmniejszy efektywnie wykorzystywał możliwości urządzenia. Bardzo często szybkość działania programu zależy od czynników zewnętrznych - jak np. połączenia sieciowe. Uwzględniając możliwe "przestoje" aplikacji należy zadbać o odpowiednie sygnalizowanie, że program jeszcze "żyje" - zegarek, klepsydra czy zwykły pasek postępu są jak najbardziej a propos. Zdrowy rozsądek oraz odpowiednie planowanie i projektowanie aplikacji dają wiele, poniższe wskazówki pozwolą usystematyzować to, co dobry programista robi intuicyjnie. Na koniec zaprezentowana zostanie prosta aplikacja mogąca stanowić pewne udogodnienie w testowaniu i debugowaniu programów w warunkach "komórkowych", tj. wtedy gdy odpalamy program w środowisku gdzie nie ma konsoli tekstowej ani standardowego wyjścia błędów.

Ponieważ aplikacje takie mogą być tworzone na różne platformy - telefony, palmtopy i inne tego rodzaju urządzenia, zaś omawiane zagadnienia są w dużej mierze niezależne od niej, w dalszej części tego artykułu będę używał sformułowania urządzenie przenośne na określenie tych wszelkich urządzeń tego typu, czyli telefonów, palmtopów itd.

Na początek przygotujemy środowisko programistyczne. Aby skompilować program napisany, dajmy na to w notatniku (JBuilderze, Kawie, czy każdym innym edytorze), do działania na urządzeniu przenośnym potrzebujemy bibliotek J2ME. Nie są one dostarczane razem ze standardowym SDK, należy je ściągnąć oddzielnie (odpowiednie adresy na końcu artykułu). Odpowiednie aplikacje udostępnia SUN. Nas interesuje J2ME Wireless Toolkit. Plik J2ME_WIRELESS_TOOLKIT.EXE możemy ściągnąć z jej strony. Po zainstalowaniu i uruchomieniu ukazuje się okno :

Jak widać program prosi o utworzenie nowego, lub otworzenie istniejącego projektu. Jeżeli tworzymy nowy projekt należy wprowadzić nazwę projektu i nazwę głównej klasy:

Po wprowadzeniu żądanych nazw pojawia się z kolei okno definicji pliku deskryptora midletu:

Plik deskryptora zawiera informacje określające midlet. Jest to zorganizowane w postaci par atrybut - wartość. Są one podzielone na zbiory: wymagane (required), opcjonalne (optional), zdefiniowane przez użytkownika (user-defined) oraz parametry midletu. Parametry wymagane to :

MIDlet-Name - nazwa midletu
MIDlet-Version - wersja midletu
MIDlet-Vendor - dostawca midletu
MIDlet-Jar-Size - rozmiar pakietu (pliku .JAR)
MIDlet-Jar-URL - adres z którego można midlet ściągnąć
Micro Edition-Profile - profil konfiguracyjny midletu (MIDP-1.0)
Micro Edition-Configuration - konfiguracja midletu (CLDC-1.0)

Niektóre parametry opcjonalne

MIDlet-Description : opis słowny midletu
MIDlet-Info-URL : URL gdzie można znaleźć więcej informacji o midlecie
MIDlet-Data-Size Minimalna ilość bajtów pamięci schowkowej wymaganej przez midlet (domyślnie zero)
MIDlet-Icon : nazwa pliku .PNG będącego symbolem midletu (ikoną)

Midlety są identyfikowane w sieci na podstawie ich deskryptorów. Wyszukiwania midletów (plików .JAD) dokonuje się przy użyciu dostępnych metod - czyli po prostu surfując po sieci. Kiedy już natrafimy na interesujący nas midlet i obejrzywszy plik deskryptora stwierdzimy że chcemy go mieć wybieramy opcję ściągania midletu w telefonie komórkowym. Resztą zajmuje się system operacyjny telefonu.

Teraz kilka słów o strukturze katalogów WTK. W katalogu w którym zainstalowane zostało WTK (nazwijmy go $WTKDIR) stworzony został katalog apps. Zawiera on wszystkie projekty utworzone w WTK. Także przed chwilą stworzony projekt Doggz :

Struktura każdego projektu jest prosta - katalog projektu zawiera następujące podkatalogi :
1. Katalog bin - w tym katalog u zapisane zostaną piki midletu, manifest i deskryptor midletu. W przypadku testowania aplikacji, w tym katalogu będą tworzone pliki symulujące pamięć schowkową (Persistent Storage).
2. Katalog lib - katalog do umieszczania dodatkowych bibliotek.
3. Katalog res - w tym katalogu należy umieścić wszelkie obrazki i pliki zewnętrzne jakie czytać będzie aplikacja.
4. Katalog src - katalog zawierający pliki źródłowe midletu (.java, .class)

Możliwe jest uruchamianie napisanych programów w środowisku WTK (przycisk run). WTK udostępnia kilka standardowych, prostych emulatorów, różniących się parametrami (wyświetlacz, kolory). Możliwe jest także rozszerzenie listy dostępnych w emulatorów przez skopiowanie emulatora "zewnętrznego" do katalogu $WTKDIR/wtklib/devices. W ten sposób możliwe będzie kompilowanie programów przy wykorzystaniu specyficznych dla danego telefonu bibliotek (emulatory dostarczane z WTK są emulatorami zawierającymi tylko podstawowe biblioteki - pakiet J2ME)

Możliwe jest także rozbudowanie WTK o obfuscatora. W tym celu należy plik .jar zawierający to narzędzie skopiować do katalogu $WTKDIR/bin.

Kiedy już mamy WTK i wiemy jak je rozbudowywać do pracy potrzebny nam jest emulator. Tak jak WTK należy go ściągnąć ze strony producenta (adresy na końcu artykułu). Niektóre emulatory można skopiować do katalogu $WTKDIR (rozszerzając w ten sposób funkcjonalność WTK), część jednak może odmówić współpracy z WTK. Wszystkie emulatory wszelako mogą pracować jako oddzielne aplikacje, wczytujące midlety i wyświetlające wyniki ich działania.
Po skopiowaniu emulatora Nokii do katalogi $WTKDIR/wtklib/devices pojawia się on automatycznie na liście wyboru emulatorów :

Po wybraniu opcji uruchomienia programu (albo przyciskiem run w WTK, albo uruchamiając emulator oddzielnie) pojawia okno emulatora, w przypadku emulatora Nokii 3410 wygląda to następująco:

Za pomocą menu file/open można wczytać midlet.

Kiedy już mamy poprawnie działający program należałoby go ściągnąć na komórkę. Można go w tym celu umieścić w Internecie i odnajdując plik deskryptora w sieci za pomocą WAPu ściągnąć go na nasz telefon. Innym rozwiązaniem jest przesłanie midletu z komputera na komórkę za pomocą kabla. Przewód do łączenia peceta z komórką, wraz z odpowiednim oprogramowaniem jest dostarczany z razem z telefonem. Można go też kupić oddzielnie w większości sklepów z akcesoriami do telefonów komórkowych. Odpowiednie oprogramowanie (służące nie tylko do transferu midletów, lecz także np. do wgrywania nowych melodyjek, czy ekranów powitalnych) można ściągnąć z Internetu, ze strony producenta telefonu.

Tak więc zwykłe postępowanie przy tworzeniu midletów jest następujące :
1. Napisz kod programu w dowolnym edytorze
2. Zapisz źródła w katalogu $WTKDIR/src
3. Przekompiluj za pomocą WTK (przycisk Build)
4. W przypadku błędów popraw kod i idź do punktu 2
5. Połącz midlet w pakiet (menu Project/Package/Create Package)
6. Otwórz pakiet midletu w emulatorze telefonu (wybierając plik deskryptora)
7. Przetestowany midlet załaduj na komórkę

Kiedy już mamy środowisko programistyczne, mamy pomysł na program i wiemy jak go napisać by działał poprawnie przydatną wiedzą jest jak go napisać by działał efektywnie (czytaj szybko). Dalsza część artykułu prezentuje podstawowe techniki pisania aplikacji na urządzenia przenośne.

Dziel się problemami z innymi

Przede wszystkim nie wszystkie operacje trzeba wykonywać w telefonie. Strategia jest prosta - jeżeli twoja aplikacja jest dostatecznie duża, i wymaga komunikacji z serwerami sieciowymi, być może dobrym rozwiązaniem będzie umieszczenie części jej logiki na serwerze. Unikniesz w ten sposób możliwego obciążania procesora urządzenia przenośnego i co za tym idzie niepotrzebnych, a denerwujących użytkownika "zawieszeń" systemu. Odpowiedź na pytanie jak podzielić aplikację, ile obliczeń wykonywać w urządzeniu a ile na serwerze nie jest prosta. Należy rozpatrywać konkretne przypadki. Poza mocami obliczeniowymi urządzenia należy wziąć pod uwagę także dostępne pasmo transmisyjne (wyniki obliczeń trzeba przesłać z serwera), a także jego koszt (zarówno finansowy jak i czasowy).Niekonieczne też chcemy narażać poufność naszych danych przez umieszczanie ich na potencjalnie podatnym na włamania serwerze w sieci.

Proste jest piękne

Z racji ograniczenia pamięci przeznaczonej na przechowywanie midletów rozmiar programu ma bardzo duże znaczenie. Mniejszy program ściąga się szybciej i taniej, szybciej się instaluje, wreszcie szybciej się uruchamia. Przyjętym standardem rozpowszechniania midletów jest ich pakowanie do postaci pliku .JAR.
Jak można zmniejszyć rozmiar programu?
Przede wszystkim upraszczając go. Dobry program wcale nie musi być skomplikowany. Mnogość opcji, przełączników i list wyboru zwykle zaciemnia i komplikuje jego odbiór. Dłużej trwa zapoznawanie się z aplikacją, a jej obsługa przestaje być intuicyjna. W przypadku urządzeń przenośnych - jeżeli pewne opcje programu nie są używane, bądź są używane rzadko, to może warto rozważyć ich usunięcie, albo umieszczenie w oddzielnym pakiecie. Pozostałe opcje, czy też funkcje programu można także połączyć w moduły. Użytkownik będzie wtedy mógł sam zdecydować czy ściągać i instalować konkretne pakiety (i korzystać z zawartych w nich opcji).
Dobrym rozwiązaniem jest zrezygnowanie z obsługi wielu wersji językowych w jednym pakiecie. Podobnie jest z obsługą możliwości różnych urządzeń. Różne telefony różnią się nieraz znacznie - od rozmiarów ekranów, przez możliwości dźwiękowe, aż po wykorzystanie ich cech indywidualnych - to wszystko wymaga dużej ilości dodatkowego kodu, i wielu instrukcji warunkowych. Zamiast tego można napisać kilka wersji programu - na różne platformy, w różnych wersjach językowych.
Staraj się ograniczać listę klas. Nie ma sensu tworzyć oddzielnej klasy robiącej to, co może zrobić zwykła funkcja lub kilka z nich. Mimo iż z formalnego punktu widzenia klasy powinny odzwierciedlać obiektowy model problemu czasem lepiej uelastycznić swoje podejście do tematu - że każda zdefiniowana klasa to kolejny plik .class. Im więcej klas tym więcej pamięci potrzeba by uruchomić program. Należy przy tym pamiętać, że dotyczy to nie tylko klas zdefiniowanych jawnie przez programistę. Klasy wewnętrzne - zdefiniowane wewnątrz innych klas także powodują wygenerowanie pliku dodatkowego pliku klasy, czyli wbrew pozorom kompilator po przeanalizowaniu powyższego kodu wygeneruje dwa pliki .class, a nie jakby się mogło wydawać jeden.


class external
{
private int a;
private int b;
private class Internal
{
public int a;
public int b
internal () // konstruktor
{
// .... kod konstruktora
}
//... kod klasy;

}

//.. kod klasy

}
Po skompilowaniu powyższej klasy powstaną dwa pliki : external.class i external$internal.class. Tak więc próba zredukowania rozmiaru klasy przez zagnieżdżanie klas nie przyniesie oczekiwanych rezultatów. Podobnie ma się rzecz z klasami anonimowymi. Każda taka klasa to kolejny plik .class.

Zwykle duża część kodu aplikacji zajmuje się interakcją z użytkownikiem. Staraj się wykorzystywać ten kod wielokrotnie, gdzie się tylko da. Spowoduje to nie tylko zmniejszenie rozmiaru programu - podobny układ menu, podobna szata graficzna wyświetlanych komunikatów, wreszcie podobna obsługa pewnych jego części sprawi że użytkownik szybciej i łatwiej przyswoi sobie obsługę programu. Łatwość, intuicyjność obsługi i szybkość jej nauki będzie zaś dodatkowym plusem aplikacji.

Bardzo przydatnym narzędziem do zmniejszania rozmiaru aplikacji jest obfuscator. Jego działanie polega na zamianie identyfikatorów i fragmentów kodu na krótsze, charakterystyczne sekwencje znaków. Wykrywa także nieużywane pola i metody prywatne klasy i usuwa je. Sprawia że reverse enginering skompilowanych klas jest praktycznie niemożliwy i, co chyba ważniejsze, zmniejsza, czasem nawet znacznie, rozmiar aplikacji. Adresy skąd można ściągnąć to narzędzie znajdują się na końcu artykułu. Obfuscator nie zajmuje się składowymi publicznymi (polami i metodami) ponieważ inne klasy mogą z nich korzystać. Można go jednak skonfigurować tak, aby traktował zbiór klas (pakietów), których używamy, jako zbiór zamknięty. Oznacza to, że zakładamy iż żadna inna klasa nie będzie korzystała ze zdefiniowanych w naszych pakietach składowych publicznych. To umożliwi obfuscatorowi zoptymalizowanie o wiele większej liczby linii kodu. Inną możliwością jest także nakazanie traktowanie każdej publicznej lub chronionej metody (pakietu) tak jak prywatnej czyli założenie że nikt z zewnątrz nie będzie się do nich odwoływał.

Dobrą praktyką zatem jest deklarowanie samemu możliwie największej liczby składowych prywatnymi. Nie tylko ze względu na obfuscatora, po prostu im mniej publicznych składników, tym prościej optymalizować i debugować kod programu.

Szanuj pamięć

Możliwości urządzenia przenośnego są ograniczone. Ma ono dwa typy pamięci : pamięć dynamiczną, i schowek (persistent storage). Pamięć dynamiczną przechowuje dane związane z uruchomioną aplikacją - w szczególności jej stos i stertę, schowek zaś jest zabezpieczaną przed zapisem pamięcią trwałą. W konsekwencji dostęp do schowka jest dość wolny. Przykładowe wielkości pamięci na przykładzie Nokii (dokument "Characteristics of Nokia Java - Enabled Phones, ver. 1.0, 26 sept. 02" - dostępny na http://www.forum.nokia.com) ilustruje poniższa tabelka:

Model Pamięć dla midletów Sterta Schowek (Persistent Storage) Ograniczenie rozmiaru ściąganych midletów
3410 ~150kB ~164kB ~50kB
6310i ~180kB ~140kB ~20kB ~30kB
7210 ~600kB ~200kB ~64kB

Jak widać pamięć dla midletów może różnić się dość znacznie - Nokia 7210 ma jej cztery razy więcej niż Nokia 3410. Nie oznacza to jednak iż program na Nokię 7210 może mieć rozmiar 600kB. Maksymalna wielkość programu do ściągnięcia na Nokię 7210 to ok. 64kB, o ok. 14kB więcej niż na Nokię 3410. Tak więc nie w możliwej wielkości midletów siła Nokii 7210, lecz w ich ilości. Brak danych na temat rozmiaru schowka oznacza że jest on wliczony w pamięć przeznaczoną na midlety (kolumna 2)

Jak widać pamięci nie jest dużo. Efektywne nią zarządzanie pozwoli pisać szybkie i efektywne programy. Java jest bardzo wygodnym językiem jeżeli chodzi o zarządzanie pamięcią. Zwalnia programistę od kłopotliwego obsługiwania tworzenia i usuwania obiektów, przechowywania referencji i zarządzania nimi. Mimo iż odśmiecacz Javy jest pożyteczny, i bardzo upraszcza programowanie, warto zdawać sobie sprawę ze specyfiki jego działania. Jeżeli zaalokowane zostanie zbyt dużo obiektów w zbyt krótkim czasie, odśmiecacz może mieć problemy z nadążaniem ze sprzątaniem. Spowoduje to "zawieszenie" się aplikacji w oczekiwaniu na zakończenie sprzątania. Ponadto deklarując tyle obiektów na raz zwiększona zostanie ilość chwilowo potrzebnej pamięci. W końcu zostanie zwolniona, nie zostanie jednak zwrócona systemowi, mimo iż większa jej część nie będzie już potrzebna aplikacji. Ilość pamięci potrzebnej midletowi do działania jest określana na podstawie maksymalnej ilości wymaganej pamięci, nie średniej.
Szczególnie groźnymi partiami programu względem "pompowania" ilości chwilowo potrzebnej pamięci są pętle. Każda deklaracja obiektu w pętli to konieczność zaalokowania kilku lub nawet kilkunastu bajtów. Dodatkowo czasem odśmiecacz nie nadąża z usuwaniem obiektu po zakończeniu pojedynczego przebiegu. Może to spowodować w drastycznych przypadkach przerwanie działania programu, kiedy po prostu skończy się pamięć i nie będzie można zaalokować więcej obiektów. Tak więc zamiast pisać :

for (i=0;i<table.length();i++)
{
Score score = new Score(table[i]);

//... jakieś obliczenia związane z obiektem score

results[i]=score.getScore();
}

można zadeklarować obiekt obj poza pętlą:

Score score = new Score();

for (i=0;i<table.length();i++)
{
score.load(table[i]);
//... jakieś obliczenia związane z obiektem obj

results[i]=score.getScore();
}

Owszem, wymaga to dopisania kilku linijek kodu (funkcja load()), ale może oszczędzić nieco pamięci, i czasu działania programu. Inną dobrą praktyką jest wspomaganie odśmiecacza przez nadawanie nieużywanym referencjom wartości null. Przykład :

public class MyMidlet extends midlet
{

public void function (Object o)
{
Object someObject=o; // inicjalizacja referencji na obiekt
someObj.compute(); // jakieś metody obiektu someObj
//..... reszta kodu metody

someObj=null; // referencja nie jest nam już potrzebna
}
}

Przyrównując referencje do null ułatwiamy odśmiecaczowi znajdowanie i usuwanie niepotrzebnych obiektów.

Poza wspomaganiem usuwania obiektów z pamięci można zrobić mały wysiłek i zredukować ilość niepotrzebnie tworzonych obiektów. Każdy zgłoszony wyjątek powoduje utworzenie obiektu klasy dziedziczącej po klasie Exception (w zależności od tego jaki to wyjątek będzie to klasa IOException, RuntimeException..itd.) Aby temu zaradzić można spróbować obsługiwać błędy w sposób tradycyjny tam gdzie to tylko możliwe, zostawiając metodę rzucania wyjątków na naprawdę wyjątkowe sytuacje.

Każdy obiekt jakiego używasz musi być trzymany w pamięci. Każdy zdefiniowany obiekt wpływa więc a wydajność twojego programu. Aby zredukować ilość deklarowanych obiektów zamiast nich można rozważyć stosowanie typów prostych jak int, czy boolean. Dla przykładu rozważmy dwie deklaracje funkcji z pakietu java.awt.Component:

public void setSize(Dimension dim);

public void setSize(int width, int height);

Pierwsza z nich wymaga zaalokowania obiektu typu Dimension, zaś druga dwóch typów prostych. O ile drugi wymaga napisania kilku linijek kodu więcej to nie wymaga deklarowania obiektu. Poza tym, co ważniejsze, pierwsza z funkcji jest zdefiniowana następująco:

public void setSize(Dimension dim)
{
setSize(dim.width, dim.height);
}

więc jej spowoduje wykonanie niemal identycznych operacji co przy wywoływaniu drugiej. Wywołanie drugiej wersji tej funkcji oszczędzi tworzenia obiektu potrzebnego do wywołania funkcji wywołującej drugą metodę. Unikanie alokowania niepotrzebnych obiektów, i alokowanie obiektów tylko wtedy kiedy są potrzebne jest dobrą praktyką. Kontrola potrzeby alokowania obiektu praktyce polega na sprawdzaniu, czy obiekt, który mamy zamiar inicjalizować nie został przypadkiem zainicjalizowany wcześniej. Takie sprawdzanie ma sens, ponieważ java zapewnia wstępną inicjalizację deklarowanych obiektów wartościami domyślnymi - w przypadku typów złożonych jest to wartość null.

public class klasa
{

private Vector vektor;
// ..... inne pola klasy

public klasa() // konstruktor
{
if (vektor!=null) vektor=new Vector();
}

}
Nawet jeżeli polu vektor nie została w żadnej funkcji przypisana jakakolwiek wartość (także null) powyższy konstruktor wykona się poprawnie.

Bardzo pamięciożerną operacją jest łączenie łańcuchów znakowych. Każda operacja łączenia obiektów typu String powoduje utworzenie obiektu StringBuffer. Następnie wywoływana jest jego metoda append(), a następnie toString(). Szczególnie mało wydajne jest stosowanie tego w pętlach Tak więc poniższą funkcję

public String concatenate(string a, string b, int times)
{
String c;
for (int i=0;i<times;i++)
c+=b
return c;
}

można, a nawet należy zoptymalizować do postaci:

public String concatenate(string a,string b, int times)
{
StringBuffer buf = new StringBuffer();
for (int i=0;i<times;i++)
buf.append(b);
return buf.toString();
}

W ten sposób niejako przejmujemy sprawy w swoje ręce - sami tworzymy StringBuffer, i sami go obsługujemy. Różnica jest tylko taka, że tworzymy go tylko raz, a nie times razy. Oszczędność czasu i pamięci oczywista. Powyższa optymalizacja ma sens nawet wtedy gdy łączymy tylko dwa łańcuchy. W przypadku pierwszym zawsze jest inicjowana zmienna c. W każdym obrocie pętli tworzony jest StringBuffer. Jeżeli dodajemy dwa stringi pętla obraca się tylko raz, więc StringBuffer tworzony jest też raz. W sumie tworzone są dwie zmienne : StringBuffer i String. W drugim przykładzie w takim przypadku zostanie utworzony tylko StringBuffer.

Zarówno StringBuffer, jak i String, a także Vector i HashTable operują na tablicach. Są to po prostu obiekty opakowujące tablice. W przypadku dwóch pierwszych typów są to tablice znakowe, w przypadku pozostałych tablice dowolnego typu. Tak więc kiedy tworzony jest obiekt typu String, StringBuffer, Vector, lub HashTable tworzona jest zwykła tablica. Kiedy zaś z obiektu typu StringBuffer tworzysz obiekt typu String wskaźnik obiektu String wskaże na tablicę utworzoną w StringBuffer. Nie zawsze operowanie bezpośrednio na tablicach jest sensowne - zwykle korzyści są zbyt małe w stosunku do włożonego wysiłku (bardziej skomplikowany kod). Warto pamiętać o takiej możliwości poprawienia wydajności programu.

Bądź szybki

Używanie lokalnych zmiennych tak często jak to tylko możliwe pozwoli znacznie przyspieszyć działanie programu. W przypadku częstych odwołań do pola jakiejś klasy, rozsądnym wyjściem jest przypisanie wartości tego pola do zmiennej lokalnej. Pozwoli to zwykle co najmniej uniknąć wielokrotnych wywołań odpowiedniej metody zwracającej żądaną wartość. Podobnie z tablicami. Przy każdym odwołaniu do elementu tablicy java sprawdza między innymi czy indeks żądanego elementu nie wykracza poza rozmiar tablicy. Przypisanie wartości często używanego elementu tablicy do zmiennej lokalnej sprawi że sprawdzanie to zostanie wykonane tylko raz, co odrobinę przyśpieszy działanie programu. Oczywiście opisywane postępowanie ma sens tylko w przypadku zmiennych, których wartość się nie zmienia. W przeciwnym wypadku, jeżeli to ma sens, można rozważyć jakiś sposób aktualizacji zmiennej lokalnej po każdej aktualizacji zmiennej "źródłowej".

Kolejną metodą przyspieszenia działania programu jest optymalizacja wykorzystania schowka (Persistent Storage). Ponieważ dostęp do niego jest dość powolny, nie warto czytać (i zapisywać) danych w nim zapisanych bajt po bajcie. Java SE udostępnia strumienie buforowane (BufferedReader, BufferedWriter itd.), Java ME niestety nie daje takich udogodnień - należy je stworzyć samemu. Nie jest to trudne - wystarczy funkcja buforująca dane przeznaczone do zapisywania w tablicy bajtowej, i zapisująca do schowka od razu całą tablicę. Dokładny sposób postępowania zawarty jest w przykładzie. Klasa Logger zapisuje w ten sposób dane do schowka.

Za każdym razem, kiedy program próbuje alokować pamięć, powinien być przygotowany na porażkę. W przeciwnym wypadku system operacyjny urządzenia w takiej sytuacji po prostu przerwie jego działanie wyświetlając (albo i nie) nieelegancki komunikat. Będąc świadomym możliwości niepowodzenia przy alokacji pamięci aplikacja może w takiej sytuacji odpowiednio zareagować. Na przykład wyświetlić elegancko zakończyć pracę podając powód takiego postępowania użytkownikowi. Niby efekt taki sam, ale jego opinia o twoim programie będzie nieco bardziej pozytywna.

Bardzo ważną sprawą są połączenia sieciowe. Należy je zamykać tak szybko, jak tylko skończy się je wykorzystywać. Rozważmy następujący kod:

HttpConnection http = null;
InputStream in=null;
try
{
http = (HttpConnection)Connector.open(url);
in = http.openInputStream();
// ...jakieś operacje
}
catch (Exception e)
{
// obsługa wyjątku
}

W przypadku wystąpienia wyjątku połączenie może nie zostać zamknięte. Sterowanie zostanie przekazane do obsługi wyjątku i mamy katastrofę. Poniższy przykład jest ilustracją rozwiązania tego problemu. Komendy zawarte w bloku finally zostaną wykonane zawsze, bez względu na to w jaki sposób zakończy się blok try. Jest to dobre miejsce na zwolnienie nieużywanych zasobów - nastąpi to nawet wtedy, gdy zostanie zgłoszony wyjątek w trakcie operowania na zasobach. Klauzula try w bloku finally zapewnia dodatkową obsługę błędów - w przypadku wystąpienia wyjątku w trakcie zamykania połączenia (w końcu tu też może nastąpić błąd..) można zareagować w żądany sposób, choćby wyświetlając odpowiedni komunikat. Ułatwia to debugowanie programu, nie wspominając nawet o biednym użytkowniku szukającym rozpaczliwie odpowiedzi na pytanie dlaczego jego ulubiona gra wiesza się przy próbie wysłania pobitego rekordu do serwera internetowego....

HttpConnection http = null;
InputStream in=null;
try
{
http = (HttpConnection)Connector.open(url);
in = http.openInputStream();
// ...jakieś operacje
http.close();
in.close();
}
catch (Exception e)
{
// obsługa wyjątku
}
finally
{
try {
if (http!=null) http.close();
if (in!=null) in.close();
}
catch (Exception e)
{
// obsługa wyjątku
}
}

Przenośność, czyli rzeczywistość skrzeczy.

Pisząc aplikacje na urządzenia przenośne bardzo często spotykamy się z ich nieprzenośnością. Jest to związane z pewnym cechami indywidualnymi poszczególnych urządzeń, jak chociażby rozmiarami wyświetlacza. O ile klasyczne API jest dosyć dobrze opisane i zestandaryzowane, o tyle sprawa ma się zupełnie inaczej kiedy dochodzimy do interfejsu użytkownika. Każde urządzenie, czasem nawet w obrębie tej samej firmy (mogą się na przykład różnić dostępnymi kolorami ekranu, czy też liczbą diod), jest nieco różne. Tak więc mimo iż np. telefony Nokia mają podobne rozmiary wyświetlaczy, to już telefony np. Alcatela mogą mieć te rozmiary zupełne inne. Nie wspominając o stosowanych konwencjach obsługi urządzenia (układ menu, dodatkowe przyciski z określonymi funkcjami, itd.). Każdy producent udostępnia inne metody obsługi cech indywidualnych swojego telefonu. W obrębie jednej marki jest to przeważnie jeden pakiet, który należy importować. Ale co z sytuacja, kiedy chcemy aby nasza aplikacja działała i na telefonach dwóch różnych producentów? Jest prostsze rozwiązanie niż tworzenie dwóch aplikacji. Wystarczy oddzielić logikę aplikacji od interfejsu użytkownika. W ten sposób napisanie różnych wersji tego samego programu na różne urządzenia będzie mniej skomplikowane.
Jak to zrobić?
Należy podzielić program na umowne moduły. Pierwszy z nich niech zawiera klasy zajmujące się logiką programu, drugi klasy obsługujące jego interfejs. Moduły, a dokładniej poszczególne klasy komunikują się ze sobą wykorzystując funkcje publiczne. Rozpatrzmy prosty przykład sterowania pozycją postaci. Jeżeli pozycja się zmieni (użytkownik wciśnie klawisz), należy przerysować ekran, uwzględniając nowe położenie bohatera. Bohater jest on prezentowany jako pojedynczy punkt na wyświetlaczu telefonu.

class Logic
{
int x; // pozycja bohatera na ekranie urządzenia
int y;

UI ui; // interfejs użytkownika.
//... metody klasy

public int getX() // zwraca współrzędną X pozycji bohatera
{return x;}

public int getY() // zwraca współrzędną Y pozycji bohatera
{return y;}

public void setX(int x) //ustawia współrzędną X pozycji bohatera
{ this.x=x; notifyOthers();}

public void setY(int x) //ustawia współrzędną Y pozycji bohatera
{ this.y=y; notifyOthers();}


//.... metody klasy
public void notifyOthers()
{
ui.changed();
}

}


Metoda notifyOthers() powiadamia zainteresowane klasy o zmianie położenia postaci. Może to być tylko klasa UI - odpowiadająca za interfejs użytkownika, ale mogą to być też inne klasy, choćby sprawdzające konsekwencje takiego ruchu (np. czy bohater nie wszedł na minę, ścianę, inną postać itd.).

class UI
{
Logic logic;

//.. metody klasy

public void changed()
{
//.. wykonaj odpowiednie operacje - np. odśwież zawartość ekranu, uwzględniając zmianę pozycji.
}

public void up() // wywołana na żądanie przesunięcia bohatera w górę
{
logic.setY(logic.getY()-1);
}

}

Innym rozwiązaniem mogłoby być umieszczenie klasy UI w oddzielnym wątku. W pętli, co określony przedział czasu następowałoby sprawdzanie pozycji postaci i odświeżanie ekranu. Inna klasa, z kolei, należąca do modułu interfejsu użytkownika nasłuchiwałaby czy nie wciśnięty został klawisz, i w razie wystąpienia tego zdarzenia w odpowiedni sposób modyfikowałaby zmienne X i Y klasy Logic.

Klasa UI oraz pozostałe klasy modułu interfejsu użytkownika są klasami specyficznymi dla danej platformy. Przy pisaniu programu w wersji na inne urządzenie należałoby napisać od nowa tylko te klasy, uwzględniając możliwości (w praktyce dostępne funkcje w pakietach konkretnych producentów) konkretnej platformy. Klasa logiki oraz pozostałe klasy tego modułu mogłyby pozostać w niezmienione dla wszystkich platform.

Napisałeś? Sprawdź jak działa!

Java daje duże możliwości do testowania wydajności programów, znajdowania wąskich gardeł i podglądania wykorzystania pamięci. Mimo iż część z metod wykorzystywanych do tych celów nie została zaimplementowana w J2ME, pozostało kilka użytecznych i, na dobrą sprawę wystarczających, sposobów aby dowiedzieć się ciekawych rzeczy na temat swojego programu.
Java.lang.Runtime() udostępnia dwie interesujące nas metody:

public long freeMemory() i
public long totalMemory();

pierwsza metoda zwraca ilość wolnej pamięci (w bajtach) środowiska uruchomieniowego aplikacji, druga rozmiar tegoż środowiska (też w bajtach). Co ciekawe, rozmiar pamięci może się zmieniać - system operacyjny urządzenia może przydzielić aplikacji (a w zasadzie jej środowisku) dodatkową pamięć. Działanie w/w funkcji ilustruje przykład:

public void memoryTest()
{
Runtime runtime=Runtime.getRuntime();

long c,b,a;

System.gc(); // uruchomienie odśmiecacza

c=runtime.totalMemory();
b=runtime.freeMemory();
String s=new String("ciag znakow");
a=runtime.freeMemory();

System.out.println("total memory:"+c+" string size:"+a-b+" memory left:"+a);
}

Tyle o pamięci. Teraz chcielibyśmy sprawdzić szybkość działania programu. Służy do tego metoda public static long currentTimeMillis() z pakietu java.lang.System :

public void timeCounter()
{
long start, finish;

start=System.currentTimeMillis();

my_function(); // dowolna funkcja, której czas działania chcemy sprawdzić

finish=System.currentTimeMillis();

System.out.println("egzecution time:"+finish-start);
}

Przykład.

Zajmiemy się teraz prostą aplikacją zapisującą logi systemowe. Klasy Logger i Level należą do pakietu java.util.logging J2SE w wersji 1.4. Ponieważ w J2ME nie ma żadnej klasy o podobnej funkcjonalności adaptujemy do naszych celów klasy z J2SE. W tym celu znacznie je uprościmy.

Najpierw należy zdefiniować poziomy logowania. Służy do tego klasa Level. Określa ona zbiór standardowych poziomów logowania. Włączenie logowania na określonym poziomie włącza także logowanie na wszystkich wyższych poziomach. Poziomy określone są przez nazwę i wartość liczbową. Są one następujące :
SEVERE - Jest to poziom najwyższy, wiadomości o tym poziomie powinny określać takie zdarzenia występujące w czasie wykonywania programu, które poważnie wpływają na jego działanie, bądź wręcz uniemożliwiają poprawną pracę. Komunikaty te powinny być tak sformułowane aby były zrozumiałe zarówno dla zwykłych użytkowników aplikacji, jak i dla jej administratorów. Wartość liczbowa poziomu SEVERE to 1500;
WARNING - wiadomość o tym poziomie powinna opisywać wydarzenia mogące oznaczać potencjalne niebezpieczeństwo, zagrożenie poprawnego wykonania programu. Wartość 800.
INFO - wiadomości typu INFO powinny być raczej wypisywane na konsole. Opisywać powinny normalną pracę programu, ich rola to pomoc przy ewentualnym debugowaniu kodu. Wartość liczbowa 600;
FINE - wiadomości zawierające informacje interesujące dla programistów/ deweloperów. Informacje o ewentualnych spadkach wydajności, opisujące zachowanie programu, itd. Wartość liczbowa tego poziomu to 200;
ALL - jest to właściwie komenda loggera. Oznacza polecenie zapisywania wszystkich komunikatów, bez względu na ich poziom. Wartość liczbowa 0 - w ten sposób wartości liczbowe wszystkich pozostałych poziomów logowania są większe i komunikaty o takich poziomach ważności są logowane.
OFF - komenda wyłączająca logowanie. Odbywa się to przez ustalenie wartości poziomy logowania najwyższej spośród wszystkich komend. W ten sposób żadna z pozostałych komend nie ma wartości poziomu ważności wyższej niż OFF i przez to komunikaty o dowolnych dopuszczalnych wartościach poziomu logowania nie są zapisywane.

W pakiecie java.util.logging zdefiniowano więcej poziomów, nam wystarczy te sześć.

package logger;

public class Level
{

private final String name; // nazwa poziomu logowania

private final int value; // wartość liczbowa poziomu


// poziomy logowania :
public static final Level OFF = new Level("OFF",2000);
public static final Level SEVERE = new Level("SEVERE",1500);
public static final Level WARNING = new Level("WARNING", 800);
public static final Level INFO = new Level("INFO", 600);
public static final Level FINE = new Level("FINE", 200);
public static final Level ALL = new Level("ALL", 0);

// konstruktor
protected Level(String name, int value)
{
this.name=name;
this.value=value;
}

// i funkcje dostępu do pól obiektu.
public String getName() {return name; }
public final int getValue() {return value; }
public final int intValue() {return value;}
}

Następnie zdefiniujemy klasę Logger, dostosowując istniejącą klasę z pakietu java.util.logger do naszych potrzeb.

package logger;
import java.io.*;
import java.util.*;
import javax.microedition.rms.* // pakiet zawierający metody dostępu do pamięci
// schowkowej;



public class Logger {

private Level level; // poziom logowania
private static boolean logToStdOut = true; // czy logować na stdOut
private static boolean logToRS = true; // czy logować do Record Store -
// pamięci schowkowej
private static RecordStore rs; // referencja do Record Store

Zmienna level określa poziom logowania. Komunikaty o niższym poziomie (mniej ważne) zostaną odrzucone (nie będą zapisane). Możliwe jest także takie ustalenie poziomu logowania klasy Logger aby ni zostały zapisywane żadne komunikaty (poziom OFF), bądź też były zapisywane wszystkie (poziom ALL). Dwie kolejne zmienne typu boolean - zmienne logToStdOut i logToRS określają miejsce zapisu logów. Jeżeli zmienna logToStdOut ma wartość true logi będą wypisywane na standardowe wyjście systemu (zwykle na ekran). Często jednak nie ma możliwości odczytu takich logów jako że część emulatorów telefonów nie udostępnia terminalu tekstowego, nie wspominając już o uruchamianiu programu na rzeczywistym urządzeniu. Przydatna wtedy jest możliwość zapisywania logów do schowka. Takie zapisane dane można wtedy za pomocą innego midletu (jednakże z tego samego midlet suite) odczytać i odpowiednio zinterpretować. Ponieważ wyświetlacz telefonu jest zwykle zbyt mały by wygodnie odczytywać logi, można je przesłać do serwera sieciowego, gdzie będzie je można dalej przetwarzać, lub po prostu odczytać. Aby włączyć logowanie do schowka zmienna logToRS musi mieć wartość true. Obie zmienne są ustalane niezależnie, tzn. jeżeli obie mają wartość true logowanie nastąpi i na konsole i do schowka.
Zmienna rs jest referencją do zbioru rekordów (Record Store). Dostęp do schowka odbywa się właśnie za pomocą Record Store. Aby móc korzystać ze schowka należy utworzyć własny zbiór rekordów w schowku. Można w nim przechowywać dane właśnie w postaci rekordów. Sposób użytkowania Record Store nie różni się bardzo od użytkowania zwykłej bazy danych. Jeden Record Store odpowiada jednej tabeli w bazie danych. Tabele (zbiór rekordów) możemy modyfikować - dodawać, usuwać i modyfikować rekordy. Pakiet java.microedition.rms udostępnia odpowiednie do tego metody.

public Logger(Level level) // konstruktor
{ this.level = level; }

public Logger(String name,int value ) // konstruktor
{ this.level= new Level(name,value); }

Zdefiniowaliśmy dwa konstruktory, różniące się argumentami wywołania. Można samemu utworzyć obiekt typu Level, lub przekazując odpowiednie argumenty obiektowi Logger zlecić mu utworzenie takiego obiektu. W obydwu przypadkach powstanie dokładnie jeden obiekt Level (w przypadku przekazania go jako parametru konstruktora do obiektu Logger zostanie jedynie przypisana mu referencja).

public static RecordStore openLog() throws RecordStoreException
{
if( rs == null )
{
rs = RecordStore.openRecordStore( "log", true );
}

return rs;
}
Metoda openLog() tworzy nowy log systemu. Wywołuje ona metodę openRecordStore(string,boolean) klasy RecordStore. Metoda ta otwiera zbiór rekordów, czyli po prostu tabelę w bazie danych i zwraca uchwyt (referencję) do niej. Pierwszy parametr określa nazwę zbioru rekordów. Drugi wskazuje sposób otwierania. Wartość true oznacza, że jeżeli zbiór rekordów o żądanej nazwie nie istnieje należy go utworzyć.


public synchronized void clearLog() throws RecordStoreException
{ RecordStore.deleteRecordStore( "log" ); }

Metoda clearLog() czyści log systemowy. Obywa się to przez wywołanie metody klasy RecordStore kasującej zbiór rekordów ze schowka. Parametrem tej metody jest nazwa zbioru rekordów. W przypadku nie znalezienia zbioru rekordów, lub jakiegokolwiek innego błędu związanego ze schowkiem metoda ta zwraca RecordStoreException

public static void closeLog()
{
try {
if( rs != null )
{ rs.closeRecordStore(); }
}
catch( RecordStoreException e )
{ }
finally
{ rs = null; }
}

Zamykanie logu odbywa się także przez wywołanie odpowiedniej metody klasy Record Store. Najpierw sprawdzamy czy zbiór rekordów nie jest przypadkiem już zamknięty. Wtedy wywołujemy metodę closeRecordStore(). W przypadku wystąpienia błędu (metoda ta może zwrócić wyjątek) upewniamy się, że pamięć zaalokowana na rs zostanie zwolniona przez przypisanie rs wartości null w bloku finally. W ten sposób nawet jeżeli próba zamknięcia schowka się nie powiedzie, odśmiecacz powinien zwolnić pamięć zaalokowaną na rs

// zapisz wiadomość o poziomie FINE
public void fine( String msg )
{log( Level.FINE, msg ); }

// zwróć poziom logowania
public Level getLevel()
{return level; }

// Zapisz wiadomość o poziomie INFO
public void info( String msg )
{log( Level.INFO, msg ); }

// czy dany poziom błedu jest logowany - innymi słowy - czy komunikat
// o poziomie test zostanie zapisany do logów

public boolean isLoggable( Level test )
{
if( test == null ) return false;

// w poniższym bloku odwołujemy się do getLevel() kilka razy.
// aby przyśpieszyć działanie programu i nie wywoływać getLevel()
// wielokrotnie podstawiamy wynik działanie getLevel() do zmiennej
// actual

Level actual = getLevel();

if( actual != null )
{
if( actual == Level.OFF ) return false;
else if (actual == Level.ALL) return true;
return( test.intValue() >= actual.intValue() );
}
return false;
}

// zapisz daną wiadomość msg o poziomie logowania level
// tak naprawdę wywoływana jest funkcja log(Level,String, Throwable)
// poniższa funkcja jest tylko jej nieco prostszym "interfejsem"
public void log( Level level, String msg )
{
log( level, msg, null );
}

// jak wyżej, ale dodatkowe informacje o zgłoszonym wyjątku
public void log( Level level, String msg,Throwable e )
{
if( level == null ) level = Level.FINE;
// jeżeli poziom komunikatu jest niższy niż ustalony poziom
// logowania nie rób nic.
if( !isLoggable( level ) ) return;

// zapisujemy tak wiele informacji jak się da
// czas systemowy
long time = System.currentTimeMillis();

// watek zgłaszający komunikat
String tname = Thread.currentThread().toString();

// jeżeli mamy logować do stdout
if( logToStdOut )
{
String tmp = msg;

if( e != null )
{
tmp = msg + " " + e;
}
System.out.println( time + " " +tname + " " + level +" " + tmp );
}

if( !logToRS ) return;

// przygotowujemy strumienie danych
ByteArrayOutputStream byteOut = new ByteArrayOutputStream();
DataOutputStream dataOut = new DataOutputStream( byteOut );

//zapisujemy wszystkie dane do strumienia...prowadzącego do tablicy bajtowej
try
{
dataOut.writeInt( level.intValue() );
dataOut.writeLong( System.currentTimeMillis() );
dataOut.writeUTF(Thread.currentThread().toString() );
dataOut.writeUTF( msg != null ? msg : "" );
dataOut.writeUTF( e != null ? e.toString() : "" );
dataOut.flush();
}
catch( IOException ex ){ }


// ze strumienia odczytujemy naszą tablicę...
byte[] data = byteOut.toByteArray();

// i zapisujemy w całości do schowka - w ten sposób zamiast zapisywać
// pojedyncze bajty zapisujemy całą tablicę. Dodatkowo, użycie strumieni
// oszczędza nam kłopotliwego dzielenia zmiennych typu int, long, string na
// bajty, i znacznie upraszcza kod programu.

synchronized( this )
{
RecordStore record = null; // będzie wskazywać na zmienna rs

try
{
record = openLog();
record.addRecord( data, 0, data.length );
}
catch( RecordStoreException ex ){ }

finally
{
if( record==null ) // openLog() albo addRecord() zgłosił wyjątek
{
closeLog(); // próba zamknięcia Record Storage
}
}
record=null; // zmienna nie jest już potrzebna, dajemy znak
// odśmiecaczowi ze może ją posprzątać :)
}

}

// Ustalenie poziomu logowania
public void setLevel( Level level )
{
this.level = level;
}

//czy logować na stdOut

public void setLogToStdOut( boolean log )
{
logToStdOut = log;
}

// czy logować do Record Store
public void setLogToRS( boolean log )
{
logToRS = log;
}


// zapisz wiadomość o poziomie SEVERE

public void severe( String msg )
{
log( Level.SEVERE, msg );
}

// zapisz wiadomość o poziomie WARNING

public void warning( String msg )
{
log( Level.WARNING, msg );
}


} koniec klasy Logger


W artykule zostały zaprezentowane podstawowe założenia poprawnego stylu programowania z uwzględnieniem specyfiki projektowania oprogramowania na urządzenia przenośne. Mimo iż urządzenia te ciągle ewoluują i ich możliwość stają się coraz większe, nabranie pewnych nawyków pozwoli coraz lepiej wykorzystywać ich możliwości. Wykorzystanie zamieszczonych tu wskazówek pozwoli zoptymalizować wydajność tworzonych programów i w pełni wykorzystać możliwości urządzeń przenośnych. Pozwoli to tworzyć ciekawsze aplikacje, zarówno w sensie funkcjonalnym, jak i graficznym.

Tomasz Rybicki
rybicki@2com.pl

literatura i dodatkowe informacje
http://www.coreJ2ME.com
http://wireless.java.sun.com
http://www6.software-IBM.com
http://docs.sun.com - dokumentacja do SDK 1.4
http://javamobiles.com - telefony z obsługą Javy
http://java.sun.com/j2se - SDK SE,
http://java.sun.com/j2me - Wireless Toolkit,

emulatory telefonów
http://www.forum.nokia.com
http://www.siemens-moblie.com
http://developers.motorola.com/developers

obfuscator
http://www.retrologic.com/retroguard-main.html
http://www.alphaWorks.ibm.com/tech/JAX