Artykuły

[Konkurs Adobers.org] Flash - architektura aplikacji

06:12:07

Wstęp

Podczas tworzenia stron i aplikacji w Action Script. głównie w środowisku Flash, zawsze mamy do wykonania te same zadania. Najczęściej jest to zapewnienie łatwości nawigacji, skalowalności, łatwości rozbudowy strony, obsługa danych zewnętrznych, obsługa wielu języków. W tym tutorialu skupię się na tym, jak zaplanować projekt, żeby zapewnić sobie łatwość obsługi (pobierania i wyświetlania) danych zewnętrznych w taki sposób, aby zwiększenie ilości wymagań dla projektu i rozbudowa struktury nie wpływało na zwiększenie złożoności aplikacji.
Sam artykuł nie jest tutorialem w pełnym tego słowa znaczeniu - jest to zbiór pewnych proponowanych i sprawdzonych dobrych rozwiązań popartych fragmentami kodu.

Zakładmy, że dane zewnętrzne są dostarczane do klienta Flashowego przez jakiś serwer usług zdalnych. W przykładach pokazane jest wykorzystanie najpopularniejszych usług AMF (np. Fluorine, OpenAMF, AMFPHP, WebOrb itd.), jednak wykorzystanie WebService będzie równie proste. Implementacją samego serwera nie będziemy się zajmować. Zaczniemy od najczęściej popełnianych błędów:

Zły przykład!

class pl.peper.examples.BadExample {
	
	public function getData() {
		
		var service:Service = new Service(
			"http://someaddress.com/flashservices/gateway",
			null,
			"SomeService"
			);
		
		var pc:PendingCall = service.getSomeData("parameters");

		pc.onResult = function() {
			doSomething();
			//...
		}
		
		pc.onFault = function() {
			doSomethingElse();
			//...
		}
	}
}

Dlaczego poprzedni przykład jest zły ?

  • Deklaracja serwisu (adres bramki, nazwa usługi) przy każdym jej użyciu.
  • Zmiana po stronie serwera (np. nazwy usługi) pociąga zmiany w aplikacji w wielu miejscach.
  • Nie mamy kontroli nazw metod zdalnych na poziomie kompilacji - bardzo czułe na literówki.
  • Zmiana po stronie serwera z Flash Remoting na np. Web Services wymaga przepisania połowy kodu.
  • Użycie funkcji inline jest na dłuższą metę uciążliwe i mało wygodne. Można w to miejsce przynajmniej uzyć RelayResponder lub zaimplementować interfejs Responder.
  • Czy kod w tym stylu jest do opanowania gdyby różnych metod zdalnych było kilkadziesiąt ?
  • Czy bylibyśmy w stanie poprawić taki kod po roku?
  • Jak sobie z tym poradzić podczas animacji, gdy pojawiają się zależności czasowe pomiędzy instancjami obiektów?

Założenia

Chcemy aby architektura naszej aplikacji była:

  • Uniwersalna - dawała się zastosować w różnych projektach bez zmiany sposobu myślenia. Pozwala to odnaleźć się nawet w projektach kogoś innego i dużą część kodu używać wieloktrotnie.
  • Skalowalna - dawała się zastosować bez znaczących zmian do małych projektów, jak i do dużych gdzie przeróżnych funkcjonalności może być mnóstwo.
  • Prosta w implementacji
  • Odporna na pomyłki i błędy
  • Łatwa w późniejszej rozbudowie, pielęgnacji i wprowadzaniu ewentualnych zmian.

Abstrakcja usług zdalnych - Service Delegate

Pierwsze co musimy zrobić, to „opakować" kod wywołujący zdalne usługi tak, żeby zapewnić łatwe w obsłudze i bezpieczne interfejsy do obsługi danych zewnętrznych. W przypadku zmian w wymaganiach niefunkcjonalnych po stronie serwera ewentualne zamiany po stronie Flasha powinny byc wykonane tylko w tej części. Co robimy ?

  • Tworzymy klasę ActionScript dla każdej zdalnej klasy. W tej klasie tworzymy metody o takich samych nazwach jak metody zdalne.
  • Nazwa zdalnej klasy jak i adres bramki jest zapisana w jednym miejscu.
  • Pozostałe klasy wywołują metody naszej klasy opakowującej.

Korzyści:

  • Zmiana adresu bramki, nazwy zdalnej klasy czy metody jest bardzo łatwa i wymaga zmiany w tylko jednym miejscu.
  • Możemy przetestować (nawet automatycznie) wszystkie metody tej klasy, żeby mieć pewność, że nie pomyliliśmy nigdzie nazwy i jednocześnie mieć pewność, że serwer działa jak powinien.
  • Wywołania zewnętrzne są wywołaniami klasy AS, zatem mamy kontrolę poprawności na poziomie kompilacji.
  • Przezroczystość implementacji - w razie zmiany serwera z Flash Remoting na np Web Services czy inne trzeba zmienić tylko jedną klasę (lub klasy na jednym poziomie).
  • Możliwość zaimplementowania dodatkowych funkcjonalności, np: logowanie akcji, cache'owanie wyników często wywoływanych metod itp.
W przypadku, gdy korzystamy z danych poprzez WebService lub gdy pobieramy dane np. z plików XML możemy wykonać analogiczne kroki tak, aby ukryć faktyczną implementację sposobu pobierania danych i dalej projektować aplikację niezależnie od tego.

Service Delegate - rozwiązanie 1

Klasa typu service delegate:

class pl.peper.examples.ServiceDelegate {

  private var responder:Responder;
  private var service:Service;

  public function ServiceDelegate(responder:Responder) {
   this.service = new Service(
		"http://someaddress.com/flashservices/gateway",
			new Log(),
			"SomeService"
			);
   this.responder = responder;
  }
  
  public function getSomeData(p1:String, p2:Number):Void {
	var pc:PendingCall = this.service.getSomeData(p1,p2);
	pc.responder = this.responder;

  }
}

Przykład zastosowania

var responder:Responder = ...;
var delegate:ServiceDelegate =
		 new ServiceDelegate(responder);

delegate.getSomeData("news",1);

Responder może być obiektem typu Relay Responder lub inną klasą implementującą interfejs mx.remoting.Responder (np this). Implementując metodę onResult Respondera możemy wyświetlić w odpowedni sposób pobrane dane

Service Delegate - rozwiązanie 2

class done.examples.Ex2ServiceDelegate { 
private var service:Service;

  public function Ex2ServiceDelegate() {
   this.service = new Service(
		"http://someaddress.com/flashservices/gateway",
			new Log(),
			"SomeService"
			);
  }
  public static function initialize():Void {
   instance = new ServiceDelegate();
  }
  
  public static function getInstance():Ex2ServiceDelegate {
    return instance;
  }

  public function getSomeData(p1:String, p2:Number, 	
				responder:Responder):Void {
	var pc:PendingCall = this.service.getSomeData(p1,p2);
	pc.responder = responder;

  }
}

Przykład użycia
Przy starcie aplikacji wykonujemy :

ExServiceDelegate.initialize();

Responder jak w poprzednim przypadku.

  • Wszystkie usługi tworzone raz są przy starcie aplikacji.
  • Nie trzeba tworzyć nowych obiektów usług.
  • Można dodać jakieś parametry przy inicjalizacji.

Róznica jak widać jest jedynie w stypu programowania i trudno jest mi znaleźć argumenty przeważające dla jednej lub drugiej metody.

Uwagi do klas typu service delegate:
  • Klasy delegatów mogą mieć jakieś zmienne (np. debug) sterujące tym, czy tworzony jest obiekt typu Log do debugowania.
  • Przy tworzeniu obiektów klasy Service powinna być najpierw sprawdzana zmienna _root.gatewayUrl - jest to standardowa nazwa zmiennej, którą można podać jako parametr przy osadzaniu flasha w html. W razie gdy nie jest ustawiona, przyjmujemy adres domyślny. To pozwala łatwo i bez kompilacji przenieść naszą aplikacjię na inny serwer.
  • W przypadku rozwiązania 1 można pomyśleć o współdzieleniu połączenia (Connection) lub usługi pomiędzy instancjami. Responder można podać jako parametr konstruktora klasy Service i zmniejszyć ilość kodu dalej.
  • Czasami (np dla małej stronki) wystarcza jedna taka klasa, zwykle jednak grupuje się funkcjonalności w kilka klas. Tworzymy wtedy kilka takich delegatów w analogiczny sposób.

Najprostsze zastosowanie

Wywołanie i renderowanie wyników w widoku:

Diagram ilustrujący przykład.
class pl.peper.examples.SomeSection extends Section {

...
private function show():Void {
Ex2ServiceDelegate.getInstance().getSomeData(
"news",1, new RelayResponder(this,"showData","onFault"));
super.show();
}

private function showData(re:ResultEvent):Void {
txt.htmlText = re.result;
//jakiś rendering danych
}

private function onFault(fe:FaultEvent):Void {
//informacja o błędzie
}
}

  • W wielu przypadkach prostych aplikacji takie rozwiązanie się sprawdza i wystarcza.
  • Kontrola funkcjonalności i renderowanie wyników jest „przemieszane".
  • Wymaga to zwykle aby widoki i kod aplikacji tworzyła jedna osoba.
  • Podczas tworzenia animacji należy kontrolować kiedy i jak zwracane są wyniki i odwrotnie.
  • Zmiany w widokach wymagają poprawek w kodzie aplikacji.

Zaawansowane architektury

Przy tworzeniu bardziej rozbudowanych aplikacji w większym zespole stosujemy bardziej rozbudowane architektury. Zależy nam na spełnieniu założenia skalowalności - dodawanie kolejnych funkcjonalności nie powinno zwiększać złożoności kodu. Przy kilkudziesięciu różnych funkcjonalnościach kod powinien być tak samo łatwy do zarządzania, pielęgnowania i opanowania jak przy 10. Staramy się stworzyć i odseparować od siebie warstwy na tyle by umożliwić jednoczesną i niezależną pracę wielu osób. Nie chcemy aby animacje i szczegóły wizualne wpływały na wykonywanie zdalnych metod i odwrotnie (oczywiście czasem trzeba poczekać z animacją na dane z serwera).

Separacja modelu i widoku

Identyfikacja problemu Widoki często są animowane, graficy puszczają wodze fantazji (i bardze dobrze), powoduje to, że:

  • animacje są wielokrotnie zagnieżdżone
  • klipy są tworzone i usuwane ze sceny podczas animacji
  • klipy (tweeny) często nie są ponazywane
  • to może się zmieniać w zależności od humoru klienta, a zmiany wprowadza ktoś zupełnie inny

Dane umieszczane dynamicznie w takich widokach pochodzą z usług przez co widoki są od nich zależne.

  • trzeba stosować niewygodne _parent._parent._parent._parent._parent.
  • gdy dane pojawiają się asynchronicznie musi je obsłużyć kontroler i wtedy this.mc.mc1anim.tween.textField.text = ...
  • tweeny wewnętrzne są tworzone podczas animacji w różnym czasie - jak to zsynchronizować ?
  • jak dojdą ze dwa poziomy zagnieżdżenia jeszcze to trzeba wszystko pozmieniać

Nie możemy upraszczać widoków. Pamiętajmy o tym, że zwykle główną funkjcą aplikacji flashowych jest to, żeby atrakcyjnie wyglądały. Dlatego powinniśmy dopasować architekturę w taki sposób, żeby umożliwić tworzenie dowolnch, niezależnych widoków i efektów wizualnych.

Diagram ilustrujący przykład
Model
Tworzymy, klasę nazwijmy ją ModelLocator, która będize zbierać wszelkie dynamiczne dane. Niech będzie singletonem.

class pl.peper.example.ModelLocator {
	
	private static var _instance:ModelLocator = null;
 
	public static function get instance():ModelLocator {
		if (!_instance) {
			_instance = new ModelLocator();
		}
		return _instance;
	}
	
	public var newsTitle:String = "";
	public var newsContent:String = "";
	
}

