Najszybszy(?) sposób na aktualizację UI z innego wątku
27 sierpnia 08 11:53 | chaniewski | 5 komentarzy   

... czyli Kochamy Delegaty, Wyrażenia Lambda i Extension Methods :)

Pisząc aplikacje Windows nie raz i nie dwa zdarza się, że musimy zaktualizować zawartość interfejsu użytkownika z poziomu innego wątku (który np. wykonuje jakieś obliczenia lub wykonuje długotrwałą operację). Niestety nie możemy tego zrobić bezpośrednio przez ustawienie odpowiednich właściwości w kodzie. Spróbujmy w oddzielnym wątku ustawić tytuł formularza:

   1:  private void button1_Click(object sender, EventArgs e)
   2:  {
   3:      var asyncCode = new Thread(() => Text = "Ala ma kota") { IsBackground = true };
   4:      asyncCode.Start();
   5:  }

W efekcie otrzymamy elegancki wyjątek:

Cross-thread operation not valid: Control 'Form1' accessed from a thread other than the thread it was created on.

(dodatkowo widać, jak wyrażenia lambda upraszczają nasz kod - w tym przypadku wykonanie operacji asynchronicznie)

Cóż, trzeba to jakoś naprawić. W tym celu zrobimy sobie Extension Method, którego będziemy mogli używać na wszystkich obiektach dziedziczących z klasy Form.

   1:  public delegate void AsyncMethodInvoker();
   2:   
   3:  public static class FormAsyncUpdateExtension
   4:  {
   5:      public static void AsyncUpdate(this Form form, AsyncMethodInvoker asyncDelegate)
   6:      {
   7:          if(form.InvokeRequired)
   8:          {
   9:              form.Invoke(asyncDelegate);
  10:          }
  11:          else
  12:          {
  13:              asyncDelegate();
  14:          }
  15:      }
  16:  }
W linii pierwszej definiujemy delegata, który nam się dalej przyda (wiem wiem, mamy we Frameworku gotowy identyczny delegat MethodInvoker, ale przykładów nigdy za wiele). A zaraz potem używamy go do zdefiniowania metody rozszerzającej klasę Form. Od tej pory będziemy mogli pisać kod typu: moj_formularz.AsyncUpdate(() => moj_formularz.Text = "Ala ma kota");. Sprawdźmy:
   1:  private void button1_Click(object sender, EventArgs e)
   2:  {
   3:      var asyncCode = 
   4:          new Thread(() => 
   5:              this.AsyncUpdate(() => Text = "Ala ma kota")) 
   6:              { IsBackground = true };
   7:      asyncCode.Start();
   8:  }

Działa! Czy ktoś zna metodę, która po napisaniu raz, jest potem jeszcze zgrabniejsza w użyciu?

Filed under:
Wyszukiwanie plug-inów z szacunkiem dla pamięci
27 sierpnia 08 09:43 | chaniewski | 3 komentarzy   

Bardzo często, gdy tworzymy nietrywialną aplikację, pojawia się konieczność rozszerzania jej za pomocą plug-inów, czy też mówiąc po naszemu po prostu wtyczek. W tej notce nie będę skupiał się na tym jak tworzyć mechanizmy rozszerzalności czy też jak zaprojektować architekturę aplikacji, by udostępniała taką funkcjonalność. Zamiast tego, założę że te fragmenty mamy już gotowe i chcemy po prostu wyszukać na dysku pliki (assemblies) zawierające wtyczki - a także wyciągnąć z tych wtyczek pewne informacje.

Temat w zasadzie nie jest skomplikowany - Assembly.Load(), do tego trochę refleksji, i już. Co się jednak dzieje, jeżeli na dysku mamy więcej assemblies? Co, jeżeli jedynie niektóre wtyczki używamy - bo aplikacja pozwala w innym miejscu je wyłączyć, albo steruje ich użyciem innego rodzaju logika?

Zwykłe Assembly.Load() jest nieodwołalne. Kod załadowany do bieżącej domeny aplikacji pozostanie tam do czasu, gdy zamkniemy aplikację. Jeżeli przeglądaliśmy w poszukiwaniu wtyczek wiele bibliotek, to może się okazać, że w pamięci trzymamy znacznie więcej śmiecia niż jest to nam konieczne.

W poprzednim akapicie kluczowym fragmentem było "załadowany do bieżącej domeny aplikacji". Rozwiązaniem naszego problemu jest utworzenie domeny tymczasowej, wywołanie w niej kodu wykrywającego pluginy, a następnie usunięcie całej tej domeny z pamięci. Ale niestety, życie nie jest tak proste jak mogłoby się wydawać...

Pierwsze podejście

   1:  AppDomain temporary = AppDomain.CreateDomain("TEMPORARY");
   2:  Assembly a = temporary.Load("testowane_assembly.dll");
   3:   
   4:  // wyszukanie pluginów wewnątrz załadowanego assembly
   5:  // zapewne korzystające z a.GetExportedTypes()
   6:   
   7:  AppDomain.Unload(temporary);

