Mój model zdarzeń w AS2.0

1. Model

Od kiedy zasmakowałem C# chciałem stworzyć model zdarzeń w AS2.0 na podobieństwo C#. W C# zdarzenie to osobny typ do którego możemy dodawać kolejne obiekty klasy Delegate. Rozgłoszenie zdarzenia polega na wywołaniu: event(someArg);

Przykład:

class Explosive {
  public delegate void ExplodeHandler(string msg);

  public static event ExplodeHandler Explode;

  public void trigger() {
    Explode("boom");
  }
}

Zdarzenie jest zmienną statyczną. Fakt iż jest to zdarzenie określa słowo "event", "ExplodeHandler" to delegacja jaka może się rejestrować, a "Explode" to nazwa zdarzenia.
Aby zarejestrować się jako listener używamy:

<ścieżka do zdarzenia> += new <ścieżka do delegacji>(<metoda>);

Explosive.Explode += new Explosive.ExplodeHandler(someMethod);

Oczywiście metoda (czy to obiektu czy funkcja statyczna) musi mieć identyczną sygnaturę jak delegacja.

OK. Teraz pokażę jak przerobiłem to na AS2.0.

1.1 Delegacja

Delegacja to osobny typ opakowujący obiekt i funkcję. W ActionScript na dowolnym obiekcie możemy wywołać dowolną metodę. Niestety. Niestety dlatego, bo taka dowolność nie jest dobrą praktyką. Nic bowiem nie stoi na przeszkodzie aby wywołać stone.turnOnTheLights(); stosując bądź notację tablicową bądź metodę call lub apply. Delegacja posłuży nam jako ujście eventu czyli poprostu funkcja obsługująca zdarzenie, ale nie taka zwykła funkcja tylko metoda czyli funkcja w kontekście konkretnego obiektu.

class pl.arthwood.events.Delegate {
  private var _object:Object;
  private var _method:Function;
  private var _args:Array;

  public function Delegate(obj_:Object, meth_:Function) {
    _object = obj_;
    _method = meth_;
    _args = arguments.slice(2);
  }

  public function invoke():Object {
    return _method.apply(_object, arguments.concat(_args));
  }

  public function get object():Object {
    return _object;
  }

  public function get method():Function {
    return _method;
  }

  public function get args():Array {
    return _args;
  }

  public function get callback():Function {
    var vDelegate:Delegate = this;

    return function() {
      vDelegate.invoke.source = this;

      return vDelegate.invoke.apply(vDelegate, arguments);
    };
  }
  
  public function toString():String {
    return "Delegate";
  }
}

Do kostruktora trafia zawsze obiekt i funkcja która będzie wywołana w kontekście tego obiektu. Dalej następują opcjonalne argumenty które zostaną przekazane do podanej przez nas funkcji. Wszystko to jest zapamiętywane jako prywatne pola. W razie potrzeby dodałem gettery.
Co robi metoda invoke()?
Wywołuje funkcję w kontekście obiektu przekazując opcjonalne argumenty które mogły wystąpić w konstruktorze. Są one jednak doklejone "za" argumentami wywołania bezpośredniego invoke(). Przykład takiego wywołania będzie w dalszej części.
Właściwość callback zwraca nam funkcję której wywołanie jest równoznaczne z wywołaniem invoke, ale... aby wywołać invoke potrzebujemy obiektu delegacji. callback zwraca nam czystą funkcję której wywołanie gwarantuje nam poprawne zadziałanie invoke(). Dodatkowo - wykorzystując łańcuch zasięgu (zmienna lokalna vDelegate nie jest usuwana z pamięci po wyjściu z funkcji "callback") - uzbrajamy się w źródło które wywołało callback tzn. w kontekście którego została wywołana wewnętrza (zwracana) funkcja. Przykład wykorzystania właściwości callback również będzie w dalszej części artykułu.

1.2 Zdarzenie

Oto centrum artykułu - klasa reprezentująca zdarzenie:

import pl.arthwood.events.Delegate;
import pl.arthwood.events.MulticastDelegate;

class pl.arthwood.events.Event {
  private var _multicastDelegate:MulticastDelegate;
  private var _eventName:String;

  public function Event(eventName_:String) {
    _multicastDelegate = new MulticastDelegate();
    _eventName = eventName_;
  }

  public function fire():Object {
    return _multicastDelegate.invoke.apply(_multicastDelegate, arguments);
  }

  public function _add(delegate_:Delegate):Void {
    _multicastDelegate.combine(delegate_);
  }

  public function _remove(obj_:Object):Void {
    _multicastDelegate.remove(obj_)
  }
  
  public function removeAll():Void {
    _multicastDelegate.clear();
  }
  
  public function get name():String {
    return _eventName;
  }
  
  public function get length():Number {
    return _multicastDelegate.length;
  }
  
  public function toString():String {
    return "Event " + name + ", length = " + length;
  }
}

1.3 Multicast Delegate

Najistotniejszą częścią obiektu klasy Event jest wewnętrzny pojemnik na delegacje - obiekt klasy MulticastDelegate o którym niżej.

import pl.arthwood.data.IIterator;
import pl.arthwood.events.Delegate;
import pl.arthwood.math.MathUtils;
import pl.arthwood.utils.UtilsArray;

class pl.arthwood.events.MulticastDelegate implements IIterator {
  private var _delegates:Array;
  private var _pointer:Number = 0;

  public function MulticastDelegate(delegates_:Array) {
    _delegates = delegates_? delegates_ : new Array();
  }

  public function invoke():Array {
    var n:Number = _delegates.length;
    var vResults:Array = new Array();
    var vDelegate:Delegate;
  
    for (var i:Number = 0; i < n; i++) {
    	vDelegate = Delegate(_delegates[i]);
    	vResults.push(vDelegate.invoke.apply(vDelegate, arguments));
    }
  
    return vResults;
  }

  public function combine(delegate_:Delegate):Void {
    _delegates.push(delegate_);
  }

  public function clear():Void {
    _delegates.splice(0);
  }

  public function getDelegateAt(i_:Number):Delegate {
    return Delegate(_delegates[isNaN(i_)? 0 : i_]);
  }

  /**
   *	Executes current delegate and moves pointer to the next delegate.
   */
  public function execute():Object {
    var vResult:Object = Delegate(current).callback.apply(null, arguments);

    pointer++;

    return vResult;
  }

  //////
  //
  // IIterator implementation
  //
  public function getPointer():Number {
    return _pointer;
  }
  
  public function setPointer(pointer_:Number):Void {
    _pointer = MathUtils.getLimitedValue(pointer_, 0, length - 1);
  }
  
  public function reset():Void {
    pointer = 0;
  }
  
  public function getCurrent():Object {
    return getDelegateAt(_pointer);
  }
  
  public function getPrev():Object {
    return (pointer == 0)? null : getDelegateAt(--_pointer);
  }
  
  public function getNext():Object {
    return (pointer == (length - 1))? null : getDelegateAt(++_pointer);
  }
  
  public function getLength():Number {
    return _delegates.length;
  }
  //
  //	end of IIterator
  //
  //////
  
  /**
   *	Removes function from delegate. Can be index (Number) or reference (Function)
   */
  public function remove(item_:Object):Void {
    // item_ is Number
    if (!isNaN(item_)) {
      removeDelegateAt(Number(item_));
    } // item_ is Delegate
    else if (item_ instanceof Delegate) {
      removeDelegate(Delegate(item_));
    } // item_ is Object
    else {
      var vDelegate:Delegate;
      var vMeth:Function = arguments[1];
      var vIsMeth:Boolean = (typeof(vMeth) == "function");
      var n:Number = _delegates.length;
      var vRemoveAt:Function = UtilsArray.removeAt;

      while (n-- > 0) {
        vDelegate = Delegate(_delegates[n]);

        if ((vDelegate.object === item_) && (!vIsMeth || (vDelegate.method === vMeth))) {
          vRemoveAt(_delegates, n);
        }
      }
    }
  }
  
  public function removeDelegateAt(i_:Number) {
    UtilsArray.removeAt(_delegates, i_);
  }
  
  public function removeDelegate(delegate_:Delegate) {
    UtilsArray.removeItem(_delegates, delegate_);
  }
  
  public function set pointer(pointer_:Number):Void {
    setPointer(pointer_);
  }
  
  public function get pointer():Number {
    return getPointer();
  }
  
  public function get next():Object {
    return getNext();
  }
  
  public function get current():Object {
    return getCurrent();
  }
  
  public function get prev():Object {
    return getPrev();
  }
  
  public function get length():Number {
    return getLength();
  }
}

Klasa implementuje następujący interfejs iteratora:

interface pl.arthwood.data.IIterator {
  public function reset():Void;
  public function getPointer():Number;
  public function setPointer(pointer_:Number):Void;
  public function getCurrent():Object;
  public function getPrev():Object;
  public function getNext():Object;
  public function getLength():Number;
}

Czyli możemy sobie chodzić po delegacjach.

Dwie klasy pomocnicze:

class pl.arthwood.math.MathUtils {
  public static function getLimitedValue(v_:Number, min_:Number, max_:Number):Number {
    return Math.min(Math.max(v_, min_), max_);
  }
}

class pl.arthwood.utils.UtilsArray {
  public static function removeAt(arr_:Array, at_:Number):Object {
    return arr_.splice(at_, 1);
  }
  
  public static function removeItem(arr_:Array, item_:Object):Void {
    var n:Number = arr_.length;
    
    while (n-- > 0) {
      if (arr_[n] === item_) {
        arr_.splice(n, 1);
      }
    }
  }
}

Przeznaczenie użytych funkcji statycznych jest na tyle proste że nie będę ich wyjaśniał.
Obiekt MulticastDelegate - jak wspomniałem wyżej - jest uporządkowanym zbiorem obiektów Delegate.
- invoke() powoduje wywołanie wszystkich delegacji zwracając ich rezultaty w postaci tablicy.
- combine() dokłada przekazaną delegację na koniec do zbioru
- clear() opróżnia zbiór
- getDelegateAt() zwraca delegację na danej pozycji
- execute() wywołuje bieżącą delegację i przesuwa wskaźnik
- remove() jest "dociążoną" metodą. Przyjmuje Number, Delegate, Object, lub Object oraz Function. W zależności od tego podejmowana jest odpowiednia akcja mająca na celu usunięcie jednej lub wielu delegacji spełniających podane kryteria. Podanie samego Object powoduje usunięcie wszystkich delegacji w których on występuje.
Wróćmy do zdarzenia.
Dodanie delegacji do zdarzenia realizowane jest za pomocą metody _add() i w istocie polega na dodaniu tej delegacji do wewnętrznego obiektu klasy MulticastDelegate. Czemu z underscorem? bo "add" bez jest aliasem operatora +. Nie wiem czemu nie uwzględniono tego przy definiowaniu obiektu flash.geom.Point ;)
Usunięcie jest realizowanie identycznie jak usunięcie delegacji z MulticastDelegate.
Za pomocą metody clear() usuwamy wszystkich słuchaczy.
fire() powoduje rozgłoszenie zdarzenia czyli tak naprawdę wywołujemy invoke na MulticastDelegate z tym że przekazujemy opcjonalne argumenty. String podawany w konstruktorze jest opcjonalny i pomaga w debugowaniu aplikacji.

1.4 Jak tego używać?

Użycie tego modelu przedstawię na przykładzie fragmentu głównej klasy hierarchii komponentów których używam.

import pl.arthwood.events.Event;

class pl.arthwood.components.Component extends MovieClip {
  public var onComponentLoad:Event;

  private var _loaded:Boolean = false;

  /**
  *  Constructor.
  */
  public function Component() {
    onComponentLoad = new Event();
  }

  /**
  *  onLoad event handler.
  */
  public function onLoad():Void {
    _loaded = true;
    onComponentLoad.fire(this);
  }

  /**
  *  Returns true if component is loaded (if onLoad event has been invoked).
  */
  public function get loaded():Boolean {
    return _loaded;
  }
}

Teraz przypuśćmy że robimy attach movie clipa na scenę.
Niestety aby dobrać się do jego nie-wbudowanych właściwości musimy poczekać aż nastąpi zdarzenie onLoad tego movie clipa.

var vRef:Component = Component(attachMovie("myComponent", "mcComp", 1));

vRef.onComponentLoad._add(new Delegate(this, onMyComponentLoaded));

function onMyComponentLoaded(myComp_:Component):Void {
  trace(myComp_ + " is now ready to use :)");
}

et voila ;)

Oczywiście jest wiele rzeczy ktorych nie sposób ominąć: - do delegacji musimy przekazać zarówno obiekt jak i metodę (w C# wystarczy poprostu metoda) - zdarzenie odpalane jest poprzez fire() a nie operator (). ...i wiele innych

Mimo to użycie jest bardzo wygodne i naturalne a kod w miarę restrykcyjny.

2. Dlaczego zdecydowałem się opracować swój model zdarzeń?

Oto kilka punktów które odpowiadają na to pytanie (M: Macromedia, A: arthwood)

  1. Wolę kompozycję niż dekorację - dekoracja jest run-time i nie będę miał autouzupełniania.

    M: EventDispatcher.initialize(this);

    Taki zapis powoduje że klasa zostaje poddana modyfikacji w trakcie działanie programu.

    A: public var onLoad:Event;

    Taki zapis powoduje że moja klasa jest zmodyfikowana w trakcie jej pisania.
  2. Nie znoszę typu Object bo nie wiadomo co to jest, MovieClip czy natka pietruszki.

    M: function dispatchEvent(eventObj:Object):Void

    Pisząc obsługę takiego zdarzenia nie wiem czym jest eventObj i co mogę odczytać (jeśli nie zajrzę uprzednio do helpa).

    A: fire(this, xml, success)

    Tutaj też tak naprawdę nie wiem co jest przekazane do funkcji obsługującej zdarzenie dopóki nie zerknę do specyfikacji klasy rozgłaszającej. Mimo to gdy już wiem mogę zdefiniować delegację która będzie przygotowana na odbiór konkretnych parametrów co ułatwia mi kodowanie. [Patrz niżej]
  3. Nie lubię parametryzować zdarzeń Stringiem

    M: function addEventListener(event:String, handler):Void

    A: myObj.onLoad._add(new Delegate(this, onLoad));

    Delegacja jest przygotowana na odbiór konkretnych danych
    Po co mam dodawać słuchacza czegoś co nie istnieje?.

    M: myObj.addEventListener("onJakisNieistniejacyEvent", this);

    Mogę zarejestrować się na co chcę, nawet jeśli takie zdarzenie nie istnieje (nikt go nie rozgłasza).

    A: W przypadku tego modelu eventy mam wyświetlone w podpowiedziach i stanowią zamknięty zbiór.

    Rejestruje się na to na co mi pozwala obiekt rozgłaszający.

    Często spotykane są rozwiązania zdarzeń oparte nad jednym centralnym punkcie aplikacji który przechowuje mnóstwo tablic parametryzowanych nazwą zdarzenia. Są więc także tworzone tablice które zawsze będą puste. W momencie rozgłoszenia wszystkie te tablice są przeczesywane i po spełnieniu jakiegoś warunku na obiekcie w tablicy określonego zdarzenia wywoływana jest jakaś metoda [ba, nawet nie wiemy czy ona istnieje].
    W tym modelu zdarzenia nie są tak zcentralizowane. Każdy obiekt ma zamknięty zbiór Eventów (który notabene ma swoją tablicę [MulticastDelegate]) który zawiera obiekty typu Delegate. Wszystkie one są tam wrzucane na prośbę słuchacza który przekazuje tę delegację w momencie rejestracji (więc obiekt nasłuchujący metodę obsługującą posiadać musi, inaczej dostalibyśmy błąd kompilacji). W chwili odpalenia danego eventu przeczesywana jest jedna tablica i następuje wywołanie wszystkich delegacji w tej tablicy. Nie ma tu więc żadnego "marnotrawstwa".
  4. EventDispatcher jest napisany specjalnie dla Components v2.

    M: static var exceptions:Object = {move: 1, draw: 1, load:1};

    A: Nie jest. Jest ogólny.

    Tu nie będzie wiele komentarza. Jeśli w definicji modelu zdarzeń pojawiają się jakieś wyjątki, konkretne nazwy zdarzeń itp. to nie możemy mówić o ogólności takiego modelu.
  5. "Uwielbiam" zapis

    M: var queueName:String = "__q_" + eventObj.type;
    var queue:Array = queueObj[queueName];

    Czyli wrzucamy do wora co tylko się da ;] a potem w dispatchQueue jedziemy po całości wstawiając 10 ifów ;) O tym pisałem w punkcie 3. Generalnie stosowanie zapisu tablicowego jest silnie niezalecane. Tracimy wtedy kontrolę nad tym co możemy a czego nie możemy zrobić. Tracimy również sprawdzanie poprawności kodu pod względem zgodności typów.

    A: Każdy event ma swoją listę (MulticastDelegate)