Obługa danych:
Obsługując akcję pokazania jakiejś podstrony pobieramy dane.

class pl.peper.examples.MyExampleClass {

	...
	
  private function show():Void {
    ServiceDelegate.getInstance().getSomeData(
      "news",1, new RelayResponder(this,"handleData","onFault"));
    
  }

  private function handleData(re:ResultEvent):Void {
    ModelLocator.instance.newsTitle = re.result.title;
    ModelLocator.instance.newsContent = re.result.content;
    //...
  }

  private function onFault(fe:FaultEvent):Void {
    //informacja o błędzie
  }

}

Widok
Klasa jakiegoś MovieClip'a, która pokazuje newsy gdiześ w animacji

class pl.peper.example.NewsView extends MovieClip {

  private var txtNewsTitle:TextField;	
  private var txtNewsContent:TextField;

  function onLoad():Void {
    //gdyby jakieś dane już były wcześniej
    txtNewsTitle.text = ModelLocator.instance.newsTitle;
    txtNewsContent.text = ModelLocator.instance.newsContent;
    modelLocator.instance.watch("newsTitle",newsChange);
    modelLocator.instance.watch("newsContent",newsChange);
  }

  private function newsChange(propName:String, 
				oldVal:String,
				newVal:String):Void {
    if(propName == "newsTitle") {
    	txtNewsTitle.text = newVal;
    } else if(propName == "newsContent") {
	txtNewsContent.text = newVal;      
    }
	//jakieś animacje
	return newVal;
  }
}

Użycie metody watch jest najprostszym rozwiązaniem. Można w tym celu wykorzystać też zdarzenia.
Takie rozwiązanie pozwala wykonać jakieś animacje np. rozsypanka tekstu czy inne gdy pojawią się nowe dane bez znajomości nawet klasy, która pobiera te dane.
Zmiana animacji, zagnieżdżenie newsów w jeszcze 2 tweenach nic nie zmienia w kodzie.
Wykorzystanie metody watch bardzo przypomina data binding, który pojawia się np. w komponentach UI flasha czy w innych językach np. Flex, Java (Java Server Faces, Jface) i innych. Tego typu rozwiązanie jeszcze się gdzieś na pewno pojawi.
Jeśli klasę, która obsłużyła dane, nazwiemy formalnie kontrolerem, to widzimy, że w sposób naturalny uzyskaliśmy pewną realizację wzorca Model-View-Controller.

Użycie wzorca polecenia w kontrolerach

Mogą zdarzyć się sytuacje kiedy poprzednie rozwiązanie okaże się niewystarczające. Akcje (w sensie realizacji jakiejś funkcjonalności) pojawiają się w aplikacji na skutek pewnych gestów użytkownika (klikanie, wciskanie klawiszy, przycisków itp.) W poprzednich przypadkach obsługiwaliśmy to zdarzenie tam gdzie się pojawiło - w sekcji. Gdy jednak liczba tych funkcjonalności zacznie rosnąć, każda z klas sekcji będzie ich obsługiwać kilka lub kilkanaście, kod zacznie się stawać trudny do ogarnięcia. Znalezienie konkretnej funkcjonalności stanie się coraz trudniejsze. Te same akcje mogą być pojawiać w kilku sekcjach. Te same akcje mogą być wynikiem różnych gestów (kliknięcie przycisku, wybór z menu lub użycie skrótu klawiszowego) Wykonanie pewnych akcji może być zależne od siebie (trzeba je kolejkować), czasami trzeba anulować akcję lub zrobić "undo".

Wzorzec:


Wzorzec polecenia

Interfejs polecenia zawiera tylko jedną metodę - execute.

interface pl.peper.examples.Command {
public function execute():Void;
}

Przykładowe polecenie (receiverem jest tu ModelLocator):

class pl.peper.examples.GetNewsCommand implements Command, Responder {
  private var param1:String;
  private var param2:Number;
 
  public function GetNewsCommand(param1:String, param2:Number) {
    this.param1 = param1;
    this.param2 = param2;
  }

  public function execute():Void {
    ServiceDelegate.getInstance().getSomeData(this.param1,this.param2, this);
  }

  private function result(re:ResultEvent):Void {
    ModelLocator.instance.newsTitle = re.result.title;
    ModelLocator.instance.newsContent = re.result.content;
  }

  private function fault(fe:FaultEvent):Void {
    //informacja o błędzie
  }

}

Bardzo prosty menadżer poleceń (przy korzystaniu z Gugga Flash Framework można wykorzystać tamtejszy)

class pl.peper.examples.CommandManager {
  private static var _instance:CommandManager;
  private var commandHistory:Array;	
  
  private function CommandManager():Void {
    this.commandHistory = new Array();
  }
  public static function get instance():CommandManager {
    if (!_instance) {
	_instance = new CommandManager();
    }
    return _instance;
  }

  public function executeCommand(command:Command):Void {
    command.execute();
    this.commandHistory.push(command);
  }
}

Przykład zastosowania

class pl.peper.examples.SomeSesction extends Section {

  private function show():Void {
    CommandManager.instance.executeCommand(
				new GetNewsCommand("news",1)
			);
  }

}

Gdy pojawią się nowe dane w widoku zostaną automatycznie odświeżone. Te same dane mogą zostać wyświetlone w kilku widokach, np. na wykresie i w tabelce w przypadku danych tabelarycznych.

Rozwiązanie modelowe - ARP

Można wprowadzić jeszcze jedną modyfikację.

  • W widokach nie są tworzone i wywoływane polecenia, tylko widok rozsyła zdarzenie np. GetNewsEvent("news", 1)
  • Zdarzenie to jest przechwytywane i obsługiwane przez pewien centralny obiekt obsługujący zdarzenia. Istnieje przypisanie zdarzeń do poleceń obsługujących te zdarzenia. Jest możliwość obsługi kilku zdarzeń przez to samo polecenie.
  • Dopiero teraz jest tworzone i wykonywane polecenie.
Takie rozwiązanie jest przydatne gdy tworzymy aplikację modułową i chcemy oddzielić implementację konkretnych funkcjonalności od modułów. Ewentualnie móc wymieniać moduł implementujący funkcjonalności, lub widok (np jeden kod logiki sklepu internetowego i wymienialne widoki) Jest to standardowa architektura aplikacji znajdująca swoje implementację w wielu językach np:

Wybór rozwiązania

Jeśli ktoś stwierdzi, że to wszystko przerost formy nad treścią to częściowo będzie miał rację. Bardzo istotne jest, aby dopasować architekturę do złożoności aplikacji, którą budujemy. Zwykle na poziomie projektowania wstępnego można ocenić jak bardzo skomplikowana będzie aplikacja i jakich narzędzi trzeba użyć.

Kilka wskazówek z doświadczenia:

  • Tworzenie klasy typu Service Delegate jest absolutnie obowiązkowe
  • Oddzielenie widoku od modelu przydaje się, gdy ktoś inny robi widok, lub gdy po prostu tak lubimy. Z czasem może się okazać, że docenimy takie rozwiązanie.
  • Tworzenie osobnych poleceń dla funkcjonalności zwykle nie jest potrzebne przy tworzeniu standardowych stron, które jedynie prezentują jakieś informacje, ale może się przydać np. w sklepie internetowym. Na pewno jest przydatne, kiedy przewidujemy możliwość wykonywania tych samych akcji np z poziom menu głównego, menu kontekstowego, przycisku, skrótu klawiszowego itd.
  • Kiedy już decydujemy się na model z poleceniami, warto zobaczyć czy nie opłaci się skorzystać z gotowego frameworka zamiast pisać samemu to samo od nowa.
  • Uwaga! Bądźmy konsekwentni. Jeśli już zdecydujemy się na jakieś rozwiązanie w danej aplikacji, to stosujmy je konsekwentnie.
  • Jeśli część funkcjonalności będzie zaimplementowana jako polecenia, a część inaczej to po jakimś czasie (np. po roku) taki kod będzie nie do opanowania.

Info

Piotr Gwiazda