Niestety, powyższy kod, choć na pierwszy rzut oka mógłby wydawać się poprawny, nie zadziała jak trzeba. Owszem, załaduje assembly z dysku. Owszem, wyszuka w nim wtyczki. Owszem, usunie tymczasową domenę aplikacji... ale wywołanie AppDomain.CurrentDomain.GetAssemblies() pokaże, że w bieżącej, głównej domenie naszej aplikacji, ciągle tkwi załadowane assembly które przeszukiwaliśmy... i którego się w tym miejscu w żaden sposób nie spodziewaliśmy.

Dlaczego tak się dzieje? Co sprowadziło nasze assembly na złą drogę?

Dzieje się tak dlatego, że klasa Assembly nie dziedziczy z MarshalByRefObject. W praktyce oznacza to, że efekt wywołania metody temporary.Load(...), czyli właśnie obiekt klasy Assembly, nie może być używany "zdalnie" (marshalowany? jak to po polsku się mówi?) pomiędzy dwiema domenami aplikacji. W rezultacie, nasza biblioteka zawierająca (lub nie) wtyczkę jest ładowana również do podstawowej domeny aplikacji. A to pech.

Podejście drugie, właściwe

Cóż, jeżeli jedynym problemem powodującym wyciekanie assembly do naszej podstawowej domeny aplikacji jest fakt, że klasa Assembly nie dziedziczy z MarshalByRefObject... to zróbmy sobie przejściówkę, która się może takim przodkiem poszczycić. I niech ta przejściówka po cichu zrobi co ma zrobić, tak by podstawowa domena aplikacji o tym nie wiedziała, a potem zwróci jedynie rezultat.

Spróbujmy zatem w ten sposób.

Zróbmy sobie malutki dodatkowy projekcik, niech nazywa się on Proxy, zawierający klasę:

   1:  public class Gateway : MarshalByRefObject
   2:  {
   3:      public RezultatSzukania LoadAndMatch(string fileName)
   4:      {
   5:          try
   6:          {
   7:              // załadowanie assembly
   8:              Assembly pluginAssembly = Assembly.LoadFile(fileName);
   9:   
  10:              // wyszukanie wtyczek...
  11:              foreach (Type type in pluginAssembly.GetExportedTypes())
  12:              {
  13:                  // ...
  14:              }
  15:          }
  16:          catch
  17:          {
  18:              // ... tu paskudnie połykam wyjątki :p
  19:          }
  20:   
  21:          return rezultatSzukania;
  22:      }
  23:  }

Pozostaje teraz tylko skorzystać z gotowej przelotki:

   1:  AppDomain temporary = AppDomain.CreateDomain("TEMPORARY");
   2:  try
   3:  {
   4:      Gateway proxy =
   5:                      temporary.CreateInstanceAndUnwrap("Proxy", "Proxy.Gateway") as
   6:                      Gateway;
   7:      RezultatSzukania rezultat = proxy.LoadAndMatch("testowane_assembly.dll");
   8:  }
   9:  finally
  10:  {
  11:      AppDomain.Unload(temporary);
  12:  }

Co tu się dzieje? No cóż, to dosyć proste. Po utworzeniu tymczasowej domeny aplikacji, używamy na tej domenie metody CreateInstanceAndUnwrap(), która ładuje assembly Proxy.dll do tymczasowej domeny, tworzy obiekt klasy Gateway - również w tej domenie, a następnie tworzy proxy do tego obiektu w bieżącej domenie. Zupełnie jak w remotingu - aby to sobie unaocznić, najlepiej na chwilę sobie wyobrazić, że tymczasowa domena powstała na innym komputerze, w innej galaktyce... więc używamy lokalnego obiektu proxy reprezentującego kod działający zdalnie. Gdy wywołamy metodę LoadAndMatch na lokalnym obiekcie proxy, tak naprawdę jej argumenty będą przesłane (przemarshalowane, blah) do metody na obiekcie w tymczasowej domenie - a rezultat zostanie przesłany z powrotem do proxy i do głównej domeny aplikacji.

Kilka uwag:

  • RezultatSzukania musi być serializowalny (jest to konieczne, aby można było przekazać go między domenami aplikacji).
  • Definicję tej klasy należy umieścić ponadto w takim miejscu, aby była widoczna zarówno dla obiektu Gateway w assembly Proxy, jak i dla naszej głównej aplikacji.
  • Assembly Proxy przecieka do naszej głównej domeny aplikacji. Jeżeli chce się tego uniknąć, to trzeba wyciągnąć z obiektu Gateway interfejs (np. IGateway), umieścić go w miejscu widocznym zarówno dla assembly Proxy jak i głównej aplikacji, usunąć referencję do assembly Proxy z aplikacji - a przy wywołaniu CreateInstanceAndUnwrap() rezultat rzutować na IGateway zamiast na Gateway.
Filed under: